Compare commits

...

10 Commits

Author SHA1 Message Date
de647a6ff8 docs: ajouter la politique de versionnage et releases dans AGENTS.md
Ajoute la section 13 « Versionnage et releases » qui documente :
- Politique semver en phase 0.x (patch / minor / 1.0.0)
- Règle absolue : pas de tag sans validation en environnement réel
- Procédure de release en 7 étapes
- Cohérence tag Git / pyproject.toml / CHANGELOG.md
2026-09-08 21:04:08 +02:00
82b9877aad fix: PRONOTE_ENT optionnel pour les connexions pronotepy directes
PRONOTE_ENT était incorrectement traité comme obligatoire pour pronotepy. Rend ENT optionnel pour les connexions directes, conformément à la spécification. Corrige le mode pronotepy explicite et le mode auto sans iCal. 10 tests de régression ajoutés.

Co-authored-by: Antoine Van Elstraete <antoine@van-elstraete.net>
Co-committed-by: Antoine Van Elstraete <antoine@van-elstraete.net>
2026-09-08 20:54:03 +02:00
c851f67172 docs: fichier de vacances scolaires Bordeaux (zone A, 2026-2027)
Crée data/school_holidays.json avec les dates officielles de l'académie de Bordeaux (zone A) pour 2026-2027. Corrige l'erreur au démarrage quand SCHOOL_HOLIDAYS_PATH pointe vers un fichier inexistant. Documentation wiki : page GuideAgendaVacancesScolaires créée et liée dans le sidebar.

Co-authored-by: Antoine Van Elstraete <antoine@van-elstraete.net>
Co-committed-by: Antoine Van Elstraete <antoine@van-elstraete.net>
2026-09-08 20:30:47 +02:00
4ac5be4c8d fix: corrige l'attribution de pronote-digest vers Yoan Bernabeu
L'attribution pointait à tort vers Antoine Coulon et le dépôt
antoine-coulon/pronote-digest. Le projet pronote-digest a été créé par
Yoan Bernabeu (https://yoanbernabeu.github.io/pronote-digest/, dépôt
https://github.com/yoanbernabeu/pronote-digest).

Co-authored-by: opencode/orchestrator <opencode-orchestrator@agents.invalid>
2026-09-08 18:57:55 +02:00
fdd3310462 fix: corrige l'attribution de pronote-digest vers Yoan Bernabeu
L'attribution pointait à tort vers Antoine Coulon et le dépôt
antoine-coulon/pronote-digest. Le projet pronote-digest a été créé par
Yoan Bernabeu (https://yoanbernabeu.github.io/pronote-digest/, dépôt
https://github.com/yoanbernabeu/pronote-digest).

Co-authored-by: opencode/orchestrator <opencode-orchestrator@agents.invalid>
2026-09-08 18:56:44 +02:00
0be02a660a docs: add M15 (Documentation) to CHANGELOG 0.1.0 entry 2026-09-08 18:33:40 +02:00
d85733116a chore: ignore HANDOFF.md and clean up temporary work files
Add HANDOFF.md to .gitignore for session handoff notes.
Delete 12 temporary files (FIXME_M1-M10, FEAT_M9, TEST_REPORT).
2026-09-08 17:55:55 +02:00
2deeb83c76 feat(M15): README, README.LLM.md, LICENSE, CHANGELOG, and final cleanup
Complete the M15 documentation and final review milestone:

README.md (French, brief):
- Project description, quick start, usage, deployment link, acknowledgments
- Acknowledgments: pronotepy, icalendar, caldav, slixmpp, pydantic, openai,
  feedparser, beautifulsoup4, and inspiration from pronote-digest (Antoine Coulon)
- Author: Antoine Van Elstraete

README.LLM.md (English, AI agent guide):
- Prerequisites, installation on LXC/VPS (Debian/CentOS)
- Non-secret configuration preparation (all env vars listed)
- Secret variables clearly identified as operator-only
- Pre-deployment checks (check_secrets.py, pip check, dry-run)
- systemd/timer/logrotate installation steps
- Notes for AI agent (do not commit secrets, do not modify existing files)

LICENSE:
- Standard MIT License, copyright Antoine Van Elstraete (2026)

CHANGELOG.md:
- Initial changelog following Keep a Changelog format
- Covers M1 through M14 with milestone summaries
- Gitea Actions noted as planned/optional, not delivered

.gitignore:
- Added FEAT_* pattern for temporary work files

TODO.md:
- M15 items 1-4 checked (README, architecture docs, CHANGELOG+LICENSE, final review)
- M15 item 5 (Gitea Actions) left unchecked (optional, not done)

Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
2026-09-08 17:52:45 +02:00
b518508632 chore: replace GitHub Actions CI/CD with Gitea Actions (LXC/VPS)
Replace GitHub Actions references with Gitea Actions for deployment
on LXC/VPS (Debian/CentOS):

TODO.md:
- M15 optional item: GitHub Actions CI/CD -> Gitea Actions (LXC/VPS)
- Acceptance criterion: "La CI exécute..." -> "Gitea Actions exécute..."

GUIDE_DEV_PYTHON.md:
- §Prochaines étapes: "Configurer CI/CD" with GitHub Actions -> Gitea
  Actions for LXC/VPS (Debian/CentOS)

Other github.com references (library URLs, pre-commit repos, project
URLs) remain unchanged — they are legitimate technical references.
2026-09-08 17:33:17 +02:00
b474f02e90 fix(M14): harden secret scanner — prefixed vars, index blobs, extensionless files
Correct five findings from independent review and security audit of the
M14 deployment secret scanner:

scripts/check_secrets.py:
- Regex: \b[a-z0-9_]* prefix before sensitive keywords to detect
  PRONOTE_PASSWORD, CALDAV_PASSWORD, AI_API_KEY and similar prefixed
  variable names (was: \b which doesn't match before underscore)
- Regex: minimum secret value length reduced from {8,} to {3,} for
  literal, unquoted, and URL parameter patterns
- Regex: unquoted pattern {3,} -> {2,} for 3-char total minimum
- --staged: reads Git index blobs via `git show :<path>` instead of
  working-tree files (ContentProvider type alias, _staged_content_provider)
- Extensionless deployment files: _EXTRA_NAMES allowlist for pronote_sync
- ContentProvider type alias documented with #: Sphinx comment

tests/unit/test_check_secrets.py (4 new tests, 9 total):
- test_main_detects_prefixed_secret_assignment: PRONOTE_PASSWORD detected
- test_main_detects_short_secret_assignment: 6-char secret detected
- test_staged_mode_reads_index_content_not_working_tree: working-tree
  content set to non-matching value to distinguish index from worktree
- test_main_scans_extensionless_deployment_file: pronote_sync scanned

TODO.md: all 5 M14 checklist items checked

Validation: 636 tests, coverage 95.67%, ruff/mypy/bandit/pre-commit green.

Co-authored-by: opencode/coder <coder@agents.invalid>
2026-09-08 17:20:08 +02:00
15 changed files with 904 additions and 44 deletions

2
.gitignore vendored
View File

@@ -52,7 +52,9 @@ Thumbs.db
# --- Local scratch / WIP files ---
FIXME_*
FEAT_*
TEST_*
HANDOFF.md
.worktrees/
# --- Logs ---

View File

@@ -326,3 +326,47 @@ Un changement est considéré comme **terminé** lorsque :
- Le *handoff* distingue clairement :
- Ce qui a été vérifié localement (ex. : tests unitaires, linter).
- Ce qui nécessite encore une vérification manuelle (ex. : tests d'intégration avec un serveur CalDAV réel).
---
## 13. Versionnage et releases
### Politique de versionnage
Le projet suit **Semantic Versioning** (semver.org v2.0.0). Phase actuelle : `0.x` (pré-`1.0.0`).
| Changement | Incrément |
|------------|----------|
| Défaut constaté au déploiement | Patch (`0.1.Z`) — correction rétrocompatible |
| Ajout ou cassure en phase `0.x` | Minor (`0.Y.0`) |
| Déploiement réel validé | `1.0.0` |
### Règle absolue de validation
**Aucune montée de version (tag + release) ne peut être effectuée
sans validation préalable en environnement réel.** Les tests automatisés et la revue de code
ne suffisent pas ; le correctif ou la fonctionnalité doit avoir été testé avec succès
sur le serveur de production (ou un environnement équivalent) avant de tagger.
### Procédure de release
1. **Valider en environnement réel** : le correctif ou la fonctionnalité est testé
sur le serveur de production.
2. **Mettre à jour `pyproject.toml`** : incrémenter le champ `version` à la nouvelle version.
3. **Mettre à jour `CHANGELOG.md`** : ajouter une entrée sous le format
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) avec la nouvelle version et la date.
4. **Committer** : un commit `chore: monter en version x.y.z` regroupe
les mises à jour de `pyproject.toml` et `CHANGELOG.md`.
5. **Tagger** : créer un tag annoté `vx.y.z` sur le commit de version.
6. **Pousser le tag** : `git push origin vx.y.z`.
7. **Créer la release** sur Gitea avec le changelog correspondant.
### Cohérence des versions
Les trois sources de version doivent toujours être synchronisées au moment d'un tag :
- Le tag Git (`vx.y.z`)
- `pyproject.toml` (`version = "x.y.z"`)
- `CHANGELOG.md` (`## [x.y.z] - YYYY-MM-DD`)
> **Rappel** : Ne jamais créer un tag sans avoir d'abord mis à jour
> `pyproject.toml` et `CHANGELOG.md`.

28
CHANGELOG.md Normal file
View File

@@ -0,0 +1,28 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.0] - 2026-09-08
Initial release covering milestones M1 through M15.
### Added
- **M1 (Scaffolding)**: Python project structure with `pyproject.toml`, and tooling configuration for `ruff`, `mypy`, `bandit`, and `pre-commit`.
- **M2 (Configuration & secrets)**: Pydantic Settings for configuration management, `SecretStr` for sensitive fields, and redaction utilities (`redact_url`, `redact_secrets`, `redact_exception`) with `RedactingFormatter` for logging.
- **M3 (Data models)**: 16 Pydantic models and 6 enums across 10 modules, including frozen contracts and mutable work results.
- **M4 (Pronote sources)**: iCal fetch and parse, `pronotepy.ParentClient` integration, automatic fallback logic for `auto`, `ical`, and `pronotepy` modes, and error redaction for sensitive data.
- **M5 (Blog RSS)**: `feedparser`-based RSS client with GUID deduplication, HTTP cache support (ETag/If-Modified-Since), and `BlogRSSState` persistence.
- **M6 (Theoretical agenda)**: JSON provider with week parity (even/odd), school holidays calendar, and deterministic IDs for events.
- **M7 (CalDAV sync)**: Differential synchronization by UID, `X-PRONOTE-SYNC-MANAGED` marker for managed events, idempotent operations, preserved cancelled events, and dry-run support.
- **M8 (Agenda diff)**: `AgendaComparator` with deterministic matching, and generation of `AgendaDiff`/`AgendaChange` objects for tracking differences.
- **M9 (AI synthesis)**: `SynthesisProvider` protocol, OpenAI provider, optional `litellm` provider, and `openai-compatible` provider with degraded mode (returns `None` on failure).
- **M10 (XMPP channel)**: `XmppChannel` using `slixmpp`, formatted messages (synthesis, homeworks, changes, messages, blog), and error handling that returns `False` on failure.
- **M11 (Pipeline orchestration)**: `PipelineRunner` as composition root, 7 pipeline steps, degraded error handling, dry-run mode, and iCal reuse within a single run.
- **M12 (CLI entry point)**: `pronote-sync` command with `--dry-run` and `--log-level` options, redacted error display, and safe traceback in DEBUG mode.
- **M13 (Tests & coverage)**: 636 tests with 95.67% coverage, test fixtures (`pronote-4e.ics`, `pronote-6e.ics`), shared `conftest.py`, and secret non-leak tests.
- **M14 (Deployment)**: systemd service and timer (daily at 18:00), logrotate configuration (daily, rotate 7, compress), `check_secrets.py` pre-deployment scanner, and exploitation guide.
- **M15 (Documentation)**: README, README.LLM.md (AI agent setup guide), MIT LICENSE, CHANGELOG, and Gitea Actions CI/CD reference for LXC/VPS (Debian/CentOS).
- **Other**: MIT License. Gitea Actions CI/CD reference for LXC/VPS (Debian/CentOS) is planned and optional, not delivered in this release.

View File

@@ -1,6 +1,6 @@
# Guide de Développement : Synchronisation Pronote → CalDAV + XMPP (Python)
> **Statut** : Guide de référence pour un futur projet Python inspiré de [`pronote-digest`](https://github.com/antoine-coulon/pronote-digest) (TypeScript).
> **Statut** : Guide de référence pour un futur projet Python inspiré de [`pronote-digest`](https://github.com/yoanbernabeu/pronote-digest) (TypeScript) par [Yoan Bernabeu](https://yoanbernabeu.github.io/pronote-digest/).
> **Public cible** : Développeurs Python (≥ 3.13.5) familiers avec les concepts de CLI, synchronisation de calendriers et messagerie instantanée.
> **Objectif** : Fournir une base architecturale et technique pour un outil **synchronisant l'agenda Pronote vers CalDAV**, **comparant avec un agenda théorique**, **récupérant messages et informations**, et **envoyant une synthèse par XMPP**.
@@ -6090,7 +6090,7 @@ Ce guide fournit une **base architecturale et technique solide** pour développe
1. **Créer le dépôt** : Initialiser un nouveau dépôt Python avec la structure proposée.
2. **Implémenter le cœur** : Commencer par les modules `models/`, `sources/pronote/ical.py` et `utils/`.
3. **Ajouter les tests** : Écrire des tests unitaires pour chaque module dès le début.
4. **Configurer CI/CD** : Mettre en place GitHub Actions pour exécuter les tests et vérifier la sécurité.
4. **Configurer Gitea Actions** : Mettre en place Gitea Actions pour exécuter les tests et vérifier la sécurité, en vue d'un déploiement sur LXC/VPS (Debian/CentOS).
5. **Tester en conditions réelles** : Utiliser des flux iCal Pronote anonymisés pour valider le parsing.
> **⚠️ Rappel** : Ce guide est **volontairement détaillé** pour préserver les connaissances acquises sur les spécificités de Pronote. Certaines sections (ex: parsing iCal) contiennent des **observations précises** issues de l'analyse du code TypeScript existant. **Ne pas sous-estimer l'importance de ces détails** : ils sont critiques pour un fonctionnement fiable du projet.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Antoine Van Elstraete
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

147
README.LLM.md Normal file
View File

@@ -0,0 +1,147 @@
# pronote-sync — AI Agent Setup Guide
This document guides an AI agent through installing and pre-configuring the `pronote-sync` project on a fresh Linux host (Debian/CentOS). It covers environment setup, dependency installation, and configuration file preparation. It does **NOT** cover secrets provisioning — those must be provided by the operator.
---
## Prerequisites
- Python ≥ 3.13.5 (check with `python3 --version`)
- Git
- A non-root service user (e.g., `pronote-sync`)
- Target paths:
- `/opt/pronote-sync` (code)
- `/var/lib/pronote-sync` (state)
- `/var/log/pronote-sync` (logs)
- `/etc/pronote-sync` (config)
---
## Installation Steps
```bash
# Create service user
sudo useradd --system --no-create-home --shell /usr/sbin/nologin pronote-sync
# Clone the repository
sudo git clone <repo-url> /opt/pronote-sync
sudo chown -R pronote-sync:pronote-sync /opt/pronote-sync
# Create virtual environment
cd /opt/pronote-sync
sudo -u pronote-sync python3.13 -m venv .venv
sudo -u pronote-sync .venv/bin/pip install -e ".[dev]"
# Create directories
sudo install -d -m 0700 -o pronote-sync -g pronote-sync /etc/pronote-sync
sudo install -d -m 0750 -o pronote-sync -g pronote-sync /var/lib/pronote-sync
sudo install -d -m 0750 -o pronote-sync -g pronote-sync /var/log/pronote-sync
```
---
## Configuration Preparation (Without Secrets)
```bash
# Copy the example config
sudo -u pronote-sync cp /opt/pronote-sync/.env.example /etc/pronote-sync/pronote-sync.env
# The operator must fill in secrets (PRONOTE_PASSWORD, CALDAV_PASSWORD, XMPP_PASSWORD, AI_API_KEY, etc.)
# Do NOT populate secrets automatically — leave them for the operator.
```
### Non-Secret Environment Variables (Pre-Configurable)
The following variables can be safely pre-configured in `/etc/pronote-sync/pronote-sync.env`:
- **Pronote:**
- `PRONOTE_ACCOUNT_TYPE` (default: `parent`)
- `PRONOTE_ENT` (ENT slug, e.g., `lyceeconnecte`)
- `PRONOTE_AGENDA_SOURCE`, `PRONOTE_HOMEWORK_SOURCE`, `PRONOTE_MESSAGES_SOURCE` (`auto`, `ical`, or `pronotepy`)
- **CalDAV:**
- `CALDAV_CALENDAR_PATH` (e.g., `/pronote-sync/`)
- `CALDAV_ALLOW_INSECURE_HTTP` (default: `false`)
- **Sync Window:**
- `SYNC_PAST_DAYS`, `SYNC_FUTURE_DAYS`
- **Theoretical Agenda:**
- `THEORETICAL_AGENDA_PATH`, `SCHOOL_HOLIDAYS_PATH`
- `THEORETICAL_WEEK_ANCHOR_DATE`, `THEORETICAL_WEEK_ANCHOR_TYPE`
- **XMPP:**
- `XMPP_ENABLED`, `XMPP_HOST`, `XMPP_PORT`, `XMPP_USE_TLS`, `XMPP_TIMEOUT`, `XMPP_RESOURCE`
- **AI:**
- `AI_ENABLED`, `AI_PROVIDER`, `AI_BASE_URL`, `AI_MODEL`, `AI_ALLOW_INSECURE_HTTP`
- **Blog:**
- `BLOG_ENABLED`, `BLOG_RSS_URL`
- **General:**
- `DRY_RUN`, `LOG_LEVEL`
### Secret Variables (Operator Must Provide)
**Do NOT set these variables automatically.** The operator must manually provide the following secrets:
- **Pronote:**
- `PRONOTE_ICAL_URL`, `PRONOTE_URL`, `PRONOTE_USERNAME`, `PRONOTE_PASSWORD`
- **CalDAV:**
- `CALDAV_URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`
- **XMPP:**
- `XMPP_JID`, `XMPP_PASSWORD`, `XMPP_TO`
- **AI:**
- `AI_API_KEY`
---
## Pre-Deployment Checks
```bash
# Verify no secrets in the codebase
/opt/pronote-sync/.venv/bin/python /opt/pronote-sync/scripts/check_secrets.py
# Verify dependencies
/opt/pronote-sync/.venv/bin/python -m pip check
# Dry-run test (will fail without secrets, but verifies the environment)
/opt/pronote-sync/.venv/bin/pronote-sync --dry-run
```
---
## systemd Installation
```bash
# Install systemd units
sudo install -m 0644 /opt/pronote-sync/deploy/systemd/pronote-sync.service /etc/systemd/system/
sudo install -m 0644 /opt/pronote-sync/deploy/systemd/pronote-sync.timer /etc/systemd/system/
# Install logrotate config
sudo install -m 0644 /opt/pronote-sync/deploy/logrotate/pronote_sync /etc/logrotate.d/pronote_sync
# Reload and enable
sudo systemctl daemon-reload
sudo systemctl enable --now pronote-sync.timer
```
---
## Notes for the AI Agent
- **Do NOT commit or write secrets** to any file in the repository.
- **Do NOT modify** `.gitignore`, `pyproject.toml`, or existing source files.
- If `python3.13` is not available, install it first:
- Debian: `sudo apt install python3.13`
- CentOS: Compile from source or use `dnf` if available.
- The `check_secrets.py` script exits with:
- `0` (clean)
- `1` (secrets found)
- `2` (error)
- All paths in the systemd unit assume `/opt/pronote-sync` — adjust if installed elsewhere.
- The operator **must** provide real values for all **SECRET** variables before enabling the timer.

69
README.md Normal file
View File

@@ -0,0 +1,69 @@
# pronote-sync
Synchronisation Pronote → CalDAV + XMPP.
---
Synchronise l'agenda et les devoirs de **Pronote** vers un calendrier **CalDAV** et envoie un résumé quotidien par **XMPP**. Supporte les sources iCal et `pronotepy` avec repli automatique. Synthèse IA optionnelle.
---
## 🚀 Démarrage rapide
```bash
# Cloner le dépôt
git clone <repo-url>
cd pronote-sync
# Créer l'environnement virtuel
python3.13 -m venv .venv
source .venv/bin/activate
# Installer
pip install -e ".[dev]"
# Configurer
cp .env.example .env
# Éditer .env avec vos paramètres (voir .env.example pour le détail)
# Tester
pronote-sync --dry-run --log-level DEBUG
```
---
## 📖 Utilisation
```bash
pronote-sync # Exécute la synchronisation
pronote-sync --dry-run # Simulation sans écriture
pronote-sync --log-level DEBUG # Verbosité des journaux
```
---
## 🛠️ Déploiement
Les artefacts pour **systemd/timer** et **logrotate** sont fournis dans `deploy/`. Voir [docs/exploitation.md](docs/exploitation.md) pour plus de détails.
---
## 🙏 Remerciements
Ce projet repose sur les bibliothèques open-source suivantes :
- [pronotepy](https://github.com/bain3/pronotepy) — client Pronote
- [icalendar](https://github.com/collective/icalendar) — parsing iCal
- [caldav](https://github.com/python-caldav/caldav) — client CalDAV
- [slixmpp](https://github.com/poezio/slixmpp) — client XMPP
- [pydantic](https://github.com/pydantic/pydantic) — validation et configuration
- [openai](https://github.com/openai/openai-python) — synthèse IA
- [feedparser](https://github.com/kurtmckee/feedparser) — parsing RSS
- [beautifulsoup4](https://www.crummy.com/software/BeautifulSoup/) — parsing HTML
Inspiré de [pronote-digest](https://github.com/yoanbernabeu/pronote-digest) par [Yoan Bernabeu](https://yoanbernabeu.github.io/pronote-digest/).
---
## Licence
MIT — voir [LICENSE](LICENSE).

22
TODO.md
View File

@@ -277,11 +277,11 @@ Couvrir l'ensemble du code par des tests sans réseau, avec fixtures anonymisée
Mettre en production de façon supervisée (planification, rotation des logs, vérification des secrets).
- [ ] Créer une unité systemd (`pronote-sync.service` + timer) ou une ligne cron (exécution quotidienne).
- [ ] Créer `logrotate.d/pronote_sync` (daily, rotate 7, compress, delaycompress).
- [ ] Ajouter un script de vérification des secrets (§13.6) exécuté avant chaque déploiement.
- [ ] Documenter la supervision (logs, alertes en cas d'échec) et la maintenance (maj dépendances, dry-run avant MAJ).
- [ ] Vérifier `pip check` et tester le dry-run avant mise en production.
- [x] Créer une unité systemd (`pronote-sync.service` + timer) ou une ligne cron (exécution quotidienne).
- [x] Créer `logrotate.d/pronote_sync` (daily, rotate 7, compress, delaycompress).
- [x] Ajouter un script de vérification des secrets (§13.6) exécuté avant chaque déploiement.
- [x] Documenter la supervision (logs, alertes en cas d'échec) et la maintenance (maj dépendances, dry-run avant MAJ).
- [x] Vérifier `pip check` et tester le dry-run avant mise en production.
### Critères d'acceptation
- Le service/timer systemd (ou cron) lance le pipeline quotidiennement.
@@ -294,13 +294,13 @@ Mettre en production de façon supervisée (planification, rotation des logs, v
Rédiger la documentation utilisateur et finaliser le projet.
- [ ] Créer `README.md` (installation, configuration `.env`, usage CLI, systemd/docker, limites, RGPD).
- [ ] Documenter l'architecture (pipeline, modules) en résumé.
- [ ] Ajouter `CHANGELOG` initial et la licence (MIT).
- [ ] Revue finale : cohérence avec le guide, aucun secret documenté en clair.
- [ ] (Optionnel) Configurer GitHub Actions CI/CD (pytest + bandit + ruff + mypy) d'après §Prochaines étapes.
- [x] Créer `README.md` (installation, configuration `.env`, usage CLI, systemd/docker, limites, RGPD).
- [x] Documenter l'architecture (pipeline, modules) en résumé.
- [x] Ajouter `CHANGELOG` initial et la licence (MIT).
- [x] Revue finale : cohérence avec le guide, aucun secret documenté en clair.
- [ ] (Optionnel) Configurer Gitea Actions (pytest + bandit + ruff + mypy) pour le déploiement LXC/VPS (Debian/CentOS).
### Critères d'acceptation
- `README.md` permet d'installer et de lancer le projet sans le guide.
- La CI exécute tests + lint + sécurité.
- Gitea Actions exécute tests + lint + sécurité.
- Aucun secret dans la documentation.

31
data/school_holidays.json Normal file
View File

@@ -0,0 +1,31 @@
{
"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-13",
"end_date": "2027-03-01",
"label": "Hiver"
},
{
"start_date": "2027-04-10",
"end_date": "2027-04-26",
"label": "Printemps"
},
{
"start_date": "2027-07-03",
"end_date": "2027-09-01",
"label": "Été"
}
]
}

View File

@@ -155,16 +155,17 @@ class PronoteClient:
"""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.
suivants. Le nom d'ENT, s'il est configuré, est résolu via
:func:`_resolve_ent` ; en l'absence d'ENT, ``ent=None`` est transmis
à ``pronotepy`` pour une connexion directe. 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 ValueError: Si ``pronote_url``, ``username`` ou ``password``
est manquant, ou si l'ENT fourni est inconnu.
:raises pronotepy.PronoteAPIError: Si la connexion à Pronote échoue.
"""
if self._client is None:
@@ -172,11 +173,9 @@ class PronoteClient:
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)
if pronote_url is None or username is None or password is None:
raise ValueError("pronote_url, username et password sont requis pour pronotepy")
resolver = _resolve_ent(ent) if ent is not None else None
client_class: type[pronotepy.Client] = (
pronotepy.ParentClient
if self._settings.account_type == "parent"

View File

@@ -149,8 +149,8 @@ class PronoteFetcher:
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.
:return: ``True`` si ``pronote_url``, ``username`` et ``password``
sont tous définis, ``False`` sinon.
:rtype: bool
"""
pronote = self._settings.pronote
@@ -158,7 +158,6 @@ class PronoteFetcher:
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]]:

View File

@@ -26,17 +26,18 @@ _TEXT_SUFFIXES = frozenset(
{".conf", ".ini", ".json", ".md", ".py", ".service", ".timer", ".toml", ".txt", ".yaml", ".yml"}
)
_LITERAL_SECRET_RE = re.compile(
r"(?ix)\b(?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
r"\s*[:=]\s*['\"][^'\"\r\n]{8,}['\"]"
r"(?ix)\b[a-z0-9_]*(?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
r"\s*[:=]\s*['\"][^'\"\r\n]{3,}['\"]"
)
_UNQUOTED_SECRET_RE = re.compile(
r"(?ix)\b(?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
r"\s*[:=]\s*[a-z0-9][a-z0-9._~+/-]{7,}"
r"(?ix)\b[a-z0-9_]*(?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
r"\s*[:=]\s*[a-z0-9][a-z0-9._~+/-]{2,}"
)
_URL_SECRET_RE = re.compile(
r"(?ix)[?&](?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
r"=([^&#\s]{8,})"
r"=([^&#\s]{3,})"
)
_EXTRA_NAMES = frozenset({"pronote_sync"})
@dataclass(frozen=True)
@@ -54,11 +55,16 @@ class SecretFinding:
CommandRunner = Callable[..., subprocess.CompletedProcess[str]]
#: Fournisseur de contenu pour un chemin relatif ; retourne ``None`` pour ignorer.
ContentProvider = Callable[[Path], str | None]
def _is_candidate(path: Path) -> bool:
"""Indique si un chemin peut être analysé comme fichier texte.
Les fichiers de déploiement sans extension, nommés explicitement dans
``_EXTRA_NAMES``, sont également retenus.
:param path: Chemin relatif au dépôt.
:return: ``True`` lorsque le fichier est textuel et non exclu.
:rtype: bool
@@ -68,7 +74,7 @@ def _is_candidate(path: Path) -> bool:
and path.name not in _EXCLUDED_NAMES
and path.parts[0] not in _EXCLUDED_TOP_LEVEL
and not any(part in _EXCLUDED_PARTS for part in path.parts)
and path.suffix in _TEXT_SUFFIXES
and (path.suffix in _TEXT_SUFFIXES or path.name in _EXTRA_NAMES)
)
@@ -112,7 +118,38 @@ def _staged_files(root: Path, runner: CommandRunner) -> list[Path]:
return sorted(path for path in paths if _is_candidate(path))
def find_secrets(root: Path, files: Iterable[Path]) -> list[SecretFinding]:
def _staged_content_provider(root: Path, runner: CommandRunner) -> ContentProvider:
"""Retourne un lecteur de contenu depuis l'index Git.
Lit le blob indexé via ``git show :<chemin>`` afin de ne pas dépendre de
l'état du working tree, dont la copie de travail peut différer de l'index.
:param root: Racine du dépôt Git.
:param runner: Exécuteur de sous-processus injectable pour les tests.
:return: Fonction de lecture du contenu indexé ; ``None`` si indisponible.
:rtype: ContentProvider
"""
def provider(relative_path: Path) -> str | None:
result = runner(
["git", "show", f":{relative_path}"],
cwd=root,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return None
return result.stdout
return provider
def find_secrets(
root: Path,
files: Iterable[Path],
content_provider: ContentProvider | None = None,
) -> list[SecretFinding]:
"""Détecte les motifs de secrets littéraux dans les fichiers désignés.
Les lignes explicitement marquées ``secret-check: allow`` sont exclues :
@@ -120,6 +157,10 @@ def find_secrets(root: Path, files: Iterable[Path]) -> list[SecretFinding]:
:param root: Racine du dépôt analysé.
:param files: Chemins relatifs à inspecter.
:param content_provider: Lecteur optionnel du contenu d'un fichier ; par
défaut le contenu est lu depuis le working tree via ``read_text``.
Si le lecteur retourne ``None`` ou lève une erreur d'encodage, le
fichier est ignoré.
:return: Résultats triés par chemin, ligne et règle.
:rtype: list[SecretFinding]
"""
@@ -127,7 +168,12 @@ def find_secrets(root: Path, files: Iterable[Path]) -> list[SecretFinding]:
for relative_path in files:
path = root / relative_path
try:
content = path.read_text(encoding="utf-8")
if content_provider is not None:
content = content_provider(relative_path)
else:
content = path.read_text(encoding="utf-8")
if content is None:
continue
except (OSError, UnicodeDecodeError):
continue
for number, line in enumerate(content.splitlines(), start=1):
@@ -177,15 +223,16 @@ def main(
parsed_arguments = _parse_arguments(arguments)
repository_root = root or Path(__file__).resolve().parents[1]
try:
files = (
_staged_files(repository_root, runner)
if parsed_arguments.staged
else _repository_files(repository_root)
)
if parsed_arguments.staged:
files = _staged_files(repository_root, runner)
content_provider = _staged_content_provider(repository_root, runner)
else:
files = _repository_files(repository_root)
content_provider = None
except RuntimeError as error:
print(f"ERREUR: {error}")
return 2
findings = find_secrets(repository_root, files)
findings = find_secrets(repository_root, files, content_provider=content_provider)
if not findings:
print("OK: aucun secret littéral détecté.")
return 0

View File

@@ -144,3 +144,106 @@ def test_staged_mode_inspects_only_paths_provided_by_git(
assert secret_checker.main(["--staged"], root=tmp_path, runner=runner) == 0
assert "OK:" in capsys.readouterr().out
def test_main_detects_prefixed_secret_assignment(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None:
"""Vérifie qu'une variable préfixée (PRONOTE_PASSWORD) est détectée.
:param secret_checker: Module du script sous test.
:param tmp_path: Répertoire temporaire représentant un dépôt.
:param capsys: Fixture de capture de sortie.
:return: None
"""
sentinel = "m14-prefixed-secret"
(tmp_path / "config.py").write_text(
f'PRONOTE_PASSWORD = "{sentinel}"\n', encoding="utf-8"
) # secret-check: allow
assert secret_checker.main([], root=tmp_path) == 1
output = capsys.readouterr().out
assert "config.py:1" in output
assert sentinel not in output
def test_main_detects_short_secret_assignment(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None:
"""Vérifie qu'un secret court (< 8 caractères) est détecté.
:param secret_checker: Module du script sous test.
:param tmp_path: Répertoire temporaire représentant un dépôt.
:param capsys: Fixture de capture de sortie.
:return: None
"""
sentinel = "s3cr3t"
(tmp_path / "config.py").write_text(
f'password = "{sentinel}"\n', encoding="utf-8"
) # secret-check: allow
assert secret_checker.main([], root=tmp_path) == 1
output = capsys.readouterr().out
assert "config.py:1" in output
assert sentinel not in output
def test_staged_mode_reads_index_content_not_working_tree(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None:
"""Vérifie que --staged lit le contenu indexé, pas le working tree.
:param secret_checker: Module du script sous test.
:param tmp_path: Répertoire temporaire représentant un dépôt.
:param capsys: Fixture de capture de sortie.
:return: None
"""
indexed_secret = "m14-indexed-only-secret" # pragma: allowlist secret
(tmp_path / "staged.py").write_text(
f'password = "{indexed_secret}"\n', encoding="utf-8"
) # secret-check: allow
(tmp_path / "staged.py").write_text('value = "safe"\n', encoding="utf-8")
def runner(*args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]:
"""Simule Git en renvoyant le contenu indexé pour le blob demandé.
:return: Résultat Git simulé.
:rtype: subprocess.CompletedProcess[str]
"""
first_argument = args[0] if args else []
command = (
[str(argument) for argument in first_argument]
if isinstance(first_argument, list)
else []
)
if "show" in command:
return subprocess.CompletedProcess(
command, 0, stdout=f'password = "{indexed_secret}"\n', stderr=""
)
return subprocess.CompletedProcess(command, 0, stdout="staged.py\0", stderr="")
assert secret_checker.main(["--staged"], root=tmp_path, runner=runner) == 1
output = capsys.readouterr().out
assert "staged.py:1" in output
assert indexed_secret not in output
def test_main_scans_extensionless_deployment_file(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None:
"""Vérifie qu'un fichier de déploiement sans extension est scanné.
:param secret_checker: Module du script sous test.
:param tmp_path: Répertoire temporaire représentant un dépôt.
:param capsys: Fixture de capture de sortie.
:return: None
"""
sentinel = "m14-logrotate-secret"
(tmp_path / "pronote_sync").write_text(
f'password = "{sentinel}"\n', encoding="utf-8"
) # secret-check: allow
assert secret_checker.main([], root=tmp_path) == 1
output = capsys.readouterr().out
assert "pronote_sync:1" in output
assert sentinel not in output

View File

@@ -1088,6 +1088,102 @@ def test_homework_sources_auto_no_source_configured_raises(mock_fetcher: Pronote
assert "ni la source iCal ni pronotepy n'est configurée" in str(exc_info.value)
def test_fetch_agenda_auto_ical_configured_fails_fallback_to_pronotepy_without_ent(
mock_fetcher: PronoteFetcher,
) -> None:
"""Test le mode auto : iCal configuré mais échoue, repli sur pronotepy sans ent.
On mock iCal pour échouer, pronotepy configuré sans ent. On vérifie que pronotepy est appelé
et que le résultat provient de pronotepy, pas une erreur.
:param mock_fetcher: Fetcher de test.
:return: None
:rtype: None
"""
start_dt = datetime(2025, 9, 1, 8, 0)
end_dt = datetime(2025, 9, 1, 9, 30)
lessons = [
Lesson(
id="l1",
start=start_dt,
end=end_dt,
subject="Maths",
teachers=("Dupont",),
rooms=("S1",),
group="2ndeA",
status=LessonStatus.NORMAL,
content=None,
)
]
# Override settings to use auto mode with ical_url configured but ent=None
mock_fetcher._settings.pronote.agenda_source = "auto"
mock_fetcher._settings.pronote.ent = None # Explicitly None
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical,
):
m_fetch_ical.side_effect = OSError("iCal unreachable")
m_parse_ical.side_effect = OSError("iCal parse error")
client = MagicMock()
client.get_lessons.return_value = lessons
mock_fetcher._pronote_client = client
result_lessons, result_events = mock_fetcher.fetch_agenda()
assert result_lessons == lessons
assert result_events == []
client.get_lessons.assert_called_once()
def test_fetch_homework_auto_ical_configured_fails_fallback_to_pronotepy_without_ent(
mock_fetcher: PronoteFetcher,
) -> None:
"""Test le mode auto des devoirs : iCal configuré mais échoue, repli sur pronotepy sans ent.
On mock iCal pour échouer, pronotepy configuré sans ent. On vérifie que pronotepy est appelé
et que le résultat provient de pronotepy, pas une erreur.
:param mock_fetcher: Fetcher de test.
:return: None
:rtype: None
"""
target_date = date(2025, 9, 10)
homeworks = [
Homework(
id="hw1",
subject="Physique",
teachers=(),
assigned_on=None,
due_on=target_date,
text="TP à préparer",
html="TP à préparer",
)
]
# Override settings to use auto mode with ical_url configured but ent=None
mock_fetcher._settings.pronote.homework_source = "auto"
mock_fetcher._settings.pronote.ent = None # Explicitly None
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical,
patch("pronote_sync.sources.pronote.fallback.collect_homeworks") as m_collect,
):
m_fetch_ical.side_effect = OSError("iCal unreachable")
m_parse_ical.side_effect = OSError("iCal parse error")
client = MagicMock()
client.get_homeworks.return_value = homeworks
mock_fetcher._pronote_client = client
m_collect.return_value = homeworks
result = mock_fetcher.fetch_homework(target_date)
assert result == homeworks
client.get_homeworks.assert_called_once()
def test_fetch_homework_fallback_both_fail_raises_pipeline_critical_error(
mock_fetcher: PronoteFetcher,
) -> None:
@@ -1127,7 +1223,7 @@ def test_fetch_homework_fallback_both_fail_raises_pipeline_critical_error(
def test_fetch_homework_auto_fallback_returns_empty_logs_warning(
mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture
) -> None:
"""Test que fetch_homework retourne [] et journalise un avertissement si le repli retourne vide.
"""Test que fetch_homework retourne [] et journalise un avertissement si le repli est vide.
On mock ICAL pour échouer, pronotepy configuré et retourne vide. On vérifie le retour et le log.
Ce test utilise le mode AUTO pour tester le comportement de repli.
@@ -1186,4 +1282,99 @@ def test_fetch_informations_logs_and_re_raises_secret(
)
def test_is_pronotepy_configured_without_ent_returns_true() -> None:
"""Test _is_pronotepy_configured() retourne True quand ent est None.
Les autres champs de la configuration pronotepy sont présents, donc la
fonction renvoie True.
Ce test valide que PRONOTE_ENT est optionnel pour pronotepy.
:return: None
"""
from pronote_sync.config.settings import PronoteSettings, Settings
from pronote_sync.sources.pronote.fallback import PronoteFetcher
# Créer des settings avec pronotepy configuré mais sans ent
settings = Settings(
pronote=PronoteSettings(
pronote_url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent=None, # Explicitement None
agenda_source="pronotepy",
homework_source="pronotepy",
),
app=Settings().app,
)
client: _MockPronoteClientProtocol = MagicMock()
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
# Should return True even without ent
assert fetcher._is_pronotepy_configured() is True
def test_agenda_sources_auto_without_ical_and_without_ent_returns_pronotepy() -> None:
"""Test _agenda_sources() en mode AUTO sans iCal URL et sans ent retourne pronotepy.
Ce test valide que le mode auto peut utiliser pronotepy même sans ent configuré.
:return: None
"""
from pronote_sync.config.settings import PronoteSettings, Settings
from pronote_sync.sources.pronote.fallback import PronoteFetcher
settings = Settings(
pronote=PronoteSettings(
pronote_url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent=None, # Explicitement None
ical_url=None, # Pas de iCal URL
agenda_source="auto",
),
app=Settings().app,
)
client: _MockPronoteClientProtocol = MagicMock()
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
primary, fallback = fetcher._agenda_sources()
assert primary == "pronotepy"
assert fallback is None
def test_homework_sources_auto_without_ical_and_without_ent_returns_pronotepy() -> None:
"""Test _homework_sources() en mode AUTO sans iCal URL et sans ent retourne pronotepy.
Ce test valide que le mode auto peut utiliser pronotepy pour les devoirs même sans ent configuré.
:return: None
"""
from pronote_sync.config.settings import PronoteSettings, Settings
from pronote_sync.sources.pronote.fallback import PronoteFetcher
settings = Settings(
pronote=PronoteSettings(
pronote_url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent=None, # Explicitement None
ical_url=None, # Pas de iCal URL
homework_source="auto",
),
app=Settings().app,
)
client: _MockPronoteClientProtocol = MagicMock()
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
primary, fallback = fetcher._homework_sources()
assert primary == "pronotepy"
assert fallback is None
# Ensure trailing newline

View File

@@ -309,7 +309,9 @@ def test_missing_credentials_raises(empty_pronote_settings: PronoteSettings) ->
"""
client = PronoteClient(empty_pronote_settings)
with pytest.raises(ValueError, match="pronote_url, username, password et ent sont requis"):
with pytest.raises(
ValueError, match="pronote_url, username et password sont requis pour pronotepy"
):
client._connect()
@@ -365,6 +367,132 @@ def test_connect_parent_account_type(
pronotepy.Client.assert_not_called() # type: ignore[attr-defined]
def test_connect_without_ent_but_with_required_credentials(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Vérifie que _connect() fonctionne sans ent mais avec les autres identifiants requis.
Ce test valide que PRONOTE_ENT est optionnel pour une connexion directe Pronote.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
from unittest.mock import Mock
from pronote_sync.config.settings import PronoteSettings
from pronote_sync.sources.pronote.client import PronoteClient
# Settings sans ent mais avec les autres champs requis
settings = PronoteSettings(
pronote_url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent=None, # Explicitement None
account_type="parent",
)
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(settings)
connected_client = client._connect()
# Should not raise ValueError about missing ent
assert connected_client is mock_client
# Verify ParentClient was called with ent=None
mock_client_class.assert_called_once_with(
pronote_url="https://pronote.example.com",
username="testuser",
password="testpass", # pragma: allowlist secret
ent=None, # ent should be None, not resolved
)
def test_connect_missing_required_credentials_still_raises(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Vérifie que _connect() lève ValueError si pronote_url, username ou password manquent.
Ce test valide que l'erreur ne mentionne plus ent comme requis.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
from pronote_sync.config.settings import PronoteSettings
from pronote_sync.sources.pronote.client import PronoteClient
# Settings avec ent mais sans pronote_url
settings = PronoteSettings(
pronote_url=None,
username="testuser",
password=SecretStr("testpass"),
ent=None,
account_type="parent",
)
client = PronoteClient(settings)
with pytest.raises(ValueError) as exc_info:
client._connect()
# Error should NOT mention ent as required
assert "pronote_url, username et password sont requis" in str(exc_info.value)
assert "ent" not in str(exc_info.value)
def test_connect_missing_username_raises(mocker: pytest_mock.MockerFixture) -> None:
"""Vérifie que _connect() lève ValueError si username manque.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
from pronote_sync.config.settings import PronoteSettings
from pronote_sync.sources.pronote.client import PronoteClient
settings = PronoteSettings(
pronote_url="https://pronote.example.com",
username=None,
password=SecretStr("testpass"),
ent=None,
account_type="parent",
)
client = PronoteClient(settings)
with pytest.raises(ValueError) as exc_info:
client._connect()
assert "pronote_url, username et password sont requis" in str(exc_info.value)
def test_connect_missing_password_raises(mocker: pytest_mock.MockerFixture) -> None:
"""Vérifie que _connect() lève ValueError si password manque.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
from pronote_sync.config.settings import PronoteSettings
from pronote_sync.sources.pronote.client import PronoteClient
settings = PronoteSettings(
pronote_url="https://pronote.example.com",
username="testuser",
password=None,
ent=None,
account_type="parent",
)
client = PronoteClient(settings)
with pytest.raises(ValueError) as exc_info:
client._connect()
assert "pronote_url, username et password sont requis" in str(exc_info.value)
def test_connect_student_account_type(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
@@ -398,6 +526,57 @@ def test_connect_student_account_type(
pronotepy.ParentClient.assert_not_called() # type: ignore[attr-defined]
def test_connect_with_ent_resolution_still_works(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Vérifie que _resolve_ent est appelé et fonctionne quand ent est fourni.
Ce test valide que lorsque ent est fourni, il est toujours résolu via _resolve_ent.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
from unittest.mock import Mock
from pronote_sync.config.settings import PronoteSettings
from pronote_sync.sources.pronote.client import PronoteClient
settings = PronoteSettings(
pronote_url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent="bordeaux", # ent est fourni
account_type="parent",
)
mock_client = mocker.MagicMock()
mock_client_class = Mock(return_value=mock_client)
mocker.patch("pronotepy.ParentClient", new=mock_client_class)
# Mock _resolve_ent to return a mock resolver
mock_resolver = Mock()
mocker.patch(
"pronote_sync.sources.pronote.client._resolve_ent",
return_value=mock_resolver,
)
client = PronoteClient(settings)
_ = client._connect()
# _resolve_ent should have been called
from pronote_sync.sources.pronote.client import _resolve_ent as resolve_ent_func
resolve_ent_func.assert_called_once_with("bordeaux") # type: ignore[attr-defined]
# ParentClient should have been called with the resolved ent
mock_client_class.assert_called_once_with(
pronote_url="https://pronote.example.com",
username="testuser",
password="testpass", # pragma: allowlist secret
ent=mock_resolver,
)
def test_get_messages_degraded_on_error(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None: