Compare commits

...

28 Commits

Author SHA1 Message Date
ebbe39f1f0 docs(M7): aligner guide, TODO et pre-commit pour la sync CalDAV
Met à jour la documentation et la configuration pour le jalon M7 selon
les décisions d'architecture :

- GUIDE_DEV_PYTHON.md §7 : API réelle caldav>=1.3.0 (pas le pseudo-code),
  calendar_path (pas calendar_name), plan CalDAVSyncPlan explicite avant
  exécution, pas d'état local (scan distant), événements non marqués
  jamais modifiés
- TODO.md M7 : suppression de sync/state.py et BlogRSSState, ajout de
  l'exécution du plan et de la protection des événements non gérés
- .pre-commit-config.yaml : caldav>=1.3.0 ajouté aux additional_dependencies
  du hook mypy

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
2026-09-07 00:00:54 +02:00
958bb3ec5d merge: corrections d'audit FIXME_M6 dans l'agenda théorique
Intègre les corrections de la relecture indépendante :
- Normalisation des matières (NFKC + espaces + ponctuation + casse)
- Expurgation du secret dans l'erreur de collision d'IDs
- Tests renforcés (parité isolée, normalisation, non-fuite)
- Documentation alignée (TODO coché, guide §8.4 corrigé)

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-06 23:31:25 +02:00
1d26d49e74 fix(M6): corrections d'audit FIXME_M6 — normalisation, secret, tests, doc
Corrige les 5 points de l'audit FIXME_M6 :

1. Normalisation des matières : fonction normalize_subject (NFKC +
   unification des espaces + suppression ponctuation + minuscule)
   partagée par la génération d'ID et le futur comparateur M8.
2. Expurgation du secret dans l'erreur de collision d'IDs :
   redact_secrets enveloppe l'identifiant dans le message.
3. Test even/odd avec même matière pour isoler la parité comme seul
   différenciateur d'ID ; tests de normalisation (casse, espaces,
   Unicode) ; test de non-fuite de secret.
4. TODO.md M6 : 8 items cochés après validation.
5. GUIDE_DEV §8.4 : bloc de code corrigé (clôture, types Lesson/
   TheoreticalLesson, comparaison des horaires en minutes, début ET
   fin, référence à normalize_subject).

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-06 23:30:47 +02:00
29270427ef merge: jalon M6 — agenda théorique JSON avec parité et vacances scolaires
Intègre le jalon M6 complet :
- Source d'agenda théorique au format JSON (avec parité paire/impaire)
- Service de parité des semaines (WeekParityService) basé sur une date
  de référence configurée
- Calendrier de vacances scolaires (SchoolHolidayCalendar, zone A)
- Provider JSON avec filtrage par parité et vacances
- Factory de câblage de configuration
- Fixtures theoretical.json et school_holidays.json
- 57 tests unitaires
- Documentation alignée (TODO.md, GUIDE_DEV_PYTHON.md, .env.example)

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-06 23:06:47 +02:00
f9a1a5aa43 feat(M6): agenda théorique JSON avec parité des semaines et vacances scolaires
Implémentation complète de la source d'agenda théorique :

- model.py : modèles Pydantic de parsing JSON (TheoreticalLessonEntry,
  TheoreticalAgendaFile) avec validation des formats d'heure et de l'ordre
  début/fin.
- parity.py : WeekParityService déterministe calculant la parité d'une
  semaine (paire/impaire) à partir d'une date de référence.
- holidays.py : SchoolHolidayCalendar lisant un fichier JSON de vacances
  scolaires (zone A) avec bornes inclusives.
- provider.py : protocole TheoreticalAgendaProvider (get_lessons,
  get_lessons_for_range).
- file.py : JsonTheoreticalAgendaProvider implémentant le protocole :
  filtrage par parité et vacances, génération d'IDs déterministes
  incluant le type de semaine, validation de l'unicité des IDs,
  tri stable par identifiant.
- __init__.py : factory get_theoretical_provider câblant la
  configuration (None si désactivé, erreur si config de parité partielle).
- Fixtures : theoretical.json (9 leçons all/even/odd) et
  school_holidays.json (zone A, 4 périodes).
- 57 tests unitaires couvrant parsing, parité, vacances, provider,
  factory, déduplication de range, collisions d'IDs.
- Guide : §8 et §12 alignés avec le format JSON.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-06 23:06:29 +02:00
4ec827a945 docs(M6): aligner guide, TODO et configuration pour l'agenda théorique JSON
Met à jour la documentation et la configuration pour le jalon M6 selon
les décisions d'architecture :

- GUIDE_DEV_PYTHON.md §8 : remplace iCal/CSV par JSON avec parité de
  semaine (paire/impaire) et calendrier de vacances scolaires séparé
- TODO.md M6 : nouveaux items (WeekParityService, SchoolHolidayCalendar,
  configuration, fixtures JSON)
- .env.example : THEORETICAL_AGENDA_PATH passe en .json, ajout de
  SCHOOL_HOLIDAYS_PATH, THEORETICAL_WEEK_ANCHOR_DATE et
  THEORETICAL_WEEK_ANCHOR_TYPE
- AppSettings : 3 nouveaux champs (school_holidays_path,
  theoretical_week_anchor_date, theoretical_week_anchor_type)

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
2026-09-06 22:30:50 +02:00
7fdca2ca55 merge: jalon M5 — source blog RSS (fetch, parsing, déduplication, état)
Intègre le jalon M5 complet incluant les corrections d'audit FIXME_M5 :
- BlogRSSClient : client sans état, transport HTTP via requests,
  parsing feedparser, déduplication par known_guids, cache HTTP
  conditionnel, tri déterministe, mode dégradé complet
- BlogRSSFetchResult : résultat immuable (articles + cache + not_modified)
- BlogRSSState : persistance JSON atomique et tolérante
- Fixture blog_rss.xml anonymisée (3 articles, dates fixes)
- 49 tests unitaires (32 client + 17 state)
- Documentation TODO.md et GUIDE_DEV_PYTHON.md alignés
- Configuration pre-commit : feedparser ajouté au hook mypy

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-06 20:58:18 +02:00
344745d725 merge: corrections d'audit FIXME_M5 dans M5 blog RSS
Intègre les corrections de la revue indépendante (FIXME_M5.md) :
- Transport HTTP séparé du parsing (requests.get + feedparser.parse)
- Rejet des statuts HTTP d'erreur (raise_for_status)
- Préservation des validateurs de cache sur les chemins d'échec
- Sauvegarde atomique de l'état (tmp + Path.replace)
- Déduplication normale silencieuse

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-06 20:58:13 +02:00
bfae1ca87f fix(M5): corrections d'audit — transport HTTP, statuts d'erreur, cache atomique
Corrige les 5 points de l'audit FIXME_M5 :

1. (Bloquant) Sépare transport HTTP et parsing : utilise requests.get()
   avec timeout explicite et en-têtes conditionnels, puis transmet le
   contenu à feedparser.parse() — supprime le paramètre inexistant
   request_timeout qui faisait échouer toute récupération réelle.
2. Rejette les statuts HTTP 4xx/5xx via raise_for_status() avant le
   parsing.
3. Préserve les validateurs de cache (etag, last_modified) d'entrée sur
   les chemins d'échec (exception, bozo) au lieu de les écraser à None.
4. Sauvegarde atomique de BlogRSSState : écrit dans un .tmp puis
   Path.replace() pour éviter la corruption sur interruption.
5. Déduplication normale silencieuse : les GUID déjà connus sont
   ignorés sans warning ; seuls les doublons intra-flux génèrent un
   avertissement.

Tests : 49 tests (32 client + 17 state) dont 11 nouveaux couvrant
transport HTTP réel, statuts 401/404/500, préservation des validateurs,
en-têtes conditionnels, doublons intra-flux et sauvegarde atomique.
Guide : §5 bis.7.1 aligné avec le nouveau pattern transport/parsing.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-06 20:58:04 +02:00
6d1a7a649f feat(M5): source blog RSS — fetch, parsing, déduplication et état persistant
Implémentation complète de la source blog RSS du collège :
- BlogRSSClient (sources/blog/rss.py) : client sans état récupérant et
  parsant le flux via feedparser, avec déduplication par ensemble de
  GUIDs connus, cache HTTP conditionnel (ETag/Last-Modified), conversion
  HTML→texte (BeautifulSoup), tri déterministe (date desc puis id asc),
  et mode dégradé (flux invalide/erreur → warning expurgé + liste vide).
- BlogRSSFetchResult (sources/blog/result.py) : résultat immuable
  contenant articles, en-têtes de cache et indicateur not_modified.
- BlogRSSState (sources/blog/state.py) : persistance JSON tolérante
  (GUIDs triés, version, ETag, Last-Modified) avec redaction des chemins
  dans les logs.
- Fixture tests/fixtures/blog_rss.xml : flux RSS 2.0 anonymisé, 3
  articles, dates fixes, ordre non chronologique.
- 38 tests unitaires (22 client + 16 state) couvrant parsing nominal,
  déduplication intra-flux, 304, bozo, erreurs réseau, non-fuite de
  secrets, tri secondaire, persistance d'état et tolérance aux fichiers
  corrompus.
- Documentation : TODO.md M5 coché, GUIDE_DEV_PYTHON.md §5 bis aligné
  avec l'API livrée (known_guids, BlogRSSFetchResult, BlogRSSState).
- Configuration : feedparser ajouté aux additional_dependencies du hook
  mypy pre-commit pour aligner l'environnement isolé avec le .venv.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-06 20:23:49 +02:00
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
bb1f90bf5f test(M4): couverture fallback.py 81→98% (11 tests ciblés)
- _fetch_agenda_ical sans ical_url → ValueError
- _agenda_sources AUTO pronotepy seul, _homework_sources modes
  explicites + AUTO + aucune source configurée
- fetch_agenda/fetch_homework : repli échoue → PipelineCriticalError,
  repli retourne [] → warning d'ambiguité
- fetch_informations : log redact_exception + re-raise sans secrets
- Couverture pronote_sync/sources/pronote/ : 95% (≥90% requis)

Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-06 15:39:07 +02:00
9d9a55ed40 test+docs(M4): tests get_lessons/get_homeworks + repli unique + doc sync
Tests:
- test_pronote_client : get_lessons/get_homeworks success + error
  propagation, ENT resolution, factory parent/student, mode dégradé
  messages/informations (ConnectionError, TimeoutError)
- test_fallback : repli unique ICAL→pronotepy et PRONOTEPY→iCal,
  filtre target_date, fetch_messages log + re-raise sans secrets,
  aucune source configurée → PipelineCriticalError
- 59 tests passent (16 client + 17 fallback + 26 iCal)

Documentation:
- TODO.md : 7 items M4 cochés, contrat repli corrigé (unique, non
  réciproque) selon décision D4
- GUIDE_DEV_PYTHON.md : get_agenda_fallback → get_lessons/get_homeworks,
  pronote_url/account_type ajoutés au modèle + env vars + .env.example,
  AgendaSource StrEnum, §5.1.8 fallback contract mis à jour (repli unique)

Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
2026-09-06 15:36:42 +02:00
1b550aa818 fix(M4): pronote_url + account_type et parsing iCal (STATUS:CANCELLED + blocs multi-dates)
Configuration (D1+D3) :
- PronoteSettings : ajout pronote_url (str | None) et account_type
  (Literal student/parent, défaut parent)

Parsing iCal (Fix 4+7) :
- parse_ical : détection STATUS:CANCELLED en plus de CATEGORIES
- parse_body : list[tuple[date, str]] au lieu de dict[date, str]
  pour préserver les blocs multiples à la même date
- parse_homework_blocks : adapté aux listes

Co-authored-by: opencode/coder <coder@agents.invalid>
2026-09-06 15:23:34 +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
e0cd073f9a test(M4): tests unitaires iCal, client pronotepy et repli
- test_ical.py : 20 tests (fetch file://, parsing VEVENT, détection
  cours annulé, collect_homeworks avec déduplication, normalisation,
  génération d'ID déterministe, redaction des erreurs)
- test_pronote_client.py : 9 tests (Protocol, messages mockés,
  informations, agenda fallback, credentials manquants, sécurité
  mot de passe)
- test_fallback.py : 16 tests (modes ICAL/PRONOTEPY/AUTO, repli,
  PipelineCriticalError si les deux sources échouent, redaction
  des erreurs)
- pre-commit : ajout de responses et pytest-mock au hook mypy
- Total : 42 nouveaux tests M4 (122 tests au total sur le projet)

Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-06 14:10:16 +02:00
f2be495f55 feat(M4): sources/pronote/fallback.py — repli iCal/pronotepy
- AgendaSource : enum StrEnum (AUTO/ICAL/PRONOTEPY)
- PronoteFetcherProtocol : Protocol consommé par le pipeline
- PronoteFetcher : unifie iCal et pronotepy avec repli automatique
  - AUTO : iCal d'abord, repli pronotepy, PipelineCriticalError si
    les deux échouent
  - ICAL/PRONOTEPY : source explicite, erreur redactée puis
    PipelineCriticalError
  - Messages et informations : toujours via pronotepy
- Avertissement quand le repli pronotepy retourne un résultat vide
  après échec iCal (limite du mode dégradé)

Co-authored-by: opencode/coder <coder@agents.invalid>
2026-09-06 13:45:08 +02:00
f3177e70f6 feat(M4): sources/pronote/client.py — client pronotepy avec Protocol
- PronoteClientProtocol : Protocol (get_messages, get_informations,
  get_agenda_fallback) pour la logique de repli
- PronoteClient : encapsule pronotepy.Client (connexion paresseuse)
  avec API réelle 2.15.7 (lessons/homework/discussions/information_and_surveys)
- Erreurs PronoteAPIError et ValueError catchées, loggées avec
  redact_exception, retournent des résultats vides
- Aucune fuite de secret (get_secret_value() uniquement à la connexion)
- pre-commit : ajout de pronotepy au hook mypy

Co-authored-by: opencode/coder <coder@agents.invalid>
2026-09-06 13:32:54 +02:00
cb621c15f4 feat(M4): sources/pronote/ical.py — fetch, parsing et collecte des devoirs
- fetch_ical : HTTP via requests, support file:// (URI décodé),
  validation BEGIN:VCALENDAR, exceptions redactées
- get_calendar_name : extraction X-WR-CALNAME (paramètres + lignes repliées)
- parse_ical : parsing VEVENT → Lesson/SchoolEvent, détection cours
  annulé/déplacé, UID déterministe si absent, homeworks toujours vide
- collect_homeworks : deux passes (due + assigned), déduplication
  par ID, tri par (subject, text), target_date injecté
- generate_homework_id : SHA-1 12 chars (usedforsecurity=False)
- normalize_homework_text : unification whitespace/HTML/lowercase
- pre-commit : ajout de types-requests et icalendar au hook mypy

Co-authored-by: opencode/coder <coder@agents.invalid>
2026-09-06 13:32:11 +02:00
f82e79360b feat(M4): hiérarchie d'erreurs, fixture iCal et correction TODO
- pronote_sync/errors.py : PronoteSyncError (base) + PipelineCriticalError
- tests/fixtures/pronote-4e.ics : fixture anonymisée (3 VEVENTs : cours
  normal avec devoirs, cours annulé, événement scolaire)
- TODO.md ligne 82 : normalize_uid → normalize_pronote_uid (alignement
  sur le code existant dans utils/uid.py)

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-06 13:20:24 +02:00
292aef4f5b fix(M3): immuabilité et validation des modèles — correction des écarts FIXME_M3
Immuabilité :
- Collections des modèles frozen : list → tuple (Lesson, TheoreticalLesson,
  SchoolEvent, AgendaDiff, Homework, XmppMessage, ExternalInfo)
- Gel de HomeworkBlock et ExternalInfo (sous-modèles imbriqués dans contrats)
- Valeurs par défaut immuables : Field(default=(), ...)

Validation de domaine :
- TheoreticalLesson.day_of_week : Field(ge=0, le=6)
- Lesson/TheoreticalLesson : start < end (validateur de modèle)
- SchoolEvent : from_date <= to_date
- AgendaChange : cohérence type/payload (ADDED/MODIFIED → lesson,
  REMOVED → theoretical_lesson)
- CalDAVSyncResult : compteurs Field(ge=0) sur added/updated/removed

Tests : 66 tests (construction/sérialisation, immuabilité, invariants)
couvrant le gel, la mutabilité légitime, et les cas invalides par invariant.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-06 12:36:27 +02:00
2f6184df0b fix(M2): configuration, secrets et rédaction — correction des écarts FIXME_M2
Sécurité :
- ical_url : str → SecretStr | None (masquage dans str/repr/model_dump_json)
- En-têtes Authorization/Proxy-Authorization : masquage complet de la valeur
- _URL_PATTERN : insensible à la casse (HTTPS:// reconnu)
- redact_url() : masquage du nom d'utilisateur (userinfo complet)

Configuration :
- Sous-configs : Field(default_factory=...) pour rechargement à chaque appel
- Suppression du singleton settings (injection de dépendances)
- .env.example : ajout AI_PROVIDER et CALDAV_CALENDAR_PATH
- Guide : BLOG_RSS_ENABLED → BLOG_ENABLED, AI_MODEL=None par défaut
- .secrets.baseline : ligne décalée 5112 → 5117 (faux positif audité)
- pre-commit : ajout de pytest aux additional_dependencies du hook mypy

Tests : 15 tests (config + redaction) couvrant rechargement, masquage
SecretStr, en-têtes Authorization, URL auth intégrée et casse variable.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-06 12:35:55 +02:00
a0ac75cec6 fix(M1): correction des écarts d'audit FIXME_M1
- requires-python : >=3.13 → >=3.13.5 (alignement sur le contrat TODO.md)
- test de fumée : ajout de tests/unit/test_smoke.py (pytest sort code 0)
- .gitignore : ajout de la règle *.tmp.ics pour les artefacts iCal temporaires
- .secrets.baseline : audit du faux positif GUIDE_DEV_PYTHON.md:5112
  (is_secret: false, is_verified: true → VERIFIED_FALSE)

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/verifier <verifier@agents.invalid>
2026-09-06 11:59:50 +02:00
aaca78c55d docs+fix: renforcement de l'architecture agentique et corrections de sécurité
AGENTS.md :
- Rôles d'agents renforcés : @coder ne valide pas, @debugger ne code pas,
  @verifier ne modifie pas, etc.
- Table « Séparation des rôles » : tâche → agent responsable → ne pas confier à
- Workflow étape 5 : délégation explicite à @verifier pour la validation

GUIDE_DEV_PYTHON.md :
- 7 notes « Décision d'implémentation » ajoutées aux sections concernées :
  3.1.1 (XMPP_RECIPIENT→XMPP_TO, vars ajoutées), 3.1.2 (SYNC_PAST_DAYS→AppSettings),
  3.2 (Pydantic v2 style, defaults corrigés), 4.2.1 (redact_exception module function),
  4.2.2 (getLevelNamesMapping), 5.1.5 (normalize_pronote_uid, usedforsecurity=False),
  6 (ConfigDict, StrEnum, alias _date, external_info Optional)

Sécurité (audit @security-auditor, corrections @coder, validation @verifier) :
- RedactingFormatter : redaction APRÈS formatage (corrige TypeError %s + fuite traceback)
- redact_url : masquage des credentials dans userinfo URL (HTTP Basic Auth)
- redact_secrets : patterns étendus (api_key, access_token, authorization, auth)
- redact_secrets : support JSON-style « key: value » avec guillemets

Validations (@verifier) :
- ruff check : PASS | mypy strict : PASS | bandit : PASS (0 issue)
- %s formatting : OK (password=REDACTED, pas de TypeError)
- Traceback redaction : OK (icalsecurise=REDACTED)
- URL userinfo : OK (user:REDACTED@host)
- JSON-style redaction : OK ({"token": "REDACTED"})
- Régression red-to-green : OK (historical HEAD reproduction)

Co-authored-by: OpenCode/orchestrator <opencode-orchestrator@agents.invalid>
2026-09-05 23:52:02 +02:00
52 changed files with 10758 additions and 1429 deletions

View File

@@ -1,5 +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/eleve.html
PRONOTE_ACCOUNT_TYPE=parent
PRONOTE_USERNAME=parent.dupont
PRONOTE_PASSWORD=your_secure_password
PRONOTE_ENT=monbureaunumerique
@@ -13,13 +15,17 @@ PRONOTE_MESSAGES_SOURCE=pronotepy
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
CALDAV_USERNAME=user@example.com
CALDAV_PASSWORD=your_caldav_password
CALDAV_CALENDAR_PATH=/pronote-sync/
# Fenêtre de synchronisation (jours)
SYNC_PAST_DAYS=7
SYNC_FUTURE_DAYS=30
# --- Agenda théorique ---
THEORETICAL_AGENDA_PATH=./data/theoretical.ics
THEORETICAL_AGENDA_PATH=./data/theoretical.json
SCHOOL_HOLIDAYS_PATH=./data/school_holidays.json
THEORETICAL_WEEK_ANCHOR_DATE=2026-09-01
THEORETICAL_WEEK_ANCHOR_TYPE=even
# --- XMPP ---
XMPP_ENABLED=false
@@ -34,9 +40,10 @@ XMPP_TIMEOUT=30
# --- IA (optionnelle) ---
AI_ENABLED=true
AI_PROVIDER=openai
AI_BASE_URL=https://api.openai.com/v1
AI_API_KEY=your_ai_api_key
AI_MODEL=gpt-4o-mini
# AI_MODEL=gpt-4o-mini # exemple recommandé, non activé par défaut
# --- Blog ---
BLOG_ENABLED=false

9
.gitignore vendored
View File

@@ -49,5 +49,14 @@ Thumbs.db
.caldav_sync_state.json
*.state.json
# --- Local scratch / WIP files ---
FIXME_*
TEST_*
# --- Logs ---
*.log
# --- Artefacts iCal temporaires ---
# Ignorer les exports iCal temporaires (peuvent contenir des jetons ou données scolaires).
# Les fixtures iCal anonymisées (M13) restent suivies car elles ne correspondent pas au suffixe *.tmp.ics.
*.tmp.ics

View File

@@ -26,7 +26,7 @@ repos:
name: mypy
entry: mypy
language: python
additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0"]
additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "pytest>=8.0.0", "types-requests>=2.31.0", "icalendar>=5.0.0", "pronotepy>=2.15.0", "responses>=0.25.0", "pytest-mock>=3.10.0", "feedparser>=6.0.0", "caldav>=1.3.0"]
types: [python]
pass_filenames: true

View File

@@ -90,6 +90,10 @@
{
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
},
{
"path": "detect_secrets.filters.common.is_baseline_file",
"filename": ".secrets.baseline"
},
{
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
"min_level": 2
@@ -135,10 +139,11 @@
"type": "Hex High Entropy String",
"filename": "GUIDE_DEV_PYTHON.md",
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
"is_verified": false,
"line_number": 5073
"is_verified": true,
"line_number": 5034,
"is_secret": false
}
]
},
"generated_at": "2026-09-05T17:53:24Z"
"generated_at": "2026-09-06T22:00:48Z"
}

View File

@@ -14,9 +14,10 @@
## 2. Stack technique
### Langage et dépendances
- **Python** : ≥ 3.13 (actuellement 3.14 dans `.venv/`)
- **Python** : ≥ 3.13.5 (actuellement 3.14 dans `.venv/`)
- **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
- **Linter** : `ruff` (longueur de ligne : 100)
@@ -36,6 +37,7 @@
```
pronote_sync/
├── config/ # Configuration et paramètres
├── errors.py # Hiérarchie canonique des erreurs du pipeline
├── models/ # Modèles de données (Pydantic v2)
├── sources/ # Connecteurs (Pronote, iCal, etc.)
├── sync/ # Logique de synchronisation
@@ -110,6 +112,8 @@ pronote-sync --dry-run
### Architecture
- **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
- **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**.
- **Mode dégradé** :
- Si l'IA échoue → retourner un message **sans synthèse**.
- Si `pronotepy` échoue → fallback vers le parsing **iCal**.
- Si tout échoue → lever une **erreur explicite**.
- En mode source `auto`, essayer iCal puis utiliser `pronotepy` uniquement si iCal lève une
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)
- **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.
- **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** :
- Une ligne de résumé courte (une phrase).
- Une description étendue optionnelle.
@@ -157,6 +179,11 @@ def fetch_ical(url: str) -> str:
### Règles absolues
- **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`.
- **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
- **Types sécurisés** : Les mots de passe et clés API doivent utiliser `pydantic.SecretStr`.
@@ -189,34 +216,53 @@ Cette section s'applique uniquement lorsque le travail est exécuté avec le sys
Les rôles d'agents disponibles pour ce projet sont les suivants :
- `@architect` : Arbitrages d'architecture et choix techniques structurants pour le pipeline `pronote-sync`.
- `@coder` : Opérations de développement et changements de code dans le projet.
- `@debugger` : Reproduction d'un symptôme et établissement de sa cause profonde (ex. : échec de synchronisation, repli iCal/pronotepy).
- `@explorer` : Exploration du dépôt en lecture seule et fourniture de contexte factuel.
- `@orchestrator` : Compréhension globale du projet, définition des jalons, coordination et garantie du résultat.
- `@planner` : Transformation d'une demande complexe en unités exécutables avec frontières et dépendances claires.
- `@reviewer` : Revues indépendantes de correction, régression, contrats et maintenabilité.
- `@security-auditor` : Audit indépendant d'une surface de sécurité désignée (ex. : gestion des secrets, masquage des données).
- `@tech-writer` : Rédaction et maintenance de documentation technique exacte et vérifiable.
- `@test-engineer` : Conception, écriture et exécution de tests ciblés (unitaires, intégration, mocks).
- `@ui-designer` : conception et implémentation d'interfaces Web et terminal.
- `@verifier` : Vérification indépendante du comportement livré, des régressions et du respect des conventions (idempotence, mode dégradé).
- `@web-explorer` : Recherche et extraction de sources Web vérifiables (ex. : documentation Pronote, CalDAV, XMPP).
- `@architect` : Arbitrages d'architecture et choix techniques structurants. **Ne produit pas de code.**
- `@coder` : Écrit et modifie du code, de la configuration et des scripts. **Ne valide pas** (ruff, mypy, pytest) — c'est le rôle de `@verifier`. **Ne diagnostique pas** — c'est le rôle de `@debugger`.
- `@debugger` : Reproduit un symptôme et établit sa cause profonde. **Ne modifie pas le code.**
- `@explorer` : Explore le dépôt en lecture seule. **Ne modifie rien, n'exécute pas de commandes.**
- `@orchestrator` : Compréhension globale, définition des jalons, coordination et garantie du résultat. **N'écrit pas de code.**
- `@planner` : Transforme une demande complexe en unités exécutables. **Ne dirige aucun technicien.**
- `@reviewer` : Revues indépendantes de correction, régression, contrats et maintenabilité. **Ne modifie pas le code.**
- `@security-auditor` : Audit indépendant d'une surface de sécurité. **Ne modifie pas le code.**
- `@tech-writer` : Rédige et maintient la documentation. **N'écrit pas de code applicatif.**
- `@test-engineer` : Conçoit, écrit et exécute des tests ciblés. **N'écrit pas de code de production.**
- `@ui-designer` : Conçoit et implémente les interfaces Web et terminal.
- `@verifier` : Vérifie indépendamment le comportement livré, les régressions et le respect des conventions (ruff, mypy, pytest, bandit, idempotence, mode dégradé). **Ne modifie pas le code.**
- `@web-explorer` : Recherche et extrait des sources Web vérifiables. **Ne modifie pas le dépôt.**
> **Note** : Ne pas utiliser `@coder` pour les tâches de documentation (`@tech-writer`) ni pour les tests (`@test-engineer`).
### Séparation des rôles
| Type de tâche | Agent responsable | Ne pas confier à |
|---|---|---|
| Écrire/modifier du code | `@coder` | `@verifier`, `@explorer` |
| Valider (ruff, mypy, pytest, bandit) | `@verifier` | `@coder` |
| Diagnostiquer un bug | `@debugger` | `@coder` |
| Écrire un test | `@test-engineer` | `@coder` |
| Rédiger de la documentation | `@tech-writer` | `@coder` |
| Explorer le dépôt (lecture) | `@explorer` | `@coder`, `@verifier` |
| Arbitrage technique structurant | `@architect` | `@coder`, `@planner` |
| Revue de code | `@reviewer` | `@coder`, `@verifier` |
| Audit de sécurité | `@security-auditor` | `@coder`, `@verifier` |
| Recherche web | `@web-explorer` | `@explorer` |
| Découpage de travail complexe | `@planner` | `@coder` |
---
## 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.
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).
5. Vérifier le comportement nominal et les cas d'erreur, notamment :
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 :
- Succès de la synchronisation Pronote → CalDAV/XMPP.
- Repli vers iCal en cas d'échec de `pronotepy`.
- Gestion des erreurs explicites.
- Repli d'iCal vers `pronotepy` en mode `auto`, sans repli dans les modes 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.
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.
@@ -261,7 +307,8 @@ Les rôles d'agents disponibles pour ce projet sont les suivants :
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.
- 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).

File diff suppressed because it is too large Load Diff

81
TODO.md
View File

@@ -73,19 +73,23 @@ 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.
- [ ] Créer `sources/pronote/ical.py` : `fetch_ical(url)` (HTTP via `requests`, erreurs redactées) et parsing iCal → `Lesson`/`Homework`/`SchoolEvent` (`icalendar`).
- [ ] Extraire les blocs de devoirs (`HomeworkBlock`) depuis `DESCRIPTION` et dédupliquer les devoirs (clé normalisée par date).
- [ ] Détecter les statuts (`CANCELLED`/`MOVED`) via `CATEGORIES` et `STATUS:CANCELLED`.
- [ ] Créer `sources/pronote/client.py` : client `pronotepy` (messages, informations, discussions, sondages, et devoirs en repli) avec masquage des erreurs.
- [ ] Créer `sources/pronote/fallback.py` : sélection de source selon `PRONOTE_*_SOURCE` (auto/ical/pronotepy) et `PronoteFetcher` unifiant `fetch_agenda`/`fetch_homework`/`fetch_messages`.
- [ ] Implémenter le repli : iCal échoue → pronotepy ; pronotepy échoue → iCal ; les deux échouent → `PipelineCriticalError`.
- [ ] Normaliser les UID via `utils/uid.normalize_uid` pour la stabilité des événements.
- [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` 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] 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] 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.
### Critères d'acceptation
- `fetch_ical` parse `tests/fixtures/pronote-4e.ics` en leçons/devoirs/événements corrects (cours annulé détecté).
- Le client pronotepy récupère messages/devoirs (mocké).
- Le repli bascule correctement et lève une erreur critique si aucune source disponible.
- Aucun secret dans les messages d'erreur de fetch.
- `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.
- 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 constructeur `ParentClient` est testé avec l'ordre réel de ses paramètres, l'URL Pronote et une fonction ENT autorisée.
- 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.
---
@@ -93,11 +97,11 @@ Récupérer et normaliser l'agenda, les devoirs et les messages Pronote, avec re
Récupérer le flux RSS du blog du collège, parser et dédupliquer les articles.
- [ ] Créer `sources/blog/rss.py` : `BlogRSSClient.fetch_and_parse(known_guids)` avec `feedparser` (§5 bis.7.1).
- [ ] Parser les dates (RFC 822 / ISO 8601) et convertir le HTML en texte brut (`BeautifulSoup` + `html.unescape`).
- [ ] Créer `sources/blog/state.py` (ou `sync/blog_state.py`) : `BlogRSSState` (JSON : `known_guids`, `etag`, `last_modified`).
- [ ] Implémenter la déduplication par GUID et le cache HTTP (`If-Modified-Since` / `etag`).
- [ ] Gérer un flux invalide (`bozo`) et les exceptions sans fuite de secret (retour `[]`/warning).
- [x] Créer `sources/blog/rss.py` : `BlogRSSClient.fetch_and_parse(known_guids)` avec `feedparser` (§5 bis.7.1).
- [x] Parser les dates (RFC 822 / ISO 8601) et convertir le HTML en texte brut (`BeautifulSoup` + `html.unescape`).
- [x] Créer `sources/blog/state.py` (ou `sync/blog_state.py`) : `BlogRSSState` (JSON : `known_guids`, `etag`, `last_modified`).
- [x] Implémenter la déduplication par GUID et le cache HTTP (`If-Modified-Since` / `etag`).
- [x] Gérer un flux invalide (`bozo`) et les exceptions sans fuite de secret (retour `[]`/warning).
### Critères d'acceptation
- `fetch_and_parse` renvoie les nouveaux articles triés par date décroissante, sans doublons.
@@ -108,16 +112,23 @@ Récupérer le flux RSS du blog du collège, parser et dédupliquer les articles
## M6. Source agenda théorique — Priorité : Moyenne
Lire l'agenda théorique (iCal ou CSV) via une interface de provider extensible.
Lire l'agenda théorique (JSON) via une interface de provider extensible, avec gestion de la parité des semaines (paire/impaire) et des vacances scolaires.
- [ ] Créer `sources/theoretical/provider.py` : protocole `TheoreticalAgendaProvider` (§8.2).
- [ ] Créer `sources/theoretical/file.py` : lecture fichier iCal/CSV → liste de `TheoreticalLesson` (§8.3).
- [ ] Normaliser les matières et créneaux pour le matching déterministe.
- [ ] Supporter les deux formats (iCal et CSV) derrière la même interface.
- [x] Créer `sources/theoretical/provider.py` : protocole `TheoreticalAgendaProvider` (§8.2).
- [x] Créer `sources/theoretical/file.py` : parser JSON → liste de `TheoreticalLesson` avec filtrage par parité de semaine (paire/impaire/toutes).
- [x] Créer `sources/theoretical/parity.py` : service `WeekParityService` déterminant la parité d'une date à partir d'une date de référence configurée.
- [x] Créer `sources/theoretical/holidays.py` : service `SchoolHolidayCalendar` lisant un fichier JSON de vacances scolaires (zone A) et exposant `is_holiday(date)`.
- [x] Implémenter le provider JSON : filtrage par parité + vacances, génération d'identifiants déterministes incluant le type de semaine.
- [x] Ajouter la configuration : `SCHOOL_HOLIDAYS_PATH`, `THEORETICAL_WEEK_ANCHOR_DATE`, `THEORETICAL_WEEK_ANCHOR_TYPE` dans `AppSettings`.
- [x] Normaliser les matières et créneaux pour le matching déterministe.
- [x] Créer les fixtures : `tests/fixtures/theoretical.json` et `tests/fixtures/school_holidays.json`.
### Critères d'acceptation
- `file.py` lit `tests/fixtures/theoretical.ics` et `theoretical.csv` en `TheoreticalLesson`.
- `file.py` lit `tests/fixtures/theoretical.json` en `TheoreticalLesson` avec filtrage par parité.
- Le provider renvoie une liste vide pendant les vacances scolaires.
- Le provider renvoie une liste stable et déterministe (tri par identifiant).
- Les identifiants sont distincts pour des leçons de parité différente sur le même créneau.
- Une configuration incomplète (ancre de parité manquante alors que des leçons `even`/`odd` existent) produit une erreur explicite.
---
@@ -125,17 +136,20 @@ Lire l'agenda théorique (iCal ou CSV) via une interface de provider extensible.
Synchroniser différentiellement les événements Pronote vers le calendrier CalDAV, de façon idempotente.
- [ ] Créer `sync/caldav.py` : `CalDAVClient` (connexion, liste/ajout/MAJ/suppression, marqueur `X-PRONOTE-SYNC-MANAGED: v1`).
- [ ] Créer `sync/state.py` : état local de sync (SQLite ou JSON) assurant l'idempotence (UID connus).
- [ ] Calculer le `CalDAVSyncPlan` (to_add / to_update / to_remove) par UID stable.
- [ ] Créer `sync/caldav.py` : passerelle CalDAV isolant la bibliothèque `caldav>=1.3.0` (connexion via `DAVClient`, résolution du calendrier via `calendar_path`, récupération/ajout/MAJ/suppression des événements, marqueur `X-PRONOTE-SYNC-MANAGED: v1`).
- [ ] Calculer le `CalDAVSyncPlan` (to_add / to_update / to_remove) par UID stable, explicitement avant l'exécution de la sync.
- [ ] Implémenter l'exécution du plan : ajout, mise à jour (si modifié), suppression (si absent). En mode `dry_run`, loguer le plan sans écrire.
- [ ] Vérifier sur fixture anonymisée que le même cours provenant d'iCal et de `pronotepy` possède le même identifiant canonique ; corriger la normalisation des UID dans `sources/pronote/client.py` à la frontière des sources si nécessaire.
- [ ] Implémenter la sync différentielle : conserver les cours annulés (`STATUS:CANCELLED`), ne pas supprimer.
- [ ] Garantir l'idempotence (2 exécutions identiques → même `CalDAVSyncResult`).
- [ ] Réutiliser `BlogRSSState` pour l'état blog si pertinent (sinon `sync/blog_state.py`).
- [ ] Garantir l'idempotence (2 exécutions identiques → même `CalDAVSyncResult`), sans état local persistant (scan du calendrier distant).
- [ ] Ne jamais modifier ou supprimer les événements non marqués `X-PRONOTE-SYNC-MANAGED`.
### Critères d'acceptation
- Le plan de sync est correctement calculé (PronoteData vs état local).
- Le plan de sync est correctement calculé (données Pronote vs événements distants gérés).
- Un changement de source iCal ↔ `pronotepy` ne crée ni doublon ni suppression/ajout artificiel pour un cours équivalent.
- Un run dry-run n'écrit rien ; deux runs identiques donnent un résultat identique.
- Les événements annulés restent (`STATUS:CANCELLED`) et sont marqués `MANAGED`.
- Les événements non marqués ne sont jamais modifiés ni supprimés.
---
@@ -194,15 +208,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.
- [ ] 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 `pipeline/run.py` : `PipelineRunner` (composition root) orchestrant fetch → normalize → fetch_blog → compare → caldav_sync → synthesis → send.
- [ ] Gérer les erreurs dégradées (continuer sauf critique) et renvoyer `(PronoteData, erreurs + warns)`.
- [ ] Implémenter le mode `dry_run` (aucune écriture CalDAV/XMPP).
- [ ] Câbler l'injection des dépendances (Protocol + composition root), sans singleton global.
- [ ] 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
- 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.
- `dry_run=True` n'effectue aucune écriture ; aucune source disponible → erreur critique explicite.
@@ -220,7 +236,7 @@ Exposer le lancement du pipeline via une interface en ligne de commande.
### Critères d'acceptation
- `pronote-sync --dry-run --log-level DEBUG` s'exécute sans effet de bord.
- Le script console est installable (`[project.scripts]` dans `pyproject.toml`).
- Les erreurs affichées ne contiennent aucun secret.
- Les erreurs affichées ne contiennent aucun secret, y compris avec l'affichage d'un traceback complet en mode debug.
---
@@ -228,13 +244,14 @@ Exposer le lancement du pipeline via une interface en ligne de commande.
Couvrir l'ensemble du code par des tests sans réseau, avec fixtures anonymisées, jusqu'à ≥ 90 %.
- [ ] Créer `tests/fixtures/` : `pronote-4e.ics`, `pronote-6e.ics`, `theoretical.ics`, `theoretical.csv`, `blog_rss.xml` (anonymisés, sans `icalsecurise`).
- [ ] Créer `tests/fixtures/` : `pronote-4e.ics`, `pronote-6e.ics`, `theoretical.json`, `school_holidays.json`, `blog_rss.xml` (anonymisés, sans `icalsecurise`).
- [ ] Créer `tests/conftest.py` : fixtures partagées (sample_lesson, sample_cancelled_lesson, sample_homework, sample_school_event, sample_message, sample_pronote_data…).
- [ ] Écrire `tests/unit/` : `test_models`, `test_parsing` (iCal), `test_uid`, `test_redaction`, `test_diff`, `test_sync`.
- [ ] 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/e2e/test_cli.py` : exécution CLI en dry-run.
- [ ] Tests sans réseau (mocks `responses`/`aioresponses`/`pytest-mock`) ; couverture ≥ 90 %.
- [ ] Ajouter un test négatif : les messages d'erreur ne fuient pas de secrets (`icalsecurise`, clés API, mots de passe).
- [ ] 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
- `pytest` passe et `pytest --cov` atteint ≥ 90 % (`fail_under = 90`).

View File

@@ -8,9 +8,10 @@ depuis les variables d'environnement (préfixées par groupe) et le fichier
from __future__ import annotations
from datetime import date
from typing import Literal
from pydantic import SecretStr
from pydantic import Field, SecretStr, field_serializer
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -23,14 +24,28 @@ class PronoteSettings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="PRONOTE_")
ical_url: str | None = None
ical_url: SecretStr | None = None
username: str | None = None
password: SecretStr | None = None
ent: str | None = None
pronote_url: str | None = None
account_type: Literal["student", "parent"] = "parent"
agenda_source: Literal["auto", "ical", "pronotepy"] = "auto"
homework_source: Literal["auto", "ical", "pronotepy"] = "auto"
messages_source: Literal["pronotepy"] = "pronotepy"
@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).
:param value: Valeur du champ ``ical_url``.
:return: ``"**********"`` si la valeur est définie, ``None`` sinon.
:rtype: str | None
"""
if value is None:
return None
return "**********"
class CalDAVSettings(BaseSettings):
"""Paramètres d'accès au serveur CalDAV de destination.
@@ -100,7 +115,10 @@ class AppSettings(BaseSettings):
"""Paramètres généraux de l'application, sans préfixe d'environnement.
Contient notamment la fenêtre de synchronisation en jours
(``SYNC_PAST_DAYS`` / ``SYNC_FUTURE_DAYS``).
(``SYNC_PAST_DAYS`` / ``SYNC_FUTURE_DAYS``) et la configuration de
l'agenda théorique (``THEORETICAL_AGENDA_PATH``,
``THEORETICAL_WEEK_ANCHOR_DATE``, ``THEORETICAL_WEEK_ANCHOR_TYPE`` ainsi
que ``SCHOOL_HOLIDAYS_PATH`` pour les vacances scolaires).
"""
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@@ -108,6 +126,9 @@ class AppSettings(BaseSettings):
dry_run: bool = False
log_level: str = "INFO"
theoretical_agenda_path: str | None = None
school_holidays_path: str | None = None
theoretical_week_anchor_date: date | None = None
theoretical_week_anchor_type: Literal["even", "odd"] | None = None
sync_past_days: int = 7
sync_future_days: int = 30
@@ -121,12 +142,9 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
pronote: PronoteSettings = PronoteSettings()
caldav: CalDAVSettings = CalDAVSettings()
xmpp: XmppSettings = XmppSettings()
ai: AISettings = AISettings()
blog: BlogSettings = BlogSettings()
app: AppSettings = AppSettings()
settings = Settings()
pronote: PronoteSettings = Field(default_factory=PronoteSettings)
caldav: CalDAVSettings = Field(default_factory=CalDAVSettings)
xmpp: XmppSettings = Field(default_factory=XmppSettings)
ai: AISettings = Field(default_factory=AISettings)
blog: BlogSettings = Field(default_factory=BlogSettings)
app: AppSettings = Field(default_factory=AppSettings)

33
pronote_sync/errors.py Normal file
View File

@@ -0,0 +1,33 @@
"""Hiérarchie d'erreurs transversale du projet pronote-sync."""
from __future__ import annotations
class PronoteSyncError(Exception):
"""Erreur de base pour toutes les exceptions du projet pronote-sync.
Toutes les exceptions métier héritent de cette classe pour permettre
une interception unifiée des erreurs applicatives.
"""
def __init__(self, message: str) -> None:
"""Initialise l'erreur avec un message descriptif.
:param message: Message décrivant la cause de l'erreur.
"""
super().__init__(message)
class PipelineCriticalError(PronoteSyncError):
"""Erreur critique du pipeline, levée quand aucune récupération n'est possible.
Par exemple : échec simultané des sources iCal et pronotepy,
rendant impossible toute synchronisation.
"""
def __init__(self, message: str) -> None:
"""Initialise l'erreur critique avec un message descriptif.
:param message: Message décrivant la cause de l'erreur critique.
"""
super().__init__(message)

View File

@@ -8,7 +8,7 @@ from datetime import time as _time
from enum import StrEnum
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
class Status(StrEnum):
@@ -30,6 +30,8 @@ class LessonStatus(StrEnum):
class HomeworkBlock(BaseModel):
"""Représente un bloc de devoir extrait de la description d'un cours."""
model_config = ConfigDict(frozen=True)
kind: Literal["due", "assigned"] = Field(
..., description="Type de bloc (échéance ou attribution)"
)
@@ -47,16 +49,28 @@ class Lesson(BaseModel):
start: datetime = Field(..., description="Date/heure de début")
end: datetime = Field(..., description="Date/heure de fin")
subject: str = Field(..., description="Matière")
teachers: list[str] = Field(default_factory=list, description="Liste des professeurs")
rooms: list[str] = Field(default_factory=list, description="Liste des salles")
teachers: tuple[str, ...] = Field(default=(), description="Liste des professeurs")
rooms: tuple[str, ...] = Field(default=(), description="Liste des salles")
group: str | None = Field(None, description="Groupe (ex: Classe entière)")
status: LessonStatus = Field(default=LessonStatus.NORMAL, description="Statut du cours")
content: str | None = Field(None, description="Contenu pédagogique")
homework_blocks: list[HomeworkBlock] = Field(
default_factory=list,
homework_blocks: tuple[HomeworkBlock, ...] = Field(
default=(),
description="Blocs de devoirs extraits de la description",
)
@model_validator(mode="after")
def _validate_time_order(self) -> Lesson:
"""Valide que l'heure de fin est postérieure à l'heure de début.
:return: L'instance validée.
:rtype: Lesson
:raises ValueError: Si ``end`` n'est pas postérieur à ``start``.
"""
if self.end <= self.start:
raise ValueError("end doit être postérieur à start")
return self
class SchoolEventKind(StrEnum):
"""Type d'événement scolaire."""
@@ -75,6 +89,18 @@ class SchoolEvent(BaseModel):
from_date: _date = Field(..., description="Date de début (inclusive)")
to_date: _date = Field(..., description="Date de fin (exclusive)")
@model_validator(mode="after")
def _validate_date_order(self) -> SchoolEvent:
"""Valide que la date de fin est postérieure ou égale à la date de début.
:return: L'instance validée.
:rtype: SchoolEvent
:raises ValueError: Si ``to_date`` est antérieur à ``from_date``.
"""
if self.to_date < self.from_date:
raise ValueError("to_date doit être postérieur ou égal à from_date")
return self
class TheoreticalLesson(BaseModel):
"""Représente un cours dans l'agenda théorique."""
@@ -82,9 +108,23 @@ class TheoreticalLesson(BaseModel):
model_config = ConfigDict(frozen=True)
id: str = Field(..., description="Identifiant unique")
day_of_week: int = Field(..., description="Jour de la semaine (0=lundi, 6=dimanche)")
day_of_week: int = Field(
..., ge=0, le=6, description="Jour de la semaine (0=lundi, 6=dimanche)"
)
start_time: _time = Field(..., description="Heure de début")
end_time: _time = Field(..., description="Heure de fin")
subject: str = Field(..., description="Matière")
teachers: list[str] = Field(default_factory=list, description="Liste des professeurs")
rooms: list[str] = Field(default_factory=list, description="Liste des salles")
teachers: tuple[str, ...] = Field(default=(), description="Liste des professeurs")
rooms: tuple[str, ...] = Field(default=(), description="Liste des salles")
@model_validator(mode="after")
def _validate_time_order(self) -> TheoreticalLesson:
"""Valide que l'heure de fin est postérieure à l'heure de début.
:return: L'instance validée.
:rtype: TheoreticalLesson
:raises ValueError: Si ``end_time`` n'est pas postérieur à ``start_time``.
"""
if self.end_time <= self.start_time:
raise ValueError("end_time doit être postérieur à start_time")
return self

View File

@@ -26,17 +26,22 @@ class BlogArticle(BaseModel):
class ExternalInfo(BaseModel):
"""Agrège les informations externes (blog, messages Pronote) pour le message XMPP."""
"""Agrège les informations externes (blog, messages Pronote) pour le message XMPP.
blog_articles: list[BlogArticle] = Field(
default_factory=list,
Modèle figé (``frozen``) : les instances sont immuables après création.
"""
model_config = ConfigDict(frozen=True)
blog_articles: tuple[BlogArticle, ...] = Field(
default=(),
description="Liste des nouveaux articles du blog",
)
pronote_messages: list[Message] = Field(
default_factory=list,
pronote_messages: tuple[Message, ...] = Field(
default=(),
description="Liste des messages Pronote",
)
other_info: list[str] = Field(
default_factory=list,
other_info: tuple[str, ...] = Field(
default=(),
description="Autres informations (extensible)",
)

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import date
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pronote_sync.models.agenda import Lesson, TheoreticalLesson
@@ -30,6 +30,22 @@ class AgendaChange(BaseModel):
)
details: str = Field(default="", description="Détails du changement")
@model_validator(mode="after")
def _validate_payload_consistency(self) -> AgendaChange:
"""Valide la cohérence entre le type de changement et le payload.
:return: L'instance validée.
:rtype: AgendaChange
:raises ValueError: Si le payload ne correspond pas au type de changement.
"""
if self.type in (AgendaChangeType.ADDED, AgendaChangeType.MODIFIED):
if self.lesson is None:
raise ValueError(f"lesson est requis pour le type {self.type!r}")
if self.type == AgendaChangeType.REMOVED:
if self.theoretical_lesson is None:
raise ValueError(f"theoretical_lesson est requis pour le type {self.type!r}")
return self
class AgendaDiff(BaseModel):
"""Représente les différences entre l'agenda réel et l'agenda théorique."""
@@ -37,4 +53,4 @@ class AgendaDiff(BaseModel):
model_config = ConfigDict(frozen=True)
target_date: date = Field(..., description="Date cible de la comparaison")
changes: list[AgendaChange] = Field(default_factory=list, description="Liste des changements")
changes: tuple[AgendaChange, ...] = Field(default=(), description="Liste des changements")

View File

@@ -14,7 +14,7 @@ class Homework(BaseModel):
id: str = Field(..., description="ID stable (hachage)")
subject: str = Field(..., description="Matière")
teachers: list[str] = Field(default_factory=list, description="Liste des professeurs")
teachers: tuple[str, ...] = Field(default=(), description="Liste des professeurs")
assigned_on: date | None = Field(None, description="Date de distribution")
due_on: date = Field(..., description="Date d'échéance")
text: str = Field(..., description="Texte du devoir (brut)")

View File

@@ -36,7 +36,7 @@ class CalDAVSyncResult(BaseModel):
"""Résultat d'une synchronisation CalDAV (mutable)."""
status: CalDAVSyncStatus = Field(..., description="Statut global")
added: int = Field(0, description="Nombre d'événements ajoutés")
updated: int = Field(0, description="Nombre d'événements mis à jour")
removed: int = Field(0, description="Nombre d'événements supprimés")
added: int = Field(0, ge=0, description="Nombre d'événements ajoutés")
updated: int = Field(0, ge=0, description="Nombre d'événements mis à jour")
removed: int = Field(0, ge=0, description="Nombre d'événements supprimés")
errors: list[str] = Field(default_factory=list, description="Liste des erreurs")

View File

@@ -19,13 +19,11 @@ class XmppMessage(BaseModel):
target_date: date = Field(..., description="Date cible")
synthesis: str | None = Field(None, description="Synthèse IA (optionnelle, 3-5 phrases)")
homeworks: list[Homework] = Field(default_factory=list, description="Liste brute des devoirs")
changes: list[AgendaChange] = Field(
default_factory=list, description="Liste des changements d'agenda"
)
messages: list[Message] = Field(
default_factory=list, description="Liste des messages/informations"
homeworks: tuple[Homework, ...] = Field(default=(), description="Liste brute des devoirs")
changes: tuple[AgendaChange, ...] = Field(
default=(), description="Liste des changements d'agenda"
)
messages: tuple[Message, ...] = Field(default=(), description="Liste des messages/informations")
external_info: ExternalInfo | None = Field(
None, description="Informations externes (blog, messages Pronote)"
)

View File

@@ -0,0 +1,21 @@
"""Source du blog du collège : récupération et suivi du flux RSS.
Ce package expose l'API publique du connecteur du blog du collège :
- :class:`BlogRSSClient` (:mod:`pronote_sync.sources.blog.rss`) : télécharge
et parse le flux RSS, déduplique les entrées par GUID et renvoie les
nouveaux articles dans un :class:`BlogRSSFetchResult`.
- :class:`BlogRSSFetchResult` (:mod:`pronote_sync.sources.blog.result`) :
type de retour figé d'une récupération : nouveaux articles, en-têtes
HTTP de cache (``ETag``/``Last-Modified``) et indicateur ``304 Not
Modified``.
- :class:`BlogRSSState` (:mod:`pronote_sync.sources.blog.state`) : état
local persistant (GUID connus et en-têtes de cache) pour la
déduplication et les requêtes conditionnelles.
"""
from pronote_sync.sources.blog.result import BlogRSSFetchResult
from pronote_sync.sources.blog.rss import BlogRSSClient
from pronote_sync.sources.blog.state import BlogRSSState
__all__ = ["BlogRSSClient", "BlogRSSFetchResult", "BlogRSSState"]

View File

@@ -0,0 +1,54 @@
"""Résultat de la récupération du flux RSS du blog du collège.
Ce module définit :class:`BlogRSSFetchResult`, le type de retour figé du
client RSS du blog (:mod:`pronote_sync.sources.blog`).
"""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
from pronote_sync.models.blog import BlogArticle
class BlogRSSFetchResult(BaseModel):
"""Résultat d'une récupération du flux RSS du blog du collège.
Modèle figé (``frozen``) : les instances sont immuables après création.
Il regroupe les nouveaux articles, triés par date de publication
décroissante puis par identifiant croissant, ainsi que les en-têtes
HTTP utiles aux requêtes conditionnelles (``ETag`` et
``Last-Modified``).
:param articles: Nouveaux articles absents de ``known_guids``, triés
par date de publication décroissante puis par identifiant
croissant. Vide par défaut.
:param etag: Valeur de l'en-tête ``ETag`` de la réponse RSS, si elle
est disponible. ``None`` par défaut.
:param last_modified: Valeur de l'en-tête ``Last-Modified`` de la
réponse RSS, si elle est disponible. ``None`` par défaut.
:param not_modified: Vaut ``True`` si le serveur a répondu avec le
statut ``304 Not Modified``, ``False`` sinon.
"""
model_config = ConfigDict(frozen=True)
articles: tuple[BlogArticle, ...] = Field(
default=(),
description=(
"Nouveaux articles absents de known_guids, triés par date de "
"publication décroissante puis par identifiant croissant"
),
)
etag: str | None = Field(
default=None,
description="Valeur de l'en-tête ETag de la réponse RSS, si disponible",
)
last_modified: str | None = Field(
default=None,
description="Valeur de l'en-tête Last-Modified de la réponse RSS, si disponible",
)
not_modified: bool = Field(
default=False,
description="Vaut True si le serveur a répondu 304 Not Modified",
)

View File

@@ -0,0 +1,293 @@
"""Client de récupération et de parsing du flux RSS du blog du collège.
Ce module définit :class:`BlogRSSClient`, un client sans état qui
télécharge le flux RSS du blog via ``requests``, le parse via
``feedparser``, déduplique les entrées par GUID et les convertit en
:class:`~pronote_sync.models.blog.BlogArticle`.
Le résultat d'une récupération est un
:class:`~pronote_sync.sources.blog.result.BlogRSSFetchResult` : les
nouveaux articles (triés par date de publication décroissante, puis par
identifiant croissant) accompagnés des en-têtes HTTP ``ETag`` et
``Last-Modified`` de la réponse. Toute erreur de récupération ou de
parsing est journalisée (URL et exception rédigées) puis dégradée en
résultat vide : une liste vide est un succès valide, pas une panne.
"""
from __future__ import annotations
import logging
import re
from datetime import UTC, datetime
from html import unescape
import feedparser # type: ignore[import-untyped]
import requests
from bs4 import BeautifulSoup
from pronote_sync.models.blog import BlogArticle
from pronote_sync.sources.blog.result import BlogRSSFetchResult
from pronote_sync.utils.redaction import redact_exception, redact_url
logger = logging.getLogger(__name__)
class BlogRSSClient:
"""Client de récupération et de parsing du flux RSS du blog du collège.
Client sans état : aucune E/S n'est effectuée à la construction et
aucune donnée n'est conservée entre deux appels à
:meth:`fetch_and_parse`. Toute erreur de récupération ou de parsing
est journalisée puis dégradée en résultat vide.
:param rss_url: URL du flux RSS du blog du collège.
:param timeout: Timeout HTTP en secondes (défaut : 20).
"""
def __init__(self, rss_url: str, timeout: int = 20) -> None:
"""Initialise le client RSS du blog.
Aucune opération d'E/S n'est réalisée ici : le téléchargement et
le parsing n'ont lieu qu'à l'appel de :meth:`fetch_and_parse`.
:param rss_url: URL du flux RSS du blog du collège.
:param timeout: Timeout HTTP en secondes (défaut : 20).
"""
self.rss_url = rss_url
self.timeout = timeout
def fetch_and_parse(
self,
*,
known_guids: frozenset[str] | None = None,
etag: str | None = None,
last_modified: str | None = None,
) -> BlogRSSFetchResult:
"""Télécharge et parse le flux RSS du blog en nouveaux articles.
Le flux est téléchargé par ``requests`` avec les en-têtes de
requête conditionnelle fournis (``ETag``/``Last-Modified``), puis
parsé par ``feedparser``. Si le serveur répond ``304 Not Modified``,
le résultat est vide avec
``not_modified=True`` et les en-têtes passés en entrée sont
restitués tels quels. Chaque entrée est dédupliquée par GUID,
convertie en :class:`~pronote_sync.models.blog.BlogArticle`, puis
l'ensemble est trié par date de publication décroissante puis par
identifiant croissant. Toute erreur est journalisée (URL et
exception rédigées) et dégradée en résultat vide : aucune
exception n'est propagée.
:param known_guids: Ensemble des GUID d'articles déjà traités ; les
entrées correspondantes sont ignorées. ``None`` pour tout
conserver (défaut).
:param etag: Valeur de l'en-tête ``ETag`` mémorisée pour la requête
conditionnelle, ou ``None`` (défaut).
:param last_modified: Valeur de l'en-tête ``Last-Modified`` mémorisée
pour la requête conditionnelle, ou ``None`` (défaut).
:return: Résultat de la récupération : nouveaux articles (tuple vide
si aucun nouvel article, réponse ``304`` ou erreur), en-têtes de
cache de la réponse et indicateur ``not_modified``.
:rtype: :class:`~pronote_sync.sources.blog.result.BlogRSSFetchResult`
"""
try:
# Téléchargement HTTP explicite via requests : feedparser 6.x
# n'accepte aucun paramètre de transport ; les requêtes
# conditionnelles sont gérées avec les en-têtes HTTP standards.
headers: dict[str, str] = {"user-agent": "pronote-sync"}
if etag is not None:
headers["If-None-Match"] = etag
if last_modified is not None:
headers["If-Modified-Since"] = last_modified
response = requests.get(self.rss_url, headers=headers, timeout=self.timeout)
# Réponse 304 Not Modified : rien n'a changé, on restitue les
# en-têtes mémorisés tels quels pour les conserver.
if response.status_code == 304:
return BlogRSSFetchResult(
articles=(),
etag=etag,
last_modified=last_modified,
not_modified=True,
)
# Les statuts 4xx/5xx lèvent une exception HTTP, attrapée par le
# gestionnaire général et dégradée en résultat vide.
response.raise_for_status()
response_etag: str | None = response.headers.get("ETag", None)
if response_etag is None:
response_etag = response.headers.get("etag", None)
response_last_modified: str | None = response.headers.get("Last-Modified", None)
if response_last_modified is None:
response_last_modified = response.headers.get("last-modified", None)
# feedparser ne reçoit que le contenu brut de la réponse.
feed = feedparser.parse(response.content)
# Flux invalide (XML malformé, etc.) : avertissement puis résultat
# vide, sans propager l'exception brute. Les validateurs de cache
# d'entrée sont conservés : on ne fait pas confiance aux en-têtes
# d'une réponse au contenu invalide.
if getattr(feed, "bozo", None):
bozo_exception = getattr(feed, "bozo_exception", None)
if bozo_exception is not None:
logger.warning(
"Flux RSS du blog invalide (%s), ignoré : %s",
redact_exception(bozo_exception),
redact_url(self.rss_url),
)
else:
logger.warning(
"Flux RSS du blog invalide, ignoré : %s",
redact_url(self.rss_url),
)
return BlogRSSFetchResult(
articles=(),
etag=etag,
last_modified=last_modified,
not_modified=False,
)
articles: list[BlogArticle] = []
# Déduplication silencieuse des GUID déjà connus (exécutions
# précédentes) et détection des doublons au sein de la réponse.
known_set = set(known_guids) if known_guids is not None else None
seen_in_feed: set[str] = set()
for entry in getattr(feed, "entries", []):
guid_source = entry.get("id") or entry.get("link")
if not guid_source:
logger.warning(
"Entrée RSS sans GUID ni lien, ignorée : %s",
redact_url(self.rss_url),
)
continue
guid = str(guid_source)
if known_set is not None and guid in known_set:
# Déduplication normale (GUID connu d'une exécution
# précédente) : aucun journal n'est nécessaire.
continue
if guid in seen_in_feed:
logger.warning(
"Entrée RSS en double dans le flux, ignorée : %s",
redact_url(self.rss_url),
)
continue
seen_in_feed.add(guid)
published_at = self._parse_date(
entry.get("published_parsed") or entry.get("pubdate_parsed")
)
if published_at is None:
logger.warning(
"Entrée RSS sans date de publication valide, ignorée : %s",
redact_url(self.rss_url),
)
continue
updated_at = self._parse_date(entry.get("updated_parsed"))
raw_content = entry.get("content")
if raw_content:
content_html = str(raw_content[0].get("value") or "")
else:
content_html = str(entry.get("description") or "")
tags = entry.get("tags")
category_value = tags[0].get("term") if tags else None
if not category_value:
category_value = entry.get("category")
category = str(category_value) if category_value else None
author_value = entry.get("author")
author = str(author_value) if author_value else None
title = str(entry.get("title") or guid)
url = str(entry.get("link") or guid)
articles.append(
BlogArticle(
id=guid,
title=title,
url=url,
published_at=published_at,
updated_at=updated_at,
category=category,
author=author,
content_html=content_html,
content_text=self._html_to_text(content_html),
)
)
# Tri stable : d'abord par identifiant croissant, puis par date de
# publication décroissante ; l'ordre par identifiant est conservé
# entre articles de même date.
articles.sort(key=lambda article: article.id)
articles.sort(key=lambda article: article.published_at, reverse=True)
return BlogRSSFetchResult(
articles=tuple(articles),
etag=response_etag,
last_modified=response_last_modified,
not_modified=False,
)
except Exception as exc:
logger.error(
"Échec de la récupération du flux RSS du blog %s : %s",
redact_url(self.rss_url),
redact_exception(exc),
)
return BlogRSSFetchResult(
articles=(),
etag=etag,
last_modified=last_modified,
not_modified=False,
)
@staticmethod
def _parse_date(date_tuple: tuple[int, ...] | None) -> datetime | None:
"""Convertit un tuple de date ``struct_time`` en :class:`datetime` UTC.
:param date_tuple: Tuple horodaté au format ``time.struct_time``
(indices 0 à 5 : année, mois, jour, heure, minute, seconde), ou
``None`` si absent.
:return: Date/heure consciente du fuseau UTC, ou ``None`` si le
tuple est absent, vide ou invalide.
:rtype: datetime | None
"""
if not date_tuple:
return None
try:
return datetime(
date_tuple[0],
date_tuple[1],
date_tuple[2],
date_tuple[3],
date_tuple[4],
date_tuple[5],
tzinfo=UTC,
)
except (ValueError, IndexError):
return None
@staticmethod
def _html_to_text(html: str) -> str:
"""Convertit du HTML en texte brut nettoyé.
Le HTML est parsé avec BeautifulSoup, les balises sont remplacées
par des espaces, les entités HTML sont décodées et les suites
d'espaces sont unifiées.
:param html: Contenu HTML à convertir.
:return: Texte brut sans balises, entités décodées et espaces
unifiés ; chaîne vide si ``html`` est vide.
:rtype: str
"""
if not html:
return ""
soup = BeautifulSoup(html, "html.parser")
text = soup.get_text(separator=" ", strip=True)
text = unescape(text)
return re.sub(r"\s+", " ", text).strip()

View File

@@ -0,0 +1,174 @@
"""Gestion de l'état local du flux RSS du blog du collège.
Ce module définit :class:`BlogRSSState`, un gestionnaire d'état persistant
dans un fichier JSON local (``.blog_rss_state.json`` par défaut). Il
mémorise les identifiants (GUID) des articles déjà traités — pour la
déduplication — ainsi que les en-têtes HTTP ``ETag`` et ``Last-Modified``
de la dernière réponse — pour les requêtes conditionnelles.
La lecture et l'écriture sont tolérantes aux erreurs : un fichier absent,
corrompu ou illisible ne fait jamais échouer le pipeline ; l'état vide est
alors utilisé. La sortie JSON est déterministe (``known_guids`` triés
alphabétiquement, champ ``version`` constant).
"""
from __future__ import annotations
import json
import logging
from collections.abc import Iterable
from pathlib import Path
from pronote_sync.utils.redaction import redact_exception, redact_secrets
logger = logging.getLogger(__name__)
_STATE_VERSION = 1
class BlogRSSState:
"""Gère l'état local pour la déduplication des articles et le cache HTTP du flux RSS.
L'état regroupe l'ensemble des GUID d'articles déjà publiés
(``known_guids``) et les en-têtes de cache HTTP (``etag``,
``last_modified``). Il est chargé depuis le fichier JSON à la
construction et sauvegardé à chaque modification. Toute erreur de
lecture ou d'écriture est journalisée sans être propagée.
:param state_file: Chemin du fichier d'état JSON (``str`` ou
:class:`~pathlib.Path`). ``".blog_rss_state.json"`` par défaut.
"""
def __init__(self, state_file: Path | str = ".blog_rss_state.json") -> None:
"""Initialise le gestionnaire d'état depuis le fichier JSON.
:param state_file: Chemin du fichier d'état JSON (``str`` ou
:class:`~pathlib.Path`). ``".blog_rss_state.json"`` par défaut.
"""
self._state_file = Path(state_file)
self._known_guids: set[str] = set()
self._etag: str | None = None
self._last_modified: str | None = None
self._load()
def _load(self) -> None:
"""Charge l'état depuis le fichier JSON.
Si le fichier n'existe pas, l'état reste vide. Si le fichier est
corrompu, illisible ou que la version est absente ou différente
de 1, un avertissement est journalisé et l'état reste vide.
Aucune exception n'est propagée.
"""
if not self._state_file.exists():
return
try:
data = json.loads(self._state_file.read_text(encoding="utf-8"))
if not isinstance(data, dict) or data.get("version") != _STATE_VERSION:
logger.warning(
"Fichier d'état blog RSS %s : version absente ou non supportée, "
"démarrage avec un état vide.",
redact_secrets(str(self._state_file)),
)
return
guids_data = data.get("known_guids", [])
if isinstance(guids_data, list):
self._known_guids = {guid for guid in guids_data if isinstance(guid, str)}
etag_data = data.get("etag")
if isinstance(etag_data, str):
self._etag = etag_data
last_modified_data = data.get("last_modified")
if isinstance(last_modified_data, str):
self._last_modified = last_modified_data
except Exception as exc:
logger.warning(
"Impossible de charger le fichier d'état blog RSS %s : %s, "
"démarrage avec un état vide.",
redact_secrets(str(self._state_file)),
redact_exception(exc),
)
def _save(self) -> None:
"""Sauvegarde l'état dans le fichier JSON de manière atomique.
La sortie est déterministe : ``known_guids`` est trié
alphabétiquement et le champ ``version`` vaut 1. Le JSON est
d'abord écrit dans un fichier temporaire du même répertoire, puis
remplacé atomiquement par :meth:`~pathlib.Path.replace` afin de ne
jamais laisser un fichier partiel en cas d'interruption. En cas
d'erreur d'écriture, une erreur est journalisée sans être
propagée et le fichier temporaire est supprimé.
"""
payload = {
"version": _STATE_VERSION,
"known_guids": sorted(self._known_guids),
"etag": self._etag,
"last_modified": self._last_modified,
}
tmp_file = self._state_file.with_suffix(".tmp")
try:
with open(tmp_file, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2)
tmp_file.replace(self._state_file)
except Exception as exc:
logger.error(
"Impossible d'écrire le fichier d'état blog RSS %s : %s.",
redact_secrets(str(self._state_file)),
redact_exception(exc),
)
try:
tmp_file.unlink(missing_ok=True)
except Exception as cleanup_exc:
logger.debug(
"Nettoyage du fichier temporaire échoué : %s",
redact_exception(cleanup_exc),
)
def get_known_guids(self) -> frozenset[str]:
"""Renvoie une copie immuable des GUID d'articles déjà connus.
:return: Copie de type :class:`frozenset` des GUID connus.
:rtype: frozenset[str]
"""
return frozenset(self._known_guids)
def add_guids(self, guids: Iterable[str]) -> None:
"""Ajoute des GUID d'articles à l'état connu et sauvegarde.
Si l'itérable ne contient aucun GUID, l'état n'est pas modifié et
aucune sauvegarde n'est déclenchée.
:param guids: Itérable des GUID d'articles à enregistrer.
"""
new_guids = set(guids)
if not new_guids:
return
self._known_guids.update(new_guids)
self._save()
def get_cache_headers(self) -> tuple[str | None, str | None]:
"""Renvoie les en-têtes de cache HTTP mémorisés.
:return: Tuple ``(etag, last_modified)``, chaque valeur pouvant
être ``None`` si elle n'a jamais été reçue.
:rtype: tuple[str | None, str | None]
"""
return self._etag, self._last_modified
def update_cache_headers(self, etag: str | None, last_modified: str | None) -> None:
"""Met à jour les en-têtes de cache HTTP et sauvegarde.
:param etag: Nouvelle valeur de l'en-tête ``ETag``, ou ``None``
pour l'effacer.
:param last_modified: Nouvelle valeur de l'en-tête
``Last-Modified``, ou ``None`` pour l'effacer.
"""
self._etag = etag
self._last_modified = last_modified
self._save()
def clear(self) -> None:
"""Réinitialise l'état (GUID et en-têtes de cache) et sauvegarde."""
self._known_guids = set()
self._etag = None
self._last_modified = None
self._save()

View File

@@ -0,0 +1,339 @@
"""Client d'accès à Pronote via ``pronotepy``.
Ce module fournit l'encapsulation du client ``pronotepy`` pour la source
Pronote : récupération des messages des professeurs, des informations et
sondages, ainsi que des cours et devoirs en mode repli lorsque le flux
iCal échoue. Les erreurs des méthodes dégradées (messages, informations)
sont journalisées avec des secrets masqués ; les erreurs de récupération
des cours et des devoirs se propagent pour déclencher le repli iCal.
"""
from __future__ import annotations
import logging
from datetime import date
from typing import Any, Protocol
import pronotepy
import pronotepy.ent as pronotepy_ent
import requests
from pronote_sync.config.settings import PronoteSettings
from pronote_sync.models.agenda import Lesson, LessonStatus
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message, MessageType
from pronote_sync.utils.redaction import redact_exception
logger = logging.getLogger(__name__)
def _get_ent_callable(name: str) -> Any:
"""Retourne le callable ``pronotepy`` associé à un nom d'ENT.
L'accès par :func:`getattr` évite les erreurs ``attr-defined`` de mypy
sur les attributs non exportés explicitement par ``pronotepy.ent``.
:param name: Nom de l'attribut dans ``pronotepy.ent``.
:return: Callable ``pronotepy`` associé.
:rtype: Any
"""
return getattr(pronotepy_ent, name)
_ENT_NAMES: list[str] = [
"monbureaunumerique",
"ent_elyco",
"bordeaux",
"ent_creuse",
"occitanie_montpellier",
"paris_classe_numerique",
"ile_de_france",
"ent_hdf",
"ac_orleans_tours",
"ac_poitiers",
"ac_rennes",
"laclasse_educonnect",
"ent77",
"ent_ecollege78",
"ent_essonne",
"val_doise",
"val_de_marne",
"ent_var",
"atrium_sud",
"laclasse_lyon",
"eclat_bfc",
"cas_arsene76",
"cas_ent27",
"cas_kosmos",
"ent_creuse_educonnect",
"ent_mayotte",
"ent_somme",
"ent_94",
"extranet_colleges_somme",
"ac_reunion",
]
_ENT_RESOLVERS: dict[str, Any] = {name: _get_ent_callable(name) for name in _ENT_NAMES}
def _resolve_ent(ent_name: str) -> Any:
"""Résout un nom d'ENT en callable ``pronotepy``.
:param ent_name: Nom de l'ENT tel que configuré (ex. ``"bordeaux"``).
:return: Callable ``pronotepy`` associé à l'ENT.
:raises ValueError: Si le nom d'ENT n'est pas reconnu.
"""
resolver = _ENT_RESOLVERS.get(ent_name)
if resolver is None:
supported = ", ".join(sorted(_ENT_RESOLVERS.keys()))
raise ValueError(f"ENT inconnu : {ent_name!r}. ENT supportés : {supported}")
return resolver
class PronoteClientProtocol(Protocol):
"""Interface du client Pronote consommée par la logique de repli."""
def get_messages(self) -> list[Message]:
"""Récupère les messages des discussions Pronote.
:return: Liste des messages des professeurs.
:rtype: list[Message]
"""
...
def get_informations(self) -> list[Message]:
"""Récupère les informations et sondages Pronote.
:return: Liste des informations et sondages.
:rtype: list[Message]
"""
...
def get_lessons(self, start: date, end: date) -> list[Lesson]:
"""Récupère les cours via ``pronotepy`` (repli iCal).
:param start: Date de début de la fenêtre (incluse).
:param end: Date de fin de la fenêtre (incluse).
:return: Liste des cours.
:rtype: list[Lesson]
"""
...
def get_homeworks(self, start: date, end: date) -> list[Homework]:
"""Récupère les devoirs via ``pronotepy``.
:param start: Date de début de la fenêtre (incluse).
:param end: Date de fin de la fenêtre (incluse).
:return: Liste des devoirs.
:rtype: list[Homework]
"""
...
class PronoteClient:
"""Client d'accès à Pronote via ``pronotepy``.
Encapsule ``pronotepy.Client`` ou ``pronotepy.ParentClient`` selon le
type de compte, avec une connexion paresseuse : la connexion n'est
établie qu'à la première méthode de récupération appelée. Les erreurs
des méthodes dégradées (``get_messages()``, ``get_informations()``)
sont journalisées avec des secrets masqués et retournent une valeur
vide ; ``get_lessons()`` et ``get_homeworks()`` laissent les
exceptions se propager pour déclencher le repli iCal.
"""
def __init__(self, settings: PronoteSettings) -> None:
"""Initialise le client Pronote sans se connecter.
:param settings: Paramètres d'accès à Pronote (username, password, ent).
"""
self._settings: PronoteSettings = settings
self._client: pronotepy.Client | None = None
def _connect(self) -> pronotepy.Client:
"""Crée et connecte le client ``pronotepy`` (connexion paresseuse).
Le client est créé une seule fois puis réutilisé pour les appels
suivants. Le nom d'ENT est résolu via :func:`_resolve_ent` et le
type de compte (``student`` ou ``parent``) détermine la classe de
client utilisée. L'erreur de connexion est relancée sans
journalisation, la méthode publique appelante étant responsable
de la journaliser.
:return: Le client ``pronotepy`` connecté.
:rtype: pronotepy.Client
:raises ValueError: Si ``pronote_url``, ``username``, ``password``
ou ``ent`` est manquant, ou si l'ENT est inconnu.
:raises pronotepy.PronoteAPIError: Si la connexion à Pronote échoue.
"""
if self._client is None:
pronote_url = self._settings.pronote_url
username = self._settings.username
password = self._settings.password
ent = self._settings.ent
if pronote_url is None or username is None or password is None or ent is None:
raise ValueError(
"pronote_url, username, password et ent sont requis pour pronotepy"
)
resolver = _resolve_ent(ent)
client_class: type[pronotepy.Client] = (
pronotepy.ParentClient
if self._settings.account_type == "parent"
else pronotepy.Client
)
self._client = client_class(
pronote_url=pronote_url,
username=username,
password=password.get_secret_value(),
ent=resolver,
)
return self._client
def get_messages(self) -> list[Message]:
"""Récupère les messages des discussions Pronote.
Chaque message d'une discussion est mappé sur un modèle
:class:`Message` de type ``DISCUSSION``, le sujet de la discussion
servant de titre.
:return: Liste des messages des professeurs ; vide en cas d'erreur.
:rtype: list[Message]
"""
try:
client = self._connect()
messages: list[Message] = []
for discussion in client.discussions():
for message in discussion.messages:
messages.append(
Message(
id=message.id,
type=MessageType.DISCUSSION,
title=discussion.subject,
content=message.content,
author=message.author or "",
date=message.created,
read=message.seen,
)
)
return messages
except (
pronotepy.PronoteAPIError,
ValueError,
requests.RequestException,
ConnectionError,
TimeoutError,
) as exc:
logger.error(
"Échec de la récupération des messages Pronote : %s",
redact_exception(exc),
)
return []
def get_informations(self) -> list[Message]:
"""Récupère les informations et sondages Pronote.
Chaque entrée est mappée sur un modèle :class:`Message` de type
``SURVEY`` si c'est un sondage, ``INFORMATION`` sinon.
:return: Liste des informations et sondages ; vide en cas d'erreur.
:rtype: list[Message]
"""
try:
client = self._connect()
messages: list[Message] = []
for info in client.information_and_surveys():
messages.append(
Message(
id=info.id,
type=MessageType.SURVEY if info.survey else MessageType.INFORMATION,
title=info.title or "",
content=info.content(),
author=info.author,
date=info.creation_date,
read=info.read,
)
)
return messages
except (
pronotepy.PronoteAPIError,
ValueError,
requests.RequestException,
ConnectionError,
TimeoutError,
) as exc:
logger.error(
"Échec de la récupération des informations Pronote : %s",
redact_exception(exc),
)
return []
def get_lessons(self, start: date, end: date) -> list[Lesson]:
"""Récupère les cours via ``pronotepy`` (repli iCal).
Les exceptions ne sont pas attrapées : elles se propagent afin que
l'appelant puisse détecter l'échec et déclencher le repli (ou une
erreur explicite).
:param start: Date de début de la fenêtre (incluse).
:param end: Date de fin de la fenêtre (incluse).
:return: Liste des cours.
:rtype: list[Lesson]
:raises pronotepy.PronoteAPIError: Si l'API Pronote échoue.
:raises ValueError: Si la configuration ou l'ENT est invalide.
:raises requests.RequestException: Si une requête réseau échoue.
:raises ConnectionError: Si la connexion réseau échoue.
:raises TimeoutError: Si la requête réseau expire.
"""
client = self._connect()
lessons: list[Lesson] = []
for lesson in client.lessons(start, end):
content = lesson.content
lessons.append(
Lesson(
id=lesson.id,
start=lesson.start,
end=lesson.end,
subject=lesson.subject.name if lesson.subject is not None else "",
teachers=tuple(lesson.teacher_names or ()),
rooms=tuple(lesson.classrooms or ()),
group=lesson.group_name,
status=(LessonStatus.CANCELLED if lesson.canceled else LessonStatus.NORMAL),
content=content.description if content is not None else None,
)
)
return lessons
def get_homeworks(self, start: date, end: date) -> list[Homework]:
"""Récupère les devoirs via ``pronotepy``.
Les exceptions ne sont pas attrapées : elles se propagent afin que
l'appelant puisse détecter l'échec et déclencher le repli (ou une
erreur explicite). **pronotepy** ne fournissant ni la date de
distribution ni les professeurs des devoirs, ces champs restent
vides.
:param start: Date de début de la fenêtre (incluse).
:param end: Date de fin de la fenêtre (incluse).
:return: Liste des devoirs.
:rtype: list[Homework]
:raises pronotepy.PronoteAPIError: Si l'API Pronote échoue.
:raises ValueError: Si la configuration ou l'ENT est invalide.
:raises requests.RequestException: Si une requête réseau échoue.
:raises ConnectionError: Si la connexion réseau échoue.
:raises TimeoutError: Si la requête réseau expire.
"""
client = self._connect()
homeworks: list[Homework] = []
for hw in client.homework(start, end):
homeworks.append(
Homework(
id=hw.id,
subject=hw.subject.name,
teachers=(),
assigned_on=None,
due_on=hw.date,
text=hw.description,
html=hw.description,
)
)
return homeworks

View File

@@ -0,0 +1,408 @@
"""Logique de repli unifiant les sources iCal et pronotepy.
Ce module fournit l'enum :class:`AgendaSource`, le protocole
:class:`PronoteFetcherProtocol` consommé par le pipeline ainsi que la
classe :class:`PronoteFetcher` qui sélectionne la source selon la
configuration (``PRONOTE_AGENDA_SOURCE`` / ``PRONOTE_HOMEWORK_SOURCE``).
Contrat strict : les modes explicites n'utilisent que la source
configurée, sans aucun repli ; seul le mode ``auto`` applique un repli
unique iCal → pronotepy, en cas d'exception uniquement. Les messages
et informations proviennent toujours de pronotepy. Toutes les erreurs
sont journalisées avec des secrets masqués via
:func:`~pronote_sync.utils.redaction.redact_exception` ; les exceptions
d'origine ne sont jamais chaînées (``from None``).
"""
from __future__ import annotations
import logging
from datetime import date, timedelta
from enum import StrEnum
from typing import Literal, Protocol
from pronote_sync.config.settings import Settings
from pronote_sync.errors import PipelineCriticalError
from pronote_sync.models.agenda import Lesson, SchoolEvent
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message
from pronote_sync.sources.pronote.client import PronoteClientProtocol
from pronote_sync.sources.pronote.ical import collect_homeworks, fetch_ical, parse_ical
from pronote_sync.utils.redaction import redact_exception
logger = logging.getLogger(__name__)
_SourceName = Literal["ical", "pronotepy"]
class AgendaSource(StrEnum):
"""Source configurée pour la récupération de l'agenda et des devoirs."""
AUTO = "auto"
ICAL = "ical"
PRONOTEPY = "pronotepy"
class PronoteFetcherProtocol(Protocol):
"""Interface de récupération des données Pronote consommée par le pipeline."""
def fetch_agenda(self) -> tuple[list[Lesson], list[SchoolEvent]]:
"""Récupère les cours et les événements scolaires.
:return: Tuple ``(cours, événements scolaires)``.
:rtype: tuple[list[Lesson], list[SchoolEvent]]
:raises PipelineCriticalError: Si toutes les sources configurées échouent.
"""
...
def fetch_homework(self, target_date: date) -> list[Homework]:
"""Récupère les devoirs pour la date cible.
:param target_date: Date cible pour laquelle collecter les devoirs.
:return: Liste des devoirs.
:rtype: list[Homework]
:raises PipelineCriticalError: Si toutes les sources configurées échouent.
"""
...
def fetch_messages(self) -> list[Message]:
"""Récupère les messages des discussions Pronote.
:return: Liste des messages.
:rtype: list[Message]
"""
...
def fetch_informations(self) -> list[Message]:
"""Récupère les informations et sondages Pronote.
:return: Liste des informations et sondages.
:rtype: list[Message]
"""
...
class PronoteFetcher:
"""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
(``agenda_source`` / ``homework_source``) : les modes explicites
n'utilisent que la source configurée, sans aucun repli ; seul le mode
``auto`` essaie une source primaire puis, si elle échoue, une seule
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:
"""Initialise le fetcher sans récupérer aucune donnée.
:param settings: Configuration racine du pipeline (Pronote, fenêtre de synchronisation).
:param pronote_client: Client pronotepy utilisé pour les sources pronotepy.
"""
self._settings: Settings = settings
self._pronote_client: PronoteClientProtocol = pronote_client
def _fetch_window(self) -> tuple[date, date]:
"""Calcule la fenêtre de synchronisation autour de la date du jour.
:return: Tuple ``(date de début, date de fin)`` de la fenêtre.
:rtype: tuple[date, date]
"""
today = date.today()
start = today - timedelta(days=self._settings.app.sync_past_days)
end = today + timedelta(days=self._settings.app.sync_future_days)
return start, end
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.
:rtype: bool
"""
return self._settings.pronote.ical_url is not None
def _is_pronotepy_configured(self) -> bool:
"""Vérifie que la source pronotepy est entièrement configurée.
:return: ``True`` si ``pronote_url``, ``username``, ``password``
et ``ent`` sont tous définis, ``False`` sinon.
:rtype: bool
"""
pronote = self._settings.pronote
return (
pronote.pronote_url is not None
and pronote.username is not None
and pronote.password is not None
and pronote.ent is not None
)
def _fetch_agenda_ical(self) -> tuple[list[Lesson], list[SchoolEvent]]:
"""Récupère l'agenda depuis le flux iCal.
: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 OSError: Si le fichier iCal local est illisible.
:raises requests.RequestException: Si la récupération HTTP échoue.
"""
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())
lessons, _, school_events = parse_ical(raw_ical)
return lessons, school_events
def _fetch_agenda_pronotepy(self) -> tuple[list[Lesson], list[SchoolEvent]]:
"""Récupère l'agenda depuis pronotepy.
Les événements scolaires (vacances, jours fériés) ne sont pas
fournis par pronotepy : la liste retournée est vide.
:return: Tuple ``(cours, événements scolaires)``.
:rtype: tuple[list[Lesson], list[SchoolEvent]]
:raises pronotepy.PronoteAPIError: Si l'API Pronote échoue.
:raises ValueError: Si la configuration ou l'ENT est invalide.
:raises requests.RequestException: Si une requête réseau échoue.
:raises ConnectionError: Si la connexion réseau échoue.
:raises TimeoutError: Si la requête réseau expire.
"""
start, end = self._fetch_window()
lessons = self._pronote_client.get_lessons(start, end)
return lessons, []
def _agenda_sources(self) -> tuple[_SourceName, _SourceName | None]:
"""Sélectionne la source primaire et le repli unique pour l'agenda.
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
configuration pronotepy est complète), sinon pronotepy sans repli.
:return: Tuple ``(source primaire, source de repli ou ``None``)``.
:rtype: tuple[_SourceName, _SourceName | None]
:raises PipelineCriticalError: Si aucune source n'est configurée en mode ``AUTO``.
"""
source = AgendaSource(self._settings.pronote.agenda_source)
if source is AgendaSource.ICAL:
return "ical", None
if source is AgendaSource.PRONOTEPY:
return "pronotepy", None
if self._is_ical_configured():
return "ical", "pronotepy" if self._is_pronotepy_configured() else None
if self._is_pronotepy_configured():
return "pronotepy", None
raise PipelineCriticalError(
"Impossible de récupérer l'agenda : ni la source iCal ni pronotepy n'est configurée"
) from None
def _fetch_agenda_source(self, name: _SourceName) -> tuple[list[Lesson], list[SchoolEvent]]:
"""Récupère l'agenda depuis la source nommée.
:param name: Nom de la source (``"ical"`` ou ``"pronotepy"``).
:return: Tuple ``(cours, événements scolaires)``.
:rtype: tuple[list[Lesson], list[SchoolEvent]]
"""
if name == "ical":
return self._fetch_agenda_ical()
return self._fetch_agenda_pronotepy()
def fetch_agenda(self) -> tuple[list[Lesson], list[SchoolEvent]]:
"""Récupère les cours et les événements scolaires selon la source configurée.
En mode explicite (``ical`` ou ``pronotepy``), la source désignée
est la seule tentée : si elle échoue, une erreur critique est levée
sans repli. En mode ``auto``, la source primaire est essayée en
premier puis, si elle échoue, la source de repli unique (l'autre
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)``.
:rtype: tuple[list[Lesson], list[SchoolEvent]]
:raises PipelineCriticalError: Si toutes les sources tentées échouent.
"""
primary, fallback = self._agenda_sources()
try:
return self._fetch_agenda_source(primary)
except Exception as exc:
logger.error(
"Échec de la récupération %s pour l'agenda : %s",
primary,
redact_exception(exc),
)
if fallback is None:
raise PipelineCriticalError(
f"Impossible de récupérer l'agenda : la source {primary} a échoué"
) from None
logger.info("Repli sur %s pour l'agenda.", fallback)
try:
lessons, school_events = self._fetch_agenda_source(fallback)
except Exception as exc:
logger.error(
"Échec de la récupération %s pour l'agenda : %s",
fallback,
redact_exception(exc),
)
raise PipelineCriticalError(
f"Impossible de récupérer l'agenda : les sources {primary}"
f" et {fallback} ont échoué"
) from None
if not lessons:
logger.warning(
"Le repli %s pour l'agenda a retourné un résultat vide après l'échec "
"de %s : impossible de distinguer une absence de cours d'un échec "
"silencieux.",
fallback,
primary,
)
return lessons, school_events
def _fetch_homework_ical(self, target_date: date) -> list[Homework]:
"""Récupère les devoirs depuis le flux iCal pour la date cible.
: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 OSError: Si le fichier iCal local est illisible.
:raises requests.RequestException: Si la récupération HTTP échoue.
"""
lessons, _ = self._fetch_agenda_ical()
return collect_homeworks(lessons, target_date)
def _fetch_homework_pronotepy(self, target_date: date) -> list[Homework]:
"""Récupère les devoirs depuis pronotepy pour la date cible.
Les devoirs sont filtrés sur la date d'échéance : seuls ceux dont
``due_on`` correspond à ``target_date`` sont conservés.
:param target_date: Date cible pour laquelle collecter les devoirs.
:return: Liste des devoirs.
:rtype: list[Homework]
:raises pronotepy.PronoteAPIError: Si l'API Pronote échoue.
:raises ValueError: Si la configuration ou l'ENT est invalide.
:raises requests.RequestException: Si une requête réseau échoue.
:raises ConnectionError: Si la connexion réseau échoue.
:raises TimeoutError: Si la requête réseau expire.
"""
start, end = self._fetch_window()
homeworks = self._pronote_client.get_homeworks(start, end)
return [hw for hw in homeworks if hw.due_on == target_date]
def _homework_sources(self) -> tuple[_SourceName, _SourceName | None]:
"""Sélectionne la source primaire et le repli unique pour les devoirs.
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
configuration pronotepy est complète), sinon pronotepy sans repli.
:return: Tuple ``(source primaire, source de repli ou ``None``)``.
:rtype: tuple[_SourceName, _SourceName | None]
:raises PipelineCriticalError: Si aucune source n'est configurée en mode ``AUTO``.
"""
source = AgendaSource(self._settings.pronote.homework_source)
if source is AgendaSource.ICAL:
return "ical", None
if source is AgendaSource.PRONOTEPY:
return "pronotepy", None
if self._is_ical_configured():
return "ical", "pronotepy" if self._is_pronotepy_configured() else None
if self._is_pronotepy_configured():
return "pronotepy", None
raise PipelineCriticalError(
"Impossible de récupérer les devoirs : ni la source iCal ni pronotepy n'est configurée"
) from None
def _fetch_homework_source(self, name: _SourceName, target_date: date) -> list[Homework]:
"""Récupère les devoirs depuis la source nommée.
:param name: Nom de la source (``"ical"`` ou ``"pronotepy"``).
:param target_date: Date cible pour laquelle collecter les devoirs.
:return: Liste des devoirs.
:rtype: list[Homework]
"""
if name == "ical":
return self._fetch_homework_ical(target_date)
return self._fetch_homework_pronotepy(target_date)
def fetch_homework(self, target_date: date) -> list[Homework]:
"""Récupère les devoirs selon la source configurée.
En mode explicite (``ical`` ou ``pronotepy``), la source désignée
est la seule tentée : si elle échoue, une erreur critique est levée
sans repli. En mode ``auto``, la source primaire est essayée en
premier puis, si elle échoue, la source de repli unique (l'autre
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.
:return: Liste des devoirs.
:rtype: list[Homework]
:raises PipelineCriticalError: Si toutes les sources tentées échouent.
"""
primary, fallback = self._homework_sources()
try:
return self._fetch_homework_source(primary, target_date)
except Exception as exc:
logger.error(
"Échec de la récupération %s pour les devoirs : %s",
primary,
redact_exception(exc),
)
if fallback is None:
raise PipelineCriticalError(
f"Impossible de récupérer les devoirs : la source {primary} a échoué"
) from None
logger.info("Repli sur %s pour les devoirs.", fallback)
try:
homeworks = self._fetch_homework_source(fallback, target_date)
except Exception as exc:
logger.error(
"Échec de la récupération %s pour les devoirs : %s",
fallback,
redact_exception(exc),
)
raise PipelineCriticalError(
f"Impossible de récupérer les devoirs : les sources {primary}"
f" et {fallback} ont échoué"
) from None
if not homeworks:
logger.warning(
"Le repli %s pour les devoirs a retourné un résultat vide après "
"l'échec de %s : impossible de distinguer une absence de devoirs "
"d'un échec silencieux.",
fallback,
primary,
)
return homeworks
def fetch_messages(self) -> list[Message]:
"""Récupère les messages des discussions Pronote (toujours via pronotepy).
:return: Liste des messages.
:rtype: list[Message]
"""
try:
return self._pronote_client.get_messages()
except Exception as exc:
logger.error(
"Échec de la récupération des messages : %s",
redact_exception(exc),
)
raise
def fetch_informations(self) -> list[Message]:
"""Récupère les informations et sondages Pronote (toujours via pronotepy).
:return: Liste des informations et sondages.
:rtype: list[Message]
"""
try:
return self._pronote_client.get_informations()
except Exception as exc:
logger.error(
"Échec de la récupération des informations : %s",
redact_exception(exc),
)
raise

View File

@@ -0,0 +1,467 @@
"""Récupération et parsing du flux iCal Pronote.
Ce module fournit le téléchargement du flux iCal Pronote (via HTTP ou
``file://`` pour les tests) ainsi que son parsing en modèles : cours
(:class:`~pronote_sync.models.agenda.Lesson`), événements scolaires
(:class:`~pronote_sync.models.agenda.SchoolEvent`) et devoirs
(:class:`~pronote_sync.models.homework.Homework`).
La collecte finale des devoirs est réalisée à part, une fois la date
cible connue, via :func:`collect_homeworks`.
"""
from __future__ import annotations
import hashlib
import re
import urllib.parse
from datetime import date, datetime
from html import unescape
from pathlib import Path
from typing import TypedDict
import requests
from icalendar import Calendar
from ...models.agenda import (
HomeworkBlock,
Lesson,
LessonStatus,
SchoolEvent,
SchoolEventKind,
)
from ...models.homework import Homework
from ...utils.redaction import redact_exception, redact_url
from ...utils.uid import generate_deterministic_uid, normalize_pronote_uid
_HEADER_LABEL_PATTERN = re.compile(r"\b(Matière|Professeurs?|Salles?|Groupe)\s*:\s*")
_CALNAME_PATTERN = re.compile(r"^X-WR-CALNAME(?:;[^:]*)?:([^\r\n]*)", re.MULTILINE)
_TAG_PATTERN = re.compile(r"<[^>]+>")
_CONTENT_PATTERN = re.compile(
r"<strong>Contenu pédagogique\s*:\s*</strong>(.*?)(?=<strong>|</div>\s*$|\Z)",
re.DOTALL,
)
_DUE_PATTERN = re.compile(
r"<strong>Pour le (\d{2}/\d{2}/\d{4})\s*:\s*</strong>(.*?)(?=<strong>|</div>\s*$|\Z)",
re.DOTALL,
)
_ASSIGNED_PATTERN = re.compile(
r"<strong>Donné le (\d{2}/\d{2}/\d{4})\s*:\s*</strong>(.*?)(?=<strong>|</div>\s*$|\Z)",
re.DOTALL,
)
_HEADERS = {
"accept": "text/calendar",
"user-agent": "pronote-sync",
}
class HeaderInfo(TypedDict):
"""Métadonnées du cours extraites de l'en-tête de la DESCRIPTION."""
subject: str
teachers: list[str]
rooms: list[str]
group: str | None
def fetch_ical(url: str, timeout: int = 20) -> str:
"""Récupère le contenu brut d'un flux iCal Pronote.
Gère les URLs ``file://`` pour les tests locaux (le chemin est
décodé de l'échappement URI, ex. ``%20`` → espace) et valide que
le flux commence bien par ``BEGIN:VCALENDAR``. Toutes les erreurs
sont relancées avec un message dont les secrets (token
``icalsecurise``) sont masqués.
:param url: URL du flux iCal (avec token ``icalsecurise``) ou chemin ``file://``.
:param timeout: Timeout HTTP en secondes (défaut : 20).
:return: Contenu brut du flux iCal.
:rtype: str
:raises OSError: Si le fichier local ``file://`` est illisible.
:raises requests.RequestException: Si la récupération HTTP échoue.
:raises ValueError: Si le flux ne commence pas par ``BEGIN:VCALENDAR``.
"""
if url.startswith("file://"):
parsed_url = urllib.parse.urlparse(url)
path = Path(urllib.parse.unquote(parsed_url.path))
try:
content = path.read_text(encoding="utf-8")
except OSError as exc:
raise OSError(
f"Impossible de lire le fichier iCal {redact_url(url)} : {redact_exception(exc)}"
) from exc
if not content.lstrip().startswith("BEGIN:VCALENDAR"):
raise ValueError(f"Fichier iCal invalide (pas de BEGIN:VCALENDAR) : {redact_url(url)}")
return content
try:
response = requests.get(url, headers=_HEADERS, timeout=timeout)
response.raise_for_status()
content = response.text
except Exception as exc:
raise requests.RequestException(
f"Échec de la récupération du flux iCal {redact_url(url)} : {redact_exception(exc)}"
) from exc
if not content.lstrip().startswith("BEGIN:VCALENDAR"):
raise ValueError(f"Flux iCal invalide (pas de BEGIN:VCALENDAR) : {redact_url(url)}")
return str(content)
def _unfold_ical(raw_ical: str) -> str:
"""Déplie les lignes de continuation iCalendar.
Une ligne commençant par un espace ou une tabulation prolonge la
ligne précédente : le caractère d'espacement initial est retiré et
la suite est jointe à la ligne précédente.
:param raw_ical: Contenu brut du flux iCal.
:return: Contenu avec les lignes de continuation dépliées.
:rtype: str
"""
unfolded: list[str] = []
for line in raw_ical.splitlines():
if line.startswith((" ", "\t")) and unfolded:
unfolded[-1] += line[1:]
else:
unfolded.append(line)
return "\n".join(unfolded)
def get_calendar_name(raw_ical: str) -> str | None:
"""Extrait le nom du calendrier depuis la propriété ``X-WR-CALNAME``.
La propriété peut comporter des paramètres (par exemple
``X-WR-CALNAME;LANGUAGE=fr:Nom``) et les lignes de continuation
iCalendar sont dépliées avant la recherche.
:param raw_ical: Contenu brut du flux iCal.
:return: Nom du calendrier ou ``None`` si la propriété est absente.
:rtype: str | None
"""
match = _CALNAME_PATTERN.search(_unfold_ical(raw_ical))
if match is None:
return None
return match.group(1).strip()
def split_header_and_body(description: str) -> tuple[str, str]:
"""Sépare l'en-tête texte du corps HTML dans la DESCRIPTION.
L'en-tête est la partie avant le premier ``<strong>``, le corps
HTML commence à partir du premier ``<strong>``.
:param description: Contenu brut de la DESCRIPTION d'un VEVENT.
:return: Tuple ``(en-tête, corps)`` ; le corps est vide si aucun ``<strong>``.
:rtype: tuple[str, str]
"""
strong_start = description.find("<strong>")
if strong_start == -1:
return description.strip(), ""
header = description[:strong_start].strip()
body = description[strong_start:]
return header, body
def parse_header(header: str) -> HeaderInfo:
"""Parse l'en-tête texte pour extraire les métadonnées du cours.
Les labels reconnus sont : ``Matière :``, ``Professeur(s) :``,
``Salle(s) :`` et ``Groupe :``. La recherche se fait par position
des labels, ce qui supporte aussi bien un en-tête multi-lignes
qu'un en-tête dont les lignes sont jointes sur une seule ligne.
:param header: En-tête texte (avant le premier ``<strong>``).
:return: Dictionnaire typé avec les champs subject, teachers, rooms, group.
:rtype: HeaderInfo
"""
info: HeaderInfo = {"subject": "", "teachers": [], "rooms": [], "group": None}
matches = list(_HEADER_LABEL_PATTERN.finditer(header))
for index, match in enumerate(matches):
value_start = match.end()
value_end = matches[index + 1].start() if index + 1 < len(matches) else len(header)
value = unescape(header[value_start:value_end].strip())
label = match.group(1).lower()
if label == "matière":
info["subject"] = value
elif label in ("professeur", "professeurs"):
info["teachers"] = [part.strip() for part in value.split(",") if part.strip()]
elif label in ("salle", "salles"):
info["rooms"] = [part.strip() for part in value.split(",") if part.strip()]
elif label == "groupe":
info["group"] = value
return info
def _strip_html(text: str) -> str:
"""Retire les balises HTML d'un texte et nettoie les espaces.
:param text: Texte pouvant contenir des balises HTML.
:return: Texte brut sans balises, entités HTML décodées.
:rtype: str
"""
cleaned = _TAG_PATTERN.sub("", text)
return unescape(cleaned).strip()
def _parse_french_date(value: str) -> date | None:
"""Convertit une date au format ``JJ/MM/AAAA`` en :class:`date`.
:param value: Date au format ``JJ/MM/AAAA``.
:return: Date parsée, ou ``None`` si le format est invalide.
:rtype: date | None
"""
try:
return datetime.strptime(value, "%d/%m/%Y").date()
except ValueError:
return None
def parse_body(body: str) -> tuple[str | None, list[tuple[date, str]], list[tuple[date, str]]]:
"""Parse le corps HTML pour extraire contenu pédagogique et devoirs.
Le contenu est extrait de la section ``<strong>Contenu pédagogique :</strong>``.
Les devoirs à faire sont extraits des sections ``<strong>Pour le JJ/MM/AAAA :</strong>``
(liste de tuples ``(date, texte)`` dans l'ordre du flux) et les devoirs donnés
des sections ``<strong>Donné le JJ/MM/AAAA :</strong>`` (liste de tuples
``(date, texte)``). Les listes préservent tous les blocs, même lorsque plusieurs
sections partagent la même date.
:param body: Corps HTML (à partir du premier ``<strong>``).
:return: Tuple ``(contenu pédagogique, devoirs dus, devoirs donnés)``.
:rtype: tuple[str | None, list[tuple[date, str]], list[tuple[date, str]]]
"""
content: str | None = None
due_blocks: list[tuple[date, str]] = []
assigned_blocks: list[tuple[date, str]] = []
content_match = _CONTENT_PATTERN.search(body)
if content_match is not None:
content = _strip_html(content_match.group(1))
for match in _DUE_PATTERN.finditer(body):
due_date = _parse_french_date(match.group(1))
if due_date is not None:
due_blocks.append((due_date, _strip_html(match.group(2))))
for match in _ASSIGNED_PATTERN.finditer(body):
assigned_date = _parse_french_date(match.group(1))
if assigned_date is not None:
assigned_blocks.append((assigned_date, _strip_html(match.group(2))))
return content, due_blocks, assigned_blocks
def parse_homework_blocks(
due_blocks: list[tuple[date, str]],
assigned_blocks: list[tuple[date, str]],
) -> tuple[HomeworkBlock, ...]:
"""Construit les :class:`HomeworkBlock` depuis les listes de devoirs.
Les blocs dus (``kind="due"``) précèdent les blocs donnés
(``kind="assigned"``), dans l'ordre des listes. Tous les blocs
sont préservés, y compris lorsque plusieurs partagent la même date.
:param due_blocks: Liste de tuples ``(date, texte)`` des devoirs à faire.
:param assigned_blocks: Liste de tuples ``(date, texte)`` des devoirs donnés.
:return: Tuple de blocs de devoirs pour le cours.
:rtype: tuple[HomeworkBlock, ...]
"""
blocks: list[HomeworkBlock] = []
for due_date, text in due_blocks:
blocks.append(HomeworkBlock(kind="due", date=due_date, text=text, html=text))
for assigned_date, text in assigned_blocks:
blocks.append(HomeworkBlock(kind="assigned", date=assigned_date, text=text, html=text))
return tuple(blocks)
def normalize_homework_text(text: str) -> str:
"""Normalise le texte d'un devoir pour la déduplication.
Unifie les espaces multiples, supprime les balises HTML, puis
applique trim et minuscules.
:param text: Texte brut du devoir.
:return: Texte normalisé.
:rtype: str
"""
normalized = re.sub(r"\s+", " ", text)
normalized = _TAG_PATTERN.sub("", normalized)
return normalized.strip().lower()
def generate_homework_id(due_on: date, normalized_text: str) -> str:
"""Génère un ID stable pour un devoir.
L'ID est la clé ``AAAA-MM-JJ|texte_normalisé`` hachée en SHA-1 dont
on garde les 12 premiers caractères hexadécimaux. Le hachage n'est
pas utilisé à des fins de sécurité (``usedforsecurity=False``).
:param due_on: Date d'échéance du devoir.
:param normalized_text: Texte normalisé du devoir.
:return: ID stable (12 caractères hexadécimaux).
:rtype: str
"""
payload = f"{due_on.isoformat()}|{normalized_text}".encode()
return hashlib.sha1(payload, usedforsecurity=False).hexdigest()[:12]
def collect_homeworks(lessons: list[Lesson], target_date: date) -> list[Homework]:
"""Collecte et déduplique les devoirs en deux passes globales.
Passe 1 : les blocs ``due`` (devoirs à faire pour ``target_date``)
de tous les cours. Passe 2 : les blocs ``assigned`` (devoirs donnés
le jour cible) des cours du jour ``target_date``. La déduplication
se fait par texte normalisé (premier venu, premier servi) et le
résultat est trié par matière puis texte.
:param lessons: Liste de tous les cours (VEVENT) parsés.
:param target_date: Date cible pour laquelle collecter les devoirs.
:return: Liste unique de devoirs, triée par matière puis texte.
:rtype: list[Homework]
"""
by_text: dict[str, Homework] = {}
for lesson in lessons:
for block in lesson.homework_blocks:
if block.kind == "due" and block.date == target_date:
key = normalize_homework_text(block.text)
if key not in by_text:
by_text[key] = Homework(
id=generate_homework_id(target_date, key),
subject=lesson.subject,
teachers=lesson.teachers,
assigned_on=lesson.start.date(),
due_on=block.date,
text=block.text,
html=block.html,
)
for lesson in lessons:
if lesson.start.date() != target_date:
continue
for block in lesson.homework_blocks:
if block.kind == "assigned":
key = normalize_homework_text(block.text)
if key not in by_text:
by_text[key] = Homework(
id=generate_homework_id(target_date, key),
subject=lesson.subject,
teachers=lesson.teachers,
assigned_on=block.date,
due_on=target_date,
text=block.text,
html=block.html,
)
return sorted(by_text.values(), key=lambda hw: (hw.subject.lower(), hw.text.lower()))
def parse_ical(raw_ical: str) -> tuple[list[Lesson], list[Homework], list[SchoolEvent]]:
"""Parse un flux iCal Pronote en événements typés.
Les VEVENT de vacances/congés (tout le jour) deviennent des
:class:`SchoolEvent` de type ``holiday``. Les VEVENT horodatés
deviennent des :class:`Lesson` dont le statut dérive de la propriété
``STATUS`` (``CANCELLED`` → ``CANCELLED``) et de la catégorie
(``Cours - Cours annulé`` → ``CANCELLED``, ``Cours - Cours déplacé`` →
``MOVED``). Les UID sont normalisés ;
un événement sans UID reçoit un UID déterministe généré à partir
de ses champs clés (début, fin, matière, enseignants, salles, groupe).
:param raw_ical: Contenu brut du flux iCal.
:return: Tuple ``(lessons, homeworks, school_events)`` où ``homeworks``
est **toujours vide** : la collecte/déduplication se fait plus tard
via :func:`collect_homeworks` une fois la date cible connue.
:rtype: tuple[list[Lesson], list[Homework], list[SchoolEvent]]
"""
calendar = Calendar.from_ical(raw_ical)
lessons: list[Lesson] = []
homeworks: list[Homework] = []
school_events: list[SchoolEvent] = []
for component in calendar.walk():
if component.name != "VEVENT":
continue
dtstart = component.get("dtstart")
dtend = component.get("dtend")
if dtstart is None or dtend is None:
continue
start = dtstart.dt
end = dtend.dt
categories_obj = component.get("categories")
if categories_obj is None:
categories: list[str] = []
else:
categories = [str(category) for category in categories_obj.cats]
# Événements de type vacances/congés (tout le jour).
if any(cat in ("Congés", "Vacances") for cat in categories):
from_date = start.date() if isinstance(start, datetime) else start
to_date = end.date() if isinstance(end, datetime) else end
summary = component.get("summary")
label = str(summary) if summary is not None else ""
school_events.append(
SchoolEvent(
kind=SchoolEventKind.HOLIDAY,
label=label,
from_date=from_date,
to_date=to_date,
)
)
continue
# Les cours sont des événements horodatés ; les événements tout
# le jour non scolaires sont ignorés.
if not isinstance(start, datetime) or not isinstance(end, datetime):
continue
status_obj = component.get("status")
status_value = str(status_obj).strip().upper() if status_obj is not None else ""
if status_value == "CANCELLED" or "Cours - Cours annulé" in categories:
status = LessonStatus.CANCELLED
elif "Cours - Cours déplacé" in categories:
status = LessonStatus.MOVED
else:
status = LessonStatus.NORMAL
description = component.get("description")
description_str = str(description) if description is not None else ""
header, body = split_header_and_body(description_str)
lesson_data = parse_header(header)
content, due_blocks, assigned_blocks = parse_body(body)
homework_blocks = parse_homework_blocks(due_blocks, assigned_blocks)
uid_value = component.get("uid")
if uid_value is None or str(uid_value).strip() == "":
uid = generate_deterministic_uid(
start=start,
end=end,
subject=lesson_data["subject"],
teachers=lesson_data["teachers"],
rooms=lesson_data["rooms"],
group=lesson_data["group"],
)
else:
uid = normalize_pronote_uid(str(uid_value))
lessons.append(
Lesson(
id=uid,
start=start,
end=end,
subject=lesson_data["subject"],
teachers=tuple(lesson_data["teachers"]),
rooms=tuple(lesson_data["rooms"]),
group=lesson_data["group"],
status=status,
content=content,
homework_blocks=homework_blocks,
)
)
return lessons, homeworks, school_events

View File

@@ -0,0 +1,75 @@
"""Usine de construction du fournisseur d'agenda théorique.
Ce module expose l'API publique du package ``theoretical`` : les classes
:class:`~pronote_sync.sources.theoretical.provider.TheoreticalAgendaProvider`,
:class:`~pronote_sync.sources.theoretical.file.JsonTheoreticalAgendaProvider`,
:class:`~pronote_sync.sources.theoretical.parity.WeekParityService` et
:class:`~pronote_sync.sources.theoretical.holidays.SchoolHolidayCalendar`, ainsi
que la fonction :func:`get_theoretical_provider` qui assemble la configuration
(chemin du fichier JSON, parité des semaines et vacances scolaires) pour
produire un fournisseur d'agenda théorique prêt à l'emploi.
"""
from __future__ import annotations
from datetime import date
from typing import Literal
from pronote_sync.errors import PronoteSyncError
from pronote_sync.sources.theoretical.file import JsonTheoreticalAgendaProvider
from pronote_sync.sources.theoretical.holidays import SchoolHolidayCalendar
from pronote_sync.sources.theoretical.parity import WeekParityService
from pronote_sync.sources.theoretical.provider import TheoreticalAgendaProvider
__all__ = [
"TheoreticalAgendaProvider",
"JsonTheoreticalAgendaProvider",
"WeekParityService",
"SchoolHolidayCalendar",
"get_theoretical_provider",
]
def get_theoretical_provider(
agenda_path: str | None,
holidays_path: str | None,
anchor_date: date | None,
anchor_type: Literal["even", "odd"] | None,
) -> TheoreticalAgendaProvider | None:
"""Construit un fournisseur d'agenda théorique depuis la configuration.
:param agenda_path: Chemin du fichier JSON d'agenda théorique. Si None, retourne None.
:param holidays_path: Chemin du fichier JSON de vacances scolaires (optionnel).
:param anchor_date: Date de référence pour la parité des semaines.
:param anchor_type: Type de la semaine de référence ("even" ou "odd").
:return: Le fournisseur configuré, ou None si l'agenda théorique est désactivé.
:rtype: TheoreticalAgendaProvider | None
:raises PronoteSyncError: Si la configuration de parité est incomplète
(date sans type ou inversement) alors que l'agenda nécessite la parité.
"""
if agenda_path is None:
return None
# Build parity service if both anchor fields are provided
parity_service: WeekParityService | None = None
if anchor_date is not None and anchor_type is not None:
parity_service = WeekParityService(anchor_date, anchor_type)
elif anchor_date is not None or anchor_type is not None:
# Partial parity config — one field without the other
raise PronoteSyncError(
"Configuration de parité incomplète : THEORETICAL_WEEK_ANCHOR_DATE et "
"THEORETICAL_WEEK_ANCHOR_TYPE doivent être fournis ensemble."
)
# Build holiday calendar if path is provided
holiday_calendar: SchoolHolidayCalendar | None = None
if holidays_path is not None:
holiday_calendar = SchoolHolidayCalendar(holidays_path)
# Build provider — the provider's __init__ will validate that parity_service
# is provided if the JSON contains even/odd lessons
return JsonTheoreticalAgendaProvider(
file_path=agenda_path,
parity_service=parity_service,
holiday_calendar=holiday_calendar,
)

View File

@@ -0,0 +1,204 @@
"""Fournisseur d'agenda théorique basé sur un fichier JSON.
Ce module fournit :class:`JsonTheoreticalAgendaProvider`, une implémentation de
:class:`~pronote_sync.sources.theoretical.provider.TheoreticalAgendaProvider` qui charge
un fichier JSON d'emploi du temps théorique et expose les cours applicables par date ou
plage de dates. Le filtrage tient compte du jour de la semaine, de la parité de semaine
(``even``/``odd``) et du calendrier des vacances scolaires.
"""
from __future__ import annotations
import logging
import re
import unicodedata
from datetime import date, time, timedelta
from pathlib import Path
from typing import Literal
from pronote_sync.errors import PronoteSyncError
from pronote_sync.models.agenda import TheoreticalLesson
from pronote_sync.sources.theoretical.holidays import SchoolHolidayCalendar
from pronote_sync.sources.theoretical.model import TheoreticalAgendaFile, TheoreticalLessonEntry
from pronote_sync.sources.theoretical.parity import WeekParityService
from pronote_sync.utils.redaction import redact_exception, redact_secrets
logger = logging.getLogger(__name__)
def normalize_subject(subject: str) -> str:
"""Normalise une matière pour le matching déterministe.
Applique la normalisation Unicode NFKC, unifie les espaces (y compris
tabulations et espaces insécables), supprime la ponctuation et met la
chaîne en minuscules. Deux représentations visuellement identiques d'une
même matière produisent ainsi la même forme normalisée.
:param subject: La matière brute.
:return: La forme normalisée (NFKC, espaces unifiés, sans ponctuation, minuscule).
:rtype: str
"""
normalized = unicodedata.normalize("NFKC", subject)
normalized = re.sub(r"\s+", " ", normalized).strip()
normalized = re.sub(r"[^\w\s]", "", normalized)
normalized = re.sub(r"\s+", " ", normalized).strip()
return normalized.lower()
def _generate_id(entry: TheoreticalLessonEntry) -> str:
"""Génère un identifiant déterministe pour une entrée de cours.
L'identifiant intègre le type de semaine (``all``, ``even`` ou ``odd``),
le jour de la semaine, le créneau horaire et la matière : deux leçons
occupant le même créneau dans des semaines différentes (ou le même
créneau un autre jour) obtiennent ainsi des identifiants distincts.
:param entry: Entrée de cours du fichier JSON.
:return: Identifiant déterministe unique.
:rtype: str
"""
subject_slug = normalize_subject(entry.subject).replace(" ", "-")
return f"theoretical:{entry.week}:{entry.day_of_week}:{entry.start_time}-{entry.end_time}:{subject_slug}"
class JsonTheoreticalAgendaProvider:
"""Fournisseur d'agenda théorique basé sur un fichier JSON.
Charge un fichier JSON d'emploi du temps théorique au format défini par
:class:`~pronote_sync.sources.theoretical.model.TheoreticalAgendaFile` et expose
les cours théoriques pour une date ou une plage de dates. Les cours peuvent être
restreints à une parité de semaine (paire/impaire) via
:class:`~pronote_sync.sources.theoretical.parity.WeekParityService` et exclus
pendant les vacances scolaires via
:class:`~pronote_sync.sources.theoretical.holidays.SchoolHolidayCalendar`.
:param file_path: Chemin vers le fichier JSON de l'agenda théorique.
:param parity_service: Service optionnel de calcul de la parité de semaine.
:param holiday_calendar: Calendrier optionnel des vacances scolaires.
:raises PronoteSyncError: Si le fichier ne peut être lu ou analysé, si
des leçons à semaine paire/impaire sont présentes sans ancre de parité,
ou si plusieurs leçons partagent le même identifiant (explicite ou
généré).
"""
def __init__(
self,
file_path: str,
parity_service: WeekParityService | None = None,
holiday_calendar: SchoolHolidayCalendar | None = None,
) -> None:
"""Initialise le fournisseur en chargeant et analysant le fichier JSON.
Le fichier est lu et analysé immédiatement. Toute erreur de lecture,
de décodage JSON ou de validation est journalisée (chemin et exception
expurgés) puis remontée sous forme de :class:`PronoteSyncError`. Si des
leçons à semaine paire/impaire sont présentes alors qu'aucun service de
parité n'est configuré, une :class:`PronoteSyncError` est également levée.
:param file_path: Chemin vers le fichier JSON de l'agenda théorique.
:param parity_service: Service optionnel de calcul de la parité de semaine.
:param holiday_calendar: Calendrier optionnel des vacances scolaires.
:raises PronoteSyncError: Si le fichier est introuvable, invalide,
nécessite une ancre de parité non configurée ou contient plusieurs
leçons partageant le même identifiant (explicite ou généré).
"""
self._file_path: str = file_path
self._parity_service: WeekParityService | None = parity_service
self._holiday_calendar: SchoolHolidayCalendar | None = holiday_calendar
try:
content = Path(file_path).read_text(encoding="utf-8")
parsed = TheoreticalAgendaFile.model_validate_json(content)
except Exception as exc:
logger.error(
"Fichier d'agenda théorique invalide %s : %s.",
redact_secrets(str(file_path)),
redact_exception(exc),
)
raise PronoteSyncError(
f"Le fichier d'agenda théorique est invalide : {redact_secrets(str(file_path))}"
) from None
self._lessons: tuple[TheoreticalLessonEntry, ...] = parsed.lessons
if self._parity_service is None and any(
entry.week in ("even", "odd") for entry in self._lessons
):
raise PronoteSyncError(
"L'agenda théorique contient des leçons à semaine paire/impaire "
"mais aucune ancre de parité n'est configurée "
"(THEORETICAL_WEEK_ANCHOR_DATE et THEORETICAL_WEEK_ANCHOR_TYPE)"
) from None
seen_ids: set[str] = set()
for entry in self._lessons:
effective_id = entry.id if entry.id is not None else _generate_id(entry)
if effective_id in seen_ids:
raise PronoteSyncError(
f"Conflit d'identifiant dans l'agenda théorique : "
f"l'identifiant '{redact_secrets(effective_id)}' est utilisé par plusieurs leçons. "
f"Fournissez des identifiants explicites uniques."
) from None
seen_ids.add(effective_id)
def get_lessons(self, target_date: date) -> list[TheoreticalLesson]:
"""Retourne les cours théoriques applicables à la date donnée.
Si un calendrier de vacances est configuré et que la date tombe pendant
une période de vacances, la liste retournée est vide. La parité de la
semaine est déterminée via le service de parité lorsqu'il est configuré ;
sinon seuls les cours de type ``all`` sont conservés. Les entrées sont
ensuite filtrées par jour de la semaine, converties en
:class:`~pronote_sync.models.agenda.TheoreticalLesson` et triées par
identifiant.
:param target_date: Date cible.
:return: Liste des cours théoriques triée par identifiant.
:rtype: list[TheoreticalLesson]
"""
if self._holiday_calendar is not None and self._holiday_calendar.is_holiday(target_date):
return []
week_parity: Literal["all", "even", "odd"]
if self._parity_service is not None:
week_parity = self._parity_service.parity_for(target_date)
else:
week_parity = "all"
lessons: list[TheoreticalLesson] = []
for entry in self._lessons:
if entry.week != "all" and entry.week != week_parity:
continue
if entry.day_of_week != target_date.weekday():
continue
lesson_id = entry.id if entry.id is not None else _generate_id(entry)
lessons.append(
TheoreticalLesson(
id=lesson_id,
day_of_week=entry.day_of_week,
start_time=time.fromisoformat(entry.start_time),
end_time=time.fromisoformat(entry.end_time),
subject=entry.subject,
teachers=entry.teachers,
rooms=entry.rooms,
)
)
return sorted(lessons, key=lambda lesson: lesson.id)
def get_lessons_for_range(self, start_date: date, end_date: date) -> list[TheoreticalLesson]:
"""Retourne les cours théoriques pour une plage de dates (inclusives).
Chaque date de la plage, bornes incluses, est évaluée via
:meth:`get_lessons`. Les cours sont dédupliqués par identifiant : pour
un identifiant donné, la dernière occurrence (date la plus récente)
écrase la précédente. Si ``start_date`` est postérieure à ``end_date``,
la liste retournée est vide.
:param start_date: Date de début (inclusive).
:param end_date: Date de fin (inclusive).
:return: Liste des cours théoriques triée par identifiant.
:rtype: list[TheoreticalLesson]
"""
seen: dict[str, TheoreticalLesson] = {}
current_date = start_date
while current_date <= end_date:
for lesson in self.get_lessons(current_date):
seen[lesson.id] = lesson
current_date += timedelta(days=1)
return sorted(seen.values(), key=lambda lesson: lesson.id)

View File

@@ -0,0 +1,106 @@
"""Service de calendrier des vacances scolaires.
Ce module fournit les modèles de données :class:`HolidayPeriod` et
:class:`SchoolHolidayFile`, ainsi que le service :class:`SchoolHolidayCalendar`
qui charge un fichier JSON de périodes de vacances scolaires et permet de
déterminer si une date donnée tombe pendant ces vacances.
"""
from __future__ import annotations
import json
import logging
from datetime import date
from pathlib import Path
from typing import Any, Self
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pronote_sync.errors import PronoteSyncError
from pronote_sync.utils.redaction import redact_exception, redact_secrets
logger = logging.getLogger(__name__)
class HolidayPeriod(BaseModel):
"""Période de vacances scolaires, bornes incluses.
:ivar start_date: Date de début de la période (incluse).
:ivar end_date: Date de fin de la période (incluse).
:ivar label: Nom de la période (ex. « Toussaint »).
"""
model_config = ConfigDict(frozen=True)
start_date: date
end_date: date
label: str
@model_validator(mode="after")
def _validate_date_order(self) -> Self:
"""Vérifie que la date de fin n'est pas antérieure à la date de début.
:return: L'instance de période après validation.
:rtype: Self
:raises ValueError: Si ``end_date`` est strictement antérieure à ``start_date``.
"""
if self.end_date < self.start_date:
raise ValueError("end_date doit être supérieure ou égale à start_date.")
return self
class SchoolHolidayFile(BaseModel):
"""Modèle de parsing d'un fichier JSON de vacances scolaires.
:ivar zone: Zone académique (ex. « A »).
:ivar school_year: Année scolaire (ex. « 2026-2027 »).
:ivar periods: Périodes de vacances scolaires du fichier.
"""
zone: str
school_year: str
periods: tuple[HolidayPeriod, ...] = Field(default=())
class SchoolHolidayCalendar:
"""Calendrier des vacances scolaires chargé depuis un fichier JSON."""
def __init__(self, file_path: Path | str) -> None:
"""Charge les périodes de vacances scolaires depuis un fichier JSON.
:param file_path: Chemin vers le fichier JSON.
:raises PronoteSyncError: Si le fichier ne peut être lu ou analysé.
"""
path = Path(file_path)
self._periods: tuple[HolidayPeriod, ...]
if not path.is_file():
raise PronoteSyncError(
f"Le fichier de vacances scolaires est introuvable : {redact_secrets(str(path))}"
) from None
try:
data: Any = json.loads(path.read_text(encoding="utf-8"))
file_model: SchoolHolidayFile = SchoolHolidayFile.model_validate(data)
except Exception as exc:
logger.error(
"Fichier de vacances scolaires invalide %s : %s.",
redact_secrets(str(path)),
redact_exception(exc),
)
raise PronoteSyncError(
f"Le fichier de vacances scolaires est invalide : {redact_secrets(str(path))}"
) from None
self._periods = file_model.periods
def is_holiday(self, target_date: date) -> bool:
"""Vérifie si la date donnée tombe pendant une période de vacances.
La date est considérée comme étant en vacances si elle appartient à
l'intervalle d'au moins une période, bornes incluses
(``start_date <= target_date <= end_date``).
:param target_date: Date à vérifier.
:return: ``True`` si la date tombe pendant les vacances scolaires,
``False`` sinon.
:rtype: bool
"""
return any(period.start_date <= target_date <= period.end_date for period in self._periods)

View File

@@ -0,0 +1,123 @@
"""Modèles Pydantic de parsing du fichier JSON de l'agenda théorique.
Ce module définit les modèles de parsing utilisés pour lire le fichier
JSON de l'agenda théorique : :class:`TheoreticalLessonEntry` pour une
entrée de cours et :class:`TheoreticalAgendaFile` pour le fichier
complet.
Ces modèles sont distincts du modèle de domaine
:class:`~pronote_sync.models.agenda.TheoreticalLesson` : ils restent
proches du format JSON brut (heures au format ``HH:MM``) et servent
uniquement à la désérialisation, la conversion vers le modèle de domaine
étant réalisée ensuite par le fournisseur.
"""
from __future__ import annotations
import re
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
_TIME_PATTERN = re.compile(r"^(?:[01]\d|2[0-3]):[0-5]\d$")
class TheoreticalLessonEntry(BaseModel):
"""Représente une entrée de cours dans le fichier JSON de l'agenda théorique.
Modèle figé (``frozen``) : les instances sont immuables après
création. Le format des heures est validé (``HH:MM`` sur 24 heures,
avec ``HH`` entre ``00`` et ``23`` et ``MM`` entre ``00`` et ``59``)
ainsi que l'ordre des heures (fin postérieure au début).
:param week: Type de semaine auquel s'applique le cours
(``"all"``, ``"even"`` ou ``"odd"``).
:param day_of_week: Jour de la semaine (0 = lundi, 6 = dimanche).
:param start_time: Heure de début au format ``HH:MM`` sur 24 heures.
:param end_time: Heure de fin au format ``HH:MM`` sur 24 heures.
:param subject: Nom de la matière.
:param teachers: Noms des professeurs. Tuple vide par défaut.
:param rooms: Noms des salles. Tuple vide par défaut.
:param id: Identifiant explicite optionnel. ``None`` par défaut ; en
cas d'absence, le fournisseur en génère un.
"""
model_config = ConfigDict(frozen=True)
week: Literal["all", "even", "odd"] = Field(
..., description="Type de semaine concerné (all, even ou odd)"
)
day_of_week: int = Field(
..., ge=0, le=6, description="Jour de la semaine (0=lundi, 6=dimanche)"
)
start_time: str = Field(..., description="Heure de début au format HH:MM")
end_time: str = Field(..., description="Heure de fin au format HH:MM")
subject: str = Field(..., description="Nom de la matière")
teachers: tuple[str, ...] = Field(default=(), description="Noms des professeurs")
rooms: tuple[str, ...] = Field(default=(), description="Noms des salles")
id: str | None = Field(
default=None, description="Identifiant explicite optionnel (None si absent)"
)
@model_validator(mode="before")
@classmethod
def _validate_time_format(cls, data: Any) -> Any:
"""Valide le format ``HH:MM`` des heures de début et de fin.
Les heures doivent être au format ``HH:MM`` sur 24 heures, avec
``HH`` entre ``00`` et ``23`` et ``MM`` entre ``00`` et ``59``.
Cette validation précède :meth:`_validate_time_order`, dont la
comparaison par ordre lexicographique n'est fiable que si le
format est garanti.
:param data: Données brutes transmises au modèle.
:return: Les données brutes inchangées.
:rtype: Any
:raises ValueError: Si ``start_time`` ou ``end_time`` n'est pas
au format ``HH:MM``.
"""
if not isinstance(data, dict):
return data
for field_name in ("start_time", "end_time"):
if field_name not in data:
continue
value = data[field_name]
if not isinstance(value, str) or _TIME_PATTERN.fullmatch(value) is None:
raise ValueError(
f"{field_name} doit être au format HH:MM (HH entre 00 et 23, MM entre 00 et 59)"
)
return data
@model_validator(mode="after")
def _validate_time_order(self) -> TheoreticalLessonEntry:
"""Valide que l'heure de fin est postérieure à l'heure de début.
La comparaison est effectuée sur les chaînes ``HH:MM`` de façon
lexicographique ; elle n'est fiable que parce que
:meth:`_validate_time_format` a déjà garanti le format à deux
chiffres.
:return: L'instance validée.
:rtype: TheoreticalLessonEntry
:raises ValueError: Si ``end_time`` n'est pas postérieur à
``start_time``.
"""
if self.end_time <= self.start_time:
raise ValueError("end_time doit être postérieur à start_time")
return self
class TheoreticalAgendaFile(BaseModel):
"""Représente le fichier JSON complet de l'agenda théorique.
Modèle de parsing non figé : il sert uniquement à désérialiser le
fichier JSON avant conversion vers les modèles de domaine.
:param version: Version du schéma du fichier (vaut ``1``).
:param lessons: Liste des entrées de cours du fichier.
"""
version: Literal[1] = Field(default=1, description="Version du schéma (1)")
lessons: tuple[TheoreticalLessonEntry, ...] = Field(
..., description="Liste des entrées de cours"
)

View File

@@ -0,0 +1,55 @@
"""Service déterministe de calcul de la parité des semaines pour l'agenda théorique.
Ce module fournit :class:`WeekParityService`, un service sans état qui détermine
si la semaine contenant une date donnée est paire ou impaire, à partir d'une
date d'ancrage dont la parité est connue. L'algorithme repose sur le décalage
entre les lundis des deux semaines, et non sur les numéros de semaine ISO.
"""
from __future__ import annotations
from datetime import date, timedelta
from typing import Literal
class WeekParityService:
"""Service déterministe de calcul de la parité des semaines.
La parité d'une semaine est déduite d'une date d'ancrage fournie à la
construction : la semaine contenant cette date a une parité connue
(paire ou impaire). Le service est immuable après construction et ne
dépend d'aucun état global ni de l'horloge système.
"""
def __init__(self, anchor_date: date, anchor_type: Literal["even", "odd"]) -> None:
"""Initialise le service avec la date d'ancrage et sa parité.
:param anchor_date: Date de référence dont la semaine a une parité connue.
:param anchor_type: Parité de la semaine d'ancrage (``"even"`` ou ``"odd"``).
"""
self._anchor_monday = anchor_date - timedelta(days=anchor_date.weekday())
self._anchor_type = anchor_type
def parity_for(self, target_date: date) -> Literal["even", "odd"]:
"""Détermine la parité de la semaine contenant la date cible.
Algorithme :
1. Calculer le lundi de la semaine de la date cible.
2. Utiliser le lundi de la semaine de la date d'ancrage (stocké à
l'initialisation).
3. Calculer le nombre de semaines entre les deux lundis :
``(target_monday - anchor_monday).days // 7``.
4. Si le décalage de semaines est pair, la cible a la même parité que
l'ancrage.
5. Si le décalage de semaines est impair, la cible a la parité opposée.
:param target_date: Date dont il faut déterminer la parité.
:return: ``"even"`` ou ``"odd"`` selon la parité de la semaine cible.
:rtype: Literal["even", "odd"]
"""
target_monday = target_date - timedelta(days=target_date.weekday())
week_offset = (target_monday - self._anchor_monday).days // 7
if week_offset % 2 == 0:
return self._anchor_type
# Inverse la parité.
return "odd" if self._anchor_type == "even" else "even"

View File

@@ -0,0 +1,44 @@
"""Protocole pour les fournisseurs d'agenda théorique.
Ce module définit :class:`TheoreticalAgendaProvider`, le contrat que
tous les fournisseurs d'agenda théorique doivent respecter pour exposer
les cours théoriques (emploi du temps attendu) par date ou plage de
dates.
"""
from __future__ import annotations
from datetime import date
from typing import Protocol, runtime_checkable
from pronote_sync.models.agenda import TheoreticalLesson
@runtime_checkable
class TheoreticalAgendaProvider(Protocol):
"""Protocole pour un fournisseur d'agenda théorique.
Un fournisseur d'agenda théorique expose les cours théoriques
(emploi du temps attendu) pour une date ou une plage de dates.
L'implémentation encapsule la logique de filtrage par parité de
semaine et par vacances scolaires.
"""
def get_lessons(self, target_date: date) -> list[TheoreticalLesson]:
"""Retourne les cours théoriques applicables à la date donnée.
:param target_date: Date cible.
:return: Liste des cours théoriques triée par identifiant.
:rtype: list[TheoreticalLesson]
"""
...
def get_lessons_for_range(self, start_date: date, end_date: date) -> list[TheoreticalLesson]:
"""Retourne les cours théoriques pour une plage de dates (inclusives).
:param start_date: Date de début (inclusive).
:param end_date: Date de fin (inclusive).
:return: Liste des cours théoriques triée par identifiant.
:rtype: list[TheoreticalLesson]
"""
...

View File

@@ -22,26 +22,15 @@ class RedactingFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
"""Formate un enregistrement de log en masquant les secrets.
Le message et chaque argument textuel de l'enregistrement sont rédigés
avant le formatage final effectué par :class:`logging.Formatter`.
La rédaction est appliquée à la chaîne finale (message, arguments et
traceback inclus) produite par :class:`logging.Formatter`.
:param record: Enregistrement de log à formater.
:return: Message formaté, avec les secrets remplacés par ``REDACTED``.
:rtype: str
"""
record.msg = redact_secrets(str(record.msg))
args = record.args
if args:
if isinstance(args, tuple):
record.args = tuple(
redact_secrets(arg) if isinstance(arg, str) else arg for arg in args
)
else:
record.args = {
key: redact_secrets(value) if isinstance(value, str) else value
for key, value in args.items()
}
return super().format(record)
formatted = super().format(record)
return redact_secrets(formatted)
def setup_logging(level: str = "INFO") -> None:

View File

@@ -10,10 +10,30 @@ from __future__ import annotations
import re
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
_SENSITIVE_QUERY_KEYS = frozenset({"icalsecurise", "token", "key", "password", "secret"})
_URL_PATTERN = re.compile(r"https?://[^\s]+")
_SENSITIVE_QUERY_KEYS = frozenset(
{
"icalsecurise",
"token",
"key",
"password",
"secret",
"api_key",
"apikey",
"access_token",
"auth",
"authorization",
}
)
_URL_PATTERN = re.compile(r"https?://[^\s]+", re.IGNORECASE)
_AUTH_HEADER_PATTERN = re.compile(
r"((?:Proxy-)?Authorization)\s*[:=]\s*\S[^\r\n]*",
re.IGNORECASE,
)
_ISOLATED_SECRET_PATTERN = re.compile(
r"\b(icalsecurise|token|password|secret|key)\s*=\s*[^\s&]+",
r"\b(icalsecurise|access_token|api_key|apikey|authorization|token|password|secret|key|auth)"
r"(\s*['\"]?\s*[:=]\s*)"
r"(['\"]?)"
r"([^\s&'\"]+)",
re.IGNORECASE,
)
_REDACTED = "REDACTED"
@@ -21,15 +41,28 @@ _REDACTED_URL = "REDACTED_URL"
def redact_url(url: str) -> str:
"""Masque les paramètres sensibles dans une URL.
"""Masque les identifiants et les paramètres sensibles d'une URL.
:param url: URL pouvant contenir des paramètres sensibles (ex: ``icalsecurise``).
:return: URL avec les paramètres sensibles remplacés par ``REDACTED``,
Les informations d'authentification du netloc (``utilisateur:motdepasse@hôte``)
sont entièrement masquées (utilisateur et mot de passe), ainsi que les
paramètres sensibles de la requête (ex: ``icalsecurise``).
:param url: URL pouvant contenir des informations sensibles (ex: ``icalsecurise``).
:return: URL avec les éléments sensibles remplacés par ``REDACTED``,
ou ``REDACTED_URL`` si le traitement échoue.
:rtype: str
"""
try:
parts = urlsplit(url)
if parts.username is not None or parts.password is not None:
# Netloc sûr : REDACTED@hôte:port. L'utilisateur et le mot de
# passe sont entièrement masqués.
host = parts.hostname or ""
if parts.port is not None:
netloc = f"{_REDACTED}@{host}:{parts.port}"
else:
netloc = f"{_REDACTED}@{host}"
parts = parts._replace(netloc=netloc)
query: list[tuple[str, str]] = parse_qsl(parts.query, keep_blank_values=True)
redacted_query = [
(key, _REDACTED if key.lower() in _SENSITIVE_QUERY_KEYS else value)
@@ -43,16 +76,19 @@ def redact_url(url: str) -> str:
def redact_secrets(text: str) -> str:
"""Masque les secrets présents dans un texte arbitraire.
Les URLs sont d'abord traitées par :func:`redact_url`, puis les affectations
isolées de type ``cle=valeur`` (ex: ``icalsecurise=XXX``) sont masquées,
sans distinction de casse.
Les URLs sont d'abord traitées par :func:`redact_url`, puis les en-têtes
d'authentification (``Authorization``, ``Proxy-Authorization``) et les
affectations isolées de type ``cle=valeur`` ou ``cle:valeur``
(ex: ``icalsecurise=XXX``, ``"token": "XXX"``) sont masquées, sans
distinction de casse.
:param text: Texte pouvant contenir des URLs ou des secrets en clair.
:return: Texte avec les secrets remplacés par ``REDACTED``.
:rtype: str
"""
redacted = _URL_PATTERN.sub(lambda match: redact_url(match.group(0)), text)
return _ISOLATED_SECRET_PATTERN.sub(r"\1=REDACTED", redacted)
redacted = _AUTH_HEADER_PATTERN.sub(r"\1: REDACTED", redacted)
return _ISOLATED_SECRET_PATTERN.sub(r"\1\2\3REDACTED", redacted)
def redact_exception(exc: Exception) -> str:

View File

@@ -7,7 +7,7 @@ name = "pronote-sync"
version = "0.1.0"
description = "Synchronisation Pronote → CalDAV + XMPP"
license = {text = "MIT"}
requires-python = ">=3.13"
requires-python = ">=3.13.5"
authors = [
{name = "Votre Nom", email = "votre@email.com"}
]

47
tests/fixtures/blog_rss.xml vendored Normal file
View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>Blog du collège Les Mimosas</title>
<link>https://example.com/blog/</link>
<description>Actualités et informations du collège Les Mimosas</description>
<language>fr-FR</language>
<item>
<title>Information générale</title>
<link>https://example.com/blog/?p=1003</link>
<guid isPermaLink="false">https://example.com/blog/?p=1003</guid>
<pubDate>Wed, 12 Aug 2026 08:00:00 +0000</pubDate>
<description>Information générale à destination des familles.</description>
<content:encoded><![CDATA[
<p>La vie scolaire rappelle aux familles que les billets de cantine sont à commander avant le vendredi soir.</p>
<p>Pour toute question, consultez la page <a href="https://example.com/blog/cantine/">cantines et restauration</a> du site.</p>
]]></content:encoded>
</item>
<item>
<title>Réunion de rentrée</title>
<link>https://example.com/blog/?p=1001</link>
<guid isPermaLink="false">https://example.com/blog/?p=1001</guid>
<pubDate>Mon, 10 Aug 2026 09:00:11 +0000</pubDate>
<category>Administration</category>
<dc:creator>M. Dupont</dc:creator>
<description>Réunion de rentrée des parents d'élèves.</description>
<content:encoded><![CDATA[
<p>La réunion de rentrée des parents d'élèves se tiendra le mardi 15 septembre à 18 h 00 dans la salle polyvalente.</p>
<p>L'équipe pédagogique y présentera le projet d'établissement et le calendrier des conseils de classe. Un temps d'échange est prévu avec les professeurs principaux.</p>
<p>Merci de confirmer votre présence en remplissant le <a href="https://example.com/blog/reunion-rentree-inscription/">formulaire d'inscription</a> avant le 10 septembre.</p>
]]></content:encoded>
</item>
<item>
<title>Sortie pédagogique au musée</title>
<link>https://example.com/blog/?p=1002</link>
<guid isPermaLink="false">https://example.com/blog/?p=1002</guid>
<pubDate>Tue, 11 Aug 2026 14:30:00 +0000</pubDate>
<category>Pédagogie</category>
<description>Sortie pédagogique des élèves de 4e au musée d'art moderne.</description>
<content:encoded><![CDATA[
<p>Les élèves de 4e se rendront au musée d'art moderne le jeudi 8 octobre dans le cadre du cours d'arts plastiques.</p>
<p>La visite guidée portera sur la période impressionniste. Les élèves devront apporter un carnet de croquis et leur pique-nique.</p>
<p>Le détail de l'organisation figure dans la <a href="https://example.com/blog/sortie-musee-autorisation/">note d'autorisation</a> à retourner signée avant le 25 septembre.</p>
]]></content:encoded>
</item>
</channel>
</rss>

57
tests/fixtures/pronote-4e.ics vendored Normal file
View File

@@ -0,0 +1,57 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Index Education//Pronote//FR
X-WR-CALNAME:Classe de 4e
BEGIN:VEVENT
UID:Edt_12345@index-education.net-20260905T120000Z-Index-Education
DTSTAMP:20260905T120000Z
DTSTART:20260905T080000Z
DTEND:20260905T090000Z
SUMMARY:Mathématiques
CATEGORIES:Cours
DESCRIPTION:<div>
Matière : Mathématiques
Professeur : M. Dupont
Salle : 204
Groupe : Classe entière
<strong>Contenu pédagogique :
</strong>
Résoudre des équations du second degré.
<strong>Pour le 10/09/2026 :
</strong>
Exercices 1 à 5 page 42.
<strong>Donné le 05/09/2026 :
</strong>
Exercices 1 à 5 page 42.
</div>
END:VEVENT
BEGIN:VEVENT
UID:Edt_67890@index-education.net-20260905T120000Z-Index-Education
DTSTAMP:20260905T120000Z
DTSTART:20260905T090000Z
DTEND:20260905T100000Z
SUMMARY:Français
CATEGORIES:Cours - Cours annulé
STATUS:CANCELLED
DESCRIPTION:<div>
Matière : Français
Professeur : Mme Martin
Salle : 205
Groupe : Classe entière
<strong>Contenu pédagogique :
</strong>
Étude d'un texte littéraire.
</div>
END:VEVENT
BEGIN:VEVENT
UID:Edt_11111@index-education.net-20260905T120000Z-Index-Education
DTSTAMP:20260905T120000Z
DTSTART;VALUE=DATE:20261020
DTEND;VALUE=DATE:20261021
SUMMARY:Vacances de la Toussaint
CATEGORIES:Congés
DESCRIPTION:Période de vacances scolaires.
END:VEVENT
END:VCALENDAR

26
tests/fixtures/school_holidays.json vendored Normal file
View File

@@ -0,0 +1,26 @@
{
"zone": "A",
"school_year": "2026-2027",
"periods": [
{
"start_date": "2026-10-17",
"end_date": "2026-11-02",
"label": "Toussaint"
},
{
"start_date": "2026-12-19",
"end_date": "2027-01-04",
"label": "Noël"
},
{
"start_date": "2027-02-06",
"end_date": "2027-02-22",
"label": "Hiver"
},
{
"start_date": "2027-04-03",
"end_date": "2027-04-19",
"label": "Printemps"
}
]
}

87
tests/fixtures/theoretical.json vendored Normal file
View File

@@ -0,0 +1,87 @@
{
"version": 1,
"lessons": [
{
"id": "theoretical-maths-monday-1",
"week": "all",
"day_of_week": 0,
"start_time": "08:00",
"end_time": "09:00",
"subject": "Mathématiques",
"teachers": ["Mme Martin"],
"rooms": ["101"]
},
{
"week": "all",
"day_of_week": 0,
"start_time": "09:00",
"end_time": "10:00",
"subject": "Français",
"teachers": ["M. Dupont"],
"rooms": ["102"]
},
{
"week": "all",
"day_of_week": 0,
"start_time": "11:00",
"end_time": "12:00",
"subject": "Histoire-Géographie",
"teachers": ["Mme Petit"],
"rooms": ["103"]
},
{
"week": "even",
"day_of_week": 1,
"start_time": "10:00",
"end_time": "11:00",
"subject": "Anglais",
"teachers": ["Mme Bernard"],
"rooms": ["201"]
},
{
"week": "even",
"day_of_week": 1,
"start_time": "10:00",
"end_time": "11:00",
"subject": "Technologie",
"teachers": [],
"rooms": []
},
{
"week": "odd",
"day_of_week": 1,
"start_time": "10:00",
"end_time": "11:00",
"subject": "Espagnol",
"teachers": ["M. Garcia"],
"rooms": ["202"]
},
{
"week": "odd",
"day_of_week": 1,
"start_time": "10:00",
"end_time": "11:00",
"subject": "Éducation musicale",
"teachers": [],
"rooms": []
},
{
"week": "all",
"day_of_week": 2,
"start_time": "14:00",
"end_time": "15:00",
"subject": "Sciences",
"teachers": [],
"rooms": ["203"]
},
{
"week": "all",
"day_of_week": 4,
"start_time": "09:00",
"end_time": "10:00",
"subject": "Arts plastiques",
"teachers": [],
"rooms": []
}
]
}

0
tests/unit/__init__.py Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,350 @@
"""Tests unitaires pour le gestionnaire d'état du flux RSS du blog.
Ce module valide le comportement de :class:`BlogRSSState` dans
:mod:`pronote_sync.sources.blog.state`. Les tests couvrent :
- La persistance des GUID connus et des en-têtes de cache HTTP,
- La tolérance aux erreurs (fichier absent, corrompu, version incompatible),
- Le tri alphabétique des GUID lors de la sauvegarde,
- La réinitialisation complète de l'état.
Tous les tests utilisent des fichiers temporaires via la fixture ``tmp_path``.
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from pronote_sync.sources.blog.state import BlogRSSState
def test_state_file_absent_empty_state(tmp_path: Path) -> None:
"""Vérifie qu'un fichier d'état absent initialise un état vide.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "nonexistent.json"
state = BlogRSSState(state_file)
assert state.get_known_guids() == frozenset()
assert state.get_cache_headers() == (None, None)
def test_add_guids_persists(tmp_path: Path) -> None:
"""Vérifie que l'ajout de GUID persiste dans le fichier JSON.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
state = BlogRSSState(state_file)
state.add_guids(["guid-2", "guid-1", "guid-3"])
assert state.get_known_guids() == frozenset({"guid-1", "guid-2", "guid-3"})
# Vérification du contenu du fichier
saved_data = json.loads(state_file.read_text(encoding="utf-8"))
assert saved_data["known_guids"] == ["guid-1", "guid-2", "guid-3"]
def test_add_guids_empty_noop(tmp_path: Path) -> None:
"""Vérifie que l'ajout d'une liste vide ne modifie pas le fichier.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
state = BlogRSSState(state_file)
# Ajout initial de GUID
state.add_guids(["guid-1"])
original_content = state_file.read_text(encoding="utf-8")
# Ajout d'une liste vide
state.add_guids([])
# Vérification que le fichier n'a pas été modifié (comparaison par contenu)
assert state_file.read_text(encoding="utf-8") == original_content
def test_state_load_persisted_guids(tmp_path: Path) -> None:
"""Vérifie que les GUID persistés sont rechargés dans une nouvelle instance.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
# Création et sauvegarde de l'état initial
state1 = BlogRSSState(state_file)
state1.add_guids(["guid-1", "guid-2"])
# Création d'une nouvelle instance avec le même fichier
state2 = BlogRSSState(state_file)
assert state2.get_known_guids() == frozenset({"guid-1", "guid-2"})
def test_state_load_cache_headers(tmp_path: Path) -> None:
"""Vérifie que les en-têtes de cache persistés sont rechargés.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
# Création et sauvegarde des en-têtes de cache
state1 = BlogRSSState(state_file)
state1.update_cache_headers("etag-123", "Wed, 01 Sep 2026 GMT")
# Création d'une nouvelle instance avec le même fichier
state2 = BlogRSSState(state_file)
assert state2.get_cache_headers() == ("etag-123", "Wed, 01 Sep 2026 GMT")
def test_corrupt_json_warning(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""Vérifie qu'un fichier JSON corrompu déclenche un avertissement et initialise un état vide.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:param caplog: Fixture pytest pour capturer les logs.
:return: None
"""
state_file = tmp_path / "corrupt.json"
state_file.write_text("not json{", encoding="utf-8")
with caplog.at_level("WARNING"):
state = BlogRSSState(state_file)
assert state.get_known_guids() == frozenset()
assert state.get_cache_headers() == (None, None)
assert "Impossible de charger le fichier d'état blog RSS" in caplog.text
def test_wrong_version_warning(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""Vérifie qu'une version incompatible déclenche un avertissement et initialise un état vide.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:param caplog: Fixture pytest pour capturer les logs.
:return: None
"""
state_file = tmp_path / "wrong_version.json"
state_file.write_text(
json.dumps({"version": 99, "known_guids": ["x"], "etag": None, "last_modified": None}),
encoding="utf-8",
)
with caplog.at_level("WARNING"):
state = BlogRSSState(state_file)
assert state.get_known_guids() == frozenset()
assert state.get_cache_headers() == (None, None)
assert "version absente ou non supportée" in caplog.text
def test_missing_version_warning(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""Vérifie qu'un fichier sans champ version déclenche un avertissement et initialise un état vide.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:param caplog: Fixture pytest pour capturer les logs.
:return: None
"""
state_file = tmp_path / "missing_version.json"
state_file.write_text(
json.dumps({"known_guids": ["x"], "etag": None, "last_modified": None}),
encoding="utf-8",
)
with caplog.at_level("WARNING"):
state = BlogRSSState(state_file)
assert state.get_known_guids() == frozenset()
assert state.get_cache_headers() == (None, None)
assert "version absente ou non supportée" in caplog.text
def test_known_guids_sorted_on_save(tmp_path: Path) -> None:
"""Vérifie que les GUID sont triés alphabétiquement lors de la sauvegarde.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
state = BlogRSSState(state_file)
state.add_guids(["c-guid", "a-guid", "b-guid"])
saved_data = json.loads(state_file.read_text(encoding="utf-8"))
assert saved_data["known_guids"] == ["a-guid", "b-guid", "c-guid"]
def test_clear_resets_state(tmp_path: Path) -> None:
"""Vérifie que la méthode clear réinitialise complètement l'état.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
state = BlogRSSState(state_file)
# Ajout de GUID et d'en-têtes de cache
state.add_guids(["guid-1", "guid-2"])
state.update_cache_headers("etag-123", "Wed, 01 Sep 2026 GMT")
# Réinitialisation
state.clear()
assert state.get_known_guids() == frozenset()
assert state.get_cache_headers() == (None, None)
# Vérification du contenu du fichier
saved_data = json.loads(state_file.read_text(encoding="utf-8"))
assert saved_data["known_guids"] == []
assert saved_data["etag"] is None
assert saved_data["last_modified"] is None
def test_clear_persists_to_file(tmp_path: Path) -> None:
"""Vérifie que la réinitialisation est persistée dans le fichier.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
# Création, ajout de données et réinitialisation
state1 = BlogRSSState(state_file)
state1.add_guids(["guid-1"])
state1.update_cache_headers("etag-123", "Wed, 01 Sep 2026 GMT")
state1.clear()
# Création d'une nouvelle instance avec le même fichier
state2 = BlogRSSState(state_file)
assert state2.get_known_guids() == frozenset()
assert state2.get_cache_headers() == (None, None)
def test_str_path_converted_to_path(tmp_path: Path) -> None:
"""Vérifie qu'un chemin de type str est converti en Path.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = str(tmp_path / "state.json")
state = BlogRSSState(state_file)
state.add_guids(["guid-1"])
assert Path(state_file).exists()
def test_update_cache_headers_none_values(tmp_path: Path) -> None:
"""Vérifie que la mise à jour avec des valeurs None fonctionne correctement.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
state = BlogRSSState(state_file)
state.update_cache_headers(None, None)
assert state.get_cache_headers() == (None, None)
# Vérification du contenu du fichier
saved_data = json.loads(state_file.read_text(encoding="utf-8"))
assert saved_data["etag"] is None
assert saved_data["last_modified"] is None
def test_add_guids_multiple_calls(tmp_path: Path) -> None:
"""Vérifie que plusieurs appels à add_guids accumulent les GUID.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
state = BlogRSSState(state_file)
state.add_guids(["guid-1"])
state.add_guids(["guid-2"])
assert state.get_known_guids() == frozenset({"guid-1", "guid-2"})
def test_version_in_saved_file(tmp_path: Path) -> None:
"""Vérifie que le champ version est présent dans le fichier sauvegardé.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
state = BlogRSSState(state_file)
state.add_guids(["guid-1"])
saved_data = json.loads(state_file.read_text(encoding="utf-8"))
assert saved_data["version"] == 1
def test_get_known_guids_returns_frozenset(tmp_path: Path) -> None:
"""Vérifie que get_known_guids retourne un frozenset.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
state = BlogRSSState(state_file)
state.add_guids(["guid-1", "guid-2"])
result = state.get_known_guids()
assert type(result) is frozenset
def test_atomic_save_preserves_on_error(tmp_path: Path) -> None:
"""Vérifie que l'état original est préservé en cas d'erreur lors de la sauvegarde atomique.
Si une erreur survient pendant le remplacement atomique du fichier,
le fichier original doit rester intact et le fichier temporaire doit être nettoyé.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
# Créer un état initial avec des GUID
state = BlogRSSState(state_file)
state.add_guids(["original-guid-1", "original-guid-2"])
# Lire le contenu original
original_content = state_file.read_text(encoding="utf-8")
# Mock Path.replace pour simuler une erreur pendant le remplacement atomique
with patch.object(Path, "replace") as mock_replace:
mock_replace.side_effect = OSError("Simulated atomic replace failure")
# Essayer d'ajouter de nouveaux GUID, ce qui déclenchera _save()
state.add_guids(["new-guid"])
# Vérifier que le fichier original est toujours intact
assert state_file.read_text(encoding="utf-8") == original_content
# Vérifier que le fichier temporaire a été nettoyé
tmp_file = state_file.with_suffix(".tmp")
assert not tmp_file.exists()
# Vérifier que l'état en mémoire n'a pas été modifié (car la sauvegarde a échoué)
# Note: En réalité, l'état en mémoire est modifié mais pas persistant
# C'est le fichier qui doit rester intact
assert state.get_known_guids() == frozenset({"original-guid-1", "original-guid-2", "new-guid"})
# Ensure trailing newline

119
tests/unit/test_config.py Normal file
View File

@@ -0,0 +1,119 @@
"""Tests unitaires pour la configuration et le chargement des secrets.
Ce module vérifie que la configuration est chargée correctement depuis les
variables d'environnement, que les secrets sont masqués dans les représentations
textuelles et sérialisées, et que le rechargement fonctionne comme attendu.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from pydantic import SecretStr
from pronote_sync.config.env import load_settings
from pronote_sync.config.settings import PronoteSettings, Settings
if TYPE_CHECKING:
from _pytest.monkeypatch import MonkeyPatch
def test_env_prefix_loading(monkeypatch: MonkeyPatch) -> None:
"""Vérifie que les variables d'environnement avec préfixe sont chargées correctement.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
monkeypatch.setenv("PRONOTE_USERNAME", "testuser")
settings = load_settings()
assert settings.pronote.username == "testuser"
def test_env_reload_via_load_settings(monkeypatch: MonkeyPatch) -> None:
"""Vérifie que le rechargement de la configuration lit les nouvelles valeurs.
Ce test garantit que les champs configurés avec ``default_factory`` créent
des instances fraîches à chaque appel de ``load_settings()``, permettant ainsi
un rechargement dynamique des variables d'environnement.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
# Premier chargement (valeur par défaut None)
settings1 = load_settings()
assert settings1.pronote.username is None
# Modification de l'environnement
monkeypatch.setenv("PRONOTE_USERNAME", "newuser")
# Rechargement
settings2 = load_settings()
assert settings2.pronote.username == "newuser"
def test_secretstr_masking_ical_url() -> None:
"""Vérifie que l'URL iCal contenant un secret est masquée dans les représentations.
:return: None
"""
ical_url = "https://example.fr/ical?icalsecurise=SECRET_TOKEN" # pragma: allowlist secret
settings = Settings(pronote=PronoteSettings(ical_url=SecretStr(ical_url)))
# Vérification de la représentation string
str_repr = str(settings)
assert "SECRET_TOKEN" not in str_repr
assert "**********" in str_repr
# Vérification de la représentation repr
repr_repr = repr(settings)
assert "SECRET_TOKEN" not in repr_repr
assert "**********" in repr_repr
# Vérification de la sérialisation JSON
json_str = settings.model_dump_json()
assert "SECRET_TOKEN" not in json_str
assert "**********" in json_str
def test_secretstr_masking_password() -> None:
"""Vérifie que le mot de passe est masqué dans les représentations.
:return: None
"""
password = "mypass123" # pragma: allowlist secret
settings = Settings(pronote=PronoteSettings(password=SecretStr(password)))
# Vérification de la représentation string
str_repr = str(settings)
assert "mypass123" not in str_repr
assert "**********" in str_repr
# Vérification de la représentation repr
repr_repr = repr(settings)
assert "mypass123" not in repr_repr
assert "**********" in repr_repr
# Vérification de la sérialisation JSON
json_str = settings.model_dump_json()
assert "mypass123" not in json_str
assert "**********" in json_str
def test_no_singleton_import() -> None:
"""Vérifie que l'import du singleton settings lève une ImportError.
Ce test garantit que le pattern singleton a été supprimé et que la
configuration doit être chargée explicitement via ``load_settings()``.
:return: None
"""
with pytest.raises(ImportError):
# Attempt to import a non-existent attribute to trigger ImportError
# as required by the spec
from pronote_sync.config.settings import ( # type: ignore[attr-defined] # noqa: F401
settings,
)
# Ensure trailing newline

1189
tests/unit/test_fallback.py Normal file

File diff suppressed because it is too large Load Diff

600
tests/unit/test_ical.py Normal file
View File

@@ -0,0 +1,600 @@
"""Tests unitaires pour le module iCal : téléchargement et parsing.
Ce module teste :
- La récupération du flux iCal (file://, HTTP)
- Le parsing des événements en modèles Lesson, SchoolEvent
- L'extraction et normalisation des devoirs
- La collecte et déduplication des devoirs par date cible
Les tests utilisent des mocks pour éviter tout appel réseau réel.
"""
from __future__ import annotations
import tempfile
import urllib.parse
from datetime import date, datetime
from pathlib import Path
import pytest
import responses
from pronote_sync.models.agenda import HomeworkBlock, Lesson, LessonStatus
from pronote_sync.sources.pronote.ical import (
collect_homeworks,
fetch_ical,
generate_homework_id,
get_calendar_name,
normalize_homework_text,
parse_body,
parse_ical,
)
@pytest.fixture
def valid_ical_content() -> str:
"""Contenu iCal valide pour tests de parsing."""
return """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//FR
X-WR-CALNAME:Test Calendar
BEGIN:VEVENT
UID:test-1@test.net
DTSTAMP:20260905T120000Z
DTSTART:20260905T080000Z
DTEND:20260905T090000Z
SUMMARY:Math
CATEGORIES:Cours
DESCRIPTION:<div>Matière : Math\nProfesseur : M. Dupont\nSalle : 204\n<strong>Contenu pédagogique :</strong>Résoudre des équations.</div>
END:VEVENT
END:VCALENDAR
"""
@pytest.fixture
def invalid_ical_content() -> str:
"""Contenu iCal invalide (sans BEGIN:VCALENDAR)."""
return "INVALID:CONTENT\nThis is not a valid iCal file."
def test_fetch_ical_file_protocol() -> None:
"""fetch_ical("file://tests/fixtures/pronote-4e.ics") retourne un contenu commençant par BEGIN:VCALENDAR.
:return: None
"""
fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
url = f"file://{fixture_path}"
content = fetch_ical(url)
assert content.lstrip().startswith("BEGIN:VCALENDAR")
def test_fetch_ical_file_uri_decoding() -> None:
"""fetch_ical("file://path%20with%20spaces") décode correctement le chemin.
:return: None
"""
# Créer un fichier temporaire avec un espace dans le nom
with tempfile.TemporaryDirectory() as tmpdir:
temp_path = Path(tmpdir) / "fichier avec espaces.ics"
temp_path.write_text(
"BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Test//Test//FR\nEND:VCALENDAR",
encoding="utf-8",
)
# URL encodée avec espace
encoded_name = urllib.parse.quote("fichier avec espaces.ics")
url = f"file://{tmpdir}/{encoded_name}"
# Cela devrait fonctionner car Path.read_text décode l'URL
content = fetch_ical(url)
assert content.lstrip().startswith("BEGIN:VCALENDAR")
@responses.activate
def test_fetch_ical_invalid_content() -> None:
"""Si le contenu ne commence pas par BEGIN:VCALENDAR, une exception est levée.
:return: None
"""
responses.add(
responses.GET,
"https://example.com/ical.ics",
body="INVALID:CONTENT",
status=200,
)
with pytest.raises(ValueError, match="Flux iCal invalide"):
fetch_ical("https://example.com/ical.ics")
@responses.activate
def test_fetch_ical_http() -> None:
"""Mock de requests.get pour retourner un contenu iCal valide.
:return: None
"""
valid_content = "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Test//Test//FR\nEND:VCALENDAR"
responses.add(
responses.GET,
"https://pronote.example.com/ical.ics",
body=valid_content,
status=200,
)
content = fetch_ical("https://pronote.example.com/ical.ics")
assert content == valid_content
@responses.activate
def test_fetch_ical_redacts_errors() -> None:
"""Les messages d'erreur ne contiennent pas l'URL complète (doit être masquée).
:return: None
"""
responses.add(
responses.GET,
"https://pronote.example.com/ical.ics",
body=Exception("Erreur réseau"),
status=500,
)
with pytest.raises(Exception) as exc_info:
fetch_ical("https://pronote.example.com/ical.ics?token=secret123")
error_msg = str(exc_info.value)
# Vérifie que le token secret n'est pas dans le message
assert "secret123" not in error_msg
# Vérifie que l'URL est masquée (utilise redact_url)
assert "https://pronote.example.com/ical.ics" in error_msg
# Le message doit contenir la partie masquée
assert "...ics" in error_msg or "pronote.example.com/ical" in error_msg
def test_get_calendar_name() -> None:
"""get_calendar_name(raw_ical) retourne le nom du calendrier.
:return: None
"""
fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
with open(fixture_path, encoding="utf-8") as f:
content = f.read()
name = get_calendar_name(content)
assert name == "Classe de 4e"
def test_get_calendar_name_with_params() -> None:
"""Test avec X-WR-CALNAME;LANGUAGE=fr:TestName.
:return: None
"""
raw_ical = """BEGIN:VCALENDAR
X-WR-CALNAME;LANGUAGE=fr:TestName
END:VCALENDAR
"""
name = get_calendar_name(raw_ical)
assert name == "TestName"
def test_get_calendar_name_none() -> None:
"""Retourne None si X-WR-CALNAME est absent.
:return: None
"""
raw_ical = """BEGIN:VCALENDAR
VERSION:2.0
END:VCALENDAR
"""
name = get_calendar_name(raw_ical)
assert name is None
def test_parse_ical_returns_lessons() -> None:
"""parse_ical(fixture_content) retourne au moins 2 cours.
:return: None
"""
fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
with open(fixture_path, encoding="utf-8") as f:
content = f.read()
lessons, homeworks, school_events = parse_ical(content)
assert len(lessons) >= 2
def test_parse_ical_detects_cancelled_course() -> None:
"""Un cours a status == LessonStatus.CANCELLED.
:return: None
"""
fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
with open(fixture_path, encoding="utf-8") as f:
content = f.read()
lessons, _, _ = parse_ical(content)
cancelled_lessons = [lesson for lesson in lessons if lesson.status == LessonStatus.CANCELLED]
assert len(cancelled_lessons) >= 1
def test_parse_ical_returns_school_events() -> None:
"""Au moins 1 événement scolaire est retourné.
:return: None
"""
fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
with open(fixture_path, encoding="utf-8") as f:
content = f.read()
_, _, school_events = parse_ical(content)
assert len(school_events) >= 1
def test_parse_ical_homeworks_empty() -> None:
"""La liste des devoirs est toujours vide depuis parse_ical.
:return: None
"""
fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
with open(fixture_path, encoding="utf-8") as f:
content = f.read()
_, homeworks, _ = parse_ical(content)
assert homeworks == []
def test_parse_ical_lesson_fields() -> None:
"""Vérifie qu'un cours a les bons champs (matière, profs, salles).
:return: None
"""
fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
with open(fixture_path, encoding="utf-8") as f:
content = f.read()
lessons, _, _ = parse_ical(content)
assert len(lessons) > 0
# Vérifions le premier cours (Mathématiques)
lesson = lessons[0]
assert lesson.subject == "Mathématiques"
assert lesson.teachers == ("M. Dupont",)
assert lesson.rooms == ("204",)
assert lesson.status == LessonStatus.NORMAL
def test_collect_homeworks_dedup() -> None:
"""Étant donné des cours avec des blocs de devoirs, collect_homeworks retourne des devoirs dédupliqués.
:return: None
"""
# Créer des cours avec des blocs de devoirs en double
lesson1 = Lesson(
id="lesson1",
start=datetime(2026, 9, 10, 8, 0),
end=datetime(2026, 9, 10, 9, 0),
subject="Math",
teachers=("M. Dupont",),
rooms=("204",),
group=None,
status=LessonStatus.NORMAL,
content=None,
homework_blocks=(
HomeworkBlock(
kind="due",
date=date(2026, 9, 10),
text="Exercice 1 à 5 page 42",
html="<p>Exercice 1 à 5 page 42</p>",
),
),
)
lesson2 = Lesson(
id="lesson2",
start=datetime(2026, 9, 10, 10, 0),
end=datetime(2026, 9, 10, 11, 0),
subject="Physique",
teachers=("M. Martin",),
rooms=("205",),
group=None,
status=LessonStatus.NORMAL,
content=None,
homework_blocks=(
HomeworkBlock(
kind="due",
date=date(2026, 9, 10),
text="Exercice 1 à 5 page 42",
html="<p>Exercice 1 à 5 page 42</p>",
),
),
)
homeworks = collect_homeworks([lesson1, lesson2], target_date=date(2026, 9, 10))
assert len(homeworks) == 1 # Un seul devoir dédupliqué
def test_collect_homeworks_id_stability() -> None:
"""Deux devoirs avec le même texte et date d'échéance produisent le même ID.
:return: None
"""
lesson1 = Lesson(
id="lesson1",
start=datetime(2026, 9, 10, 8, 0),
end=datetime(2026, 9, 10, 9, 0),
subject="Math",
teachers=("M. Dupont",),
rooms=("204",),
group=None,
status=LessonStatus.NORMAL,
content=None,
homework_blocks=(
HomeworkBlock(
kind="due",
date=date(2026, 9, 10),
text="Devoir commun",
html="<p>Devoir commun</p>",
),
),
)
lesson2 = Lesson(
id="lesson2",
start=datetime(2026, 9, 10, 10, 0),
end=datetime(2026, 9, 10, 11, 0),
subject="Physique",
teachers=("M. Martin",),
rooms=("205",),
group=None,
status=LessonStatus.NORMAL,
content=None,
homework_blocks=(
HomeworkBlock(
kind="due",
date=date(2026, 9, 10),
text="Devoir commun",
html="<p>Devoir commun</p>",
),
),
)
homeworks = collect_homeworks([lesson1, lesson2], target_date=date(2026, 9, 10))
assert len(homeworks) == 1
assert homeworks[0].id == generate_homework_id(date(2026, 9, 10), "devoir commun")
def test_collect_homeworks_sorted() -> None:
"""Les résultats sont triés par (subject.lower(), text.lower()).
:return: None
"""
lesson1 = Lesson(
id="lesson1",
start=datetime(2026, 9, 10, 8, 0),
end=datetime(2026, 9, 10, 9, 0),
subject="Zoologie",
teachers=("M. A",),
rooms=("204",),
group=None,
status=LessonStatus.NORMAL,
content=None,
homework_blocks=(
HomeworkBlock(
kind="due", date=date(2026, 9, 10), text="Devoir B", html="<p>Devoir B</p>"
),
),
)
lesson2 = Lesson(
id="lesson2",
start=datetime(2026, 9, 10, 10, 0),
end=datetime(2026, 9, 10, 11, 0),
subject="Mathématiques",
teachers=("M. B",),
rooms=("205",),
group=None,
status=LessonStatus.NORMAL,
content=None,
homework_blocks=(
HomeworkBlock(
kind="due", date=date(2026, 9, 10), text="Devoir A", html="<p>Devoir A</p>"
),
),
)
homeworks = collect_homeworks([lesson1, lesson2], target_date=date(2026, 9, 10))
assert len(homeworks) == 2
assert homeworks[0].subject == "Mathématiques"
assert homeworks[0].text == "Devoir A"
assert homeworks[1].subject == "Zoologie"
assert homeworks[1].text == "Devoir B"
def test_collect_homeworks_empty() -> None:
"""Aucun bloc de devoir → liste vide.
:return: None
"""
lesson = Lesson(
id="lesson1",
start=datetime(2026, 9, 10, 8, 0),
end=datetime(2026, 9, 10, 9, 0),
subject="Math",
teachers=("M. Dupont",),
rooms=("204",),
group=None,
status=LessonStatus.NORMAL,
content=None,
)
homeworks = collect_homeworks([lesson], target_date=date(2026, 9, 10))
assert homeworks == []
def test_normalize_homework_text() -> None:
"""Vérifie la normalisation des espaces, suppression HTML et minuscules.
:return: None
"""
text = " <p>Exercice 1 à 5</p> \n\n page 42 "
normalized = normalize_homework_text(text)
assert normalized == "exercice 1 à 5 page 42"
def test_generate_homework_id_format() -> None:
"""Retourne un ID de 12 caractères hexadécimaux.
:return: None
"""
due_on = date(2026, 9, 10)
text = "devoir test"
homework_id = generate_homework_id(due_on, text)
assert len(homework_id) == 12
assert all(c in "0123456789abcdef" for c in homework_id)
def test_generate_homework_id_deterministic() -> None:
"""Mêmes entrées → même sortie.
:return: None
"""
due_on = date(2026, 9, 10)
text = "devoir commun"
id1 = generate_homework_id(due_on, text)
id2 = generate_homework_id(due_on, text)
assert id1 == id2
CANCELLED_STATUS_ICAL = """BEGIN:VCALENDAR
VERSION:2.0
X-WR-CALNAME:Test
BEGIN:VEVENT
UID:Test-123-20260906T120000Z-Index-Education
DTSTART:20260907T080000Z
DTEND:20260907T090000Z
SUMMARY:Test Course
STATUS:CANCELLED
DESCRIPTION:<div></div>
END:VEVENT
END:VCALENDAR"""
NORMAL_STATUS_ICAL = """BEGIN:VCALENDAR
VERSION:2.0
X-WR-CALNAME:Test
BEGIN:VEVENT
UID:Test-123-20260906T120000Z-Index-Education
DTSTART:20260907T080000Z
DTEND:20260907T090000Z
SUMMARY:Test Course
STATUS:CONFIRMED
DESCRIPTION:<div></div>
END:VEVENT
END:VCALENDAR"""
MOVED_BY_CATEGORY_ICAL = """BEGIN:VCALENDAR
VERSION:2.0
X-WR-CALNAME:Test
BEGIN:VEVENT
UID:Test-123-20260906T120000Z-Index-Education
DTSTART:20260907T080000Z
DTEND:20260907T090000Z
SUMMARY:Test Course
CATEGORIES:Cours - Cours déplacé
DESCRIPTION:<div></div>
END:VEVENT
END:VCALENDAR"""
MULTIPLE_BLOCKS_SAME_DATE_ICAL = """BEGIN:VCALENDAR
VERSION:2.0
X-WR-CALNAME:Test
BEGIN:VEVENT
UID:Test-123-20260906T120000Z-Index-Education
DTSTART:20260907T080000Z
DTEND:20260907T090000Z
SUMMARY:Test Course
CATEGORIES:Cours
DESCRIPTION:<div>
Matière : Math
Professeur : M. Dupont
Salle : 204
<strong>Pour le 10/09/2026 :</strong>
Exercice 1 à 5 page 42.
<strong>Pour le 10/09/2026 :</strong>
Exercice 6 à 10 page 43.
</div>
END:VEVENT
END:VCALENDAR"""
def test_parse_ical_status_cancelled_only() -> None:
"""Un cours avec STATUS:CANCELLED mais sans CATEGORIES contenant 'Cours annulé' a status == LessonStatus.CANCELLED.
:return: None
"""
lessons, _, _ = parse_ical(CANCELLED_STATUS_ICAL)
assert len(lessons) == 1
assert lessons[0].status == LessonStatus.CANCELLED
def test_parse_ical_status_normal_without_cancel() -> None:
"""Un cours avec STATUS:CONFIRMED (ou sans STATUS) a status == LessonStatus.NORMAL.
:return: None
"""
lessons, _, _ = parse_ical(NORMAL_STATUS_ICAL)
assert len(lessons) == 1
assert lessons[0].status == LessonStatus.NORMAL
def test_parse_ical_moved_by_category_only() -> None:
"""Un cours avec CATEGORIES:Cours - Cours déplacé et sans STATUS a status == LessonStatus.MOVED.
:return: None
"""
lessons, _, _ = parse_ical(MOVED_BY_CATEGORY_ICAL)
assert len(lessons) == 1
assert lessons[0].status == LessonStatus.MOVED
def test_parse_body_multiple_blocks_same_date() -> None:
"""Un DESCRIPTION avec deux sections 'Pour le' à la même date conserve les deux blocs.
:return: None
"""
body_html = (
"<div>\n"
" <strong>Pour le 10/09/2026:</strong>\n"
" Exercice 1 à 5 page 42.\n"
" <strong>Pour le 10/09/2026:</strong>\n"
" Exercice 6 à 10 page 43.\n"
"</div>"
)
content, due_blocks, assigned_blocks = parse_body(body_html)
assert len(due_blocks) == 2
assert due_blocks[0][0] == date(2026, 9, 10)
assert due_blocks[0][1] == "Exercice 1 à 5 page 42."
assert due_blocks[1][0] == date(2026, 9, 10)
assert due_blocks[1][1] == "Exercice 6 à 10 page 43."
def test_collect_homeworks_from_fixture() -> None:
"""Parse le fixture pronote-4e.ics, appelle collect_homeworks pour le 10/09/2026 et vérifie qu'au moins un devoir est retourné.
:return: None
"""
fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
with open(fixture_path, encoding="utf-8") as f:
content = f.read()
lessons, _, _ = parse_ical(content)
homeworks = collect_homeworks(lessons, date(2026, 9, 10))
assert len(homeworks) >= 1
# Vérifie qu'au moins un devoir a le bon sujet et texte
assert any(hw.subject == "Mathématiques" for hw in homeworks)
assert any("Exercices 1 à 5 page 42" in hw.text for hw in homeworks)

View File

@@ -0,0 +1,373 @@
"""Tests de construction et sérialisation JSON des modèles Pydantic.
Ce module vérifie que chaque modèle figé peut être sérialisé en JSON puis désérialisé
sans perte de données, garantissant la stabilité des contrats d'échange.
Les tests utilisent des données minimales valides extraites des définitions de modèles.
"""
from __future__ import annotations
from datetime import date, datetime, time
from typing import Any
import pytest
from pydantic import BaseModel
from pronote_sync.models.agenda import (
HomeworkBlock,
Lesson,
LessonStatus,
SchoolEvent,
SchoolEventKind,
TheoreticalLesson,
)
from pronote_sync.models.blog import BlogArticle, ExternalInfo
from pronote_sync.models.diff import AgendaChange, AgendaChangeType, AgendaDiff
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message, MessageType
from pronote_sync.models.xmpp import XmppMessage
@pytest.mark.parametrize(
"model_class, kwargs",
[
(
Lesson,
{
"id": "lesson-001",
"start": datetime(2024, 9, 6, 8, 0, 0),
"end": datetime(2024, 9, 6, 9, 30, 0),
"subject": "Mathématiques",
"teachers": ("M. Dupont",),
"rooms": ("Salle 101",),
"group": "Classe A",
"status": LessonStatus.NORMAL,
"content": "Algèbre linéaire",
"homework_blocks": (
HomeworkBlock(kind="due", date=date(2024, 9, 13), text="Exercice 42"),
),
},
),
(
TheoreticalLesson,
{
"id": "theo-lesson-001",
"day_of_week": 0, # Lundi
"start_time": time(8, 0, 0),
"end_time": time(9, 30, 0),
"subject": "Mathématiques",
"teachers": ("M. Dupont",),
"rooms": ("Salle 101",),
},
),
(
SchoolEvent,
{
"kind": SchoolEventKind.HOLIDAY,
"label": "Vacances de la Toussaint",
"from_date": date(2024, 10, 19),
"to_date": date(2024, 11, 3),
},
),
(
Homework,
{
"id": "hw-001",
"subject": "Mathématiques",
"teachers": ("M. Dupont",),
"assigned_on": date(2024, 9, 1),
"due_on": date(2024, 9, 15),
"text": "Exercice 42 à rendre",
"html": "<p>Exercice 42 à rendre</p>",
},
),
(
AgendaChange,
{
"type": AgendaChangeType.ADDED,
"lesson": Lesson(
id="lesson-002",
start=datetime(2024, 9, 7, 10, 0, 0),
end=datetime(2024, 9, 7, 11, 30, 0),
subject="Physique",
teachers=("M. Martin",),
rooms=("Labo 205",),
group=None,
content=None,
),
"theoretical_lesson": None,
"details": "Cours ajouté exceptionnellement",
},
),
(
AgendaDiff,
{
"target_date": date(2024, 9, 6),
"changes": (
AgendaChange(
type=AgendaChangeType.ADDED,
lesson=Lesson(
id="lesson-003",
start=datetime(2024, 9, 8, 14, 0, 0),
end=datetime(2024, 9, 8, 15, 30, 0),
subject="Histoire",
teachers=("Mme Bernard",),
rooms=("Salle 201",),
group=None,
content=None,
),
theoretical_lesson=None,
),
),
},
),
(
XmppMessage,
{
"target_date": date(2024, 9, 6),
"synthesis": "Aucun changement majeur aujourd'hui.",
"homeworks": (
Homework(
id="hw-002",
subject="Physique",
teachers=("M. Martin",),
assigned_on=None,
due_on=date(2024, 9, 7),
text="TP à préparer",
),
),
"changes": (
AgendaChange(
type=AgendaChangeType.MODIFIED,
lesson=Lesson(
id="lesson-004",
start=datetime(2024, 9, 6, 16, 0, 0),
end=datetime(2024, 9, 6, 17, 30, 0),
subject="SVT",
teachers=("M. Lefèvre",),
rooms=("Salle 302",),
group=None,
content=None,
),
theoretical_lesson=None,
),
),
"messages": (
Message(
id="msg-001",
type=MessageType.INFORMATION,
title="Rappel",
content="Réunion parents-professeurs demain",
author="Secrétariat",
date=datetime(2024, 9, 5, 10, 0, 0),
read=False,
),
),
"external_info": ExternalInfo(blog_articles=(), pronote_messages=(), other_info=()),
},
),
(
BlogArticle,
{
"id": "article-001",
"title": "Sortie scolaire à Paris",
"url": "https://college.fr/blog/sortie-paris",
"published_at": datetime(2024, 9, 5, 14, 30, 0),
"updated_at": datetime(2024, 9, 5, 16, 45, 0),
"category": "Actualités",
"author": "M. Dubois",
"content_html": "<p>Sortie prévue le 15 octobre</p>",
"content_text": "Sortie prévue le 15 octobre",
},
),
(
ExternalInfo,
{
"blog_articles": (
BlogArticle(
id="article-002",
title="Nouveau règlement intérieur",
url="https://college.fr/blog/reglement",
published_at=datetime(2024, 9, 1, 9, 0, 0),
updated_at=None,
category=None,
author=None,
content_html="<p>Mise à jour du règlement</p>",
content_text="Mise à jour du règlement",
),
),
"pronote_messages": (
Message(
id="msg-002",
type=MessageType.DISCUSSION,
title="Devoirs de maths",
content="Voir Pronote pour les détails",
author="Prof Maths",
date=datetime(2024, 9, 4, 8, 0, 0),
read=False,
),
),
"other_info": ("Info complémentaire 1", "Info complémentaire 2"),
},
),
(
Message,
{
"id": "msg-003",
"type": MessageType.SURVEY,
"title": "Enquête de satisfaction",
"content": "Merci de répondre au questionnaire",
"author": "Direction",
"date": datetime(2024, 9, 5, 11, 0, 0),
"read": False,
},
),
],
)
def test_model_json_roundtrip(model_class: type[BaseModel], kwargs: dict[str, Any]) -> None:
"""Vérifie qu'un modèle peut être sérialisé en JSON puis désérialisé sans perte.
:param model_class: Classe du modèle à tester.
:type model_class: type[BaseModel]
:param kwargs: Arguments de constructeur valides.
:type kwargs: dict[str, Any]
"""
# Construction de l'instance
instance = model_class(**kwargs)
# Sérialisation JSON
json_str = instance.model_dump_json()
# Désérialisation depuis JSON
roundtrip_instance = model_class.model_validate_json(json_str)
# Vérification des champs clés (comparaison par valeur)
for field_name in kwargs:
if field_name in (
"start",
"end",
"start_time",
"end_time",
"published_at",
"updated_at",
"date",
"due_on",
"assigned_on",
"generated_at",
):
# Pour les dates/heures, comparer les objets directement
assert getattr(instance, field_name) == getattr(roundtrip_instance, field_name), (
f"Le champ {field_name} ne correspond pas après round-trip"
)
else:
# Pour les autres champs, comparer directement
assert getattr(instance, field_name) == getattr(roundtrip_instance, field_name), (
f"Le champ {field_name} ne correspond pas après round-trip"
)
def test_lesson_status_enum_values() -> None:
"""Vérifie que les valeurs de l'enum LessonStatus sont correctes.
:raises ValidationError: Si une valeur invalide est utilisée.
"""
# Test des valeurs valides
for status in [LessonStatus.NORMAL, LessonStatus.CANCELLED, LessonStatus.MOVED]:
assert isinstance(status.value, str)
Lesson(
id="test",
start=datetime(2024, 9, 6, 8, 0),
end=datetime(2024, 9, 6, 9, 0),
subject="Test",
status=status,
group=None,
content=None,
)
def test_school_event_kind_enum_values() -> None:
"""Vérifie que les valeurs de l'enum SchoolEventKind sont correctes.
:raises ValidationError: Si une valeur invalide est utilisée.
"""
# Test des valeurs valides
for kind in [SchoolEventKind.HOLIDAY, SchoolEventKind.PUBLIC_HOLIDAY]:
assert isinstance(kind.value, str)
SchoolEvent(
kind=kind,
label="Test",
from_date=date(2024, 9, 1),
to_date=date(2024, 9, 2),
)
def test_message_type_enum_values() -> None:
"""Vérifie que les valeurs de l'enum MessageType sont correctes.
:raises ValidationError: Si une valeur invalide est utilisée.
"""
# Test des valeurs valides
for msg_type in [MessageType.DISCUSSION, MessageType.INFORMATION, MessageType.SURVEY]:
assert isinstance(msg_type.value, str)
Message(
id="test",
type=msg_type,
title="Test",
content="Test",
author="Test",
date=datetime(2024, 9, 1, 8, 0),
read=False,
)
def test_agenda_change_type_enum_values() -> None:
"""Vérifie que les valeurs de l'enum AgendaChangeType sont correctes.
:raises ValidationError: Si une valeur invalide est utilisée.
"""
# Test des valeurs valides
for change_type in [
AgendaChangeType.ADDED,
AgendaChangeType.REMOVED,
AgendaChangeType.MODIFIED,
]:
assert isinstance(change_type.value, str)
if change_type == AgendaChangeType.ADDED:
AgendaChange(
type=change_type,
lesson=Lesson(
id="test",
start=datetime(2024, 9, 6, 8, 0),
end=datetime(2024, 9, 6, 9, 0),
subject="Test",
group=None,
content=None,
),
theoretical_lesson=None,
)
elif change_type == AgendaChangeType.REMOVED:
AgendaChange(
type=change_type,
lesson=None,
theoretical_lesson=TheoreticalLesson(
id="test",
day_of_week=0,
start_time=time(8, 0, 0),
end_time=time(9, 0, 0),
subject="Test",
),
)
else:
AgendaChange(
type=change_type,
lesson=Lesson(
id="test",
start=datetime(2024, 9, 6, 8, 0),
end=datetime(2024, 9, 6, 9, 0),
subject="Test",
group=None,
content=None,
),
theoretical_lesson=None,
)

View File

@@ -0,0 +1,409 @@
"""Tests d'immuabilité des modèles Pydantic figés.
Ce module vérifie que les modèles marqués comme figés (frozen=True) ne peuvent pas
être modifiés après construction, et que leurs collections sont bien des tuples.
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Any
import pytest
from pydantic import BaseModel, ValidationError
from pronote_sync.models.agenda import HomeworkBlock, Lesson, LessonStatus
from pronote_sync.models.blog import BlogArticle, ExternalInfo
from pronote_sync.models.diff import AgendaChange, AgendaChangeType
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message, MessageType
from pronote_sync.models.pronote import PronoteData
from pronote_sync.models.sync import CalDAVSyncResult, CalDAVSyncStatus
from pronote_sync.models.xmpp import XmppMessage
@pytest.mark.parametrize(
"model_class, kwargs",
[
(
Lesson,
{
"id": "lesson-immutability",
"start": datetime(2024, 9, 6, 8, 0, 0),
"end": datetime(2024, 9, 6, 9, 30, 0),
"subject": "Mathématiques",
"teachers": ("M. Dupont", "Mme Martin"),
"rooms": ("Salle 101",),
"status": LessonStatus.NORMAL,
},
),
(
Homework,
{
"id": "hw-immutability",
"subject": "Physique",
"teachers": ("M. Martin",),
"due_on": date(2024, 9, 15),
"text": "Exercice à rendre",
},
),
(
AgendaChange,
{
"type": AgendaChangeType.ADDED,
"lesson": Lesson(
id="lesson-ref",
start=datetime(2024, 9, 6, 10, 0, 0),
end=datetime(2024, 9, 6, 11, 30, 0),
subject="Histoire",
teachers=("Mme Bernard",),
rooms=("Salle 201",),
group=None,
content=None,
),
},
),
(
XmppMessage,
{
"target_date": date(2024, 9, 6),
"synthesis": "Test immuabilité",
},
),
(
HomeworkBlock,
{
"kind": "due",
"date": date(2024, 9, 15),
"text": "Devoir à faire",
},
),
(
ExternalInfo,
{
"blog_articles": (
BlogArticle(
id="article-1",
title="Article 1",
url="https://example.com/1",
published_at=datetime(2024, 9, 1, 8, 0, 0),
updated_at=None,
category=None,
author=None,
content_html="<p>Contenu</p>",
content_text="Contenu",
),
),
"pronote_messages": (
Message(
id="msg-1",
type=MessageType.INFORMATION,
title="Message 1",
content="Message",
author="Auteur",
date=datetime(2024, 9, 1, 8, 0, 0),
read=False,
),
),
},
),
],
)
def test_frozen_model_rejects_field_assignment(
model_class: type[BaseModel], kwargs: dict[str, Any]
) -> None:
"""Vérifie qu'un modèle figé rejette toute tentative d'assignation de champ.
:param model_class: Classe du modèle figé.
:type model_class: type[BaseModel]
:param kwargs: Arguments de constructeur valides.
:type kwargs: dict[str, Any]
"""
instance: Any = model_class(**kwargs)
# Tentative d'assignation directe sur un champ
with pytest.raises(ValidationError) as exc_info:
if model_class == Lesson:
instance.subject = "Nouveau sujet"
elif model_class == Homework:
instance.subject = "Nouveau sujet"
elif model_class == AgendaChange:
instance.details = "Nouveaux détails"
elif model_class == XmppMessage:
instance.synthesis = "Nouvelle synthèse"
elif model_class == HomeworkBlock:
instance.text = "Nouveau texte"
elif model_class == ExternalInfo:
instance.other_info = ("Nouvelle info",)
error_msg = str(exc_info.value).lower()
assert "instance is frozen" in error_msg or "immutable" in error_msg
@pytest.mark.parametrize(
"model_class, kwargs, field_name, expected_type",
[
(
Lesson,
{
"id": "lesson-tuple",
"start": datetime(2024, 9, 6, 8, 0),
"end": datetime(2024, 9, 6, 9, 0),
"subject": "Test",
},
"teachers",
tuple,
),
(
Lesson,
{
"id": "lesson-tuple2",
"start": datetime(2024, 9, 6, 8, 0),
"end": datetime(2024, 9, 6, 9, 0),
"subject": "Test",
},
"rooms",
tuple,
),
(
Lesson,
{
"id": "lesson-tuple3",
"start": datetime(2024, 9, 6, 8, 0),
"end": datetime(2024, 9, 6, 9, 0),
"subject": "Test",
"homework_blocks": (),
},
"homework_blocks",
tuple,
),
(
Homework,
{
"id": "hw-tuple",
"subject": "Test",
"teachers": ("Prof 1",),
"due_on": date(2024, 9, 15),
"text": "Devoir",
},
"teachers",
tuple,
),
(
AgendaChange,
{
"type": AgendaChangeType.ADDED,
"lesson": Lesson(
id="ref",
start=datetime(2024, 9, 6, 8, 0),
end=datetime(2024, 9, 6, 9, 0),
subject="Test",
group=None,
content=None,
),
},
"details",
str,
),
(XmppMessage, {"target_date": date(2024, 9, 6)}, "homeworks", tuple),
(XmppMessage, {"target_date": date(2024, 9, 6)}, "changes", tuple),
(XmppMessage, {"target_date": date(2024, 9, 6)}, "messages", tuple),
(
ExternalInfo,
{"blog_articles": (), "pronote_messages": (), "other_info": ()},
"blog_articles",
tuple,
),
(
ExternalInfo,
{"blog_articles": (), "pronote_messages": (), "other_info": ()},
"pronote_messages",
tuple,
),
(
ExternalInfo,
{"blog_articles": (), "pronote_messages": (), "other_info": ()},
"other_info",
tuple,
),
],
)
def test_collection_fields_are_tuples(
model_class: type[BaseModel],
kwargs: dict[str, Any],
field_name: str,
expected_type: type[Any],
) -> None:
"""Vérifie que les champs de collection sont bien des tuples et non des listes.
:param model_class: Classe du modèle.
:type model_class: type[BaseModel]
:param kwargs: Arguments de constructeur valides.
:type kwargs: dict[str, Any]
:param field_name: Nom du champ de collection à vérifier.
:type field_name: str
:param expected_type: Type attendu (tuple).
:type expected_type: type
"""
instance = model_class(**kwargs)
field_value = getattr(instance, field_name)
assert isinstance(field_value, expected_type), (
f"Le champ {field_name} devrait être de type {expected_type.__name__}, "
f"got {type(field_value).__name__}"
)
@pytest.mark.parametrize(
"model_class, kwargs, field_name",
[
(
Lesson,
{
"id": "lesson-no-append",
"start": datetime(2024, 9, 6, 8, 0),
"end": datetime(2024, 9, 6, 9, 0),
"subject": "Test",
},
"teachers",
),
(
Lesson,
{
"id": "lesson-no-append2",
"start": datetime(2024, 9, 6, 8, 0),
"end": datetime(2024, 9, 6, 9, 0),
"subject": "Test",
},
"rooms",
),
(
Homework,
{
"id": "hw-no-append",
"subject": "Test",
"teachers": ("Prof 1",),
"due_on": date(2024, 9, 15),
"text": "Devoir",
},
"teachers",
),
(
ExternalInfo,
{"blog_articles": (), "pronote_messages": (), "other_info": ()},
"blog_articles",
),
(
ExternalInfo,
{"blog_articles": (), "pronote_messages": (), "other_info": ()},
"pronote_messages",
),
(
ExternalInfo,
{"blog_articles": (), "pronote_messages": (), "other_info": ()},
"other_info",
),
],
)
def test_collection_fields_reject_append(
model_class: type[BaseModel], kwargs: dict[str, Any], field_name: str
) -> None:
"""Vérifie que les champs de collection (tuples) rejettent la méthode append.
:param model_class: Classe du modèle.
:type model_class: type[BaseModel]
:param kwargs: Arguments de constructeur valides.
:type kwargs: dict[str, Any]
:param field_name: Nom du champ de collection.
:type field_name: str
"""
instance = model_class(**kwargs)
with pytest.raises(AttributeError) as exc_info:
collection = getattr(instance, field_name)
collection.append("new_item")
assert "'tuple' object has no attribute 'append'" in str(exc_info.value)
@pytest.mark.parametrize(
"model_class, kwargs, field_name, new_value",
[
(CalDAVSyncResult, {"status": CalDAVSyncStatus.SUCCESS}, "added", 5),
(CalDAVSyncResult, {"status": CalDAVSyncStatus.SUCCESS}, "updated", 3),
(CalDAVSyncResult, {"status": CalDAVSyncStatus.SUCCESS}, "removed", 2),
(
CalDAVSyncResult,
{"status": CalDAVSyncStatus.FAILED},
"errors",
["Erreur 1", "Erreur 2"],
),
],
)
def test_mutable_models_accept_field_assignment(
model_class: type[BaseModel],
kwargs: dict[str, Any],
field_name: str,
new_value: Any,
) -> None:
"""Vérifie que les modèles mutables (non figés) acceptent l'assignation de champs.
:param model_class: Classe du modèle mutable.
:type model_class: type[BaseModel]
:param kwargs: Arguments de constructeur valides.
:type kwargs: dict[str, Any]
:param field_name: Nom du champ à assigner.
:type field_name: str
:param new_value: Nouvelle valeur à assigner.
:type new_value: Any
"""
instance = model_class(**kwargs)
# Assignation du champ
setattr(instance, field_name, new_value)
# Vérification que la valeur a bien été assignée
assert getattr(instance, field_name) == new_value, (
f"Le champ {field_name} n'a pas été correctement assigné"
)
@pytest.mark.parametrize(
"model_class, kwargs",
[
(
PronoteData,
{
"target_date": date(2024, 9, 6),
"generated_at": datetime(2024, 9, 6, 8, 0, 0),
},
),
(CalDAVSyncResult, {"status": CalDAVSyncStatus.SUCCESS}),
],
)
def test_mutable_models_reject_frozen_assignment(
model_class: type[BaseModel], kwargs: dict[str, Any]
) -> None:
"""Vérifie que les modèles mutables peuvent être modifiés mais pas rendus figés.
Note: Les modèles mutables comme PronoteData et CalDAVSyncResult n'ont pas
ConfigDict(frozen=True), donc ils peuvent être modifiés.
:param model_class: Classe du modèle mutable.
:type model_class: type[BaseModel]
:param kwargs: Arguments de constructeur valides.
:type kwargs: dict[str, Any]
"""
from pronote_sync.models.pronote import PronoteData
instance: Any = model_class(**kwargs)
# Assignation normale doit fonctionner
if model_class == PronoteData:
instance.lessons.append("dummy")
else:
instance.added = 10
# Vérification que l'instance n'est pas figée
assert not hasattr(instance.model_config, "frozen") or not instance.model_config.get(
"frozen", False
)

View File

@@ -0,0 +1,287 @@
"""Tests d'invariants et de validation des modèles Pydantic.
Ce module vérifie que les validateurs de modèle rejettent les données invalides
conformément aux spécifications (ex: ordre des dates, plages de valeurs).
"""
from __future__ import annotations
from datetime import date, datetime, time
import pytest
from pydantic import ValidationError
from pronote_sync.models.agenda import (
Lesson,
SchoolEvent,
SchoolEventKind,
TheoreticalLesson,
)
from pronote_sync.models.diff import AgendaChange, AgendaChangeType
from pronote_sync.models.sync import CalDAVSyncResult, CalDAVSyncStatus
class TestTheoreticalLessonDayOfWeek:
"""Tests de validation du champ day_of_week pour TheoreticalLesson."""
def test_day_of_week_7_invalid(self) -> None:
"""Vérifie que day_of_week=7 lève une ValidationError."""
with pytest.raises(ValidationError):
TheoreticalLesson(
id="test",
day_of_week=7,
start_time=time(8, 0, 0),
end_time=time(9, 30, 0),
subject="Mathématiques",
)
def test_day_of_week_minus_1_invalid(self) -> None:
"""Vérifie que day_of_week=-1 lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
TheoreticalLesson(
id="test",
day_of_week=-1,
start_time=time(8, 0, 0),
end_time=time(9, 30, 0),
subject="Mathématiques",
)
assert any(
"greater than or equal to 0" in str(error).lower() or "le 6" in str(error).lower()
for error in exc_info.value.errors()
)
def test_day_of_week_0_valid(self) -> None:
"""Vérifie que day_of_week=0 (lundi) est valide."""
instance = TheoreticalLesson(
id="test",
day_of_week=0,
start_time=time(8, 0, 0),
end_time=time(9, 30, 0),
subject="Mathématiques",
)
assert instance.day_of_week == 0
def test_day_of_week_6_valid(self) -> None:
"""Vérifie que day_of_week=6 (dimanche) est valide."""
instance = TheoreticalLesson(
id="test",
day_of_week=6,
start_time=time(8, 0, 0),
end_time=time(9, 30, 0),
subject="Mathématiques",
)
assert instance.day_of_week == 6
class TestLessonTimeOrder:
"""Tests de validation de l'ordre temporel pour Lesson."""
def test_lesson_end_before_start_invalid(self) -> None:
"""Vérifie que end <= start lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
Lesson(
id="test",
start=datetime(2024, 9, 6, 10, 0, 0),
end=datetime(2024, 9, 6, 9, 0, 0), # end avant start
subject="Mathématiques",
group=None,
content=None,
)
assert any(
"end doit être postérieur à start" in str(error) for error in exc_info.value.errors()
)
def test_lesson_end_after_start_valid(self) -> None:
"""Vérifie que end > start est valide."""
instance = Lesson(
id="test",
start=datetime(2024, 9, 6, 8, 0, 0),
end=datetime(2024, 9, 6, 9, 30, 0),
subject="Mathématiques",
group=None,
content=None,
)
assert instance.end > instance.start
class TestTheoreticalLessonTimeOrder:
"""Tests de validation de l'ordre temporel pour TheoreticalLesson."""
def test_theoretical_lesson_end_time_before_start_time_invalid(self) -> None:
"""Vérifie que end_time <= start_time lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
TheoreticalLesson(
id="test",
day_of_week=0,
start_time=time(9, 0, 0),
end_time=time(8, 0, 0), # end_time avant start_time
subject="Mathématiques",
)
assert any(
"end_time doit être postérieur à start_time" in str(error)
for error in exc_info.value.errors()
)
def test_theoretical_lesson_end_time_after_start_time_valid(self) -> None:
"""Vérifie que end_time > start_time est valide."""
instance = TheoreticalLesson(
id="test",
day_of_week=0,
start_time=time(8, 0, 0),
end_time=time(9, 30, 0),
subject="Mathématiques",
)
assert instance.end_time > instance.start_time
class TestSchoolEventDateOrder:
"""Tests de validation de l'ordre des dates pour SchoolEvent."""
def test_school_event_to_date_before_from_date_invalid(self) -> None:
"""Vérifie que to_date < from_date lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
SchoolEvent(
kind=SchoolEventKind.HOLIDAY,
label="Test",
from_date=date(2024, 10, 20),
to_date=date(2024, 10, 19), # to_date avant from_date
)
assert any(
"to_date doit être postérieur ou égal à from_date" in str(error)
for error in exc_info.value.errors()
)
def test_school_event_to_date_equal_from_date_valid(self) -> None:
"""Vérifie que to_date == from_date est valide."""
instance = SchoolEvent(
kind=SchoolEventKind.HOLIDAY,
label="Test",
from_date=date(2024, 10, 20),
to_date=date(2024, 10, 20), # égal
)
assert instance.to_date >= instance.from_date
def test_school_event_to_date_after_from_date_valid(self) -> None:
"""Vérifie que to_date > from_date est valide."""
instance = SchoolEvent(
kind=SchoolEventKind.HOLIDAY,
label="Test",
from_date=date(2024, 10, 20),
to_date=date(2024, 10, 25),
)
assert instance.to_date >= instance.from_date
class TestAgendaChangeConsistency:
"""Tests de cohérence des changements d'agenda."""
def test_agenda_change_added_without_lesson_invalid(self) -> None:
"""Vérifie que type=ADDED avec lesson=None lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
AgendaChange(type=AgendaChangeType.ADDED, lesson=None, theoretical_lesson=None)
assert any(
"lesson est requis pour le type" in str(error) for error in exc_info.value.errors()
)
def test_agenda_change_removed_without_theoretical_lesson_invalid(self) -> None:
"""Vérifie que type=REMOVED avec theoretical_lesson=None lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
AgendaChange(
type=AgendaChangeType.REMOVED,
lesson=None,
theoretical_lesson=None,
)
assert any(
"theoretical_lesson est requis pour le type" in str(error)
for error in exc_info.value.errors()
)
def test_agenda_change_modified_without_lesson_invalid(self) -> None:
"""Vérifie que type=MODIFIED avec lesson=None lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
AgendaChange(type=AgendaChangeType.MODIFIED, lesson=None, theoretical_lesson=None)
assert any(
"lesson est requis pour le type" in str(error) for error in exc_info.value.errors()
)
def test_agenda_change_added_with_lesson_valid(self) -> None:
"""Vérifie que type=ADDED avec lesson=<valide> est valide."""
lesson = Lesson(
id="lesson-valid",
start=datetime(2024, 9, 6, 8, 0, 0),
end=datetime(2024, 9, 6, 9, 30, 0),
subject="Mathématiques",
group=None,
content=None,
)
instance = AgendaChange(
type=AgendaChangeType.ADDED,
lesson=lesson,
theoretical_lesson=None,
)
assert instance.type == AgendaChangeType.ADDED
assert instance.lesson is not None
def test_agenda_change_removed_with_theoretical_lesson_valid(self) -> None:
"""Vérifie que type=REMOVED avec theoretical_lesson=<valide> est valide."""
theoretical_lesson = TheoreticalLesson(
id="theo-lesson-valid",
day_of_week=0,
start_time=time(8, 0, 0),
end_time=time(9, 30, 0),
subject="Mathématiques",
)
instance = AgendaChange(
type=AgendaChangeType.REMOVED,
lesson=None,
theoretical_lesson=theoretical_lesson,
)
assert instance.type == AgendaChangeType.REMOVED
assert instance.theoretical_lesson is not None
def test_agenda_change_modified_with_lesson_valid(self) -> None:
"""Vérifie que type=MODIFIED avec lesson=<valide> est valide."""
lesson = Lesson(
id="lesson-valid-mod",
start=datetime(2024, 9, 6, 10, 0, 0),
end=datetime(2024, 9, 6, 11, 30, 0),
subject="Physique",
group=None,
content=None,
)
instance = AgendaChange(
type=AgendaChangeType.MODIFIED,
lesson=lesson,
theoretical_lesson=None,
)
assert instance.type == AgendaChangeType.MODIFIED
assert instance.lesson is not None
class TestCalDAVSyncResultInvariants:
"""Tests de validation des champs numériques de CalDAVSyncResult."""
def test_caldav_sync_result_added_negative_invalid(self) -> None:
"""Vérifie que added=-1 lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=-1, updated=0, removed=0)
assert any("greater than or equal to 0" in str(error) for error in exc_info.value.errors())
def test_caldav_sync_result_updated_negative_invalid(self) -> None:
"""Vérifie que updated=-1 lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=0, updated=-1, removed=0)
assert any("greater than or equal to 0" in str(error) for error in exc_info.value.errors())
def test_caldav_sync_result_removed_negative_invalid(self) -> None:
"""Vérifie que removed=-1 lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=0, updated=0, removed=-1)
assert any("greater than or equal to 0" in str(error) for error in exc_info.value.errors())
def test_caldav_sync_result_zero_values_valid(self) -> None:
"""Vérifie que les valeurs à zéro sont valides."""
instance = CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=0, updated=0, removed=0)
assert instance.added == 0
assert instance.updated == 0
assert instance.removed == 0

View File

@@ -0,0 +1,439 @@
"""Tests unitaires pour le client Pronote via pronotepy.
Ce module vérifie le comportement du client ``PronoteClient`` et de son
protocole ``PronoteClientProtocol``. Tous les tests sont unitaires et
utilisent des mocks pour éviter tout accès réseau réel à Pronote.
"""
from __future__ import annotations
from datetime import date, datetime
import pronotepy
import pytest
import pytest_mock
from pydantic import SecretStr
from pronote_sync.config.settings import PronoteSettings
from pronote_sync.models.agenda import Lesson, LessonStatus
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message, MessageType
from pronote_sync.sources.pronote.client import PronoteClient, PronoteClientProtocol
# --- Protocol tests ---
def test_protocol_methods(mocker: pytest_mock.MockerFixture) -> None:
"""Vérifie que le protocole PronoteClientProtocol expose les méthodes attendues.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
assert hasattr(PronoteClientProtocol, "get_messages")
assert hasattr(PronoteClientProtocol, "get_informations")
assert hasattr(PronoteClientProtocol, "get_lessons")
assert hasattr(PronoteClientProtocol, "get_homeworks")
# --- Client with mocked pronotepy ---
@pytest.fixture
def pronote_settings() -> PronoteSettings:
"""Fournit des paramètres Pronote valides pour les tests.
:return: Instance de PronoteSettings avec des valeurs par défaut.
:rtype: PronoteSettings
"""
return PronoteSettings(
pronote_url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent="bordeaux",
account_type="parent",
)
@pytest.fixture
def empty_pronote_settings() -> PronoteSettings:
"""Fournit des paramètres Pronote vides pour les tests.
:return: Instance de PronoteSettings avec tous les champs à None.
:rtype: PronoteSettings
"""
return PronoteSettings(username=None, password=None, ent=None)
def test_get_messages_success(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_messages retourne une liste de Message en cas de succès.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
# Mock du client pronotepy
mock_client = mocker.MagicMock()
mock_discussion = mocker.MagicMock()
mock_discussion.subject = "Test Subject"
mock_message = mocker.MagicMock()
mock_message.id = "msg-123"
mock_message.content = "Test message content"
mock_message.author = "Teacher Test"
mock_message.created = datetime(2024, 9, 1, 10, 0, 0)
mock_message.seen = True
mock_discussion.messages = [mock_message]
mock_client.discussions.return_value = [mock_discussion]
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
client = PronoteClient(pronote_settings)
messages = client.get_messages()
assert isinstance(messages, list)
assert len(messages) == 1
message = messages[0]
assert isinstance(message, Message)
assert message.id == "msg-123"
assert message.type == MessageType.DISCUSSION
assert message.title == "Test Subject"
assert message.content == "Test message content"
assert message.author == "Teacher Test"
assert message.date == datetime(2024, 9, 1, 10, 0, 0)
assert message.read is True
def test_get_messages_empty_on_error(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_messages retourne une liste vide en cas d'erreur API.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
mock_client.discussions.side_effect = pronotepy.PronoteAPIError("API error")
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
client = PronoteClient(pronote_settings)
messages = client.get_messages()
assert messages == []
def test_get_informations_success(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_informations retourne une liste de Message en cas de succès.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
mock_info = mocker.MagicMock()
mock_info.id = "info-456"
mock_info.title = "Important Info"
mock_info.content.return_value = "Important content"
mock_info.author = "Admin"
mock_info.creation_date = datetime(2024, 9, 2, 14, 30, 0)
mock_info.read = False
mock_info.survey = True
mock_client.information_and_surveys.return_value = [mock_info]
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
client = PronoteClient(pronote_settings)
messages = client.get_informations()
assert isinstance(messages, list)
assert len(messages) == 1
message = messages[0]
assert isinstance(message, Message)
assert message.id == "info-456"
assert message.type == MessageType.SURVEY
assert message.title == "Important Info"
assert message.content == "Important content"
assert message.author == "Admin"
assert message.date == datetime(2024, 9, 2, 14, 30, 0)
assert message.read is False
def test_get_informations_empty_on_error(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_informations retourne une liste vide en cas d'erreur API.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
mock_client.information_and_surveys.side_effect = pronotepy.PronoteAPIError("API error")
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
client = PronoteClient(pronote_settings)
messages = client.get_informations()
assert messages == []
def test_get_lessons_success(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_lessons retourne une liste de Lesson en cas de succès.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
# Mock des cours
mock_lesson = mocker.MagicMock()
mock_lesson.id = "lesson-789"
mock_lesson.start = datetime(2024, 9, 1, 8, 0, 0)
mock_lesson.end = datetime(2024, 9, 1, 9, 30, 0)
mock_lesson.subject = mocker.MagicMock()
mock_lesson.subject.name = "Maths"
mock_lesson.teacher_names = ["Prof A", "Prof B"]
mock_lesson.classrooms = ["Salle 101", "Salle 102"]
mock_lesson.group_name = "Classe 1"
mock_lesson.canceled = False
mock_content = mocker.MagicMock()
mock_content.description = "Lesson content"
mock_lesson.content = mock_content
mock_client.lessons.return_value = [mock_lesson]
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
client = PronoteClient(pronote_settings)
lessons = client.get_lessons(date(2024, 9, 1), date(2024, 9, 30))
assert isinstance(lessons, list)
assert len(lessons) == 1
lesson = lessons[0]
assert isinstance(lesson, Lesson)
assert lesson.id == "lesson-789"
assert lesson.start == datetime(2024, 9, 1, 8, 0, 0)
assert lesson.end == datetime(2024, 9, 1, 9, 30, 0)
assert lesson.subject == "Maths"
assert lesson.teachers == ("Prof A", "Prof B")
assert lesson.rooms == ("Salle 101", "Salle 102")
assert lesson.group == "Classe 1"
assert lesson.status == LessonStatus.NORMAL
assert lesson.content == "Lesson content"
def test_get_homeworks_success(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_homeworks retourne une liste de Homework en cas de succès.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
# Mock des devoirs
mock_hw = mocker.MagicMock()
mock_hw.id = "hw-101"
mock_hw.subject = mocker.MagicMock()
mock_hw.subject.name = "Maths"
mock_hw.date = date(2024, 9, 15)
mock_hw.description = "Do your homework"
mock_client.homework.return_value = [mock_hw]
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
client = PronoteClient(pronote_settings)
homeworks = client.get_homeworks(date(2024, 9, 1), date(2024, 9, 30))
assert isinstance(homeworks, list)
assert len(homeworks) == 1
homework = homeworks[0]
assert isinstance(homework, Homework)
assert homework.id == "hw-101"
assert homework.subject == "Maths"
assert homework.teachers == ()
assert homework.assigned_on is None
assert homework.due_on == date(2024, 9, 15)
assert homework.text == "Do your homework"
assert homework.html == "Do your homework"
def test_get_lessons_propagates_error(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_lessons propage les exceptions API.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
mock_client.lessons.side_effect = pronotepy.PronoteAPIError("API error")
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
client = PronoteClient(pronote_settings)
with pytest.raises(pronotepy.PronoteAPIError):
client.get_lessons(date(2024, 9, 1), date(2024, 9, 30))
def test_get_homeworks_propagates_error(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_homeworks propage les exceptions API.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
mock_client.homework.side_effect = pronotepy.PronoteAPIError("API error")
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
client = PronoteClient(pronote_settings)
with pytest.raises(pronotepy.PronoteAPIError):
client.get_homeworks(date(2024, 9, 1), date(2024, 9, 30))
def test_missing_credentials_raises(empty_pronote_settings: PronoteSettings) -> None:
"""Vérifie que les appels échouent avec ValueError si les identifiants sont manquants.
:param empty_pronote_settings: Paramètres Pronote avec tous les champs à None.
:return: None
"""
client = PronoteClient(empty_pronote_settings)
with pytest.raises(ValueError, match="pronote_url, username, password et ent sont requis"):
client._connect()
def test_connect_with_ent_resolution(mocker: pytest_mock.MockerFixture) -> None:
"""Vérifie que _resolve_ent retourne le callable attendu pour un ENT connu.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
from pronote_sync.sources.pronote.client import _resolve_ent
resolver = _resolve_ent("bordeaux")
assert resolver is not None
def test_connect_with_unknown_ent_raises(mocker: pytest_mock.MockerFixture) -> None:
"""Vérifie que _resolve_ent lève ValueError pour un ENT inconnu.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
from pronote_sync.sources.pronote.client import _resolve_ent
with pytest.raises(ValueError) as exc_info:
_resolve_ent("inconnu")
assert "ENT inconnu : 'inconnu'" in str(exc_info.value)
assert "ENT supportés :" in str(exc_info.value)
def test_connect_parent_account_type(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que account_type='parent' utilise pronotepy.ParentClient.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
from unittest.mock import Mock
from pronote_sync.sources.pronote.client import PronoteClient
mock_client = mocker.MagicMock()
mock_client_class = Mock(return_value=mock_client)
mocker.patch("pronotepy.ParentClient", new=mock_client_class)
mocker.patch("pronotepy.Client")
client = PronoteClient(pronote_settings)
_ = client._connect()
# Verify ParentClient was used
assert mock_client_class.call_count == 1
pronotepy.Client.assert_not_called() # type: ignore[attr-defined]
def test_connect_student_account_type(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que account_type='student' utilise pronotepy.Client.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
from unittest.mock import Mock
from pronote_sync.sources.pronote.client import PronoteClient
pronote_settings_student = PronoteSettings(
pronote_url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent="bordeaux",
account_type="student",
)
mock_client = mocker.MagicMock()
mock_client_class = Mock(return_value=mock_client)
mocker.patch("pronotepy.Client", new=mock_client_class)
mocker.patch("pronotepy.ParentClient")
client = PronoteClient(pronote_settings_student)
_ = client._connect()
# Verify Client was used
assert mock_client_class.call_count == 1
pronotepy.ParentClient.assert_not_called() # type: ignore[attr-defined]
def test_get_messages_degraded_on_error(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_messages retourne une liste vide en cas d'erreur réseau.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
mock_client.discussions.side_effect = ConnectionError("Network error")
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
client = PronoteClient(pronote_settings)
messages = client.get_messages()
assert messages == []
def test_get_informations_degraded_on_error(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_informations retourne une liste vide en cas d'erreur réseau.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
mock_client.information_and_surveys.side_effect = TimeoutError("Timeout")
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
client = PronoteClient(pronote_settings)
messages = client.get_informations()
assert messages == []
# Ensure trailing newline

View File

@@ -0,0 +1,128 @@
"""Tests unitaires pour les utilitaires de masquage des secrets.
Ce module vérifie que les fonctions de masquage des URLs, des en-têtes
et des secrets dans les textes fonctionnent correctement, sans fuite
d'informations sensibles.
"""
from __future__ import annotations
from pronote_sync.utils.redaction import redact_exception, redact_secrets, redact_url
def test_redact_url_with_sensitive_query_param() -> None:
"""Vérifie que les paramètres sensibles dans une URL sont masqués.
:return: None
"""
url = "https://example.fr/ical?icalsecurise=TOKEN123" # pragma: allowlist secret
redacted = redact_url(url)
assert "TOKEN123" not in redacted
assert "REDACTED" in redacted
def test_redact_url_with_embedded_auth() -> None:
"""Vérifie que les informations d'authentification intégrées dans une URL sont masquées.
:return: None
"""
url = "https://user:pass@host/path" # pragma: allowlist secret
redacted = redact_url(url)
assert "user" not in redacted
assert "pass" not in redacted
assert "REDACTED" in redacted
def test_redact_url_with_uppercase_scheme() -> None:
"""Vérifie que les URLs avec un schéma en majuscules sont traitées correctement.
:return: None
"""
url = "HTTPS://user:pass@host/path" # pragma: allowlist secret
redacted = redact_url(url)
assert "user" not in redacted
assert "pass" not in redacted
assert "REDACTED" in redacted
def test_redact_secrets_authorization_bearer() -> None:
"""Vérifie que les en-têtes Authorization: Bearer sont masqués.
:return: None
"""
text = "Authorization: Bearer mytoken123" # pragma: allowlist secret
redacted = redact_secrets(text)
assert "mytoken123" not in redacted
assert "REDACTED" in redacted
def test_redact_secrets_proxy_authorization_basic() -> None:
"""Vérifie que les en-têtes Proxy-Authorization: Basic sont masqués.
:return: None
"""
text = "Proxy-Authorization: Basic dXNlcjpwYXNz" # pragma: allowlist secret
redacted = redact_secrets(text)
assert "dXNlcjpwYXNz" not in redacted
assert "REDACTED" in redacted
def test_redact_secrets_lowercase_authorization() -> None:
"""Vérifie que les en-têtes authorization en minuscules sont masqués.
:return: None
"""
text = "authorization: Bearer tok" # pragma: allowlist secret
redacted = redact_secrets(text)
assert "tok" not in redacted
assert "REDACTED" in redacted
def test_redact_secrets_text_key_value() -> None:
"""Vérifie que les affectations clé=valeur dans du texte sont masquées.
:return: None
"""
text = "password=mysecret" # pragma: allowlist secret
redacted = redact_secrets(text)
assert "mysecret" not in redacted
assert "REDACTED" in redacted
def test_redact_secrets_json_key_value() -> None:
"""Vérifie que les secrets dans du JSON sont masqués.
:return: None
"""
text = '{"api_key": "myapikey"}' # pragma: allowlist secret
redacted = redact_secrets(text)
assert "myapikey" not in redacted
assert "REDACTED" in redacted
def test_redact_exception() -> None:
"""Vérifie que les exceptions contenant des secrets sont masquées.
:return: None
"""
exc = Exception("Authorization: Bearer leaked_token") # pragma: allowlist secret
redacted_msg = redact_exception(exc)
assert "leaked_token" not in redacted_msg
assert "REDACTED" in redacted_msg
def test_redact_url_preserves_host_and_path() -> None:
"""Vérifie que l'hôte et le chemin d'une URL sont préservés après masquage.
:return: None
"""
url = "https://user:pass@example.fr/path?icalsecurise=tok" # pragma: allowlist secret
redacted = redact_url(url)
assert "example.fr" in redacted
assert "/path" in redacted
assert "user" not in redacted
assert "pass" not in redacted
assert "tok" not in redacted
# Ensure trailing newline

16
tests/unit/test_smoke.py Normal file
View File

@@ -0,0 +1,16 @@
"""Test de fumée : vérifie que le package est importable.
Ce test garantit que ``pytest`` collecte au moins un test et retourne 0,
satisfaisant le critère d'acceptation de M1. Les tests détaillés relèvent
de M13.
"""
import pronote_sync
def test_package_importable() -> None:
"""Vérifie que le package ``pronote_sync`` est importable sans erreur.
:return: None
"""
assert pronote_sync is not None

View File

@@ -0,0 +1,146 @@
"""Tests unitaires pour l'usine de construction du fournisseur d'agenda théorique.
Ce module contient les tests pour la fonction :func:`get_theoretical_provider`
du module :mod:`pronote_sync.sources.theoretical`.
"""
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
import pytest
from pronote_sync.errors import PronoteSyncError
from pronote_sync.sources.theoretical import (
TheoreticalAgendaProvider,
get_theoretical_provider,
)
class TestGetTheoreticalProvider:
"""Tests pour la fonction get_theoretical_provider."""
@pytest.fixture
def fixture_path(self) -> Path:
"""Retourne le chemin du fichier de fixture theoretical.json."""
return Path(__file__).parent.parent / "fixtures" / "theoretical.json"
@pytest.fixture
def holidays_path(self) -> Path:
"""Retourne le chemin du fichier de fixture school_holidays.json."""
return Path(__file__).parent.parent / "fixtures" / "school_holidays.json"
@pytest.fixture
def all_only_path(self, tmp_path: Path) -> Path:
"""Crée un fichier JSON avec uniquement des cours "all"."""
data = {
"version": 1,
"lessons": [
{
"week": "all",
"day_of_week": 0,
"start_time": "08:00",
"end_time": "09:00",
"subject": "Test",
}
],
}
file_path = tmp_path / "all_only.json"
file_path.write_text(json.dumps(data), encoding="utf-8")
return file_path
def test_factory_returns_none_when_path_none(self) -> None:
"""Teste que l'usine retourne None quand agenda_path est None.
:assert: get_theoretical_provider(None, None, None, None) retourne None.
"""
result = get_theoretical_provider(None, None, None, None)
assert result is None
def test_factory_returns_provider_with_full_config(
self, fixture_path: Path, holidays_path: Path
) -> None:
"""Teste que l'usine retourne un fournisseur avec une configuration complète.
:assert: Un fournisseur est retourné avec tous les paramètres.
"""
result = get_theoretical_provider(
agenda_path=str(fixture_path),
holidays_path=str(holidays_path),
anchor_date=date(2026, 9, 1),
anchor_type="even",
)
assert result is not None
assert isinstance(result, TheoreticalAgendaProvider)
def test_factory_partial_parity_config_error(self, fixture_path: Path) -> None:
"""Teste qu'une configuration de parité partielle lève une PronoteSyncError.
:assert: PronoteSyncError est levée quand anchor_date est fourni sans anchor_type.
"""
with pytest.raises(PronoteSyncError) as exc_info:
get_theoretical_provider(
agenda_path=str(fixture_path),
holidays_path=None,
anchor_date=date(2026, 9, 1),
anchor_type=None,
)
assert "incomplète" in str(exc_info.value)
def test_factory_partial_parity_config_error_type_only(self, fixture_path: Path) -> None:
"""Teste qu'une configuration de parité partielle (type seulement) lève une PronoteSyncError.
:assert: PronoteSyncError est levée quand anchor_type est fourni sans anchor_date.
"""
with pytest.raises(PronoteSyncError) as exc_info:
get_theoretical_provider(
agenda_path=str(fixture_path),
holidays_path=None,
anchor_date=None,
anchor_type="even",
)
assert "incomplète" in str(exc_info.value)
def test_factory_no_holidays(self, fixture_path: Path) -> None:
"""Teste que l'usine retourne un fournisseur sans calendrier de vacances.
:assert: Un fournisseur est retourné sans calendrier de vacances.
"""
result = get_theoretical_provider(
agenda_path=str(fixture_path),
holidays_path=None,
anchor_date=date(2026, 9, 1),
anchor_type="even",
)
assert result is not None
assert isinstance(result, TheoreticalAgendaProvider)
def test_factory_no_parity(self, all_only_path: Path) -> None:
"""Teste que l'usine retourne un fournisseur sans service de parité pour des cours "all".
:assert: Un fournisseur est retourné sans service de parité.
"""
result = get_theoretical_provider(
agenda_path=str(all_only_path),
holidays_path=None,
anchor_date=None,
anchor_type=None,
)
assert result is not None
assert isinstance(result, TheoreticalAgendaProvider)
def test_factory_protocol_compliance(self, fixture_path: Path, holidays_path: Path) -> None:
"""Teste que le fournisseur retourné respecte le protocole TheoreticalAgendaProvider.
:assert: Le fournisseur satisfait isinstance(provider, TheoreticalAgendaProvider).
"""
result = get_theoretical_provider(
agenda_path=str(fixture_path),
holidays_path=str(holidays_path),
anchor_date=date(2026, 9, 1),
anchor_type="even",
)
assert result is not None
assert isinstance(result, TheoreticalAgendaProvider)

View File

@@ -0,0 +1,188 @@
"""Tests unitaires pour le calendrier des vacances scolaires.
Ce module contient les tests pour les classes :class:`SchoolHolidayCalendar`,
:class:`HolidayPeriod` et :class:`SchoolHolidayFile` du module
:mod:`pronote_sync.sources.theoretical.holidays`.
"""
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
import pytest
from pronote_sync.errors import PronoteSyncError
from pronote_sync.sources.theoretical.holidays import SchoolHolidayCalendar
class TestSchoolHolidayCalendar:
"""Tests pour la classe SchoolHolidayCalendar."""
def test_load_valid_file(self, tmp_path: Path) -> None:
"""Teste le chargement d'un fichier JSON valide.
:assert: is_holiday retourne True pour une date dans une période.
"""
holiday_data = {
"zone": "A",
"school_year": "2026-2027",
"periods": [
{
"start_date": "2026-10-17",
"end_date": "2026-11-02",
"label": "Toussaint",
}
],
}
file_path = tmp_path / "holidays.json"
file_path.write_text(json.dumps(holiday_data), encoding="utf-8")
calendar = SchoolHolidayCalendar(file_path)
# Date dans la période de Toussaint
assert calendar.is_holiday(date(2026, 10, 20)) is True
def test_date_outside_periods(self, tmp_path: Path) -> None:
"""Teste qu'une date en dehors des périodes retourne False.
:assert: is_holiday retourne False pour une date hors période.
"""
holiday_data = {
"zone": "A",
"school_year": "2026-2027",
"periods": [
{
"start_date": "2026-10-17",
"end_date": "2026-11-02",
"label": "Toussaint",
}
],
}
file_path = tmp_path / "holidays.json"
file_path.write_text(json.dumps(holiday_data), encoding="utf-8")
calendar = SchoolHolidayCalendar(file_path)
# Date en dehors de la période
assert calendar.is_holiday(date(2026, 9, 1)) is False
def test_start_date_inclusive(self, tmp_path: Path) -> None:
"""Teste que la date de début est incluse dans la période.
:assert: is_holiday retourne True pour une date égale à start_date.
"""
holiday_data = {
"zone": "A",
"school_year": "2026-2027",
"periods": [
{
"start_date": "2026-10-17",
"end_date": "2026-11-02",
"label": "Toussaint",
}
],
}
file_path = tmp_path / "holidays.json"
file_path.write_text(json.dumps(holiday_data), encoding="utf-8")
calendar = SchoolHolidayCalendar(file_path)
assert calendar.is_holiday(date(2026, 10, 17)) is True
def test_end_date_inclusive(self, tmp_path: Path) -> None:
"""Teste que la date de fin est incluse dans la période.
:assert: is_holiday retourne True pour une date égale à end_date.
"""
holiday_data = {
"zone": "A",
"school_year": "2026-2027",
"periods": [
{
"start_date": "2026-10-17",
"end_date": "2026-11-02",
"label": "Toussaint",
}
],
}
file_path = tmp_path / "holidays.json"
file_path.write_text(json.dumps(holiday_data), encoding="utf-8")
calendar = SchoolHolidayCalendar(file_path)
assert calendar.is_holiday(date(2026, 11, 2)) is True
def test_file_not_found(self, tmp_path: Path) -> None:
"""Teste qu'un fichier introuvable lève une PronoteSyncError.
:assert: PronoteSyncError est levée pour un fichier introuvable.
"""
file_path = tmp_path / "nonexistent.json"
with pytest.raises(PronoteSyncError) as exc_info:
SchoolHolidayCalendar(file_path)
assert "introuvable" in str(exc_info.value)
# Vérifier qu'aucun secret n'est fuité dans le message d'erreur
assert "nonexistent" not in str(exc_info.value) or "introuvable" in str(exc_info.value)
def test_invalid_json(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""Teste qu'un fichier JSON invalide lève une PronoteSyncError.
:assert: PronoteSyncError est levée pour un JSON invalide.
"""
file_path = tmp_path / "invalid.json"
file_path.write_text("{ invalid json }", encoding="utf-8")
with pytest.raises(PronoteSyncError) as exc_info:
SchoolHolidayCalendar(file_path)
assert "invalide" in str(exc_info.value)
def test_invalid_period_dates(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""Teste qu'une période avec end_date < start_date lève une ValidationError.
:assert: PronoteSyncError est levée pour des dates de période invalides.
"""
holiday_data = {
"zone": "A",
"school_year": "2026-2027",
"periods": [
{
"start_date": "2026-11-02",
"end_date": "2026-10-17", # Inversé
"label": "Toussaint",
}
],
}
file_path = tmp_path / "holidays.json"
file_path.write_text(json.dumps(holiday_data), encoding="utf-8")
with pytest.raises(PronoteSyncError):
SchoolHolidayCalendar(file_path)
def test_empty_periods(self, tmp_path: Path) -> None:
"""Teste qu'un fichier avec des périodes vides retourne toujours False.
:assert: is_holiday retourne False pour toutes les dates.
"""
holiday_data = {
"zone": "A",
"school_year": "2026-2027",
"periods": [],
}
file_path = tmp_path / "holidays.json"
file_path.write_text(json.dumps(holiday_data), encoding="utf-8")
calendar = SchoolHolidayCalendar(file_path)
assert calendar.is_holiday(date(2026, 10, 20)) is False
assert calendar.is_holiday(date(2026, 1, 1)) is False
def test_load_from_fixture(self) -> None:
"""Teste le chargement du fichier de fixture et vérifie une date connue.
:assert: is_holiday retourne True pour une date de vacances connue.
"""
fixture_path = Path(__file__).parent.parent / "fixtures" / "school_holidays.json"
calendar = SchoolHolidayCalendar(fixture_path)
# Date dans les vacances de Toussaint (17 oct - 2 nov 2026)
assert calendar.is_holiday(date(2026, 10, 20)) is True
# Date dans les vacances de Noël (19 déc 2026 - 4 janv 2027)
assert calendar.is_holiday(date(2026, 12, 25)) is True
# Date en dehors des vacances
assert calendar.is_holiday(date(2026, 9, 1)) is False

View File

@@ -0,0 +1,201 @@
"""Tests unitaires pour les modèles de parsing de l'agenda théorique.
Ce module contient les tests pour les classes :class:`TheoreticalLessonEntry`
et :class:`TheoreticalAgendaFile` du module
:mod:`pronote_sync.sources.theoretical.model`.
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from pronote_sync.sources.theoretical.model import (
TheoreticalAgendaFile,
TheoreticalLessonEntry,
)
class TestTheoreticalLessonEntry:
"""Tests pour la classe TheoreticalLessonEntry."""
def test_valid_lesson_entry(self) -> None:
"""Teste la construction d'une entrée de cours valide.
:assert: Tous les champs sont correctement initialisés.
"""
entry = TheoreticalLessonEntry(
week="all",
day_of_week=0,
start_time="08:00",
end_time="09:00",
subject="Mathématiques",
teachers=("Mme Martin",),
rooms=("101",),
id="math-1",
)
assert entry.week == "all"
assert entry.day_of_week == 0
assert entry.start_time == "08:00"
assert entry.end_time == "09:00"
assert entry.subject == "Mathématiques"
assert entry.teachers == ("Mme Martin",)
assert entry.rooms == ("101",)
assert entry.id == "math-1"
def test_invalid_time_format(self) -> None:
"""Teste qu'une heure invalide (25:00) lève une ValidationError.
:assert: ValidationError est levée pour start_time="25:00".
"""
with pytest.raises(ValidationError) as exc_info:
TheoreticalLessonEntry(
week="all",
day_of_week=0,
start_time="25:00",
end_time="09:00",
subject="Test",
)
assert "start_time" in str(exc_info.value)
def test_invalid_time_format_minutes(self) -> None:
"""Teste qu'une minute invalide (60) lève une ValidationError.
:assert: ValidationError est levée pour start_time="08:60".
"""
with pytest.raises(ValidationError) as exc_info:
TheoreticalLessonEntry(
week="all",
day_of_week=0,
start_time="08:60",
end_time="09:00",
subject="Test",
)
assert "start_time" in str(exc_info.value)
def test_end_before_start(self) -> None:
"""Teste qu'une fin avant le début lève une ValidationError.
:assert: ValidationError est levée pour end_time < start_time.
"""
with pytest.raises(ValidationError) as exc_info:
TheoreticalLessonEntry(
week="all",
day_of_week=0,
start_time="10:00",
end_time="09:00",
subject="Test",
)
assert "end_time" in str(exc_info.value)
def test_end_equal_start(self) -> None:
"""Teste qu'une fin égale au début lève une ValidationError.
:assert: ValidationError est levée pour end_time == start_time.
"""
with pytest.raises(ValidationError) as exc_info:
TheoreticalLessonEntry(
week="all",
day_of_week=0,
start_time="09:00",
end_time="09:00",
subject="Test",
)
assert "end_time" in str(exc_info.value)
def test_invalid_week(self) -> None:
"""Teste qu'une semaine invalide lève une ValidationError.
:assert: ValidationError est levée pour week="weekly".
"""
with pytest.raises(ValidationError) as exc_info:
TheoreticalLessonEntry(
week="weekly", # type: ignore[arg-type]
day_of_week=0,
start_time="08:00",
end_time="09:00",
subject="Test",
)
assert "week" in str(exc_info.value)
def test_invalid_day_of_week(self) -> None:
"""Teste qu'un jour de semaine invalide (7) lève une ValidationError.
:assert: ValidationError est levée pour day_of_week=7.
"""
with pytest.raises(ValidationError) as exc_info:
TheoreticalLessonEntry(
week="all",
day_of_week=7,
start_time="08:00",
end_time="09:00",
subject="Test",
)
assert "day_of_week" in str(exc_info.value)
def test_optional_fields_defaults(self) -> None:
"""Teste que les champs optionnels ont des valeurs par défaut.
:assert: teachers et rooms valent () par défaut.
"""
entry = TheoreticalLessonEntry(
week="all",
day_of_week=0,
start_time="08:00",
end_time="09:00",
subject="Test",
)
assert entry.teachers == ()
assert entry.rooms == ()
def test_frozen_model(self) -> None:
"""Teste que le modèle est immuable (frozen).
:assert: La tentative de mutation lève une exception.
"""
entry = TheoreticalLessonEntry(
week="all",
day_of_week=0,
start_time="08:00",
end_time="09:00",
subject="Test",
)
with pytest.raises((ValidationError, AttributeError)):
entry.subject = "Nouveau"
class TestTheoreticalAgendaFile:
"""Tests pour la classe TheoreticalAgendaFile."""
def test_agenda_file_valid(self) -> None:
"""Teste la construction d'un fichier d'agenda valide.
:assert: version == 1 et lessons a le bon nombre d'entrées.
"""
lessons = [
TheoreticalLessonEntry(
week="all",
day_of_week=0,
start_time="08:00",
end_time="09:00",
subject="Mathématiques",
),
TheoreticalLessonEntry(
week="even",
day_of_week=1,
start_time="10:00",
end_time="11:00",
subject="Anglais",
),
]
agenda = TheoreticalAgendaFile(version=1, lessons=tuple(lessons))
assert agenda.version == 1
assert len(agenda.lessons) == 2
def test_agenda_file_default_version(self) -> None:
"""Teste que la version par défaut est 1.
:assert: version vaut 1 par défaut.
"""
agenda = TheoreticalAgendaFile(lessons=())
assert agenda.version == 1

View File

@@ -0,0 +1,116 @@
"""Tests unitaires pour le service de parité des semaines.
Ce module contient les tests pour la classe :class:`WeekParityService`
du module :mod:`pronote_sync.sources.theoretical.parity`.
"""
from __future__ import annotations
from datetime import date
from pronote_sync.sources.theoretical.parity import WeekParityService
class TestWeekParityService:
"""Tests pour la classe WeekParityService."""
def test_same_week_as_anchor_even(self) -> None:
"""Teste qu'une date dans la même semaine que l'ancrage pair retourne "even".
:assert: parity_for retourne "even" pour une date dans la même semaine.
"""
anchor_date = date(2026, 9, 1) # Mardi
service = WeekParityService(anchor_date, "even")
# Même semaine (lundi 31 août 2026)
target_date = date(2026, 9, 1)
assert service.parity_for(target_date) == "even"
def test_one_week_after_anchor_even(self) -> None:
"""Teste qu'une date une semaine après l'ancrage pair retourne "odd".
:assert: parity_for retourne "odd" pour une date une semaine après.
"""
anchor_date = date(2026, 9, 1) # Mardi
service = WeekParityService(anchor_date, "even")
# Une semaine après (mardi 8 septembre 2026)
target_date = date(2026, 9, 8)
assert service.parity_for(target_date) == "odd"
def test_two_weeks_after_anchor_even(self) -> None:
"""Teste qu'une date deux semaines après l'ancrage pair retourne "even".
:assert: parity_for retourne "even" pour une date deux semaines après.
"""
anchor_date = date(2026, 9, 1) # Mardi
service = WeekParityService(anchor_date, "even")
# Deux semaines après (mardi 15 septembre 2026)
target_date = date(2026, 9, 15)
assert service.parity_for(target_date) == "even"
def test_same_week_as_anchor_odd(self) -> None:
"""Teste qu'une date dans la même semaine que l'ancrage impair retourne "odd".
:assert: parity_for retourne "odd" pour une date dans la même semaine.
"""
anchor_date = date(2026, 9, 1) # Mardi
service = WeekParityService(anchor_date, "odd")
target_date = date(2026, 9, 1)
assert service.parity_for(target_date) == "odd"
def test_one_week_before_anchor(self) -> None:
"""Teste qu'une date une semaine avant l'ancrage retourne la parité opposée.
:assert: parity_for retourne la parité opposée pour une date une semaine avant.
"""
anchor_date = date(2026, 9, 8) # Mardi
service = WeekParityService(anchor_date, "even")
# Une semaine avant (mardi 1er septembre 2026)
target_date = date(2026, 9, 1)
assert service.parity_for(target_date) == "odd"
def test_many_weeks_later(self) -> None:
"""Teste qu'une date 10 semaines après l'ancrage pair retourne "even".
:assert: parity_for retourne "even" pour une date 10 semaines après.
"""
anchor_date = date(2026, 9, 1) # Mardi
service = WeekParityService(anchor_date, "even")
# 10 semaines après (70 jours)
target_date = date(2026, 11, 10)
assert service.parity_for(target_date) == "even"
def test_negative_offset(self) -> None:
"""Teste qu'une date loin avant l'ancrage calcule correctement la parité.
:assert: parity_for retourne la parité correcte pour une date loin dans le passé.
"""
anchor_date = date(2026, 9, 1) # Mardi
service = WeekParityService(anchor_date, "even")
# 10 semaines avant (70 jours)
target_date = date(2026, 6, 23)
assert service.parity_for(target_date) == "even"
def test_different_day_in_same_week(self) -> None:
"""Teste que lundi et vendredi de la même semaine ont la même parité.
:assert: parity_for retourne la même parité pour lundi et vendredi de la même semaine.
"""
anchor_date = date(2026, 9, 1) # Mardi
service = WeekParityService(anchor_date, "even")
# Lundi de la même semaine
monday = date(2026, 8, 31)
# Vendredi de la même semaine
friday = date(2026, 9, 4)
assert service.parity_for(monday) == "even"
assert service.parity_for(friday) == "even"
def test_crosses_year_boundary(self) -> None:
"""Teste que le calcul de parité fonctionne à cheval sur une année.
:assert: parity_for retourne la parité correcte à cheval sur une année.
"""
anchor_date = date(2026, 12, 29) # Mardi (semaine 53)
service = WeekParityService(anchor_date, "even")
# Date dans la semaine suivante (année 2027)
target_date = date(2027, 1, 5) # Mardi de la semaine suivante
assert service.parity_for(target_date) == "odd"

View File

@@ -0,0 +1,722 @@
"""Tests unitaires pour le fournisseur d'agenda théorique basé sur fichier JSON.
Ce module contient les tests pour la classe :class:`JsonTheoreticalAgendaProvider`
du module :mod:`pronote_sync.sources.theoretical.file`.
"""
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
import pytest
from pronote_sync.errors import PronoteSyncError
from pronote_sync.sources.theoretical.file import JsonTheoreticalAgendaProvider, normalize_subject
from pronote_sync.sources.theoretical.holidays import SchoolHolidayCalendar
from pronote_sync.sources.theoretical.parity import WeekParityService
class TestJsonTheoreticalAgendaProvider:
"""Tests pour la classe JsonTheoreticalAgendaProvider."""
@pytest.fixture
def fixture_path(self) -> Path:
"""Retourne le chemin du fichier de fixture theoretical.json."""
return Path(__file__).parent.parent / "fixtures" / "theoretical.json"
@pytest.fixture
def holidays_path(self) -> Path:
"""Retourne le chemin du fichier de fixture school_holidays.json."""
return Path(__file__).parent.parent / "fixtures" / "school_holidays.json"
@pytest.fixture
def all_only_path(self, tmp_path: Path) -> Path:
"""Crée un fichier JSON avec uniquement des cours "all"."""
data = {
"version": 1,
"lessons": [
{
"id": "theoretical-maths-monday-1",
"week": "all",
"day_of_week": 0,
"start_time": "08:00",
"end_time": "09:00",
"subject": "Mathématiques",
"teachers": ["Mme Martin"],
"rooms": ["101"],
},
{
"week": "all",
"day_of_week": 0,
"start_time": "09:00",
"end_time": "10:00",
"subject": "Français",
"teachers": ["M. Dupont"],
"rooms": ["102"],
},
{
"week": "all",
"day_of_week": 0,
"start_time": "11:00",
"end_time": "12:00",
"subject": "Histoire-Géographie",
"teachers": ["Mme Petit"],
"rooms": ["103"],
},
{
"week": "all",
"day_of_week": 2,
"start_time": "14:00",
"end_time": "15:00",
"subject": "Sciences",
"teachers": [],
"rooms": ["203"],
},
{
"week": "all",
"day_of_week": 4,
"start_time": "09:00",
"end_time": "10:00",
"subject": "Arts plastiques",
"teachers": [],
"rooms": [],
},
],
}
file_path = tmp_path / "all_only.json"
file_path.write_text(json.dumps(data), encoding="utf-8")
return file_path
@pytest.fixture
def provider_no_parity_no_holidays(self, all_only_path: Path) -> JsonTheoreticalAgendaProvider:
"""Fournisseur sans service de parité ni calendrier de vacances."""
return JsonTheoreticalAgendaProvider(
file_path=str(all_only_path),
parity_service=None,
holiday_calendar=None,
)
@pytest.fixture
def provider_with_parity(self, fixture_path: Path) -> JsonTheoreticalAgendaProvider:
"""Fournisseur avec service de parité (ancrage sur 2026-09-01, even)."""
parity_service = WeekParityService(date(2026, 9, 1), "even")
return JsonTheoreticalAgendaProvider(
file_path=str(fixture_path),
parity_service=parity_service,
holiday_calendar=None,
)
@pytest.fixture
def provider_with_holidays(
self, fixture_path: Path, holidays_path: Path
) -> JsonTheoreticalAgendaProvider:
"""Fournisseur avec calendrier de vacances."""
holiday_calendar = SchoolHolidayCalendar(holidays_path)
return JsonTheoreticalAgendaProvider(
file_path=str(fixture_path),
parity_service=None,
holiday_calendar=holiday_calendar,
)
@pytest.fixture
def provider_full(
self, fixture_path: Path, holidays_path: Path
) -> JsonTheoreticalAgendaProvider:
"""Fournisseur avec service de parité et calendrier de vacances."""
parity_service = WeekParityService(date(2026, 9, 1), "even")
holiday_calendar = SchoolHolidayCalendar(holidays_path)
return JsonTheoreticalAgendaProvider(
file_path=str(fixture_path),
parity_service=parity_service,
holiday_calendar=holiday_calendar,
)
def test_get_lessons_all_weeks(
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
) -> None:
"""Teste la récupération des cours pour un lundi (jour 0) sans parité.
:assert: Les cours "all" pour le lundi sont retournés.
"""
# Lundi 2026-08-31 (weekday = 0)
target_date = date(2026, 8, 31)
lessons = provider_no_parity_no_holidays.get_lessons(target_date)
# Le fichier all_only a 3 cours "all" pour le lundi (day_of_week=0)
assert len(lessons) == 3
subjects = [lesson.subject for lesson in lessons]
assert "Mathématiques" in subjects
assert "Français" in subjects
assert "Histoire-Géographie" in subjects
def test_get_lessons_even_week(
self, provider_with_parity: JsonTheoreticalAgendaProvider
) -> None:
"""Teste la récupération des cours pour une semaine paire.
:assert: Les cours "even" pour le mardi sont retournés.
"""
# Mardi 2026-09-01 (weekday = 1), semaine paire (ancrage 2026-09-01 even)
# Note: 2026-09-01 est mardi (weekday=1)
target_date = date(2026, 9, 1)
lessons = provider_with_parity.get_lessons(target_date)
# Le fixture a 2 cours "even" pour le mardi (day_of_week=1)
subjects = [lesson.subject for lesson in lessons]
assert "Anglais" in subjects
assert "Technologie" in subjects
def test_get_lessons_odd_week(
self, provider_with_parity: JsonTheoreticalAgendaProvider
) -> None:
"""Teste la récupération des cours pour une semaine impaire.
:assert: Les cours "odd" pour le mardi sont retournés.
"""
# Mardi 2026-09-08 (weekday = 1), semaine impaire (1 semaine après l'ancrage 2026-09-01)
target_date = date(2026, 9, 8)
lessons = provider_with_parity.get_lessons(target_date)
# Le fixture a 2 cours "odd" pour le mardi (day_of_week=1)
subjects = [lesson.subject for lesson in lessons]
assert "Espagnol" in subjects
assert "Éducation musicale" in subjects
def test_get_lessons_holiday_returns_empty(
self, provider_full: JsonTheoreticalAgendaProvider
) -> None:
"""Teste qu'une date en vacances retourne une liste vide.
:assert: get_lessons retourne [] pour une date en vacances.
"""
# Date pendant les vacances de Toussaint (17 oct - 2 nov 2026)
target_date = date(2026, 10, 20)
lessons = provider_full.get_lessons(target_date)
assert lessons == []
def test_get_lessons_no_holiday_calendar(
self, provider_with_parity: JsonTheoreticalAgendaProvider
) -> None:
"""Teste que sans calendrier de vacances, les cours sont retournés même en vacances.
:assert: Les cours sont retournés pour une date en vacances.
"""
# Date pendant les vacances de Toussaint, mais sans calendrier de vacances
target_date = date(2026, 10, 20) # Mardi (weekday = 1)
lessons = provider_with_parity.get_lessons(target_date)
# Sans calendrier de vacances, les cours "even" pour le mardi sont retournés
assert len(lessons) == 2
def test_get_lessons_sorted_by_id(
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
) -> None:
"""Teste que les cours sont triés par identifiant.
:assert: Les cours sont triés par id.
"""
# Lundi 2026-08-31 (weekday = 0)
target_date = date(2026, 8, 31)
lessons = provider_no_parity_no_holidays.get_lessons(target_date)
ids = [lesson.id for lesson in lessons]
assert ids == sorted(ids)
def test_get_lessons_deterministic(
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
) -> None:
"""Teste que get_lessons retourne des résultats identiques pour la même date.
:assert: Deux appels avec la même date retournent des résultats identiques.
"""
# Lundi 2026-08-31 (weekday = 0)
target_date = date(2026, 8, 31)
lessons1 = provider_no_parity_no_holidays.get_lessons(target_date)
lessons2 = provider_no_parity_no_holidays.get_lessons(target_date)
assert lessons1 == lessons2
def test_even_odd_different_subjects_different_ids(
self, provider_with_parity: JsonTheoreticalAgendaProvider
) -> None:
"""Teste que les cours even/odd sur le même créneau avec des matières différentes ont des IDs différents.
:assert: Les IDs des cours even et odd sont différents.
"""
# Mardi 2026-09-01 (semaine paire, même semaine que l'ancrage)
even_lessons = provider_with_parity.get_lessons(date(2026, 9, 1))
# Mardi 2026-09-08 (semaine impaire, 1 semaine après l'ancrage)
odd_lessons = provider_with_parity.get_lessons(date(2026, 9, 8))
# Les deux semaines ont des cours sur le même créneau (10:00-11:00)
# mais avec des matières différentes (Anglais/Technologie vs Espagnol/Éducation musicale)
even_subjects = {lesson.subject for lesson in even_lessons}
odd_subjects = {lesson.subject for lesson in odd_lessons}
# Les matières doivent être différentes
assert "Anglais" in even_subjects
assert "Espagnol" in odd_subjects
# Les IDs ne doivent pas être identiques
even_ids = {lesson.id for lesson in even_lessons}
odd_ids = {lesson.id for lesson in odd_lessons}
assert even_ids.isdisjoint(odd_ids)
def test_even_odd_same_subject_different_ids(self, tmp_path: Path) -> None:
"""Vérifie que les leçons paire/impaire sur le même créneau avec la même matière ont des IDs distincts.
Utilise la même matière pour isoler la parité comme seul différenciateur.
:assert: Les IDs des cours even et odd sont différents, avec la même matière.
"""
json_content = json.dumps(
{
"version": 1,
"lessons": [
{
"week": "even",
"day_of_week": 1,
"start_time": "10:00",
"end_time": "11:00",
"subject": "Langue vivante",
"teachers": [],
"rooms": [],
},
{
"week": "odd",
"day_of_week": 1,
"start_time": "10:00",
"end_time": "11:00",
"subject": "Langue vivante",
"teachers": [],
"rooms": [],
},
],
}
)
file_path = tmp_path / "theoretical.json"
file_path.write_text(json_content, encoding="utf-8")
anchor_date = date(2026, 9, 1) # Tuesday
parity = WeekParityService(anchor_date, "even")
provider = JsonTheoreticalAgendaProvider(file_path=str(file_path), parity_service=parity)
# Tuesday in even week
even_tuesday = date(2026, 9, 1) # Same week as anchor (even)
odd_tuesday = date(2026, 9, 8) # One week later (odd)
even_lessons = provider.get_lessons(even_tuesday)
odd_lessons = provider.get_lessons(odd_tuesday)
assert len(even_lessons) == 1
assert len(odd_lessons) == 1
assert even_lessons[0].id != odd_lessons[0].id
# The only difference in the ID should be the week type
assert "even" in even_lessons[0].id
assert "odd" in odd_lessons[0].id
def test_explicit_id_preserved(
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
) -> None:
"""Teste qu'un ID explicite est préservé.
:assert: L'ID explicite "theoretical-maths-monday-1" est utilisé.
"""
# Lundi 2026-08-31 (weekday = 0)
target_date = date(2026, 8, 31)
lessons = provider_no_parity_no_holidays.get_lessons(target_date)
ids = [lesson.id for lesson in lessons]
assert "theoretical-maths-monday-1" in ids
def test_generated_id_includes_week(
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
) -> None:
"""Teste qu'un ID généré inclut le type de semaine.
:assert: L'ID généré contient le type de semaine.
"""
# Lundi 2026-08-31 (weekday = 0)
target_date = date(2026, 8, 31)
lessons = provider_no_parity_no_holidays.get_lessons(target_date)
# Trouver un cours sans ID explicite (Français ou Histoire-Géographie)
for lesson in lessons:
if lesson.subject in ("Français", "Histoire-Géographie"):
assert "all" in lesson.id
break
def test_no_parity_service_with_all_only_lessons(self, all_only_path: Path) -> None:
"""Teste qu'un fournisseur sans service de parité fonctionne avec des cours "all".
:assert: Les cours "all" sont retournés correctement.
"""
provider = JsonTheoreticalAgendaProvider(
file_path=str(all_only_path),
parity_service=None,
holiday_calendar=None,
)
# Lundi 2026-08-31 (weekday = 0)
target_date = date(2026, 8, 31)
lessons = provider.get_lessons(target_date)
assert len(lessons) == 3
def test_even_lessons_without_parity_service_error(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Teste qu'un fichier avec des cours even/odd sans service de parité lève une erreur.
:assert: PronoteSyncError est levée.
"""
# Créer un fichier JSON avec uniquement des cours even/odd
data = {
"version": 1,
"lessons": [
{
"week": "even",
"day_of_week": 0,
"start_time": "08:00",
"end_time": "09:00",
"subject": "Test",
}
],
}
file_path = tmp_path / "even_only.json"
file_path.write_text(json.dumps(data), encoding="utf-8")
with pytest.raises(PronoteSyncError) as exc_info:
JsonTheoreticalAgendaProvider(
file_path=str(file_path),
parity_service=None,
holiday_calendar=None,
)
assert "ancre de parité" in str(exc_info.value)
def test_invalid_json_file(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""Teste qu'un fichier JSON invalide lève une PronoteSyncError.
:assert: PronoteSyncError est levée pour un JSON invalide.
"""
file_path = tmp_path / "invalid.json"
file_path.write_text("{ invalid json }", encoding="utf-8")
with pytest.raises(PronoteSyncError) as exc_info:
JsonTheoreticalAgendaProvider(
file_path=str(file_path),
parity_service=None,
holiday_calendar=None,
)
assert "invalide" in str(exc_info.value)
def test_get_lessons_for_range(
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
) -> None:
"""Teste la récupération des cours pour une plage de 5 jours.
:assert: Tous les cours uniques sont retournés, triés par ID.
"""
# Lundi 2026-08-31 à Vendredi 2026-09-04
start_date = date(2026, 8, 31) # Lundi
end_date = date(2026, 9, 4) # Vendredi
lessons = provider_no_parity_no_holidays.get_lessons_for_range(start_date, end_date)
# Le fichier all_only a des cours pour lundi (3), mercredi (1), vendredi (1)
# Total: 3 + 1 + 1 = 5 cours uniques
assert len(lessons) == 5
ids = [lesson.id for lesson in lessons]
assert ids == sorted(ids)
def test_get_lessons_for_range_with_holidays(
self, provider_full: JsonTheoreticalAgendaProvider
) -> None:
"""Teste la récupération des cours pour une plage incluant des jours de vacances.
:assert: Les jours de vacances ne contribuent pas de cours.
"""
# Plage incluant des vacances de Toussaint (17 oct - 2 nov 2026)
# Le 15 oct 2026 est un mercredi (day_of_week=2), le fixture a un cours "all" pour mercredi
# Le 16 oct 2026 est un jeudi (day_of_week=3), pas de cours dans le fixture
# Le 17 oct 2026 est un vendredi (day_of_week=4), le fixture a un cours "all" pour vendredi
# Mais le 17 oct est le début des vacances, donc pas de cours
start_date = date(2026, 10, 15) # Mercredi (avant vacances)
end_date = date(2026, 10, 16) # Jeudi (avant vacances)
lessons = provider_full.get_lessons_for_range(start_date, end_date)
# Le 15 oct (mercredi) devrait avoir un cours "all" pour Sciences
# Le 16 oct (jeudi) n'a pas de cours dans le fixture
# Donc on devrait avoir au moins 1 cours
assert len(lessons) >= 1
def test_get_lessons_for_range_empty(
self, provider_full: JsonTheoreticalAgendaProvider
) -> None:
"""Teste qu'une plage où tous les jours sont en vacances retourne une liste vide.
:assert: get_lessons_for_range retourne [] pour une plage entièrement en vacances.
"""
# Plage entièrement pendant les vacances de Toussaint
start_date = date(2026, 10, 17)
end_date = date(2026, 10, 24)
lessons = provider_full.get_lessons_for_range(start_date, end_date)
assert lessons == []
def test_get_lessons_sunday(
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
) -> None:
"""Teste qu'un dimanche retourne une liste vide.
:assert: get_lessons retourne [] pour un dimanche.
"""
# Dimanche 2026-09-06 (weekday = 6)
target_date = date(2026, 9, 6)
lessons = provider_no_parity_no_holidays.get_lessons(target_date)
# Aucun cours n'est prévu pour le dimanche dans le fichier all_only
assert lessons == []
def test_get_lessons_for_range_dedup_across_weeks(self, tmp_path: Path) -> None:
"""Vérifie que get_lessons_for_range déduplique les leçons récurrentes.
:assert: Une leçon récurrente sur plusieurs semaines n'apparaît qu'une fois.
"""
# Créer un fichier JSON avec une seule leçon "all" le lundi 08:00-09:00 "Maths"
data = {
"version": 1,
"lessons": [
{
"week": "all",
"day_of_week": 0, # Lundi
"start_time": "08:00",
"end_time": "09:00",
"subject": "Maths",
"teachers": [],
"rooms": [],
}
],
}
file_path = tmp_path / "dedup_test.json"
file_path.write_text(json.dumps(data), encoding="utf-8")
provider = JsonTheoreticalAgendaProvider(
file_path=str(file_path),
parity_service=None,
holiday_calendar=None,
)
# Plage du 31 août 2026 (lundi) au 11 septembre 2026 (vendredi)
# Cela couvre 2 lundis (31 août et 7 septembre)
start_date = date(2026, 8, 31)
end_date = date(2026, 9, 11)
lessons = provider.get_lessons_for_range(start_date, end_date)
# La leçon récurrente devrait apparaître une seule fois
assert len(lessons) == 1
assert lessons[0].subject == "Maths"
def test_duplicate_explicit_ids_rejected(self, tmp_path: Path) -> None:
"""Vérifie que les IDs explicites dupliqués sont rejetés.
:assert: PronoteSyncError est levée pour des IDs explicites dupliqués.
"""
# Créer un fichier JSON avec 2 leçons ayant le même ID explicite
data = {
"version": 1,
"lessons": [
{
"id": "dup-id",
"week": "all",
"day_of_week": 0,
"start_time": "08:00",
"end_time": "09:00",
"subject": "Maths",
"teachers": [],
"rooms": [],
},
{
"id": "dup-id",
"week": "all",
"day_of_week": 1,
"start_time": "09:00",
"end_time": "10:00",
"subject": "Français",
"teachers": [],
"rooms": [],
},
],
}
file_path = tmp_path / "duplicate_ids.json"
file_path.write_text(json.dumps(data), encoding="utf-8")
with pytest.raises(PronoteSyncError) as exc_info:
JsonTheoreticalAgendaProvider(
file_path=str(file_path),
parity_service=None,
holiday_calendar=None,
)
assert "Conflit d'identifiant" in str(exc_info.value) or "identifiant" in str(
exc_info.value
)
def test_generated_id_collision_rejected(self, tmp_path: Path) -> None:
"""Vérifie que les collisions d'IDs générés sont détectées.
:assert: PronoteSyncError est levée pour des IDs générés identiques.
"""
# Créer un fichier JSON avec 2 leçons qui produisent le même ID généré
# Même week, day_of_week, start_time, end_time, subject mais enseignants différents
data = {
"version": 1,
"lessons": [
{
"week": "all",
"day_of_week": 0,
"start_time": "08:00",
"end_time": "09:00",
"subject": "Maths",
"teachers": ["Prof1"],
"rooms": [],
},
{
"week": "all",
"day_of_week": 0,
"start_time": "08:00",
"end_time": "09:00",
"subject": "Maths",
"teachers": ["Prof2"],
"rooms": [],
},
],
}
file_path = tmp_path / "generated_id_collision.json"
file_path.write_text(json.dumps(data), encoding="utf-8")
with pytest.raises(PronoteSyncError) as exc_info:
JsonTheoreticalAgendaProvider(
file_path=str(file_path),
parity_service=None,
holiday_calendar=None,
)
assert "Conflit d'identifiant" in str(exc_info.value) or "identifiant" in str(
exc_info.value
)
def test_get_lessons_for_range_mixed_holidays_and_normal(self, tmp_path: Path) -> None:
"""Vérifie qu'une plage mixte (jours normaux et vacances) retourne les leçons des jours non vacanciers.
:assert: Les leçons des jours non vacanciers sont retournées, les jours de vacances sont ignorés.
"""
# Créer un fichier JSON avec une leçon "all" le jeudi 14:00-15:00 "Sciences"
# Note: 2026-10-15 est un jeudi (weekday=3)
data = {
"version": 1,
"lessons": [
{
"week": "all",
"day_of_week": 3, # Jeudi
"start_time": "14:00",
"end_time": "15:00",
"subject": "Sciences",
"teachers": [],
"rooms": [],
}
],
}
file_path = tmp_path / "mixed_holidays.json"
file_path.write_text(json.dumps(data), encoding="utf-8")
# Créer un fichier de vacances avec une période du 16 au 20 octobre 2026
holidays_data = {
"zone": "A",
"school_year": "2026-2027",
"periods": [
{"start_date": "2026-10-16", "end_date": "2026-10-20", "label": "Test Vacances"}
],
}
holidays_path = tmp_path / "test_holidays.json"
holidays_path.write_text(json.dumps(holidays_data), encoding="utf-8")
holiday_calendar = SchoolHolidayCalendar(holidays_path)
provider = JsonTheoreticalAgendaProvider(
file_path=str(file_path),
parity_service=None,
holiday_calendar=holiday_calendar,
)
# Plage du 15 octobre (jeudi, pas en vacances) au 17 octobre (samedi, en vacances)
start_date = date(2026, 10, 15) # Jeudi
end_date = date(2026, 10, 17) # Samedi
lessons = provider.get_lessons_for_range(start_date, end_date)
# Seule la leçon du 15 octobre (jeudi) devrait être retournée
assert len(lessons) == 1
assert lessons[0].subject == "Sciences"
def test_normalize_subject_variants_produce_same_id(self, tmp_path: Path) -> None:
"""Vérifie que des variantes de casse, d'espacement et d'Unicode produisent le même ID.
:assert: Les variantes de la même matière produisent le même ID normalisé.
"""
# Test the normalize_subject function directly
# Note: hyphens are removed entirely (not replaced with spaces) by normalize_subject
variants = [
"Mathématiques avancées",
"mathématiques avancées",
"Mathématiques avancées",
"MATHÉMATIQUES AVANCÉES",
]
normalized = [normalize_subject(variant) for variant in variants]
# All should normalize to the same value
assert all(n == normalized[0] for n in normalized)
# Should be lowercase, no extra spaces, no punctuation
assert normalized[0] == "mathématiques avancées"
def test_collision_error_no_secret_leak(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Vérifie qu'une sentinelle dans un ID dupliqué n'apparaît pas dans l'erreur.
:assert: PronoteSyncError est levée et la sentinelle n'apparaît pas dans l'erreur.
"""
# Use a secret pattern that the redaction system will actually catch
# The _ISOLATED_SECRET_PATTERN looks for things like secret=value or secret: value
sentinel_value = "SENTINELLE_M6_SECRET"
# Create an ID that contains a pattern like "secret=value" which will be redacted
secret_pattern = f"secret={sentinel_value}"
# Créer un fichier JSON avec 2 entrées ayant le même ID explicite contenant la sentinelle
data = {
"version": 1,
"lessons": [
{
"id": f"theoretical-{secret_pattern}-1",
"week": "all",
"day_of_week": 0,
"start_time": "08:00",
"end_time": "09:00",
"subject": "Maths",
"teachers": [],
"rooms": [],
},
{
"id": f"theoretical-{secret_pattern}-1",
"week": "all",
"day_of_week": 1,
"start_time": "09:00",
"end_time": "10:00",
"subject": "Français",
"teachers": [],
"rooms": [],
},
],
}
file_path = tmp_path / "collision_test.json"
file_path.write_text(json.dumps(data), encoding="utf-8")
with caplog.at_level("ERROR"):
with pytest.raises(PronoteSyncError) as exc_info:
JsonTheoreticalAgendaProvider(
file_path=str(file_path),
parity_service=None,
holiday_calendar=None,
)
# Check that the sentinel value does not appear in the error message
assert sentinel_value not in str(exc_info.value)
# Check that the sentinel value does not appear in the logs
assert sentinel_value not in caplog.text
# Check that the secret pattern was redacted (should contain REDACTED)
assert "REDACTED" in str(exc_info.value)
# Check that the sentinel does not appear in the cause
if exc_info.value.__cause__ is not None:
assert sentinel_value not in repr(exc_info.value.__cause__)