Compare commits

...

6 Commits

Author SHA1 Message Date
2c935ef648 docs: marquer les étapes M4 comme terminées dans TODO.md
Co-authored-by: coder/coder coder@agents.invalid
2026-09-06 17:43:15 +02:00
16f9b57dc2 fix(M4): modes explicites ical/pronotepy stricts — suppression du repli réciproque
Les modes explicites (ical, pronotepy) ne basculent plus silencieusement vers
l'autre source en cas d'échec. Seul le mode auto conserve le repli iCal →
pronotepy sur exception. Conforme au contrat de TODO.md M4 et AGENTS.md.

- fallback.py : _agenda_sources() et _homework_sources() retournent
  (source, None) en mode explicite ; docstrings mises à jour.
- test_fallback.py : 4 tests remplacés, 4 tests ajoutés (non-repli +
  empty-list-is-success), 4 tests ajustés ; :rtype: None ajouté.

Co-authored-by: coder/coder coder@agents.invalid
Co-authored-by: test-engineer/test-engineer test-engineer@agents.invalid
2026-09-06 17:42:04 +02:00
8b50731bab chore: ignorer les fichiers locaux FIXME_* et TEST_*
Co-Authored-By: Warp <agent@warp.dev>
2026-09-06 17:01:27 +02:00
373aba2ef0 docs: aligner les instructions agents sur le guide
Co-authored-by: codex/gpt-5.6-sol <codex-gpt-5.6-sol@agents.invalid>
2026-09-06 16:49:16 +02:00
8b7ca4b42b fix(M4): correction des 10 FIXME — client pronotepy, repli unique, couverture 95%
Branch fix/m4-fixme fusionnée depuis feature/m4-sources-pronote.

Corrections:
- Client pronotepy : ENT_RESOLVERS (30 callables), factory
  ParentClient/Client, pronote_url/account_type, propagation erreurs
- fallback.py : repli unique (non réciproque), filtre target_date,
  from None sur PipelineCriticalError, fetch_messages/informations
  log + re-raise
- ical.py : STATUS:CANCELLED sans CATEGORIES, blocs multi-dates
- .env.example : PRONOTE_URL, PRONOTE_ACCOUNT_TYPE
- Tests : 150 tests, couverture 93.34% (sources/pronote/ 95%)
- Docs : TODO.md M4 coché, GUIDE synchronisé

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>

# Conflicts:
#	GUIDE_DEV_PYTHON.md
#	TODO.md
2026-09-06 16:37:51 +02:00
d0e1f92d7f docs: aligner les jalons sur le contrat M4
Co-authored-by: codex/gpt-5.6-sol <codex-gpt-5.6-sol@agents.invalid>
2026-09-06 14:59:58 +02:00
7 changed files with 543 additions and 525 deletions

4
.gitignore vendored
View File

@@ -49,6 +49,10 @@ Thumbs.db
.caldav_sync_state.json .caldav_sync_state.json
*.state.json *.state.json
# --- Local scratch / WIP files ---
FIXME_*
TEST_*
# --- Logs --- # --- Logs ---
*.log *.log

View File

@@ -140,10 +140,10 @@
"filename": "GUIDE_DEV_PYTHON.md", "filename": "GUIDE_DEV_PYTHON.md",
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
"is_verified": true, "is_verified": true,
"line_number": 5124, "line_number": 4893,
"is_secret": false "is_secret": false
} }
] ]
}, },
"generated_at": "2026-09-06T13:36:29Z" "generated_at": "2026-09-06T14:37:44Z"
} }

View File

@@ -14,9 +14,10 @@
## 2. Stack technique ## 2. Stack technique
### Langage et dépendances ### Langage et dépendances
- **Python** : ≥ 3.13 (actuellement 3.14 dans `.venv/`) - **Python** : ≥ 3.13.5 (actuellement 3.14 dans `.venv/`)
- **Dépendances principales** : - **Dépendances principales** :
`pydantic>=2.0`, `pydantic-settings`, `icalendar`, `caldav`, `slixmpp`, `pronotepy`, `feedparser`, `requests`, `httpx` `pydantic>=2.0`, `pydantic-settings`, `icalendar`, `caldav`, `slixmpp`, `pronotepy`,
`feedparser`, `beautifulsoup4`, `requests`, `httpx`, `openai`
### Outils de développement ### Outils de développement
- **Linter** : `ruff` (longueur de ligne : 100) - **Linter** : `ruff` (longueur de ligne : 100)
@@ -36,6 +37,7 @@
``` ```
pronote_sync/ pronote_sync/
├── config/ # Configuration et paramètres ├── config/ # Configuration et paramètres
├── errors.py # Hiérarchie canonique des erreurs du pipeline
├── models/ # Modèles de données (Pydantic v2) ├── models/ # Modèles de données (Pydantic v2)
├── sources/ # Connecteurs (Pronote, iCal, etc.) ├── sources/ # Connecteurs (Pronote, iCal, etc.)
├── sync/ # Logique de synchronisation ├── sync/ # Logique de synchronisation
@@ -110,6 +112,8 @@ pronote-sync --dry-run
### Architecture ### Architecture
- **Injection de dépendances** : Utiliser `typing.Protocol` et une **composition root** dans `pipeline/run.py`. **Interdiction** des singletons globaux. - **Injection de dépendances** : Utiliser `typing.Protocol` et une **composition root** dans `pipeline/run.py`. **Interdiction** des singletons globaux.
- **Erreurs** : Conserver une seule hiérarchie dans `pronote_sync/errors.py` ; ne pas créer de
doublon dans `pipeline/steps/errors.py`.
### Style ### Style
- **Longueur de ligne** : 100 caractères maximum (configuré dans `ruff`). - **Longueur de ligne** : 100 caractères maximum (configuré dans `ruff`).
@@ -123,12 +127,30 @@ pronote-sync --dry-run
- **Idempotence** : Deux exécutions identiques sans changement externe doivent produire le **même résultat**. - **Idempotence** : Deux exécutions identiques sans changement externe doivent produire le **même résultat**.
- **Mode dégradé** : - **Mode dégradé** :
- Si l'IA échoue → retourner un message **sans synthèse**. - Si l'IA échoue → retourner un message **sans synthèse**.
- Si `pronotepy` échoue → fallback vers le parsing **iCal**. - En mode source `auto`, essayer iCal puis utiliser `pronotepy` uniquement si iCal lève une
- Si tout échoue → lever une **erreur explicite**. exception.
- En mode source explicite (`ical` ou `pronotepy`), ne pas changer silencieusement de source.
- En mode `auto`, si iCal et `pronotepy` échouent → lever une erreur critique explicite.
- Une liste vide est un succès valide ; elle ne doit pas être assimilée à une panne.
### Contrat des sources Pronote
- `PRONOTE_URL` (connexion API) et `PRONOTE_ICAL_URL` (flux iCal sensible) sont deux paramètres
distincts ; ne pas déduire l'un de l'autre.
- Le compte actuellement visé est un compte parent : utiliser
`pronotepy.ParentClient(pronote_url, username, password, ent=ent_function)`.
- Résoudre le slug `PRONOTE_ENT` vers une fonction de `pronotepy.ent` au moyen d'une liste fermée.
- Exposer séparément les cours et les devoirs dans le client ; filtrer les devoirs `pronotepy` sur
la date cible.
- Les récupérations critiques agenda/devoirs propagent une erreur expurgée ; seuls les messages et
informations non critiques peuvent se dégrader en liste vide avec warning.
- Réutiliser un téléchargement/parsing iCal pour l'agenda et les devoirs pendant un même run, sans
cache global ni persistant.
### Documentation (docstrings) ### Documentation (docstrings)
- **Obligatoire** : **Toute** fonction, méthode et classe publique doit avoir une docstring. - **Obligatoire** : **Toute** fonction, méthode et classe publique doit avoir une docstring.
- **Format** : Utiliser le format **Sphinx/reST** (pas Google ou NumPy) pour une compatibilité native avec Sphinx. - **Format** : Utiliser le format **Sphinx/reST** (pas Google ou NumPy) pour une compatibilité native avec Sphinx.
- **Priorité** : Les blocs historiques de `GUIDE_DEV_PYTHON.md` utilisant `Args:`/`Returns:` sont
illustratifs ; le format Sphinx/reST défini ici prévaut pour le code de production.
- **Contenu** : - **Contenu** :
- Une ligne de résumé courte (une phrase). - Une ligne de résumé courte (une phrase).
- Une description étendue optionnelle. - Une description étendue optionnelle.
@@ -157,6 +179,11 @@ def fetch_ical(url: str) -> str:
### Règles absolues ### Règles absolues
- **Aucun secret en clair** : Ni dans le code, ni dans les logs, ni dans les erreurs, ni dans les fixtures. - **Aucun secret en clair** : Ni dans le code, ni dans les logs, ni dans les erreurs, ni dans les fixtures.
- **Masquage** : Utiliser systématiquement `redact_url()`, `redact_secrets()`, et `redact_exception()` depuis `utils/redaction.py`. - **Masquage** : Utiliser systématiquement `redact_url()`, `redact_secrets()`, et `redact_exception()` depuis `utils/redaction.py`.
- **Chaînage d'exceptions** : Ne jamais conserver comme `__cause__` ou `__context__` une exception
externe brute susceptible de contenir un secret. Journaliser la version expurgée puis utiliser
`raise ... from None`, ou chaîner une cause elle-même expurgée.
- **Tests de non-fuite** : Vérifier les messages, les logs, `__cause__`, `__context__` et le
traceback complet avec des sentinelles distinctes pour chaque secret.
### Bonnes pratiques ### Bonnes pratiques
- **Types sécurisés** : Les mots de passe et clés API doivent utiliser `pydantic.SecretStr`. - **Types sécurisés** : Les mots de passe et clés API doivent utiliser `pydantic.SecretStr`.
@@ -225,14 +252,17 @@ Les rôles d'agents disponibles pour ce projet sont les suivants :
## 10. Workflow de modification ## 10. Workflow de modification
1. Lire la demande, [`TODO.md`](./TODO.md), `git status` et les fichiers concernés. 1. Lire la demande, [`TODO.md`](./TODO.md), les sections pertinentes de
[`GUIDE_DEV_PYTHON.md`](./GUIDE_DEV_PYTHON.md), `git status` et les fichiers concernés.
2. Préserver les changements existants de l'utilisateur. 2. Préserver les changements existants de l'utilisateur.
3. Pour une correction, reproduire d'abord le défaut avec un test automatisé lorsque c'est raisonnable. 3. Pour une correction, reproduire d'abord le défaut avec un test automatisé lorsque c'est raisonnable.
4. Faire une modification étroite et cohérente, en respectant les conventions du projet (idempotence, mode dégradé, repli iCal/pronotepy). 4. Faire une modification étroite et cohérente, en respectant les conventions du projet
(idempotence, modes explicites stricts et repli iCal → `pronotepy` uniquement en mode `auto`).
5. Faire vérifier le comportement par `@verifier` (ruff, mypy, pytest, bandit) et les cas d'erreur, notamment : 5. Faire vérifier le comportement par `@verifier` (ruff, mypy, pytest, bandit) et les cas d'erreur, notamment :
- Succès de la synchronisation Pronote → CalDAV/XMPP. - Succès de la synchronisation Pronote → CalDAV/XMPP.
- Repli vers iCal en cas d'échec de `pronotepy`. - Repli d'iCal vers `pronotepy` en mode `auto`, sans repli dans les modes explicites.
- Gestion des erreurs explicites. - Distinction entre résultat vide et échec des sources.
- Gestion des erreurs explicites sans fuite dans les causes ou tracebacks.
6. Mettre à jour la documentation et les exemples dans le même changement si leur comportement public évolue. 6. Mettre à jour la documentation et les exemples dans le même changement si leur comportement public évolue.
7. Cocher dans [`TODO.md`](./TODO.md) uniquement les éléments entièrement réalisés et validés. 7. Cocher dans [`TODO.md`](./TODO.md) uniquement les éléments entièrement réalisés et validés.
8. Terminer avec un *handoff* concis : fichiers modifiés, validations exécutées, limites et prochaine étape. 8. Terminer avec un *handoff* concis : fichiers modifiés, validations exécutées, limites et prochaine étape.
@@ -277,7 +307,8 @@ Les rôles d'agents disponibles pour ce projet sont les suivants :
Un changement est considéré comme **terminé** lorsque : Un changement est considéré comme **terminé** lorsque :
- Le cas nominal et les échecs pertinents sont testés (ex. : synchronisation réussie, repli iCal, erreurs explicites). - Le cas nominal et les échecs pertinents sont testés (ex. : synchronisation réussie, repli
iCal → `pronotepy` en mode `auto`, modes explicites stricts, erreurs explicites).
- Les outils de validation (`ruff`, `mypy`, `pytest`, `bandit`) passent sans erreur. - Les outils de validation (`ruff`, `mypy`, `pytest`, `bandit`) passent sans erreur.
- Aucun secret ni configuration locale n'apparaît dans le diff ou les fichiers suivis. - Aucun secret ni configuration locale n'apparaît dans le diff ou les fichiers suivis.
- La documentation reste cohérente avec le code (ex. : mise à jour des exemples, des contrats ou des décisions d'architecture). - La documentation reste cohérente avec le code (ex. : mise à jour des exemples, des contrats ou des décisions d'architecture).

View File

@@ -47,8 +47,10 @@ Le projet doit implémenter les fonctionnalités suivantes, dans l'ordre logique
- **Idempotence** : Deux exécutions identiques sans changement externe **doivent** produire le même résultat (aucune modification en base ou CalDAV). - **Idempotence** : Deux exécutions identiques sans changement externe **doivent** produire le même résultat (aucune modification en base ou CalDAV).
- **Mode dégradé** : - **Mode dégradé** :
- Si la synthèse IA échoue → envoyer le message **sans synthèse** (mais avec la liste brute des devoirs). - Si la synthèse IA échoue → envoyer le message **sans synthèse** (mais avec la liste brute des devoirs).
- Si `pronotepy` échoue → basculer sur iCal (si disponible) pour l'agenda/devoirs. - En mode source `auto`, si iCal échoue → basculer sur `pronotepy` pour
- Si iCal et `pronotepy` échouent → **échec explicite** avec message clair. l'agenda/devoirs.
- En mode source explicite (`ical` ou `pronotepy`), ne pas basculer silencieusement.
- En mode `auto`, si iCal et `pronotepy` échouent → **échec explicite** avec message clair.
--- ---
@@ -260,15 +262,15 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati
| Variable | Description | Exemple (anonymisé) | Type | | Variable | Description | Exemple (anonymisé) | Type |
|------------------------------|-----------------------------------------------------------------------------|---------------------------------------------|---------------| |------------------------------|-----------------------------------------------------------------------------|---------------------------------------------|---------------|
| `PRONOTE_ICAL_URL` | URL du flux iCal Pronote (contient `icalsecurise`). | `https://college.ent/pronote/ical/...` | `str` | | `PRONOTE_URL` | URL de la page Pronote utilisée par `pronotepy` (page parent). | `https://college.ent/pronote/parent.html` | `str` |
| `PRONOTE_ICAL_URL` | URL du flux iCal Pronote (contient `icalsecurise`). | `https://college.ent/pronote/ical/...` | `SecretStr` |
| `PRONOTE_USERNAME` | Identifiant Pronote (si `pronotepy` utilisé). | `parent.dupont` | `str` | | `PRONOTE_USERNAME` | Identifiant Pronote (si `pronotepy` utilisé). | `parent.dupont` | `str` |
| `PRONOTE_PASSWORD` | Mot de passe Pronote (si `pronotepy` utilisé). | `SecretStr` (masqué) | `SecretStr` | | `PRONOTE_PASSWORD` | Mot de passe Pronote (si `pronotepy` utilisé). | `SecretStr` (masqué) | `SecretStr` |
| `PRONOTE_ENT` | ENT Pronote (ex: `monbureaunumerique`, `atrium`). | `monbureaunumerique` | `str` | | `PRONOTE_ENT` | Slug ENT supporté, résolu vers une fonction de `pronotepy.ent`. | `monbureaunumerique` | `str` |
| `CALDAV_URL` | URL du serveur CalDAV. | `https://caldav.example.com/calendars/...` | `str` | | `CALDAV_URL` | URL du serveur CalDAV. | `https://caldav.example.com/calendars/...` | `str` |
| `CALDAV_USERNAME` | Identifiant CalDAV. | `user@example.com` | `str` | | `CALDAV_USERNAME` | Identifiant CalDAV. | `user@example.com` | `str` |
| `CALDAV_PASSWORD` | Mot de passe CalDAV. | `SecretStr` (masqué) | `SecretStr` | | `CALDAV_PASSWORD` | Mot de passe CalDAV. | `SecretStr` (masqué) | `SecretStr` |
| `CALDAV_CALENDAR_PATH` | Chemin du calendrier CalDAV de destination. | `/pronote-sync/` | `str` | | `CALDAV_CALENDAR_PATH` | Chemin du calendrier CalDAV de destination. | `/pronote-sync/` | `str` |
| `CALDAV_CALENDAR_PATH` | Chemin du calendrier CalDAV de destination. | `/pronote-sync/` | `str` |
| `XMPP_JID` | Identifiant XMPP (ex: `user@example.com`). | `user@example.com` | `str` | | `XMPP_JID` | Identifiant XMPP (ex: `user@example.com`). | `user@example.com` | `str` |
| `XMPP_PASSWORD` | Mot de passe XMPP. | `SecretStr` (masqué) | `SecretStr` | | `XMPP_PASSWORD` | Mot de passe XMPP. | `SecretStr` (masqué) | `SecretStr` |
| `XMPP_RECIPIENT` | Destinataire XMPP (ex: `parent@example.com`). | `parent@example.com` | `str` | | `XMPP_RECIPIENT` | Destinataire XMPP (ex: `parent@example.com`). | `parent@example.com` | `str` |
@@ -278,6 +280,19 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati
> Des variables XMPP supplémentaires ont été ajoutées : `XMPP_ENABLED`, `XMPP_HOST`, `XMPP_PORT`, `XMPP_RESOURCE`, `XMPP_USE_TLS`, `XMPP_TIMEOUT`. > Des variables XMPP supplémentaires ont été ajoutées : `XMPP_ENABLED`, `XMPP_HOST`, `XMPP_PORT`, `XMPP_RESOURCE`, `XMPP_USE_TLS`, `XMPP_TIMEOUT`.
> Une section `BLOG_ENABLED` et `BLOG_RSS_URL` a été ajoutée dans `.env.example`. > Une section `BLOG_ENABLED` et `BLOG_RSS_URL` a été ajoutée dans `.env.example`.
Les variables Pronote sont obligatoires selon les sources activées :
- la source iCal exige `PRONOTE_ICAL_URL` ;
- la source `pronotepy` exige `PRONOTE_URL`, `PRONOTE_USERNAME` et
`PRONOTE_PASSWORD` ;
- `PRONOTE_ENT` reste optionnel pour une connexion directe, mais, s'il est fourni, son slug doit
appartenir à une liste fermée et être résolu vers la fonction correspondante de `pronotepy.ent`.
`PRONOTE_URL` et `PRONOTE_ICAL_URL` sont deux contrats distincts : l'un ne doit jamais être déduit
de l'autre. Le cas d'usage actuel est un compte parent ; le client à construire est donc
`pronotepy.ParentClient`. Une généralisation à plusieurs profils ne sera ajoutée qu'en présence
d'un besoin réel et testé.
#### 3.1.2 Variables optionnelles #### 3.1.2 Variables optionnelles
| Variable | Description | Valeur par défaut | Type | | Variable | Description | Valeur par défaut | Type |
@@ -285,8 +300,6 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati
| `PRONOTE_AGENDA_SOURCE` | Source pour l'agenda (`auto`, `ical`, `pronotepy`). | `auto` | `Literal` | | `PRONOTE_AGENDA_SOURCE` | Source pour l'agenda (`auto`, `ical`, `pronotepy`). | `auto` | `Literal` |
| `PRONOTE_HOMEWORK_SOURCE` | Source pour les devoirs (`auto`, `ical`, `pronotepy`). | `auto` | `Literal` | | `PRONOTE_HOMEWORK_SOURCE` | Source pour les devoirs (`auto`, `ical`, `pronotepy`). | `auto` | `Literal` |
| `PRONOTE_MESSAGES_SOURCE` | Source pour les messages (`pronotepy` uniquement). | `pronotepy` | `Literal` | | `PRONOTE_MESSAGES_SOURCE` | Source pour les messages (`pronotepy` uniquement). | `pronotepy` | `Literal` |
| `PRONOTE_URL` | URL de l'instance Pronote. | `None` | `str \| None` |
| `PRONOTE_ACCOUNT_TYPE` | Type de compte (`student` ou `parent`). | `parent` | `Literal` |
| `SYNC_PAST_DAYS` | Nombre de jours dans le passé pour la sync CalDAV. | `7` | `int` | | `SYNC_PAST_DAYS` | Nombre de jours dans le passé pour la sync CalDAV. | `7` | `int` |
| `SYNC_FUTURE_DAYS` | Nombre de jours dans le futur pour la sync CalDAV. | `30` | `int` | | `SYNC_FUTURE_DAYS` | Nombre de jours dans le futur pour la sync CalDAV. | `30` | `int` |
@@ -307,12 +320,11 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati
```ini ```ini
# --- Pronote --- # --- Pronote ---
PRONOTE_URL=https://college.ent/pronote/parent.html
PRONOTE_ICAL_URL=https://college.ent/pronote/ical/Edt_Jean.ics?icalsecurise=REPLACE_ME&version=2024 PRONOTE_ICAL_URL=https://college.ent/pronote/ical/Edt_Jean.ics?icalsecurise=REPLACE_ME&version=2024
PRONOTE_USERNAME=parent.dupont PRONOTE_USERNAME=parent.dupont
PRONOTE_PASSWORD=your_secure_password PRONOTE_PASSWORD=your_secure_password
PRONOTE_ENT=monbureaunumerique PRONOTE_ENT=monbureaunumerique
PRONOTE_URL=
PRONOTE_ACCOUNT_TYPE=parent
# Sources (auto = essayer iCal d'abord, puis pronotepy) # Sources (auto = essayer iCal d'abord, puis pronotepy)
PRONOTE_AGENDA_SOURCE=auto PRONOTE_AGENDA_SOURCE=auto
@@ -362,19 +374,18 @@ LOG_LEVEL=INFO
> `XmppSettings.resource` a pour valeur par défaut `"pronote-sync"` (et non `"pronote-digest"`). > `XmppSettings.resource` a pour valeur par défaut `"pronote-sync"` (et non `"pronote-digest"`).
```python ```python
from typing import Literal, Optional from typing import Literal
from pydantic import SecretStr, Field from pydantic import SecretStr, Field
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
class PronoteSettings(BaseSettings): class PronoteSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="PRONOTE_", env_file=".env", extra="ignore") model_config = SettingsConfigDict(env_prefix="PRONOTE_", env_file=".env", extra="ignore")
ical_url: Optional[str] = None url: str | None = None
username: Optional[str] = None ical_url: SecretStr | None = None
password: Optional[SecretStr] = None username: str | None = None
ent: Optional[str] = None password: SecretStr | None = None
pronote_url: Optional[str] = None ent: str | None = None
account_type: Literal["student", "parent"] = "parent"
agenda_source: Literal["auto", "ical", "pronotepy"] = "auto" agenda_source: Literal["auto", "ical", "pronotepy"] = "auto"
homework_source: Literal["auto", "ical", "pronotepy"] = "auto" homework_source: Literal["auto", "ical", "pronotepy"] = "auto"
messages_source: Literal["pronotepy"] = "pronotepy" messages_source: Literal["pronotepy"] = "pronotepy"
@@ -408,11 +419,11 @@ class AppSettings(BaseSettings):
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore") model_config = SettingsConfigDict(env_file=".env", extra="ignore")
pronote: PronoteSettings = PronoteSettings() pronote: PronoteSettings = Field(default_factory=PronoteSettings)
caldav: CalDAVSettings = CalDAVSettings() caldav: CalDAVSettings = Field(default_factory=CalDAVSettings)
xmpp: XmppSettings = XmppSettings() xmpp: XmppSettings = Field(default_factory=XmppSettings)
ai: AISettings = AISettings() ai: AISettings = Field(default_factory=AISettings)
app: AppSettings = AppSettings() app: AppSettings = Field(default_factory=AppSettings)
settings = Settings() settings = Settings()
@@ -437,47 +448,20 @@ settings = Settings()
**Note importante** : Tous les messages d'erreur externes (HTTP, Pronote, CalDAV, XMPP, IA) **doivent** être systématiquement expurgés des secrets avant journalisation ou réémission. Utiliser `redact_secrets(str(e))` ou `redact_exception()` pour les logs. **Note importante** : Tous les messages d'erreur externes (HTTP, Pronote, CalDAV, XMPP, IA) **doivent** être systématiquement expurgés des secrets avant journalisation ou réémission. Utiliser `redact_secrets(str(e))` ou `redact_exception()` pour les logs.
**Tests négatifs recommandés** : **Tests négatifs obligatoires** : injecter des sentinelles distinctes dans l'URL, les identifiants,
- Vérifier que les messages d'erreur ne contiennent ni `icalsecurise`, ni `SECRET`, ni mots de passe, ni clés API. le mot de passe et la clé API, provoquer une erreur externe, puis vérifier leur absence dans le
- Exemple de test : message, les logs, `__cause__`, `__context__` et le traceback formaté.
```python
def test_error_messages_do_not_leak_secrets():
"""Vérifie que les messages d'erreur ne fuient pas de secrets."""
from pronote_sync.utils.redaction import redact_secrets
from pronote_sync.sources.pronote.ical import fetch_ical
# Simuler une URL avec token
bad_url = "https://example.com/ical?icalsecurise=SECRET_TOKEN"
try:
fetch_ical(bad_url)
except Exception as e:
error_msg = str(e)
assert "SECRET_TOKEN" not in error_msg
assert "icalsecurise" not in error_msg.lower()
```
**Tests négatifs recommandés** :
- Vérifier que les tokens (`icalsecurise`), clés API et URLs ne apparaissent **jamais** dans les logs ou les messages d'erreur, même en cas d'exception.
- Exemple de test :
```python
def test_error_messages_do_not_leak_secrets():
"""Vérifie que les messages d'erreur ne fuient pas de secrets."""
from pronote_sync.utils.redaction import redact_secrets
from pronote_sync.sources.pronote.ical import fetch_ical
# Simuler une URL avec token
bad_url = "https://example.com/ical?icalsecurise=SECRET_TOKEN"
try:
fetch_ical(bad_url)
except Exception as e:
error_msg = str(e)
assert "SECRET_TOKEN" not in error_msg
assert "icalsecurise" not in error_msg.lower()
```
**Application systématique** : **Application systématique** :
Toutes les exceptions externes (HTTP, Pronote, CalDAV, XMPP, IA) **doivent** être traitées avec `redact_secrets()` ou `redact_exception()` avant toute journalisation ou réémission. Toutes les exceptions externes (HTTP, Pronote, CalDAV, XMPP, IA) **doivent** être traitées avec `redact_secrets()` ou `redact_exception()` avant toute journalisation ou réémission.
Le masquage du message extérieur ne suffit pas si l'exception brute reste chaînée dans
`__cause__` ou `__context__` : un traceback complet pourrait alors révéler l'URL ou les
identifiants d'origine. À la frontière avec une bibliothèque externe, journaliser uniquement la
version expurgée puis lever l'exception applicative avec `raise ... from None`, ou chaîner une
cause elle-même expurgée. Les tests de non-fuite doivent inspecter `str(exc)`, les logs, la cause,
le contexte et le traceback complet.
#### 4.2.1 Masquage des URLs (`redaction.py`) #### 4.2.1 Masquage des URLs (`redaction.py`)
> ⚠️ **Décision d'implémentation** : > ⚠️ **Décision d'implémentation** :
@@ -1355,6 +1339,40 @@ def _format_message(self, message: XmppMessage) -> str:
### 5.1 Flux iCal Pronote ### 5.1 Flux iCal Pronote
#### Contrat normatif des sources Pronote (M4 et jalons suivants)
Les règles ci-dessous priment sur les exemples historiques de cette section :
1. `parse_ical()` retourne les cours et événements scolaires. Sa liste de `Homework` reste vide :
les blocs bruts conservés dans chaque `Lesson` sont transformés ensuite par
`collect_homeworks(lessons, target_date)`.
2. Les blocs de devoirs sont conservés dans une séquence. Une structure `date -> texte` est
interdite, car plusieurs devoirs peuvent partager la même date. La déduplication ne s'effectue
qu'au moment de `collect_homeworks`.
3. Un cours est annulé si `STATUS:CANCELLED` **ou** la catégorie Pronote correspondante est
présente. Le statut déplacé est détecté par sa catégorie.
4. Le client `pronotepy` reçoit l'URL Pronote en premier argument, puis les identifiants, avec une
fonction ENT résolue depuis une liste fermée. Pour le compte parent actuellement visé, utiliser
`pronotepy.ParentClient(pronote_url, username, password, ent=ent_function)`.
5. Le client expose séparément la récupération des cours et celle des devoirs. Les devoirs
`pronotepy` sont filtrés strictement sur `due_on == target_date` avant d'être retournés au
pipeline.
6. Une liste vide est un résultat valide ; une exception signale un échec de source. Les méthodes
critiques d'agenda et de devoirs propagent donc une erreur expurgée au `PronoteFetcher`. Les
messages et informations, non critiques, peuvent se dégrader en listes vides accompagnées d'un
warning.
7. En mode `auto`, iCal est essayé en premier puis `pronotepy` sert de repli. Les modes explicites
`ical` et `pronotepy` sont stricts et ne changent pas silencieusement de source. En mode `auto`,
l'échec des deux sources lève `PipelineCriticalError`.
8. Pendant une exécution du pipeline, un flux iCal déjà téléchargé et parsé est réutilisé pour
l'agenda et les devoirs. Ce partage reste limité à l'exécution courante : aucun cache global ou
persistant n'est nécessaire.
Avant M7, une fixture anonymisée doit confirmer que deux événements équivalents provenant d'iCal
et de `pronotepy` aboutissent au même identifiant canonique. Si ce n'est pas le cas, la
normalisation doit être corrigée à la frontière des sources avant toute synchronisation CalDAV ;
ne pas introduire de moteur de rapprochement complexe sans données qui le justifient.
#### 5.1.1 Observations sur les flux réels #### 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` : 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` :
@@ -1557,7 +1575,7 @@ def fetch_ical(url: str, timeout: int = 20) -> str:
safe_error = redact_secrets(str(e)) safe_error = redact_secrets(str(e))
raise requests.exceptions.RequestException( raise requests.exceptions.RequestException(
f"Échec de la récupération de {safe_url}: {safe_error}" f"Échec de la récupération de {safe_url}: {safe_error}"
) from e ) from None
# Validation du flux # Validation du flux
if "BEGIN:VCALENDAR" not in content: if "BEGIN:VCALENDAR" not in content:
@@ -2079,7 +2097,8 @@ def parse_ical(raw_ical: str) -> tuple[List[Lesson], List[HomeworkModel], List[S
# Cours annulés ou déplacés # Cours annulés ou déplacés
status = LessonStatus.NORMAL status = LessonStatus.NORMAL
if "Cours - Cours annulé" in categories: ical_status = str(component.get("status", "")).upper()
if ical_status == "CANCELLED" or "Cours - Cours annulé" in categories:
status = LessonStatus.CANCELLED status = LessonStatus.CANCELLED
elif "Cours - Cours déplacé" in categories: elif "Cours - Cours déplacé" in categories:
status = LessonStatus.MOVED status = LessonStatus.MOVED
@@ -2117,275 +2136,72 @@ def parse_ical(raw_ical: str) -> tuple[List[Lesson], List[HomeworkModel], List[S
``` ```
#### 5.1.7 Client `pronotepy` pour messages et informations #### 5.1.7 Client `pronotepy`
`pronotepy` est utilisé **uniquement** pour : `pronotepy` fournit les cours et devoirs de repli, ainsi que les messages, informations et
- Les **messages** des professeurs (`client.get_discussions()`). sondages. La signature réelle de la bibliothèque doit être respectée ; l'URL Pronote est le
- Les **informations et sondages** (`client.get_information_and_surveys()`). premier argument et l'ENT est une fonction, pas une chaîne :
```python ```python
from typing import List, Optional import pronotepy.ent as pronote_ent
from pronotepy import Client, PronoteAPIError from pronotepy import ParentClient
from ..models.message import Message, MessageType
from ..redaction import redact_url
import logging
logger = logging.getLogger(__name__) ENT_RESOLVERS = {
"monbureaunumerique": pronote_ent.monbureaunumerique,
# Ajouter uniquement les ENT effectivement pris en charge et testés.
}
ent_function = ENT_RESOLVERS.get(settings.ent) if settings.ent else None
if settings.ent and ent_function is None:
raise ValueError("PRONOTE_ENT n'est pas pris en charge")
if settings.url is None or settings.username is None or settings.password is None:
raise ValueError("Configuration pronotepy incomplète")
class PronoteClient: client = ParentClient(
"""Client pour interagir avec Pronote via pronotepy.""" settings.url,
settings.username,
def __init__( settings.password.get_secret_value(),
self, ent=ent_function,
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]: Le client applicatif expose des méthodes distinctes :
"""Récupère les messages des professeurs."""
try:
client = self._get_client()
discussions = client.get_discussions()
messages = [] - `get_lessons(start, end) -> list[Lesson]` ;
for discussion in discussions: - `get_homeworks(start, end) -> list[Homework]` ;
for message in discussion.messages: - `get_messages() -> list[Message]` ;
messages.append(Message( - `get_informations() -> list[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]: Cette séparation évite qu'un appel agenda récupère inutilement les devoirs, et inversement. Les
"""Récupère les informations et sondages.""" méthodes agenda/devoirs ne transforment jamais une erreur en liste vide : elles journalisent une
try: version expurgée puis lèvent une erreur expurgée avec `from None`. Les méthodes de messages et
client = self._get_client() d'informations sont non critiques et peuvent retourner une liste vide avec un warning.
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_lessons(self, start: date, end: date) -> list[Lesson]:
"""
Récupère les cours via pronotepy (repli si iCal échoue).
Les erreurs sont propagées pour déclencher le repli.
"""
client = self._get_client()
return [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,
content=lesson.content,
) for lesson in client.lessons(start, end)]
def get_homeworks(self, start: date, end: date) -> list[HomeworkModel]:
"""
Récupère les devoirs via pronotepy.
Les erreurs sont propagées pour déclencher le repli.
"""
client = self._get_client()
return [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,
) for hw in client.homework(start, end)]
def close(self) -> None:
"""Fermeture du client."""
if self._client:
self._client.close()
self._client = None
Les objets renvoyés par `client.homework(start, end)` couvrent une fenêtre. Le résultat destiné à
un jour cible est donc filtré explicitement sur `homework.date == target_date`.
#### 5.1.8 Logique de repli (`sources/pronote/fallback.py`) #### 5.1.8 Logique de repli (`sources/pronote/fallback.py`)
```python Le `PronoteFetcher` dépend de `Settings` et d'un protocole de client injecté ; il ne construit pas
from typing import Literal, Optional de singleton et ne contient pas d'identifiants dupliqués.
from enum import StrEnum
from .ical import fetch_ical, parse_ical
from .client import PronoteClient
from ..models.agenda import Lesson, SchoolEvent
| Mode | Comportement agenda/devoirs |
|------|------------------------------|
| `ical` | iCal uniquement ; toute erreur devient critique. |
| `pronotepy` | `pronotepy` uniquement ; toute erreur devient critique. |
| `auto` | iCal d'abord, puis `pronotepy` uniquement si iCal lève une erreur. |
| `auto`, deux échecs | Lever `PipelineCriticalError` avec un message expurgé. |
class PronoteFetcher: Une réponse vide est un succès et ne déclenche pas de repli : une journée peut réellement ne
"""Gère la récupération des données Pronote avec repli.""" contenir aucun cours ou devoir. Inversement, une exception ne doit jamais être convertie en
`([], [])`, car le `PronoteFetcher` perdrait alors l'information nécessaire pour distinguer un
def __init__( échec d'un résultat vide.
self,
ical_url: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
ent: Optional[str] = None,
pronote_url: Optional[str] = None,
account_type: str = "parent",
agenda_source: str = "auto",
homework_source: str = "auto",
):
self.ical_url = ical_url
self.username = username
self.password = password
self.ent = ent
self.pronote_url = pronote_url
self.account_type = account_type
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,
pronote_url=self.pronote_url,
account_type=self.account_type,
)
return self._pronote_client
def fetch_agenda(self, start: date, end: date) -> tuple[list[Lesson], list[SchoolEvent]]:
"""
Récupère l'agenda selon la source configurée (`agenda_source`).
En mode AUTO, essaie iCal d'abord, puis pronotepy. Si les deux échouent, lève une erreur explicite.
"""
from datetime import date
if self.agenda_source == AgendaSource.ICAL:
return self._fetch_agenda_ical(start, end)
elif self.agenda_source == AgendaSource.PRONOTEPY:
return self._fetch_agenda_pronotepy(start, end)
else: # AUTO
# Essayer iCal d'abord
try:
lessons, homeworks = self._fetch_agenda_ical(start, end)
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(start, end)
def fetch_homework(self, start: date, end: date) -> list[SchoolEvent]:
"""
Récupère les devoirs selon la source configurée (`homework_source`).
En mode AUTO, essaie iCal d'abord, puis pronotepy. Si les deux échouent, lève une erreur explicite.
"""
from datetime import date
if self.homework_source == AgendaSource.ICAL:
# Récupérer uniquement les devoirs depuis iCal
try:
_, homeworks = self._fetch_agenda_ical(start, end)
return homeworks
except Exception as e:
logger.warning(f"Échec de la récupération iCal pour les devoirs: {redact_secrets(str(e))}")
raise
elif self.homework_source == AgendaSource.PRONOTEPY:
# Récupérer uniquement les devoirs depuis pronotepy
try:
return self._fetch_agenda_pronotepy(start, end)[1]
except Exception as e:
logger.warning(f"Échec de la récupération pronotepy pour les devoirs: {redact_secrets(str(e))}")
raise
else: # AUTO
# Essayer iCal d'abord
try:
_, homeworks = self._fetch_agenda_ical(start, end)
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:
return self._fetch_agenda_pronotepy(start, end)[1]
except Exception as e:
logger.warning(f"Échec de la récupération pronotepy pour les devoirs: {redact_secrets(str(e))}")
raise
def _fetch_agenda_ical(self, start: date, end: date) -> tuple[list[Lesson], list[SchoolEvent]]:
"""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, start, end)
return lessons, homeworks
def _fetch_agenda_pronotepy(self, start: date, end: date) -> tuple[list[Lesson], list[SchoolEvent]]:
"""Récupère l'agenda depuis pronotepy."""
client = self._get_pronote_client()
lessons = client.get_lessons(start, end)
return lessons, []
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
```
`fetch_homework(target_date)` applique le même contrat aux deux sources. Pour iCal, il appelle
`collect_homeworks(lessons, target_date)`. Pour `pronotepy`, il filtre les devoirs récupérés sur la
même date cible. En M11, la composition root fournit un contexte d'exécution permettant de
réutiliser le même téléchargement/parsing iCal pour l'agenda et les devoirs lorsque les deux
sélections le permettent.
### 5.2 Résumé des points clés ### 5.2 Résumé des points clés
@@ -4553,8 +4369,10 @@ def get_channel(settings: Settings, channel_name: str = "xmpp") -> Channel:
- **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). - **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** : - **Modes dégradés** :
- **Synthèse IA** : Si elle échoue → envoyer le message **sans synthèse** (mais avec la liste brute des devoirs). - **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). - **Sources Pronote en mode `auto`** : essayer iCal, puis basculer sur `pronotepy`
- **iCal** : Si la récupération échoue → basculer sur **pronotepy** (si configuré). uniquement si iCal lève une erreur.
- **Source Pronote explicite** : `ical` et `pronotepy` sont des modes stricts, sans repli
implicite.
- **CalDAV** : Si la synchronisation échoue → **logger l'erreur** mais continuer le pipeline. - **CalDAV** : Si la synchronisation échoue → **logger l'erreur** mais continuer le pipeline.
- **XMPP** : Si l'envoi échoue → **logger l'erreur** mais continuer le pipeline. - **XMPP** : Si l'envoi échoue → **logger l'erreur** mais continuer le pipeline.
- **Erreurs critiques** : - **Erreurs critiques** :
@@ -4563,6 +4381,9 @@ def get_channel(settings: Settings, channel_name: str = "xmpp") -> Channel:
### 11.2 Hiérarchie des erreurs ### 11.2 Hiérarchie des erreurs
La hiérarchie canonique réside dans `pronote_sync/errors.py`. Les jalons suivants la complètent si
nécessaire mais ne créent pas une seconde hiérarchie dans `pipeline/steps/errors.py`.
```python ```python
from enum import Enum, auto from enum import Enum, auto
from typing import Optional from typing import Optional
@@ -4617,7 +4438,7 @@ from ..models.xmpp import XmppMessage
from ..models.pronote import PronoteData from ..models.pronote import PronoteData
from ..models.sync import CalDAVSyncResult from ..models.sync import CalDAVSyncResult
from ..models.synthesis import SynthesisInput, SynthesisResult from ..models.synthesis import SynthesisInput, SynthesisResult
from ..sources.pronote.fetcher import PronoteFetcher from ..sources.pronote.fallback import PronoteFetcher
from ..sync.caldav import CalDAVClient from ..sync.caldav import CalDAVClient
from ..sync.diff import AgendaComparator from ..sync.diff import AgendaComparator
from ..synthesis.provider import SynthesisProvider from ..synthesis.provider import SynthesisProvider
@@ -4825,78 +4646,26 @@ class PipelineRunner:
Chaque étape du pipeline est **isolée** et peut lever des `PipelineError` ou `PipelineWarning`. Chaque étape du pipeline est **isolée** et peut lever des `PipelineError` ou `PipelineWarning`.
#### 11.4.1 `fetch_step.py` #### 11.4.1 `fetch.py`
```python L'étape de récupération orchestre le contrat de `PronoteFetcher` sans réimplémenter la sélection
from typing import Tuple, List des sources :
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
1. récupérer l'agenda et les événements scolaires ;
2. résoudre le jour cible ;
3. récupérer les devoirs pour cette date cible ;
4. récupérer les messages et informations non critiques ;
5. assembler `PronoteData`.
def fetch_step(fetcher: PronoteFetcher) -> Tuple[List[Lesson], List[Homework], List[SchoolEvent], List[Message]]: Quand l'agenda et les devoirs utilisent iCal pendant la même exécution, le téléchargement et le
""" parsing sont partagés dans un contexte local au run. Une simple valeur mémorisée dans l'instance du
Étape de récupération des données Pronote. fetcher ou dans le contexte d'exécution suffit ; aucun cache global, persistant ou système
d'invalidation n'est requis.
**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
```
Une liste vide est une donnée valide et ne doit pas provoquer d'erreur critique. La criticité dépend
des exceptions remontées par les sources. L'étape importe les erreurs depuis
`pronote_sync.errors`, journalise uniquement des contenus expurgés et ne chaîne jamais une
exception externe brute susceptible de contenir un secret.
#### 11.4.1 bis `fetch_blog_step.py` #### 11.4.1 bis `fetch_blog_step.py`
@@ -4960,9 +4729,9 @@ Les autres étapes (`normalize_step`, `compare_step`, etc.) suivent le même pri
### 11.5 Points clés ### 11.5 Points clés
- **Ne jamais bloquer** : Les erreurs non critiques (ex: synthèse IA) ne bloquent pas le pipeline. - **Ne jamais bloquer** : Les erreurs non critiques (ex: synthèse IA) ne bloquent pas le pipeline.
- **Modes dégradés** : - **Modes dégradés** :
- Si iCal échoue → basculer sur `pronotepy`. - En mode `auto`, si iCal échoue → basculer sur `pronotepy`.
- Si `pronotepy` échoue → basculer sur iCal. - En mode explicite, ne pas changer de source.
- Si les deux échouent → **échec critique**. - En mode `auto`, si les deux sources échouent → **échec critique**.
- **Logs clairs** : Chaque erreur est loggée avec son niveau de gravité. - **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. - **Retour d'erreur** : Le pipeline retourne toujours une liste des erreurs/warnings rencontrés.
@@ -5283,10 +5052,11 @@ def sample_settings():
return Settings( return Settings(
pronote=PronoteSettings( pronote=PronoteSettings(
ical_url="https://test.ent/pronote/ical/test.ics", url="https://test.ent/pronote/parent.html",
ical_url=SecretStr("https://test.ent/pronote/ical/test.ics"),
username="test_user", username="test_user",
password=SecretStr("test_password"), password=SecretStr("test_password"),
ent="test_ent", ent="monbureaunumerique",
agenda_source="auto", agenda_source="auto",
homework_source="auto", homework_source="auto",
messages_source="pronotepy", messages_source="pronotepy",
@@ -5338,7 +5108,12 @@ def test_parse_ical_lesson(parsed_lessons):
@pytest.mark.unittest @pytest.mark.unittest
def test_parse_ical_homework(parsed_lessons): def test_parse_ical_homework(parsed_lessons):
"""Test le parsing des devoirs depuis iCal.""" """Test le parsing des devoirs depuis iCal."""
lessons, homeworks, school_events = parsed_lessons from pronote_sync.sources.pronote.ical import collect_homeworks
lessons, parsed_homeworks, school_events = parsed_lessons
assert parsed_homeworks == []
homeworks = collect_homeworks(lessons, date(2026, 9, 10))
assert len(homeworks) == 1 assert len(homeworks) == 1
homework = homeworks[0] homework = homeworks[0]
@@ -5354,20 +5129,15 @@ def test_parse_ical_homework(parsed_lessons):
def test_pipeline_full(mock_requests_get, mock_caldav_client, mock_ai_provider, mock_xmpp_channel, sample_settings): 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.""" """Test le pipeline complet avec des mocks."""
from pronote_sync.pipeline.run import PipelineRunner from pronote_sync.pipeline.run import PipelineRunner
from pronote_sync.sources.pronote.client import PronoteClient
from pronote_sync.sources.pronote.fallback import PronoteFetcher from pronote_sync.sources.pronote.fallback import PronoteFetcher
from pronote_sync.sync.caldav import CalDAVClient from pronote_sync.sync.caldav import CalDAVClient
from pronote_sync.sync.diff import AgendaComparator from pronote_sync.sync.diff import AgendaComparator
from pronote_sync.sources.theoretical.file import CSVTheoreticalAgendaProvider from pronote_sync.sources.theoretical.file import CSVTheoreticalAgendaProvider
# Configurer le fetcher Pronote # Configurer le fetcher Pronote
fetcher = PronoteFetcher( pronote_client = PronoteClient(sample_settings.pronote)
ical_url=sample_settings.pronote.ical_url, fetcher = PronoteFetcher(sample_settings, pronote_client)
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 # Configurer le client CalDAV
caldav_client = CalDAVClient( caldav_client = CalDAVClient(
@@ -5428,7 +5198,7 @@ def test_collect_homeworks():
"""Test la collecte et déduplication des devoirs depuis des blocs de plusieurs VEVENT.""" """Test la collecte et déduplication des devoirs depuis des blocs de plusieurs VEVENT."""
from datetime import date, datetime from datetime import date, datetime
from pronote_sync.models.agenda import Lesson, LessonStatus from pronote_sync.models.agenda import Lesson, LessonStatus
from pronote_sync.models.homework import HomeworkBlock from pronote_sync.models.agenda import HomeworkBlock
from pronote_sync.sources.pronote.ical import collect_homeworks from pronote_sync.sources.pronote.ical import collect_homeworks
# Créer des cours avec des blocs de devoirs (simulant des VEVENT parsés) # Créer des cours avec des blocs de devoirs (simulant des VEVENT parsés)
@@ -6181,4 +5951,3 @@ Ce guide fournit une **base architecturale et technique solide** pour développe
| `pydantic` | [https://pydantic.dev/](https://pydantic.dev/) | Bibliothèque pour la validation des données. | | `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 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. | RFC 4791 (CalDAV) | [https://datatracker.ietf.org/doc/html/rfc4791](https://datatracker.ietf.org/doc/html/rfc4791) | Spécification officielle de CalDAV.

29
TODO.md
View File

@@ -74,18 +74,22 @@ Définir tous les modèles de domaine, immuables pour les contrats, mutables pou
Récupérer et normaliser l'agenda, les devoirs et les messages Pronote, avec repli entre iCal et pronotepy. Récupérer et normaliser l'agenda, les devoirs et les messages Pronote, avec repli entre iCal et pronotepy.
- [x] Créer `sources/pronote/ical.py` : `fetch_ical(url)` (HTTP via `requests`, erreurs redactées) et parsing iCal → `Lesson`/`Homework`/`SchoolEvent` (`icalendar`). - [x] Créer `sources/pronote/ical.py` : `fetch_ical(url)` (HTTP via `requests`, erreurs redactées) et parsing iCal → `Lesson`/`Homework`/`SchoolEvent` (`icalendar`).
- [x] Extraire les blocs de devoirs (`HomeworkBlock`) depuis `DESCRIPTION` et dédupliquer les devoirs (clé normalisée par date). - [x] Extraire les blocs de devoirs (`HomeworkBlock`) depuis `DESCRIPTION` dans une séquence qui préserve plusieurs blocs à la même date ; dédupliquer ensuite via `collect_homeworks(lessons, target_date)`.
- [x] Détecter les statuts (`CANCELLED`/`MOVED`) via `CATEGORIES` et `STATUS:CANCELLED`. - [x] Détecter les statuts (`CANCELLED`/`MOVED`) via `CATEGORIES` et `STATUS:CANCELLED`.
- [x] Créer `sources/pronote/client.py` : client `pronotepy` (messages, informations, discussions, sondages, et devoirs en repli) avec masquage des erreurs. - [x] Ajouter `PRONOTE_URL` à la configuration et créer `sources/pronote/client.py` autour de `pronotepy.ParentClient(pronote_url, username, password, ent=ent_function)` ; résoudre le slug ENT par liste fermée.
- [x] Exposer séparément les cours, devoirs, messages et informations dans le client `pronotepy` ; filtrer les devoirs sur `due_on == target_date`.
- [x] 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`. - [x] 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`.
- [x] Implémenter le repli unique : source primaire + un seul repli (l'autre source si configurée) ; les deux échouent`PipelineCriticalError`. - [x] Implémenter le contrat de source : modes `ical`/`pronotepy` stricts ; mode `auto` = iCal puis repli `pronotepy` uniquement sur exception ; deux échecs en `auto``PipelineCriticalError`.
- [x] Distinguer un succès vide d'un échec : les récupérations critiques agenda/devoirs propagent une erreur expurgée ; seuls les messages/informations non critiques peuvent se dégrader en liste vide avec warning.
- [x] Normaliser les UID via `utils/uid.normalize_pronote_uid` pour la stabilité des événements. - [x] Normaliser les UID via `utils/uid.normalize_pronote_uid` pour la stabilité des événements.
### Critères d'acceptation ### Critères d'acceptation
- `fetch_ical` parse `tests/fixtures/pronote-4e.ics` en leçons/devoirs/événements corrects (cours annulé détecté). - `parse_ical` parse `tests/fixtures/pronote-4e.ics` en leçons/événements corrects, conserve les blocs bruts et retourne une liste de `Homework` vide ; `collect_homeworks` retourne ensuite le devoir attendu pour la date cible.
- Le client pronotepy récupère messages/devoirs (mocké). - Les cours annulés sont détectés aussi bien par catégorie que par `STATUS:CANCELLED` ; plusieurs blocs de devoirs partageant une date sont tous conservés avant déduplication.
- Le repli bascule correctement et lève une erreur critique si aucune source disponible. - Le constructeur `ParentClient` est testé avec l'ordre réel de ses paramètres, l'URL Pronote et une fonction ENT autorisée.
- Aucun secret dans les messages d'erreur de fetch. - Le client `pronotepy` récupère messages/cours/devoirs (mocké) et ne retourne que les devoirs de la date cible.
- Le mode `auto` bascule uniquement après une exception et lève une erreur critique si les deux sources échouent ; un résultat vide reste un succès.
- Aucun secret n'apparaît dans le message, les logs, la cause, le contexte ou le traceback complet d'une erreur de source.
--- ---
@@ -128,12 +132,14 @@ Synchroniser différentiellement les événements Pronote vers le calendrier Cal
- [ ] Créer `sync/caldav.py` : `CalDAVClient` (connexion, liste/ajout/MAJ/suppression, marqueur `X-PRONOTE-SYNC-MANAGED: v1`). - [ ] 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). - [ ] 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. - [ ] Calculer le `CalDAVSyncPlan` (to_add / to_update / to_remove) par UID stable.
- [ ] Avant de figer le plan de sync, vérifier sur fixture anonymisée que le même cours provenant d'iCal et de `pronotepy` possède le même identifiant canonique ; corriger la normalisation à la frontière des sources si nécessaire.
- [ ] 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`).
- [ ] Réutiliser `BlogRSSState` pour l'état blog si pertinent (sinon `sync/blog_state.py`). - [ ] Réutiliser `BlogRSSState` pour l'état blog si pertinent (sinon `sync/blog_state.py`).
### 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é (PronoteData vs état local).
- 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`.
@@ -194,15 +200,17 @@ Construire et envoyer le message XMPP structuré via un compte bot dédié (mess
Composer et orchestrer toutes les étapes avec gestion d'erreurs dégradée et mode dry-run. 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`. - [ ] Compléter si nécessaire la hiérarchie canonique dans `pronote_sync/errors.py` (`ErrorSeverity`, `PipelineError`, `PipelineWarning`, `PipelineCriticalError`) ; ne pas créer de doublon dans `pipeline/steps/errors.py`.
- [ ] Créer les étapes `pipeline/steps/` : `fetch.py`, `normalize.py`, `compare.py`, `caldav_sync.py`, `synthesis.py`, `send.py`, `fetch_blog.py`. - [ ] 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. - [ ] 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)`. - [ ] 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). - [ ] Implémenter le mode `dry_run` (aucune écriture CalDAV/XMPP).
- [ ] Câbler l'injection des dépendances (Protocol + composition root), sans singleton global. - [ ] Câbler l'injection des dépendances (Protocol + composition root), sans singleton global.
- [ ] Réutiliser, dans une même exécution, un unique téléchargement/parsing iCal pour l'agenda et les devoirs lorsque les sources sélectionnées le permettent ; rester sur un cache local au run, sans cache global ni persistant.
### Critères d'acceptation ### Critères d'acceptation
- Le pipeline complet s'exécute de bout en bout (mocks) dans le bon ordre. - Le pipeline complet s'exécute de bout en bout (mocks) dans le bon ordre.
- Une sélection iCal commune à l'agenda et aux devoirs ne déclenche qu'un téléchargement/parsing du flux par run.
- Une erreur non critique (ex : synthèse IA) n'empêche pas l'envoi XMPP. - 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. - `dry_run=True` n'effectue aucune écriture ; aucune source disponible → erreur critique explicite.
@@ -220,7 +228,7 @@ Exposer le lancement du pipeline via une interface en ligne de commande.
### Critères d'acceptation ### Critères d'acceptation
- `pronote-sync --dry-run --log-level DEBUG` s'exécute sans effet de bord. - `pronote-sync --dry-run --log-level DEBUG` s'exécute sans effet de bord.
- Le script console est installable (`[project.scripts]` dans `pyproject.toml`). - Le script console est installable (`[project.scripts]` dans `pyproject.toml`).
- Les erreurs affichées ne contiennent aucun secret. - Les erreurs affichées ne contiennent aucun secret, y compris avec l'affichage d'un traceback complet en mode debug.
--- ---
@@ -231,10 +239,11 @@ Couvrir l'ensemble du code par des tests sans réseau, avec fixtures anonymisée
- [ ] Créer `tests/fixtures/` : `pronote-4e.ics`, `pronote-6e.ics`, `theoretical.ics`, `theoretical.csv`, `blog_rss.xml` (anonymisés, sans `icalsecurise`). - [ ] 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…). - [ ] 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/unit/` : `test_models`, `test_parsing` (iCal), `test_uid`, `test_redaction`, `test_diff`, `test_sync`.
- [ ] Couvrir les régressions M4 : signature réelle de `ParentClient`, ENT autorisé/inconnu, erreur vs résultat vide, `STATUS:CANCELLED` sans catégorie, plusieurs devoirs à la même date, filtrage `pronotepy` sur la date cible et stabilité d'identité entre sources.
- [ ] Écrire `tests/integration/` : `test_pipeline`, `test_caldav` (mocké), `test_xmpp` (mocké). - [ ] Écrire `tests/integration/` : `test_pipeline`, `test_caldav` (mocké), `test_xmpp` (mocké).
- [ ] Écrire `tests/e2e/test_cli.py` : exécution CLI en dry-run. - [ ] Écrire `tests/e2e/test_cli.py` : exécution CLI en dry-run.
- [ ] Tests sans réseau (mocks `responses`/`aioresponses`/`pytest-mock`) ; couverture ≥ 90 %. - [ ] 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). - [ ] Ajouter un test négatif : les messages, logs, causes, contextes et tracebacks complets ne fuient pas de secrets (`icalsecurise`, clés API, mots de passe).
### Critères d'acceptation ### Critères d'acceptation
- `pytest` passe et `pytest --cov` atteint ≥ 90 % (`fail_under = 90`). - `pytest` passe et `pytest --cov` atteint ≥ 90 % (`fail_under = 90`).

View File

@@ -4,9 +4,9 @@ Ce module fournit l'enum :class:`AgendaSource`, le protocole
:class:`PronoteFetcherProtocol` consommé par le pipeline ainsi que la :class:`PronoteFetcherProtocol` consommé par le pipeline ainsi que la
classe :class:`PronoteFetcher` qui sélectionne la source selon la classe :class:`PronoteFetcher` qui sélectionne la source selon la
configuration (``PRONOTE_AGENDA_SOURCE`` / ``PRONOTE_HOMEWORK_SOURCE``). configuration (``PRONOTE_AGENDA_SOURCE`` / ``PRONOTE_HOMEWORK_SOURCE``).
La logique applique un repli **unique** : une source primaire est Contrat strict : les modes explicites n'utilisent que la source
essayée en premier et, en cas d'échec, une seule source de repli (jamais configurée, sans aucun repli ; seul le mode ``auto`` applique un repli
réciproque ni itératif) est essayée si elle est configurée. Les messages unique iCal → pronotepy, en cas d'exception uniquement. Les messages
et informations proviennent toujours de pronotepy. Toutes les erreurs et informations proviennent toujours de pronotepy. Toutes les erreurs
sont journalisées avec des secrets masqués via sont journalisées avec des secrets masqués via
:func:`~pronote_sync.utils.redaction.redact_exception` ; les exceptions :func:`~pronote_sync.utils.redaction.redact_exception` ; les exceptions
@@ -82,13 +82,14 @@ class PronoteFetcherProtocol(Protocol):
class PronoteFetcher: class PronoteFetcher:
"""Récupère les données Pronote via iCal ou pronotepy avec repli unique. """Récupère les données Pronote via iCal ou pronotepy, repli réservé au mode ``auto``.
Unifie les sources iCal et pronotepy selon la source configurée Unifie les sources iCal et pronotepy selon la source configurée
(``agenda_source`` / ``homework_source``) : la source primaire est (``agenda_source`` / ``homework_source``) : les modes explicites
essayée en premier et, si elle échoue, une seule source de repli est n'utilisent que la source configurée, sans aucun repli ; seul le mode
essayée lorsqu'elle est configurée. Les messages et informations ``auto`` essaie une source primaire puis, si elle échoue, une seule
proviennent toujours de pronotepy. source de repli lorsqu'elle est configurée. Les messages et
informations proviennent toujours de pronotepy.
""" """
def __init__(self, settings: Settings, pronote_client: PronoteClientProtocol) -> None: def __init__(self, settings: Settings, pronote_client: PronoteClientProtocol) -> None:
@@ -171,11 +172,10 @@ class PronoteFetcher:
def _agenda_sources(self) -> tuple[_SourceName, _SourceName | None]: def _agenda_sources(self) -> tuple[_SourceName, _SourceName | None]:
"""Sélectionne la source primaire et le repli unique pour l'agenda. """Sélectionne la source primaire et le repli unique pour l'agenda.
En mode ``AUTO``, iCal est primaire si ``ical_url`` est configuré Les modes explicites ``ICAL`` et ``PRONOTEPY`` désignent la seule
(repli pronotepy si la configuration pronotepy est complète), source utilisée, sans aucun repli. En mode ``AUTO``, iCal est
sinon pronotepy sans repli. Les modes explicites ``ICAL`` et primaire si ``ical_url`` est configuré (repli pronotepy si la
``PRONOTEPY`` désignent la source primaire et tentent l'autre configuration pronotepy est complète), sinon pronotepy sans repli.
source en repli unique si elle est configurée.
:return: Tuple ``(source primaire, source de repli ou ``None``)``. :return: Tuple ``(source primaire, source de repli ou ``None``)``.
:rtype: tuple[_SourceName, _SourceName | None] :rtype: tuple[_SourceName, _SourceName | None]
@@ -183,9 +183,9 @@ class PronoteFetcher:
""" """
source = AgendaSource(self._settings.pronote.agenda_source) source = AgendaSource(self._settings.pronote.agenda_source)
if source is AgendaSource.ICAL: if source is AgendaSource.ICAL:
return "ical", "pronotepy" if self._is_pronotepy_configured() else None return "ical", None
if source is AgendaSource.PRONOTEPY: if source is AgendaSource.PRONOTEPY:
return "pronotepy", "ical" if self._is_ical_configured() else None return "pronotepy", None
if self._is_ical_configured(): if self._is_ical_configured():
return "ical", "pronotepy" if self._is_pronotepy_configured() else None return "ical", "pronotepy" if self._is_pronotepy_configured() else None
if self._is_pronotepy_configured(): if self._is_pronotepy_configured():
@@ -208,11 +208,13 @@ class PronoteFetcher:
def fetch_agenda(self) -> tuple[list[Lesson], list[SchoolEvent]]: def fetch_agenda(self) -> tuple[list[Lesson], list[SchoolEvent]]:
"""Récupère les cours et les événements scolaires selon la source configurée. """Récupère les cours et les événements scolaires selon la source configurée.
La source primaire est essayée en premier ; si elle échoue, la En mode explicite (``ical`` ou ``pronotepy``), la source désignée
source de repli unique (l'autre source, si configurée) est est la seule tentée : si elle échoue, une erreur critique est levée
essayée. Si la source primaire et le repli échouent — ou si sans repli. En mode ``auto``, la source primaire est essayée en
aucune source n'est configurée en mode ``AUTO`` — une erreur premier puis, si elle échoue, la source de repli unique (l'autre
critique est levée. source, si configurée) l'est à son tour ; si la source primaire et
le repli échouent — ou si aucune source n'est configurée — une
erreur critique est levée.
:return: Tuple ``(cours, événements scolaires)``. :return: Tuple ``(cours, événements scolaires)``.
:rtype: tuple[list[Lesson], list[SchoolEvent]] :rtype: tuple[list[Lesson], list[SchoolEvent]]
@@ -289,11 +291,10 @@ class PronoteFetcher:
def _homework_sources(self) -> tuple[_SourceName, _SourceName | None]: def _homework_sources(self) -> tuple[_SourceName, _SourceName | None]:
"""Sélectionne la source primaire et le repli unique pour les devoirs. """Sélectionne la source primaire et le repli unique pour les devoirs.
En mode ``AUTO``, iCal est primaire si ``ical_url`` est configuré Les modes explicites ``ICAL`` et ``PRONOTEPY`` désignent la seule
(repli pronotepy si la configuration pronotepy est complète), source utilisée, sans aucun repli. En mode ``AUTO``, iCal est
sinon pronotepy sans repli. Les modes explicites ``ICAL`` et primaire si ``ical_url`` est configuré (repli pronotepy si la
``PRONOTEPY`` désignent la source primaire et tentent l'autre configuration pronotepy est complète), sinon pronotepy sans repli.
source en repli unique si elle est configurée.
:return: Tuple ``(source primaire, source de repli ou ``None``)``. :return: Tuple ``(source primaire, source de repli ou ``None``)``.
:rtype: tuple[_SourceName, _SourceName | None] :rtype: tuple[_SourceName, _SourceName | None]
@@ -301,9 +302,9 @@ class PronoteFetcher:
""" """
source = AgendaSource(self._settings.pronote.homework_source) source = AgendaSource(self._settings.pronote.homework_source)
if source is AgendaSource.ICAL: if source is AgendaSource.ICAL:
return "ical", "pronotepy" if self._is_pronotepy_configured() else None return "ical", None
if source is AgendaSource.PRONOTEPY: if source is AgendaSource.PRONOTEPY:
return "pronotepy", "ical" if self._is_ical_configured() else None return "pronotepy", None
if self._is_ical_configured(): if self._is_ical_configured():
return "ical", "pronotepy" if self._is_pronotepy_configured() else None return "ical", "pronotepy" if self._is_pronotepy_configured() else None
if self._is_pronotepy_configured(): if self._is_pronotepy_configured():
@@ -327,11 +328,13 @@ class PronoteFetcher:
def fetch_homework(self, target_date: date) -> list[Homework]: def fetch_homework(self, target_date: date) -> list[Homework]:
"""Récupère les devoirs selon la source configurée. """Récupère les devoirs selon la source configurée.
La source primaire est essayée en premier ; si elle échoue, la En mode explicite (``ical`` ou ``pronotepy``), la source désignée
source de repli unique (l'autre source, si configurée) est est la seule tentée : si elle échoue, une erreur critique est levée
essayée. Si la source primaire et le repli échouent — ou si sans repli. En mode ``auto``, la source primaire est essayée en
aucune source n'est configurée en mode ``AUTO`` — une erreur premier puis, si elle échoue, la source de repli unique (l'autre
critique est levée. source, si configurée) l'est à son tour ; si la source primaire et
le repli échouent — ou si aucune source n'est configurée — une
erreur critique est levée.
:param target_date: Date cible pour laquelle collecter les devoirs. :param target_date: Date cible pour laquelle collecter les devoirs.
:return: Liste des devoirs. :return: Liste des devoirs.

View File

@@ -85,6 +85,7 @@ def test_fetch_agenda_ical_mode(mock_fetcher: PronoteFetcher) -> None:
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
start_dt = datetime(2025, 9, 1, 8, 0) start_dt = datetime(2025, 9, 1, 8, 0)
end_dt = datetime(2025, 9, 1, 9, 30) end_dt = datetime(2025, 9, 1, 9, 30)
@@ -133,6 +134,7 @@ def test_fetch_agenda_pronotepy_mode(mock_fetcher: PronoteFetcher) -> None:
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
start_dt = datetime(2025, 9, 1, 8, 0) start_dt = datetime(2025, 9, 1, 8, 0)
end_dt = datetime(2025, 9, 1, 9, 30) end_dt = datetime(2025, 9, 1, 9, 30)
@@ -168,6 +170,7 @@ def test_fetch_agenda_auto_ical_success(mock_fetcher: PronoteFetcher) -> None:
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
start_dt = datetime(2025, 9, 1, 8, 0) start_dt = datetime(2025, 9, 1, 8, 0)
end_dt = datetime(2025, 9, 1, 9, 30) end_dt = datetime(2025, 9, 1, 9, 30)
@@ -204,6 +207,7 @@ def test_fetch_agenda_auto_fallback_to_pronotepy(mock_fetcher: PronoteFetcher) -
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
start_dt = datetime(2025, 9, 1, 8, 0) start_dt = datetime(2025, 9, 1, 8, 0)
end_dt = datetime(2025, 9, 1, 9, 30) end_dt = datetime(2025, 9, 1, 9, 30)
@@ -244,6 +248,7 @@ def test_fetch_agenda_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
# Disable pronotepy so fallback is None # Disable pronotepy so fallback is None
mock_fetcher._settings.pronote.pronote_url = None mock_fetcher._settings.pronote.pronote_url = None
@@ -271,6 +276,7 @@ def test_fetch_agenda_ical_mode_failure(mock_fetcher: PronoteFetcher) -> None:
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
# Override settings to use ical mode explicitly and disable fallback # Override settings to use ical mode explicitly and disable fallback
mock_fetcher._settings.pronote.agenda_source = "ical" mock_fetcher._settings.pronote.agenda_source = "ical"
@@ -301,6 +307,7 @@ def test_fetch_agenda_pronotepy_mode_failure(mock_fetcher: PronoteFetcher) -> No
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
# Override settings to use pronotepy mode explicitly and disable fallback # Override settings to use pronotepy mode explicitly and disable fallback
mock_fetcher._settings.pronote.agenda_source = "pronotepy" mock_fetcher._settings.pronote.agenda_source = "pronotepy"
@@ -324,6 +331,7 @@ def test_fetch_homework_ical_mode(mock_fetcher: PronoteFetcher) -> None:
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
target_date = date(2025, 9, 5) target_date = date(2025, 9, 5)
@@ -385,6 +393,7 @@ def test_fetch_homework_auto_fallback(mock_fetcher: PronoteFetcher) -> None:
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
target_date = date(2025, 9, 10) target_date = date(2025, 9, 10)
homeworks = [ homeworks = [
@@ -424,6 +433,7 @@ def test_fetch_homework_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
target_date = date(2025, 9, 10) target_date = date(2025, 9, 10)
@@ -453,6 +463,7 @@ def test_fetch_messages(mock_fetcher: PronoteFetcher) -> None:
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
messages = [ messages = [
Message( Message(
@@ -483,6 +494,7 @@ def test_fetch_informations(mock_fetcher: PronoteFetcher) -> None:
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
infos = [ infos = [
Message( Message(
@@ -516,6 +528,7 @@ def test_no_secrets_in_error_messages(
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:param caplog: Fixture pytest pour capturer les logs. :param caplog: Fixture pytest pour capturer les logs.
:return: None :return: None
:rtype: None
""" """
# Disable pronotepy so fallback is None to trigger PipelineCriticalError # Disable pronotepy so fallback is None to trigger PipelineCriticalError
mock_fetcher._settings.pronote.pronote_url = None mock_fetcher._settings.pronote.pronote_url = None
@@ -541,30 +554,18 @@ def test_no_secrets_in_error_messages(
assert "icalsecurise=REDACTED" in caplog.text or "icalsecurise" not in caplog.text assert "icalsecurise=REDACTED" in caplog.text or "icalsecurise" not in caplog.text
def test_fetch_agenda_ical_mode_fallback_to_pronotepy(mock_fetcher: PronoteFetcher) -> None: def test_fetch_agenda_ical_mode_does_not_fallback_to_pronotepy(
"""Test le mode ICAL : échec iCal, repli sur pronotepy. mock_fetcher: PronoteFetcher,
) -> None:
"""Test le mode ICAL : échec iCal, PAS de repli sur pronotepy.
On mock iCal pour échouer, pronotepy pour réussir. On vérifie que pronotepy est appelé. On mock iCal pour échouer. On vérifie que pronotepy n'est PAS appelé
et qu'une PipelineCriticalError est levée.
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
start_dt = datetime(2025, 9, 1, 8, 0)
end_dt = datetime(2025, 9, 1, 9, 30)
lessons = [
Lesson(
id="l1",
start=start_dt,
end=end_dt,
subject="Maths",
teachers=("Dupont",),
rooms=("S1",),
group="2ndeA",
status=LessonStatus.NORMAL,
content=None,
)
]
# Override settings to use ical mode explicitly # Override settings to use ical mode explicitly
mock_fetcher._settings.pronote.agenda_source = "ical" mock_fetcher._settings.pronote.agenda_source = "ical"
@@ -575,40 +576,28 @@ def test_fetch_agenda_ical_mode_fallback_to_pronotepy(mock_fetcher: PronoteFetch
m_fetch_ical.side_effect = OSError("iCal unreachable") m_fetch_ical.side_effect = OSError("iCal unreachable")
m_parse_ical.side_effect = OSError("iCal parse error") m_parse_ical.side_effect = OSError("iCal parse error")
client = MagicMock() client = MagicMock()
client.get_lessons.return_value = lessons
mock_fetcher._pronote_client = client mock_fetcher._pronote_client = client
result_lessons, result_events = mock_fetcher.fetch_agenda() with pytest.raises(PipelineCriticalError) as exc_info:
mock_fetcher.fetch_agenda()
assert result_lessons == lessons assert "la source ical a échoué" in str(exc_info.value)
assert result_events == [] # Vérifie que pronotepy n'a PAS été appelé
client.get_lessons.assert_called_once() client.get_lessons.assert_not_called()
def test_fetch_agenda_pronotepy_mode_fallback_to_ical(mock_fetcher: PronoteFetcher) -> None: def test_fetch_agenda_pronotepy_mode_does_not_fallback_to_ical(
"""Test le mode PRONOTEPY : échec pronotepy, repli sur iCal. mock_fetcher: PronoteFetcher,
) -> None:
"""Test le mode PRONOTEPY : échec pronotepy, PAS de repli sur iCal.
On mock pronotepy pour échouer, iCal pour réussir. On vérifie que iCal est appelé. On mock pronotepy pour échouer. On vérifie que iCal n'est PAS appelé
et qu'une PipelineCriticalError est levée.
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
start_dt = datetime(2025, 9, 1, 8, 0)
end_dt = datetime(2025, 9, 1, 9, 30)
lessons = [
Lesson(
id="l1",
start=start_dt,
end=end_dt,
subject="SVT",
teachers=("Durand",),
rooms=("S2",),
group="3emeC",
status=LessonStatus.NORMAL,
content=None,
)
]
# Override settings to use pronotepy mode explicitly # Override settings to use pronotepy mode explicitly
mock_fetcher._settings.pronote.agenda_source = "pronotepy" mock_fetcher._settings.pronote.agenda_source = "pronotepy"
@@ -619,15 +608,14 @@ def test_fetch_agenda_pronotepy_mode_fallback_to_ical(mock_fetcher: PronoteFetch
client = MagicMock() client = MagicMock()
client.get_lessons.side_effect = OSError("Pronote API error") client.get_lessons.side_effect = OSError("Pronote API error")
mock_fetcher._pronote_client = client mock_fetcher._pronote_client = client
m_fetch_ical.return_value = "BEGIN:VCALENDAR\n..."
m_parse_ical.return_value = (lessons, [], [])
result_lessons, result_events = mock_fetcher.fetch_agenda() with pytest.raises(PipelineCriticalError) as exc_info:
mock_fetcher.fetch_agenda()
assert result_lessons == lessons assert "la source pronotepy a échoué" in str(exc_info.value)
assert result_events == [] # Vérifie que iCal n'a PAS été appelé
m_fetch_ical.assert_called_once() m_fetch_ical.assert_not_called()
m_parse_ical.assert_called_once() m_parse_ical.assert_not_called()
def test_fetch_agenda_no_source_configured_raises(mock_fetcher: PronoteFetcher) -> None: def test_fetch_agenda_no_source_configured_raises(mock_fetcher: PronoteFetcher) -> None:
@@ -637,6 +625,7 @@ def test_fetch_agenda_no_source_configured_raises(mock_fetcher: PronoteFetcher)
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
# Disable both sources # Disable both sources
mock_fetcher._settings.pronote.ical_url = None mock_fetcher._settings.pronote.ical_url = None
@@ -648,6 +637,130 @@ def test_fetch_agenda_no_source_configured_raises(mock_fetcher: PronoteFetcher)
assert "ni la source iCal ni pronotepy n'est configurée" in str(exc_info.value) assert "ni la source iCal ni pronotepy n'est configurée" in str(exc_info.value)
def test_fetch_agenda_ical_mode_empty_list_is_success(mock_fetcher: PronoteFetcher) -> None:
"""Test le mode ICAL : liste vide = succès valide, sans repli.
On mock iCal pour retourner une liste vide. On vérifie que le résultat
est ([], []) et qu'aucun repli n'est tenté.
:param mock_fetcher: Fetcher de test.
:return: None
:rtype: None
"""
mock_fetcher._settings.pronote.agenda_source = "ical"
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical,
):
m_fetch_ical.return_value = "BEGIN:VCALENDAR\n..."
m_parse_ical.return_value = ([], [], [])
client = MagicMock()
mock_fetcher._pronote_client = client
result_lessons, result_events = mock_fetcher.fetch_agenda()
assert result_lessons == []
assert result_events == []
# Vérifie que pronotepy n'a PAS été appelé
client.get_lessons.assert_not_called()
def test_fetch_agenda_pronotepy_mode_empty_list_is_success(
mock_fetcher: PronoteFetcher,
) -> None:
"""Test le mode PRONOTEPY : liste vide = succès valide, sans repli.
On mock pronotepy pour retourner une liste vide. On vérifie que le résultat
est ([], []) et qu'aucun repli n'est tenté.
:param mock_fetcher: Fetcher de test.
:return: None
:rtype: None
"""
mock_fetcher._settings.pronote.agenda_source = "pronotepy"
client = MagicMock()
client.get_lessons.return_value = []
mock_fetcher._pronote_client = client
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical,
):
result_lessons, result_events = mock_fetcher.fetch_agenda()
assert result_lessons == []
assert result_events == []
# Vérifie que iCal n'a PAS été appelé
m_fetch_ical.assert_not_called()
m_parse_ical.assert_not_called()
def test_fetch_homework_ical_mode_empty_list_is_success(
mock_fetcher: PronoteFetcher,
) -> None:
"""Test le mode ICAL des devoirs : liste vide = succès valide, sans repli.
On mock iCal pour retourner une liste vide. On vérifie que le résultat
est [] et qu'aucun repli n'est tenté.
:param mock_fetcher: Fetcher de test.
:return: None
:rtype: None
"""
target_date = date(2025, 9, 10)
mock_fetcher._settings.pronote.homework_source = "ical"
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical,
patch("pronote_sync.sources.pronote.fallback.collect_homeworks") as m_collect,
):
m_fetch_ical.return_value = "BEGIN:VCALENDAR\n..."
m_parse_ical.return_value = ([], [], [])
m_collect.return_value = []
client = MagicMock()
mock_fetcher._pronote_client = client
result = mock_fetcher.fetch_homework(target_date)
assert result == []
# Vérifie que pronotepy n'a PAS été appelé
client.get_homeworks.assert_not_called()
def test_fetch_homework_pronotepy_mode_empty_list_is_success(
mock_fetcher: PronoteFetcher,
) -> None:
"""Test le mode PRONOTEPY des devoirs : liste vide = succès valide, sans repli.
On mock pronotepy pour retourner une liste vide. On vérifie que le résultat
est [] et qu'aucun repli n'est tenté.
:param mock_fetcher: Fetcher de test.
:return: None
:rtype: None
"""
target_date = date(2025, 9, 10)
mock_fetcher._settings.pronote.homework_source = "pronotepy"
client = MagicMock()
client.get_homeworks.return_value = []
mock_fetcher._pronote_client = client
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical,
):
result = mock_fetcher.fetch_homework(target_date)
assert result == []
# Vérifie que iCal n'a PAS été appelé
m_fetch_ical.assert_not_called()
m_parse_ical.assert_not_called()
def test_fetch_homework_filters_by_target_date(mock_fetcher: PronoteFetcher) -> None: def test_fetch_homework_filters_by_target_date(mock_fetcher: PronoteFetcher) -> None:
"""Test que les devoirs sont filtrés par date cible en mode pronotepy. """Test que les devoirs sont filtrés par date cible en mode pronotepy.
@@ -656,6 +769,7 @@ def test_fetch_homework_filters_by_target_date(mock_fetcher: PronoteFetcher) ->
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
target_date = date(2025, 9, 10) target_date = date(2025, 9, 10)
other_date = date(2025, 9, 11) other_date = date(2025, 9, 11)
@@ -693,6 +807,72 @@ def test_fetch_homework_filters_by_target_date(mock_fetcher: PronoteFetcher) ->
client.get_homeworks.assert_called_once() client.get_homeworks.assert_called_once()
def test_fetch_homework_ical_mode_does_not_fallback_to_pronotepy(
mock_fetcher: PronoteFetcher,
) -> None:
"""Test le mode ICAL des devoirs : échec iCal, PAS de repli sur pronotepy.
On mock iCal pour échouer. On vérifie que pronotepy n'est PAS appelé
et qu'une PipelineCriticalError est levée.
:param mock_fetcher: Fetcher de test.
:return: None
:rtype: None
"""
target_date = date(2025, 9, 10)
mock_fetcher._settings.pronote.homework_source = "ical"
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical,
patch("pronote_sync.sources.pronote.fallback.collect_homeworks") as m_collect,
):
m_fetch_ical.side_effect = OSError("iCal unreachable")
m_parse_ical.side_effect = OSError("iCal parse error")
client = MagicMock()
mock_fetcher._pronote_client = client
m_collect.side_effect = OSError("collect error")
with pytest.raises(PipelineCriticalError) as exc_info:
mock_fetcher.fetch_homework(target_date)
assert "la source ical a échoué" in str(exc_info.value)
# Vérifie que pronotepy n'a PAS été appelé
client.get_homeworks.assert_not_called()
def test_fetch_homework_pronotepy_mode_does_not_fallback_to_ical(
mock_fetcher: PronoteFetcher,
) -> None:
"""Test le mode PRONOTEPY des devoirs : échec pronotepy, PAS de repli sur iCal.
On mock pronotepy pour échouer. On vérifie que iCal n'est PAS appelé
et qu'une PipelineCriticalError est levée.
:param mock_fetcher: Fetcher de test.
:return: None
:rtype: None
"""
target_date = date(2025, 9, 10)
mock_fetcher._settings.pronote.homework_source = "pronotepy"
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical,
):
client = MagicMock()
client.get_homeworks.side_effect = OSError("Pronote API error")
mock_fetcher._pronote_client = client
with pytest.raises(PipelineCriticalError) as exc_info:
mock_fetcher.fetch_homework(target_date)
assert "la source pronotepy a échoué" in str(exc_info.value)
# Vérifie que iCal n'a PAS été appelé
m_fetch_ical.assert_not_called()
m_parse_ical.assert_not_called()
def test_fetch_messages_logs_and_re_raises( def test_fetch_messages_logs_and_re_raises(
mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture
) -> None: ) -> None:
@@ -704,6 +884,7 @@ def test_fetch_messages_logs_and_re_raises(
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:param caplog: Fixture pytest pour capturer les logs. :param caplog: Fixture pytest pour capturer les logs.
:return: None :return: None
:rtype: None
""" """
client = MagicMock() client = MagicMock()
error_msg = "Erreur Pronote : impossible de récupérer les messages https://pronote.example.com/messages?token=SECRET_TOKEN_456" error_msg = "Erreur Pronote : impossible de récupérer les messages https://pronote.example.com/messages?token=SECRET_TOKEN_456"
@@ -729,6 +910,7 @@ def test_fetch_agenda_ical_url_none_raises_value_error(mock_fetcher: PronoteFetc
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
mock_fetcher._settings.pronote.ical_url = None mock_fetcher._settings.pronote.ical_url = None
with pytest.raises(ValueError, match="PRONOTE_ICAL_URL est requis pour la source iCal"): with pytest.raises(ValueError, match="PRONOTE_ICAL_URL est requis pour la source iCal"):
@@ -743,6 +925,7 @@ def test_agenda_sources_auto_only_pronotepy_configured(mock_fetcher: PronoteFetc
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
mock_fetcher._settings.pronote.agenda_source = "auto" mock_fetcher._settings.pronote.agenda_source = "auto"
mock_fetcher._settings.pronote.ical_url = None mock_fetcher._settings.pronote.ical_url = None
@@ -754,13 +937,14 @@ def test_agenda_sources_auto_only_pronotepy_configured(mock_fetcher: PronoteFetc
def test_fetch_agenda_fallback_both_fail_raises_pipeline_critical_error( def test_fetch_agenda_fallback_both_fail_raises_pipeline_critical_error(
mock_fetcher: PronoteFetcher, mock_fetcher: PronoteFetcher,
) -> None: ) -> None:
"""Test que fetch_agenda lève PipelineCriticalError si ICAL échoue et pronotepy aussi. """Test que fetch_agenda lève PipelineCriticalError en mode explicite ical.
On mock ICAL pour échouer, on garde pronotepy configuré mais on mock son échec. On mock ICAL pour échouer. On vérifie que l'erreur critique est levée
On vérifie que l'erreur PipelineCriticalError est levée. et que pronotepy n'est PAS appelé (pas de repli en mode explicite).
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
mock_fetcher._settings.pronote.agenda_source = "ical" mock_fetcher._settings.pronote.agenda_source = "ical"
@@ -777,21 +961,26 @@ def test_fetch_agenda_fallback_both_fail_raises_pipeline_critical_error(
with pytest.raises(PipelineCriticalError) as exc_info: with pytest.raises(PipelineCriticalError) as exc_info:
mock_fetcher.fetch_agenda() mock_fetcher.fetch_agenda()
assert "les sources ical et pronotepy ont échoué" in str(exc_info.value) # En mode explicite, seul l'échec de la source primaire est rapporté
assert "la source ical a échoué" in str(exc_info.value)
# Vérifie que pronotepy n'a PAS été appelé (pas de repli en mode explicite)
client.get_lessons.assert_not_called()
def test_fetch_agenda_fallback_returns_empty_logs_warning( def test_fetch_agenda_auto_fallback_returns_empty_logs_warning(
mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture
) -> None: ) -> None:
"""Test que fetch_agenda retourne ([], []) et journalise un avertissement si le repli retourne vide. """Test que fetch_agenda retourne ([], []) et journalise un avertissement si le repli retourne vide.
On mock ICAL pour échouer, pronotepy configuré et retourne vide. On vérifie le retour et le log. On mock ICAL pour échouer, pronotepy configuré et retourne vide. On vérifie le retour et le log.
Ce test utilise le mode AUTO pour tester le comportement de repli.
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:param caplog: Fixture pytest pour capturer les logs. :param caplog: Fixture pytest pour capturer les logs.
:return: None :return: None
:rtype: None
""" """
mock_fetcher._settings.pronote.agenda_source = "ical" mock_fetcher._settings.pronote.agenda_source = "auto"
with ( with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical, patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
@@ -813,21 +1002,22 @@ def test_fetch_agenda_fallback_returns_empty_logs_warning(
assert "a retourné un résultat vide après l'échec de ical" in caplog.text assert "a retourné un résultat vide après l'échec de ical" in caplog.text
def test_homework_sources_explicit_ical_mode(mock_fetcher: PronoteFetcher) -> None: def test_homework_sources_explicit_ical_mode_strict(mock_fetcher: PronoteFetcher) -> None:
"""Test _homework_sources en mode ICAL. """Test _homework_sources en mode ICAL : strict, sans repli.
On vérifie que la source primaire est ical et que le repli est pronotepy si configuré, On vérifie que la source primaire est ical et que le repli est TOUJOURS None,
ou None sinon. même si pronotepy est configuré.
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
mock_fetcher._settings.pronote.homework_source = "ical" mock_fetcher._settings.pronote.homework_source = "ical"
# With pronotepy configured # With pronotepy configured
primary, fallback = mock_fetcher._homework_sources() primary, fallback = mock_fetcher._homework_sources()
assert primary == "ical" assert primary == "ical"
assert fallback == "pronotepy" assert fallback is None
# Without pronotepy configured # Without pronotepy configured
mock_fetcher._settings.pronote.pronote_url = None mock_fetcher._settings.pronote.pronote_url = None
@@ -836,21 +1026,24 @@ def test_homework_sources_explicit_ical_mode(mock_fetcher: PronoteFetcher) -> No
assert fallback is None assert fallback is None
def test_homework_sources_explicit_pronotepy_mode(mock_fetcher: PronoteFetcher) -> None: def test_homework_sources_explicit_pronotepy_mode_strict(
"""Test _homework_sources en mode PRONOTEPY. mock_fetcher: PronoteFetcher,
) -> None:
"""Test _homework_sources en mode PRONOTEPY : strict, sans repli.
On vérifie que la source primaire est pronotepy et que le repli est ical si configuré, On vérifie que la source primaire est pronotepy et que le repli est TOUJOURS None,
ou None sinon. même si iCal est configuré.
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
mock_fetcher._settings.pronote.homework_source = "pronotepy" mock_fetcher._settings.pronote.homework_source = "pronotepy"
# With ical configured # With ical configured
primary, fallback = mock_fetcher._homework_sources() primary, fallback = mock_fetcher._homework_sources()
assert primary == "pronotepy" assert primary == "pronotepy"
assert fallback == "ical" assert fallback is None
# Without ical configured # Without ical configured
mock_fetcher._settings.pronote.ical_url = None mock_fetcher._settings.pronote.ical_url = None
@@ -867,6 +1060,7 @@ def test_homework_sources_auto_only_pronotepy_configured(mock_fetcher: PronoteFe
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
mock_fetcher._settings.pronote.homework_source = "auto" mock_fetcher._settings.pronote.homework_source = "auto"
mock_fetcher._settings.pronote.ical_url = None mock_fetcher._settings.pronote.ical_url = None
@@ -882,6 +1076,7 @@ def test_homework_sources_auto_no_source_configured_raises(mock_fetcher: Pronote
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
mock_fetcher._settings.pronote.homework_source = "auto" mock_fetcher._settings.pronote.homework_source = "auto"
mock_fetcher._settings.pronote.ical_url = None mock_fetcher._settings.pronote.ical_url = None
@@ -896,13 +1091,14 @@ def test_homework_sources_auto_no_source_configured_raises(mock_fetcher: Pronote
def test_fetch_homework_fallback_both_fail_raises_pipeline_critical_error( def test_fetch_homework_fallback_both_fail_raises_pipeline_critical_error(
mock_fetcher: PronoteFetcher, mock_fetcher: PronoteFetcher,
) -> None: ) -> None:
"""Test que fetch_homework lève PipelineCriticalError si ICAL échoue et pronotepy aussi. """Test que fetch_homework lève PipelineCriticalError en mode explicite ical.
On mock ICAL pour échouer, on garde pronotepy configuré mais on mock son échec. On mock ICAL pour échouer. On vérifie que l'erreur critique est levée
On vérifie que l'erreur PipelineCriticalError est levée. et que pronotepy n'est PAS appelé (pas de repli en mode explicite).
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
target_date = date(2025, 9, 10) target_date = date(2025, 9, 10)
mock_fetcher._settings.pronote.homework_source = "ical" mock_fetcher._settings.pronote.homework_source = "ical"
@@ -922,22 +1118,27 @@ def test_fetch_homework_fallback_both_fail_raises_pipeline_critical_error(
with pytest.raises(PipelineCriticalError) as exc_info: with pytest.raises(PipelineCriticalError) as exc_info:
mock_fetcher.fetch_homework(target_date) mock_fetcher.fetch_homework(target_date)
assert "les sources ical et pronotepy ont échoué" in str(exc_info.value) # En mode explicite, seul l'échec de la source primaire est rapporté
assert "la source ical a échoué" in str(exc_info.value)
# Vérifie que pronotepy n'a PAS été appelé (pas de repli en mode explicite)
client.get_homeworks.assert_not_called()
def test_fetch_homework_fallback_returns_empty_logs_warning( def test_fetch_homework_auto_fallback_returns_empty_logs_warning(
mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture
) -> None: ) -> None:
"""Test que fetch_homework retourne [] et journalise un avertissement si le repli retourne vide. """Test que fetch_homework retourne [] et journalise un avertissement si le repli retourne vide.
On mock ICAL pour échouer, pronotepy configuré et retourne vide. On vérifie le retour et le log. On mock ICAL pour échouer, pronotepy configuré et retourne vide. On vérifie le retour et le log.
Ce test utilise le mode AUTO pour tester le comportement de repli.
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:param caplog: Fixture pytest pour capturer les logs. :param caplog: Fixture pytest pour capturer les logs.
:return: None :return: None
:rtype: None
""" """
target_date = date(2025, 9, 10) target_date = date(2025, 9, 10)
mock_fetcher._settings.pronote.homework_source = "ical" mock_fetcher._settings.pronote.homework_source = "auto"
with ( with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical, patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
@@ -967,6 +1168,7 @@ def test_fetch_informations_logs_and_re_raises_secret(
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:param caplog: Fixture pytest pour capturer les logs. :param caplog: Fixture pytest pour capturer les logs.
:return: None :return: None
:rtype: None
""" """
client = MagicMock() client = MagicMock()
error_msg = "Erreur Pronote : impossible de récupérer les informations https://pronote.example.com/infos?token=SECRET_TOKEN_789" error_msg = "Erreur Pronote : impossible de récupérer les informations https://pronote.example.com/infos?token=SECRET_TOKEN_789"