Compare commits

..
Author SHA1 Message Date
Codex ee89f13d67 refactor(config): migrer les endpoints Pronote
Co-authored-by: Codex <codex@antoineve.me>
2026-09-13 11:54:19 +02:00
Codex 097a27fcf3 refactor(config): migrer le flux RSS vers l'endpoint commun
Co-authored-by: Codex <codex@antoineve.me>
2026-09-13 11:52:37 +02:00
Codex 93f66d6aca refactor(config): introduire l'endpoint CalDAV commun
Co-authored-by: Codex <codex@antoineve.me>
2026-09-13 11:50:20 +02:00
Codex eef81ea323 fix(docs): corriger le répertoire de clonage
Closes #56

Co-authored-by: Codex <codex@antoineve.me>
2026-09-13 00:25:28 +02:00
Codex a21acaa409 docs(packaging): aligner les métadonnées du projet
Closes #18

Co-authored-by: Codex <codex@antoineve.me>
2026-09-13 00:24:10 +02:00
13 changed files with 475 additions and 133 deletions
+8 -4
View File
@@ -1,6 +1,7 @@
# --- Pronote ---
PRONOTE_ICAL_URL=https://college.ent/pronote/ical/Edt_Jean.ics?icalsecurise=REPLACE_ME&version=2024
PRONOTE_URL=https://college.ent/pronote/parent.html
PRONOTE_ICAL_ENDPOINT__URL=https://college.ent/pronote/ical/Edt_Jean.ics?icalsecurise=REPLACE_ME&version=2024
PRONOTE_ENDPOINT__URL=https://college.ent/pronote/parent.html
# Anciens noms temporairement supportés avec un avertissement : PRONOTE_ICAL_URL, PRONOTE_URL
PRONOTE_ACCOUNT_TYPE=parent
PRONOTE_USERNAME=parent.dupont
PRONOTE_PASSWORD=your_secure_password
@@ -28,7 +29,9 @@ PRONOTE_AUTH_MODE=password
# PRONOTE_ACCOUNT_PIN=
# --- CalDAV ---
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
# Endpoint commun (URL potentiellement sensible, masquée dans les journaux)
CALDAV_ENDPOINT__URL=https://caldav.example.com/calendars/user/pronote/
# Ancien nom temporairement supporté avec un avertissement de dépréciation : CALDAV_URL
CALDAV_USERNAME=user@example.com
CALDAV_PASSWORD=your_caldav_password
CALDAV_CALENDAR_PATH=/pronote-sync/
@@ -92,7 +95,8 @@ AI_BASE_URL=https://api.openai.com/v1
# --- Blog ---
BLOG_ENABLED=false
BLOG_RSS_URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2
BLOG_ENDPOINT__URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2
# Ancien nom temporairement supporté avec un avertissement de dépréciation : BLOG_RSS_URL
# --- Divers ---
DRY_RUN=false
+2 -2
View File
@@ -140,7 +140,7 @@
"filename": "GUIDE_DEV_PYTHON.md",
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
"is_verified": false,
"line_number": 5186
"line_number": 5188
}
],
"tests/unit/test_caldav_gateway.py": [
@@ -185,5 +185,5 @@
}
]
},
"generated_at": "2026-09-12T22:12:56Z"
"generated_at": "2026-09-13T09:54:04Z"
}
+34 -30
View File
@@ -259,12 +259,12 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati
| Variable | Description | Exemple (anonymisé) | Type |
|------------------------------|-----------------------------------------------------------------------------|---------------------------------------------|---------------|
| `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_ENDPOINT__URL` | URL de la page Pronote via l'endpoint commun. | `https://college.ent/pronote/parent.html` | `ExternalEndpoint` |
| `PRONOTE_ICAL_ENDPOINT__URL` | URL du flux iCal Pronote via l'endpoint commun (contient `icalsecurise`). | `https://college.ent/pronote/ical/...` | `ExternalEndpoint` |
| `PRONOTE_USERNAME` | Identifiant Pronote (si `pronotepy` utilisé). | `parent.dupont` | `str` |
| `PRONOTE_PASSWORD` | Mot de passe Pronote (si `pronotepy` utilisé). | `SecretStr` (masqué) | `SecretStr` |
| `PRONOTE_ENT` | Slug ENT supporté, résolu vers une fonction de `pronotepy.ent`. | `monbureaunumerique` | `str` |
| `CALDAV_URL` | URL du serveur CalDAV (masquée en `SecretStr`). | `https://caldav.example.com/calendars/...` | `SecretStr` |
| `CALDAV_ENDPOINT__URL` | URL du serveur CalDAV, via l'endpoint commun (masquée en `SecretStr`). | `https://caldav.example.com/calendars/...` | `ExternalEndpoint` |
| `CALDAV_USERNAME` | Identifiant CalDAV. | `user@example.com` | `str` |
| `CALDAV_PASSWORD` | Mot de passe CalDAV. | `SecretStr` (masqué) | `SecretStr` |
| `CALDAV_CALENDAR_PATH` | Chemin du calendrier CalDAV de destination. | `/pronote-sync/` | `str` |
@@ -277,17 +277,22 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati
> `XMPP_RECIPIENT` a été renommé en `XMPP_TO` dans l'implémentation (aligné avec §10.2.1).
> Des variables XMPP supplémentaires ont été ajoutées : `XMPP_ENABLED`, `XMPP_HOST`, `XMPP_PORT`, `XMPP_RESOURCE`, `XMPP_TLS_MODE`, `XMPP_TIMEOUT`, `XMPP_CONNECT_TIMEOUT`, `XMPP_CLEANUP_TIMEOUT`.
> `XMPP_USE_TLS` reste supporté mais est **obsolète** (remplacé par `XMPP_TLS_MODE`).
> Une section `BLOG_ENABLED` et `BLOG_RSS_URL` a été ajoutée dans `.env.example`.
> Une section `BLOG_ENABLED` et `BLOG_ENDPOINT__URL` a été ajoutée dans `.env.example`.
> `BLOG_RSS_URL` reste temporairement accepté avec un avertissement de dépréciation.
> `CALDAV_URL` reste temporairement accepté avec un avertissement de dépréciation ; utiliser
> `CALDAV_ENDPOINT__URL` pour la nouvelle configuration.
> `PRONOTE_URL` et `PRONOTE_ICAL_URL` restent temporairement acceptés avec un avertissement de
> dépréciation ; utiliser respectivement `PRONOTE_ENDPOINT__URL` et `PRONOTE_ICAL_ENDPOINT__URL`.
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
- la source iCal exige `PRONOTE_ICAL_ENDPOINT__URL` ;
- la source `pronotepy` exige `PRONOTE_ENDPOINT__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
`PRONOTE_ENDPOINT__URL` et `PRONOTE_ICAL_ENDPOINT__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é.
@@ -323,8 +328,8 @@ d'un besoin réel et testé.
```ini
# --- 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_ENDPOINT__URL=https://college.ent/pronote/parent.html
PRONOTE_ICAL_ENDPOINT__URL=https://college.ent/pronote/ical/Edt_Jean.ics?icalsecurise=REPLACE_ME&version=2024
PRONOTE_USERNAME=parent.dupont
PRONOTE_PASSWORD=your_secure_password
PRONOTE_ENT=monbureaunumerique
@@ -335,7 +340,7 @@ PRONOTE_HOMEWORK_SOURCE=auto
PRONOTE_MESSAGES_SOURCE=pronotepy
# --- CalDAV ---
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
CALDAV_ENDPOINT__URL=https://caldav.example.com/calendars/user/pronote/
CALDAV_ALLOW_INSECURE_HTTP=false
CALDAV_USERNAME=user@example.com
CALDAV_PASSWORD=your_caldav_password
@@ -389,7 +394,7 @@ LOG_LEVEL=INFO
> L'implémentation utilise le style moderne de Pydantic v2 : `model_config = ConfigDict(frozen=True)` au lieu de `class Config`, pas de `json_encoders` (la sérialisation ISO est native en v2), `str | None` au lieu de `Optional[str]`, `list[str]` au lieu de `List[str]`.
> `AISettings.enabled` a pour valeur par défaut `False`.
> `XmppSettings` est entièrement défini en §10.2.3 avec tous les champs optionnels (valeurs par défaut) pour que `Settings()` fonctionne sans `.env`.
> `BlogSettings` a été ajouté (§5 bis.9.2) avec `enabled=False` et `rss_url` par défaut.
> `BlogSettings` a été ajouté (§5 bis.9.2) avec `enabled=False` et un endpoint RSS par défaut.
> `sync_past_days` et `sync_future_days` sont dans `AppSettings`, et non `CalDAVSettings`.
> `CalDAVSettings.calendar_path` a pour valeur par défaut `"/pronote-sync/"`.
> `XmppSettings.resource` a pour valeur par défaut `"pronote-sync"`.
@@ -403,9 +408,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class PronoteSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="PRONOTE_", env_file=".env", extra="ignore")
url: str | None = None
ical_url: SecretStr | None = None
model_config = SettingsConfigDict(env_prefix="PRONOTE_", env_file=".env", env_nested_delimiter="__", extra="ignore")
endpoint: ExternalEndpoint | None = None
ical_endpoint: ExternalEndpoint | None = None
username: str | None = None
password: SecretStr | None = None
ent: str | None = None
@@ -1113,7 +1118,7 @@ La déduplication des articles du blog repose sur leur **GUID** (ou leur URL si
Aucun fichier d'état local n'est utilisé : l'état est géré en mémoire par run.
# Initialisation
rss_client = BlogRSSClient(rss_url=settings.blog.rss_url)
rss_client = BlogRSSClient(rss_url=settings.blog.endpoint.url.get_secret_value())
blog_state = ## (section obsolète supprimée)()
# Récupération des nouveaux articles
@@ -1218,7 +1223,7 @@ Ajouter les variables suivantes dans la configuration :
| **Variable** | **Description** | **Valeur par défaut** | **Type** |
|----------------------------|-------------------------------------------------------------------------------|-----------------------|-------------------|
| `BLOG_ENABLED` | Activer la récupération du blog. | `False` | `bool` |
| `BLOG_RSS_URL` | URL du flux RSS du blog. | `https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2` | `str` |
| `BLOG_ENDPOINT__URL` | URL du flux RSS via l'endpoint commun (masquée en `SecretStr`). | `https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2` | `ExternalEndpoint` |
#### 5 bis.9.2 Modèle Pydantic pour la configuration du blog
@@ -1230,10 +1235,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class BlogSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="BLOG_", env_file=".env", extra="ignore")
enabled: bool = Field(False, description="Activer la récupération du blog")
rss_url: str = Field(
"https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2",
description="URL du flux RSS du blog",
)
endpoint: ExternalEndpoint
```
**Intégration dans `Settings`** :
@@ -1253,7 +1255,7 @@ class Settings(BaseSettings):
```ini
# --- Blog du collège ---
BLOG_ENABLED=true
BLOG_RSS_URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2
BLOG_ENDPOINT__URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2
```
---
@@ -2705,7 +2707,7 @@ Utilisation de la bibliothèque [`caldav`](https://pypi.org/project/caldav/) (Py
#### API réelle (`caldav>=1.3.0`)
- **Connexion** : `caldav.DAVClient(url, username, password)` — les paramètres proviennent
de `CalDAVSettings` (`CALDAV_URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`).
de `CalDAVSettings` (`CALDAV_ENDPOINT__URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`).
- **Résolution du calendrier** : `DAVClient.principal()` puis `principal.calendars()` ;
sélectionner le calendrier dont l'URL correspond à **`CalDAVSettings.calendar_path`**
(ex: `/pronote-sync/`). La résolution ne se fait **pas** par nom de calendrier :
@@ -4610,7 +4612,7 @@ def get_channel(settings: XmppSettings, dry_run: bool = False) -> Channel | None
- **XMPP** : Si l'envoi échoue → **logger l'erreur** mais continuer le pipeline.
- **Erreurs critiques** :
- **Aucune source disponible** (iCal + pronotepy échouent) → **échec explicite** avec message clair.
- **Configuration invalide** (ex: `PRONOTE_ICAL_URL` manquant) → **échec explicite**.
- **Configuration invalide** (ex: `PRONOTE_ICAL_ENDPOINT__URL` manquant) → **échec explicite**.
### 11.2 Hiérarchie des erreurs
@@ -5341,12 +5343,14 @@ def mock_xmpp_channel():
def sample_settings():
"""Retourne une configuration de test."""
from pydantic import SecretStr
from pronote_sync.config.settings import Settings, PronoteSettings, CalDAVSettings, XmppSettings, AISettings, AppSettings
from pronote_sync.config.settings import (
AISettings, AppSettings, CalDAVSettings, ExternalEndpoint, PronoteSettings, Settings, XmppSettings,
)
return Settings(
pronote=PronoteSettings(
url="https://test.ent/pronote/parent.html",
ical_url=SecretStr("https://test.ent/pronote/ical/test.ics"),
endpoint=ExternalEndpoint(url=SecretStr("https://test.ent/pronote/parent.html")),
ical_endpoint=ExternalEndpoint(url=SecretStr("https://test.ent/pronote/ical/test.ics")),
username="test_user",
password=SecretStr("test_password"),
ent="monbureaunumerique",
@@ -5434,7 +5438,7 @@ def test_pipeline_full(mock_requests_get, mock_caldav_client, mock_ai_provider,
# Configurer le client CalDAV
caldav_client = CalDAVClient(
url=sample_settings.caldav.url,
url=sample_settings.caldav.endpoint.url if sample_settings.caldav.endpoint else None,
username=sample_settings.caldav.username,
password=sample_settings.caldav.password,
allow_insecure_http=sample_settings.caldav.allow_insecure_http,
@@ -5629,7 +5633,7 @@ TOTAL 1000 10 99%
| **Risque** | **Mesure de mitigation** | **Vérification** | **Statut** |
|-------------------------------------|----------------------------------------------------------------------------------------|-------------------------------------------|------------|
| Tokens dans le code | Utiliser `pydantic-settings` + `SecretStr` pour les variables d'environnement. | `grep -r "icalsecurise\|password\|api_key" src/` | ❌ Interdit |
| Tokens dans les logs | Masquage systématique via `RedactingFormatter` (voir [Section 4.2](#42-implémentation)). | Tests avec `PRONOTE_ICAL_URL` contenant un token. | ✅ Obligatoire |
| Tokens dans les logs | Masquage systématique via `RedactingFormatter` (voir [Section 4.2](#42-implémentation)). | Tests avec `PRONOTE_ICAL_ENDPOINT__URL` contenant un token. | ✅ Obligatoire |
| Tokens dans les erreurs | Masquage dans les messages d'erreur (voir `redact_url` et `redact_secrets`). | Tests avec URLs contenant des tokens. | ✅ Obligatoire |
| Tokens dans les fixtures | **Anonymiser** toutes les fixtures (pas de tokens réels). | Vérification manuelle des fixtures. | ✅ Obligatoire |
| Tokens dans les commits Git | Utiliser `.gitignore` pour `.env` et `pre-commit` pour bloquer les secrets. | `git grep "icalsecurise\|password" -- .` (contenu suivi courant) | ❌ Interdit |
@@ -5948,10 +5952,10 @@ Exemple de ligne cron (exécution tous les jours à 18h) :
|---------------------------------------|------------------------------------------------------------------------------------|------------------------------------------------------------------------------|
| Échec de la récupération iCal | Token `icalsecurise` expiré ou invalide. | Régénérer le token depuis Pronote. |
| Échec de la connexion Pronote (`pronotepy`) | Identifiants incorrects ou ENT non supporté. | Vérifier `PRONOTE_USERNAME`, `PRONOTE_PASSWORD`, `PRONOTE_ENT`. |
| Échec de la connexion CalDAV | URL, identifiant ou mot de passe CalDAV incorrect, ou HTTP non autorisé pour l'hôte. | Vérifier `CALDAV_URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`, `CALDAV_ALLOW_INSECURE_HTTP`. |
| Échec de la connexion CalDAV | URL, identifiant ou mot de passe CalDAV incorrect, ou HTTP non autorisé pour l'hôte. | Vérifier `CALDAV_ENDPOINT__URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`, `CALDAV_ALLOW_INSECURE_HTTP`. |
| Échec de la connexion XMPP | Identifiant ou mot de passe XMPP incorrect. | Vérifier `XMPP_JID`, `XMPP_PASSWORD`. |
| Échec de la synthèse IA | Clé API IA invalide ou modèle non disponible. | Vérifier `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`. |
| Aucun cours récupéré | Flux iCal vide ou `pronotepy` non configuré. | Vérifier `PRONOTE_ICAL_URL` ou les identifiants `pronotepy`. |
| Aucun cours récupéré | Flux iCal vide ou `pronotepy` non configuré. | Vérifier `PRONOTE_ICAL_ENDPOINT__URL` ou les identifiants `pronotepy`. |
| Doublons dans les devoirs | Problème de déduplication. | Vérifier la logique de déduplication (voir [Section 5.1.4](#514-déduplication-des-devoirs)). |
| Synchronisation CalDAV lente | Trop d'événements à synchroniser. | Réduire `SYNC_PAST_DAYS` ou `SYNC_FUTURE_DAYS`. |
+1 -1
View File
@@ -13,7 +13,7 @@ Synchronise l'agenda et les devoirs de **Pronote** vers un calendrier **CalDAV**
```bash
# Cloner le dépôt
git clone https://git.antoineve.me/AntoineVe/college-infos
cd pronote-sync
cd college-infos
# Créer l'environnement virtuel
python3.13 -m venv .venv
+4 -4
View File
@@ -94,7 +94,7 @@ https://{etablissement}.index-education.net/pronote/ical/Edt_{prenom}.ics?icalse
🔹 **Source** : ⚠️ Recommandation du projet (inspirée des bonnes pratiques générales de sécurité).
### Intégration dans `pronote-sync`
- **Paramètre** : `PRONOTE_ICAL_URL` (ex. `.env.example` ligne 2).
- **Paramètre** : `PRONOTE_ICAL_ENDPOINT__URL` (ex. `.env.example` ligne 2).
- **Comportement** :
- Prioritaire en mode `PRONOTE_AGENDA_SOURCE=auto`.
- Si l'URL est invalide ou expire, repli automatique vers `pronotepy` (si `PRONOTE_AGENDA_SOURCE=auto`).
@@ -126,7 +126,7 @@ Connexion via le protocole propriétaire de Pronote (JSON sur HTTPS), avec **chi
### Intégration dans `pronote-sync`
- **Paramètres** :
- `PRONOTE_URL` (ex. `.env.example` ligne 3).
- `PRONOTE_ENDPOINT__URL` (ex. `.env.example` ligne 3).
- `PRONOTE_USERNAME`, `PRONOTE_PASSWORD`.
- `PRONOTE_ENT` (slug dans `_ENT_NAMES`).
- `PRONOTE_ACCOUNT_TYPE` (ex. `parent`).
@@ -288,8 +288,8 @@ Mécanisme d'appairage par QR code pour les appareils mobiles, **contournant l'a
### Alignement avec `.env.example`
| Paramètre | Document | Code | Statut |
|-----------|----------|------|--------|
| `PRONOTE_ICAL_URL` | ✅ Lignes 2, 4250 | ✅ `sources/ical.py` | **Cohérent** |
| `PRONOTE_URL` | ✅ Ligne 3 | ✅ `client.py` (ligne 294) | **Cohérent** |
| `PRONOTE_ICAL_ENDPOINT__URL` | ✅ Lignes 2, 4250 | ✅ `sources/ical.py` | **Cohérent** |
| `PRONOTE_ENDPOINT__URL` | ✅ Ligne 3 | ✅ `client.py` (ligne 294) | **Cohérent** |
| `PRONOTE_ENT` | ✅ Ligne 7 | ✅ `client.py` (ligne 297, `_ENT_NAMES`) | **Cohérent** |
| `PRONOTE_AUTH_MODE=qr_token` | ✅ Ligne 23 | ✅ `client.py` (ligne 334) | **Cohérent** |
| `PRONOTE_QR_CODE_FILE` | ✅ Ligne 24 | ✅ `client.py` (ligne 387) | **Cohérent** |
+231 -54
View File
@@ -10,21 +10,76 @@ from __future__ import annotations
import warnings
from datetime import date
from typing import Literal
from typing import Annotated, Literal
from urllib.parse import urlparse
from pydantic import (
AfterValidator,
BaseModel,
ConfigDict,
Field,
SecretStr,
ValidationInfo,
field_serializer,
field_validator,
model_validator,
)
from pydantic_settings import BaseSettings, SettingsConfigDict
from pronote_sync.utils.redaction import redact_url
_EXTERNAL_ENDPOINT_SCHEMES: frozenset[str] = frozenset({"file", "http", "https"})
_LOOPBACK_HOSTS: frozenset[str] = frozenset({"localhost", "127.0.0.1", "::1"})
def _validate_external_endpoint_url(value: SecretStr) -> SecretStr:
"""Valide la structure et le schéma d'une URL d'endpoint externe.
:param value: URL potentiellement sensible à valider.
:return: URL validée, toujours encapsulée dans ``SecretStr``.
:rtype: SecretStr
:raises ValueError: Si l'URL est malformée ou utilise un schéma inconnu.
"""
is_valid = False
try:
parsed = urlparse(value.get_secret_value())
_ = parsed.port
is_valid = (
parsed.scheme in _EXTERNAL_ENDPOINT_SCHEMES
and (parsed.scheme not in {"http", "https"} or parsed.hostname is not None)
and (parsed.scheme != "file" or bool(parsed.path))
)
except ValueError:
pass
if not is_valid:
raise ValueError("Endpoint externe invalide : URL ou schéma non supporté") from None
return value
EndpointUrl = Annotated[SecretStr, AfterValidator(_validate_external_endpoint_url)]
class ExternalEndpoint(BaseModel):
"""Représente un endpoint externe potentiellement sensible.
Le socle accepte les transports ``https``, ``http`` et ``file``. Chaque
connecteur restreint ensuite cette liste selon sa propre politique de
sécurité. L'URL reste encapsulée dans :class:`pydantic.SecretStr` et sa
sérialisation conserve uniquement une représentation expurgée.
"""
model_config = ConfigDict(extra="forbid", frozen=True, hide_input_in_errors=True)
url: EndpointUrl
@field_serializer("url")
def _serialize_url(self, value: SecretStr) -> str:
"""Expurge l'URL lors de la sérialisation.
:param value: URL encapsulée à sérialiser.
:return: URL expurgée.
:rtype: str
"""
return redact_url(value.get_secret_value())
class PronoteSettings(BaseSettings):
"""Paramètres d'accès à Pronote (flux iCal et API ``pronotepy``).
@@ -33,13 +88,28 @@ class PronoteSettings(BaseSettings):
``PRONOTE_``.
"""
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="PRONOTE_")
model_config = SettingsConfigDict(
env_file=".env",
env_nested_delimiter="__",
extra="ignore",
env_prefix="PRONOTE_",
)
ical_url: SecretStr | None = None
endpoint: ExternalEndpoint | None = None
ical_endpoint: ExternalEndpoint | None = None
ical_url: SecretStr | None = Field(
default=None,
exclude=True,
deprecated="Utiliser ical_endpoint.url à la place (PRONOTE_ICAL_URL obsolète).",
)
username: str | None = None
password: SecretStr | None = None
ent: str | None = None
url: str | None = None
url: str | None = Field(
default=None,
exclude=True,
deprecated="Utiliser endpoint.url à la place (PRONOTE_URL obsolète).",
)
account_type: Literal["student", "parent"] = "parent"
agenda_source: Literal["auto", "ical", "pronotepy"] = "auto"
homework_source: Literal["auto", "ical", "pronotepy"] = "auto"
@@ -49,17 +119,57 @@ class PronoteSettings(BaseSettings):
qr_pin: SecretStr | None = None
account_pin: SecretStr | None = None
@field_serializer("ical_url")
def _serialize_ical_url(self, value: SecretStr | None) -> str | None:
"""Masque l'URL iCal lors de la sérialisation (repr, str, JSON).
@model_validator(mode="before")
@classmethod
def _migrate_legacy_endpoints(cls, data: object) -> object:
"""Migre les URL Pronote historiques vers les endpoints communs.
:param value: Valeur du champ ``ical_url``.
:return: ``"**********"`` si la valeur est définie, ``None`` sinon.
:rtype: str | None
:param data: Données brutes du modèle.
:return: Données complétées avec les endpoints si nécessaire.
:rtype: object
"""
if value is None:
return None
return "**********"
if not isinstance(data, dict):
return data
migrated_data = data.copy()
if migrated_data.get("url") is not None:
warnings.warn(
"PRONOTE_URL est obsolète : utiliser PRONOTE_ENDPOINT__URL.",
DeprecationWarning,
stacklevel=2,
)
if migrated_data.get("endpoint") is None:
migrated_data["endpoint"] = {"url": migrated_data["url"]}
if migrated_data.get("ical_url") is not None:
warnings.warn(
"PRONOTE_ICAL_URL est obsolète : utiliser PRONOTE_ICAL_ENDPOINT__URL.",
DeprecationWarning,
stacklevel=2,
)
if migrated_data.get("ical_endpoint") is None:
migrated_data["ical_endpoint"] = {"url": migrated_data["ical_url"]}
return migrated_data
@model_validator(mode="after")
def _validate_endpoint_policies(self) -> PronoteSettings:
"""Applique les transports autorisés aux deux endpoints Pronote.
L'API Pronote utilise HTTPS. Le flux iCal accepte également ``file``
afin de préserver les fixtures locales injectées.
:return: Instance validée inchangée.
:rtype: PronoteSettings
:raises ValueError: Si un endpoint utilise un schéma interdit.
"""
if (
self.endpoint is not None
and urlparse(self.endpoint.url.get_secret_value()).scheme != "https"
):
raise ValueError("URL Pronote invalide : HTTPS requis") from None
if self.ical_endpoint is not None and urlparse(
self.ical_endpoint.url.get_secret_value()
).scheme not in {"https", "file"}:
raise ValueError("URL iCal Pronote invalide : HTTPS ou file requis") from None
return self
@field_serializer("qr_pin")
def _serialize_qr_pin(self, value: SecretStr | None) -> str | None:
@@ -91,73 +201,88 @@ class CalDAVSettings(BaseSettings):
Les variables d'environnement correspondantes sont préfixées par
``CALDAV_``. L'URL est traitée comme potentiellement sensible (au même
titre que ``PRONOTE_ICAL_URL``) : elle est de type ``SecretStr`` et
titre que ``PRONOTE_ICAL_ENDPOINT__URL``) : elle est de type ``SecretStr`` et
masquée lors de la sérialisation. Par défaut, seul HTTPS est accepté ;
HTTP n'est toléré que pour un hôte de boucle locale (``localhost``,
``127.0.0.1``, ``::1``) lorsque ``allow_insecure_http`` vaut ``True``.
Le nouvel endpoint se configure avec ``CALDAV_ENDPOINT__URL`` ;
``CALDAV_URL`` reste temporairement pris en charge avec un avertissement
de dépréciation.
"""
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="CALDAV_")
model_config = SettingsConfigDict(
env_file=".env",
env_nested_delimiter="__",
extra="ignore",
env_prefix="CALDAV_",
)
allow_insecure_http: bool = False
url: SecretStr | None = None
endpoint: ExternalEndpoint | None = None
url: SecretStr | None = Field(
default=None,
exclude=True,
deprecated="Utiliser endpoint.url à la place (CALDAV_URL obsolète).",
)
username: str | None = None
password: SecretStr | None = None
calendar_path: str = "/pronote-sync/"
@field_serializer("url")
def _serialize_url(self, value: SecretStr | None) -> str | None:
"""Masque l'URL CalDAV lors de la sérialisation (repr, str, JSON).
:param value: Valeur du champ ``url`` (secret potentiel).
:return: URL avec les éléments sensibles remplacés par ``REDACTED``,
ou ``None`` si la valeur est absente.
:rtype: str | None
"""
if value is None:
return None
return redact_url(value.get_secret_value())
@field_validator("url")
@model_validator(mode="before")
@classmethod
def _validate_url_https(cls, v: SecretStr | None, info: ValidationInfo) -> SecretStr | None:
"""Valide le schéma de l'URL CalDAV (HTTPS obligatoire par défaut).
def _migrate_legacy_url(cls, data: object) -> object:
"""Migre ``url`` vers l'endpoint commun avec un avertissement.
:param data: Données brutes du modèle.
:return: Données complétées avec ``endpoint`` si nécessaire.
:rtype: object
"""
if not isinstance(data, dict) or data.get("url") is None:
return data
migrated_data = data.copy()
warnings.warn(
"CALDAV_URL est obsolète : utiliser CALDAV_ENDPOINT__URL.",
DeprecationWarning,
stacklevel=2,
)
if migrated_data.get("endpoint") is None:
migrated_data["endpoint"] = {"url": migrated_data["url"]}
return migrated_data
@model_validator(mode="after")
def _validate_endpoint_policy(self) -> CalDAVSettings:
"""Applique la politique HTTPS/HTTP loopback propre à CalDAV.
HTTPS est toujours accepté. HTTP n'est accepté que pour un hôte de
boucle locale (``localhost``, ``127.0.0.1``, ``::1``) et uniquement
lorsque ``allow_insecure_http`` vaut ``True``. Les messages d'erreur
ne contiennent jamais l'URL brute (susceptible de contenir des
identifiants).
lorsque ``allow_insecure_http`` vaut ``True``. Les autres schémas du
socle commun sont refusés pour ce connecteur.
:param v: Valeur du champ ``url`` à valider.
:param info: Contexte de validation (accès aux autres champs).
:return: La valeur validée inchangée.
:rtype: SecretStr | None
:return: Instance validée inchangée.
:rtype: CalDAVSettings
:raises ValueError: Si le schéma n'est pas supporté ou si l'URL HTTP
n'est pas autorisée.
"""
if v is None:
return v
raw_url = v.get_secret_value()
if self.endpoint is None:
return self
raw_url = self.endpoint.url.get_secret_value()
parsed = urlparse(raw_url)
if parsed.scheme not in ("http", "https"):
raise ValueError("URL CalDAV invalide : schéma non supporté") from None
if parsed.scheme == "https":
return v
return self
# HTTP — check allow_insecure_http flag and loopback
allow_insecure = info.data.get("allow_insecure_http", False)
if not allow_insecure:
if not self.allow_insecure_http:
raise ValueError(
"URL CalDAV non sécurisée : HTTPS requis (ou activer "
"CALDAV_ALLOW_INSECURE_HTTP pour localhost)"
) from None
hostname = parsed.hostname or ""
loopback_hosts = {"localhost", "127.0.0.1", "::1"}
if hostname not in loopback_hosts:
if hostname not in _LOOPBACK_HOSTS:
raise ValueError(
"URL CalDAV non sécurisée : HTTP autorisé uniquement pour localhost"
) from None
return v
return self
_XMPP_LOOPBACK_HOSTS: frozenset[str] = frozenset({"localhost", "127.0.0.1", "::1"})
@@ -282,10 +407,60 @@ class BlogSettings(BaseSettings):
``BLOG_``.
"""
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="BLOG_")
model_config = SettingsConfigDict(
env_file=".env",
env_nested_delimiter="__",
extra="ignore",
env_prefix="BLOG_",
)
enabled: bool = False
rss_url: str = "https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2"
endpoint: ExternalEndpoint = Field(
default_factory=lambda: ExternalEndpoint(
url=SecretStr("https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2")
)
)
rss_url: str | None = Field(
default=None,
exclude=True,
deprecated="Utiliser endpoint.url à la place (BLOG_RSS_URL obsolète).",
)
@model_validator(mode="before")
@classmethod
def _migrate_legacy_rss_url(cls, data: object) -> object:
"""Migre ``rss_url`` vers l'endpoint commun avec un avertissement.
:param data: Données brutes du modèle.
:return: Données complétées avec ``endpoint`` si nécessaire.
:rtype: object
"""
if not isinstance(data, dict) or data.get("rss_url") is None:
return data
migrated_data = data.copy()
warnings.warn(
"BLOG_RSS_URL est obsolète : utiliser BLOG_ENDPOINT__URL.",
DeprecationWarning,
stacklevel=2,
)
if migrated_data.get("endpoint") is None:
migrated_data["endpoint"] = {"url": migrated_data["rss_url"]}
return migrated_data
@model_validator(mode="after")
def _validate_endpoint_policy(self) -> BlogSettings:
"""Refuse les transports non sûrs pour le flux RSS de production.
Le transport ``file`` reste autorisé pour les fixtures locales.
:return: Instance validée inchangée.
:rtype: BlogSettings
:raises ValueError: Si le schéma n'est ni ``https`` ni ``file``.
"""
scheme = urlparse(self.endpoint.url.get_secret_value()).scheme
if scheme not in {"https", "file"}:
raise ValueError("URL RSS invalide : HTTPS ou file requis") from None
return self
class AppSettings(BaseSettings):
@@ -338,13 +513,15 @@ class Settings(BaseSettings):
:rtype: tuple[SecretStr, ...]
"""
secrets = [
self.pronote.ical_url,
self.pronote.endpoint.url if self.pronote.endpoint is not None else None,
self.pronote.ical_endpoint.url if self.pronote.ical_endpoint is not None else None,
self.pronote.password,
self.pronote.qr_pin,
self.pronote.account_pin,
self.caldav.url,
self.caldav.endpoint.url if self.caldav.endpoint is not None else None,
self.caldav.password,
self.xmpp.password,
self.ai.api_key,
self.blog.endpoint.url,
]
return tuple(dict.fromkeys(secret for secret in secrets if secret is not None))
+5 -1
View File
@@ -138,7 +138,11 @@ class PipelineRunner:
comparator = (
AgendaComparator(theoretical_provider) if theoretical_provider is not None else None
)
blog_client = BlogRSSClient(settings.blog.rss_url) if settings.blog.enabled else None
blog_client = (
BlogRSSClient(settings.blog.endpoint.url.get_secret_value())
if settings.blog.enabled
else None
)
blog_state = (
BlogRSSState(persistence_enabled=persistence_enabled) if settings.blog.enabled else None
)
+3 -3
View File
@@ -294,18 +294,18 @@ class PronoteClient:
est manquant, ou si l'ENT fourni est inconnu.
:raises pronotepy.PronoteAPIError: Si la connexion à Pronote échoue.
"""
url = self._settings.url
endpoint = self._settings.endpoint
username = self._settings.username
password = self._settings.password
ent = self._settings.ent
if url is None or username is None or password is None:
if endpoint is None or username is None or password is None:
raise ValueError("url, username et password sont requis pour pronotepy")
resolver = _resolve_ent(ent) if ent is not None else None
client_class: type[pronotepy.Client] = (
pronotepy.ParentClient if self._settings.account_type == "parent" else pronotepy.Client
)
self._client = client_class(
pronote_url=url,
pronote_url=endpoint.url.get_secret_value(),
username=username,
password=password.get_secret_value(),
ent=resolver,
+13 -13
View File
@@ -141,10 +141,10 @@ class PronoteFetcher:
def _is_ical_configured(self) -> bool:
"""Vérifie que la source iCal est configurée.
:return: ``True`` si ``ical_url`` est défini, ``False`` sinon.
:return: ``True`` si ``ical_endpoint`` est défini, ``False`` sinon.
:rtype: bool
"""
return self._settings.pronote.ical_url is not None
return self._settings.pronote.ical_endpoint is not None
def _is_pronotepy_configured(self) -> bool:
"""Vérifie si la source pronotepy est utilisable selon le mode d'authentification.
@@ -155,13 +155,13 @@ class PronoteFetcher:
"""
pronote = self._settings.pronote
if pronote.auth_mode == "qr_token":
# En mode qr_token, seul PRONOTE_URL est requis.
# En mode qr_token, seul PRONOTE_ENDPOINT__URL est requis.
# Le QR code et le PIN ne sont nécessaires que pour l'enrôlement initial.
# Les exécutions suivantes utilisent le token persisté.
return pronote.url is not None
return pronote.endpoint is not None
# En mode password, URL + identifiant + mot de passe sont requis.
return (
pronote.url is not None
pronote.endpoint is not None
and pronote.username is not None
and pronote.password is not None
)
@@ -171,16 +171,16 @@ class PronoteFetcher:
:return: Tuple ``(cours, événements scolaires)``.
:rtype: tuple[list[Lesson], list[SchoolEvent]]
:raises ValueError: Si ``ical_url`` n'est pas configuré ou si le flux est invalide.
:raises ValueError: Si ``ical_endpoint`` n'est pas configuré ou si le flux est invalide.
:raises OSError: Si le fichier iCal local est illisible.
:raises requests.RequestException: Si la récupération HTTP échoue.
"""
if self._cache_ical_for_run and self._run_ical_agenda is not None:
return self._run_ical_agenda
ical_url = self._settings.pronote.ical_url
if ical_url is None:
raise ValueError("PRONOTE_ICAL_URL est requis pour la source iCal")
raw_ical = fetch_ical(ical_url.get_secret_value())
ical_endpoint = self._settings.pronote.ical_endpoint
if ical_endpoint is None:
raise ValueError("PRONOTE_ICAL_ENDPOINT__URL est requis pour la source iCal")
raw_ical = fetch_ical(ical_endpoint.url.get_secret_value())
lessons, _, school_events = parse_ical(raw_ical)
result = (lessons, school_events)
if self._cache_ical_for_run:
@@ -210,7 +210,7 @@ class PronoteFetcher:
Les modes explicites ``ICAL`` et ``PRONOTEPY`` désignent la seule
source utilisée, sans aucun repli. En mode ``AUTO``, iCal est
primaire si ``ical_url`` est configuré (repli pronotepy si la
primaire si ``ical_endpoint`` est configuré (repli pronotepy si la
configuration pronotepy est complète), sinon pronotepy sans repli.
:return: Tuple ``(source primaire, source de repli ou ``None``)``.
@@ -304,7 +304,7 @@ class PronoteFetcher:
:param target_date: Date cible pour laquelle collecter les devoirs.
:return: Liste des devoirs.
:rtype: list[Homework]
:raises ValueError: Si ``ical_url`` n'est pas configuré ou si le flux est invalide.
:raises ValueError: Si ``ical_endpoint`` n'est pas configuré ou si le flux est invalide.
:raises OSError: Si le fichier iCal local est illisible.
:raises requests.RequestException: Si la récupération HTTP échoue.
"""
@@ -335,7 +335,7 @@ class PronoteFetcher:
Les modes explicites ``ICAL`` et ``PRONOTEPY`` désignent la seule
source utilisée, sans aucun repli. En mode ``AUTO``, iCal est
primaire si ``ical_url`` est configuré (repli pronotepy si la
primaire si ``ical_endpoint`` est configuré (repli pronotepy si la
configuration pronotepy est complète), sinon pronotepy sans repli.
:return: Tuple ``(source primaire, source de repli ou ``None``)``.
+3 -2
View File
@@ -79,11 +79,12 @@ class CalDAVGateway:
else cast(Callable[..., Any], caldav.DAVClient)
)
self._calendar_path: str = settings.calendar_path
url_secret = settings.endpoint.url if settings.endpoint is not None else None
self._redacted_url: str | None = (
redact_url(settings.url.get_secret_value()) if settings.url else None
redact_url(url_secret.get_secret_value()) if url_secret else None
)
self._username: str | None = settings.username
self._url_secret: SecretStr | None = settings.url
self._url_secret: SecretStr | None = url_secret
self._password_secret: SecretStr | None = settings.password
self._client: Any = None
self._calendar: Any = None
+1 -1
View File
@@ -96,7 +96,7 @@ def synchronize(
)
if (
settings.caldav.url is None
settings.caldav.endpoint is None
or settings.caldav.username is None
or settings.caldav.password is None
):
+153 -3
View File
@@ -13,7 +13,14 @@ import pytest
from pydantic import SecretStr, ValidationError
from pronote_sync.config.env import load_settings
from pronote_sync.config.settings import AppSettings, PronoteSettings, Settings
from pronote_sync.config.settings import (
AppSettings,
BlogSettings,
CalDAVSettings,
ExternalEndpoint,
PronoteSettings,
Settings,
)
if TYPE_CHECKING:
from _pytest.monkeypatch import MonkeyPatch
@@ -73,7 +80,7 @@ def test_secretstr_masking_ical_url() -> None:
# Vérification de la sérialisation JSON
json_str = settings.model_dump_json()
assert "SECRET_TOKEN" not in json_str
assert "**********" in json_str
assert "REDACTED" in json_str
def test_secretstr_masking_password() -> None:
@@ -128,7 +135,150 @@ def test_url_from_pronote_url_env_var(monkeypatch: MonkeyPatch) -> None:
test_url = "https://example.index-education.net/pronote/parent.html"
monkeypatch.setenv("PRONOTE_URL", test_url)
settings = load_settings()
assert settings.pronote.url == test_url
assert settings.pronote.endpoint is not None
assert settings.pronote.endpoint.url.get_secret_value() == test_url
@pytest.mark.parametrize(
"url",
[
"https://endpoint.example.test/api",
"http://localhost:8080/test",
"file:///tmp/fixture.ics",
],
)
def test_external_endpoint_accepts_supported_schemes(url: str) -> None:
"""Vérifie le socle commun des URL d'endpoints autorisés.
:param url: URL représentative du schéma à valider.
:return: None
"""
endpoint = ExternalEndpoint(url=SecretStr(url))
assert endpoint.url.get_secret_value() == url
@pytest.mark.parametrize(
"url",
["ftp://endpoint.example.test", "https:///missing-host", "https://host:bad", "file://"],
)
def test_external_endpoint_rejects_invalid_urls_without_leak(url: str) -> None:
"""Vérifie que le socle rejette les URL invalides sans les afficher.
:param url: URL invalide à refuser.
:return: None
"""
with pytest.raises(ValidationError) as exc_info:
ExternalEndpoint(url=SecretStr(url))
assert url not in str(exc_info.value)
def test_external_endpoint_is_immutable_and_redacted() -> None:
"""Vérifie le contrat immuable et expurgé du value object.
:return: None
"""
endpoint = ExternalEndpoint(url=SecretStr("https://user:secret@example.test/calendar"))
with pytest.raises(ValidationError):
endpoint.url = SecretStr("https://other.example.test")
assert "secret" not in endpoint.model_dump_json()
assert "REDACTED" in endpoint.model_dump_json()
def test_caldav_endpoint_loads_from_nested_environment(monkeypatch: MonkeyPatch) -> None:
"""Vérifie le chargement du nouvel endpoint CalDAV depuis l'environnement.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
monkeypatch.setenv("CALDAV_ENDPOINT__URL", "https://caldav.example.test/dav")
settings = load_settings()
assert settings.caldav.endpoint is not None
assert settings.caldav.endpoint.url.get_secret_value() == "https://caldav.example.test/dav"
def test_caldav_legacy_url_migrates_with_warning() -> None:
"""Vérifie la migration temporaire du réglage CalDAV historique.
:return: None
"""
with pytest.warns(DeprecationWarning, match="CALDAV_URL"):
settings = CalDAVSettings(url=SecretStr("https://caldav.example.test/dav"))
assert settings.endpoint is not None
assert settings.endpoint.url.get_secret_value() == "https://caldav.example.test/dav"
def test_pronote_endpoints_load_from_nested_environment(monkeypatch: MonkeyPatch) -> None:
"""Vérifie le chargement des deux endpoints Pronote depuis l'environnement.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
monkeypatch.setenv("PRONOTE_ENDPOINT__URL", "https://pronote.example.test/parent.html")
monkeypatch.setenv("PRONOTE_ICAL_ENDPOINT__URL", "file:///tmp/pronote.ics")
settings = load_settings()
assert settings.pronote.endpoint is not None
assert settings.pronote.ical_endpoint is not None
assert (
settings.pronote.endpoint.url.get_secret_value()
== "https://pronote.example.test/parent.html"
)
assert settings.pronote.ical_endpoint.url.get_secret_value() == "file:///tmp/pronote.ics"
@pytest.mark.parametrize(
("field", "url", "message"),
[
("endpoint", "http://pronote.example.test", "HTTPS requis"),
("ical_endpoint", "http://pronote.example.test/calendar", "HTTPS ou file requis"),
],
)
def test_pronote_endpoint_policy_rejects_insecure_url(field: str, url: str, message: str) -> None:
"""Vérifie la politique de transport des endpoints Pronote.
:param field: Nom du champ endpoint à alimenter.
:param url: URL non sûre à refuser.
:param message: Fragment attendu du message sûr.
:return: None
"""
endpoint = ExternalEndpoint(url=SecretStr(url))
with pytest.raises(ValidationError, match=message) as exc_info:
if field == "endpoint":
PronoteSettings(endpoint=endpoint)
else:
PronoteSettings(ical_endpoint=endpoint)
assert url not in str(exc_info.value)
def test_blog_endpoint_loads_from_nested_environment(monkeypatch: MonkeyPatch) -> None:
"""Vérifie le chargement du nouvel endpoint RSS depuis l'environnement.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
monkeypatch.setenv("BLOG_ENDPOINT__URL", "https://blog.example.test/feed")
settings = load_settings()
assert settings.blog.endpoint.url.get_secret_value() == "https://blog.example.test/feed"
def test_blog_legacy_rss_url_migrates_with_warning() -> None:
"""Vérifie la migration temporaire du réglage RSS historique.
:return: None
"""
with pytest.warns(DeprecationWarning, match="BLOG_RSS_URL"):
settings = BlogSettings(rss_url="https://blog.example.test/feed")
assert settings.endpoint.url.get_secret_value() == "https://blog.example.test/feed"
def test_blog_endpoint_rejects_insecure_url_without_leak() -> None:
"""Vérifie que l'URL RSS HTTP est refusée sans être exposée.
:return: None
"""
url = "http://user:secret@blog.example.test/feed" # pragma: allowlist secret
with pytest.raises(ValidationError) as exc_info:
BlogSettings(endpoint=ExternalEndpoint(url=SecretStr(url)))
assert url not in str(exc_info.value)
def test_auth_mode_default_password() -> None:
+17 -15
View File
@@ -251,7 +251,7 @@ def test_fetch_agenda_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
:rtype: None
"""
# Disable pronotepy so fallback is None
mock_fetcher._settings.pronote.url = None
mock_fetcher._settings.pronote.endpoint = None
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
@@ -280,7 +280,7 @@ def test_fetch_agenda_ical_mode_failure(mock_fetcher: PronoteFetcher) -> None:
"""
# Override settings to use ical mode explicitly and disable fallback
mock_fetcher._settings.pronote.agenda_source = "ical"
mock_fetcher._settings.pronote.url = None
mock_fetcher._settings.pronote.endpoint = None
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
@@ -311,7 +311,7 @@ def test_fetch_agenda_pronotepy_mode_failure(mock_fetcher: PronoteFetcher) -> No
"""
# Override settings to use pronotepy mode explicitly and disable fallback
mock_fetcher._settings.pronote.agenda_source = "pronotepy"
mock_fetcher._settings.pronote.ical_url = None
mock_fetcher._settings.pronote.ical_endpoint = None
client = MagicMock()
client.get_lessons.side_effect = OSError("Pronote API error")
@@ -438,7 +438,7 @@ def test_fetch_homework_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
target_date = date(2025, 9, 10)
# Disable pronotepy so fallback is None
mock_fetcher._settings.pronote.url = None
mock_fetcher._settings.pronote.endpoint = None
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
@@ -531,7 +531,7 @@ def test_no_secrets_in_error_messages(
:rtype: None
"""
# Disable pronotepy so fallback is None to trigger PipelineCriticalError
mock_fetcher._settings.pronote.url = None
mock_fetcher._settings.pronote.endpoint = None
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
@@ -628,8 +628,8 @@ def test_fetch_agenda_no_source_configured_raises(mock_fetcher: PronoteFetcher)
:rtype: None
"""
# Disable both sources
mock_fetcher._settings.pronote.ical_url = None
mock_fetcher._settings.pronote.url = None
mock_fetcher._settings.pronote.ical_endpoint = None
mock_fetcher._settings.pronote.endpoint = None
with pytest.raises(PipelineCriticalError) as exc_info:
mock_fetcher.fetch_agenda()
@@ -912,8 +912,10 @@ def test_fetch_agenda_ical_url_none_raises_value_error(mock_fetcher: PronoteFetc
:return: None
:rtype: None
"""
mock_fetcher._settings.pronote.ical_url = None
with pytest.raises(ValueError, match="PRONOTE_ICAL_URL est requis pour la source iCal"):
mock_fetcher._settings.pronote.ical_endpoint = None
with pytest.raises(
ValueError, match="PRONOTE_ICAL_ENDPOINT__URL est requis pour la source iCal"
):
mock_fetcher._fetch_agenda_ical()
@@ -928,7 +930,7 @@ def test_agenda_sources_auto_only_pronotepy_configured(mock_fetcher: PronoteFetc
:rtype: None
"""
mock_fetcher._settings.pronote.agenda_source = "auto"
mock_fetcher._settings.pronote.ical_url = None
mock_fetcher._settings.pronote.ical_endpoint = None
primary, fallback = mock_fetcher._agenda_sources()
assert primary == "pronotepy"
assert fallback is None
@@ -1020,7 +1022,7 @@ def test_homework_sources_explicit_ical_mode_strict(mock_fetcher: PronoteFetcher
assert fallback is None
# Without pronotepy configured
mock_fetcher._settings.pronote.url = None
mock_fetcher._settings.pronote.endpoint = None
primary, fallback = mock_fetcher._homework_sources()
assert primary == "ical"
assert fallback is None
@@ -1046,7 +1048,7 @@ def test_homework_sources_explicit_pronotepy_mode_strict(
assert fallback is None
# Without ical configured
mock_fetcher._settings.pronote.ical_url = None
mock_fetcher._settings.pronote.ical_endpoint = None
primary, fallback = mock_fetcher._homework_sources()
assert primary == "pronotepy"
assert fallback is None
@@ -1063,7 +1065,7 @@ def test_homework_sources_auto_only_pronotepy_configured(mock_fetcher: PronoteFe
:rtype: None
"""
mock_fetcher._settings.pronote.homework_source = "auto"
mock_fetcher._settings.pronote.ical_url = None
mock_fetcher._settings.pronote.ical_endpoint = None
primary, fallback = mock_fetcher._homework_sources()
assert primary == "pronotepy"
assert fallback is None
@@ -1079,8 +1081,8 @@ def test_homework_sources_auto_no_source_configured_raises(mock_fetcher: Pronote
:rtype: None
"""
mock_fetcher._settings.pronote.homework_source = "auto"
mock_fetcher._settings.pronote.ical_url = None
mock_fetcher._settings.pronote.url = None
mock_fetcher._settings.pronote.ical_endpoint = None
mock_fetcher._settings.pronote.endpoint = None
with pytest.raises(PipelineCriticalError) as exc_info:
mock_fetcher._homework_sources()