From fd9b604849231fe3628134d720f460adea9d5e26 Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Tue, 8 Sep 2026 15:57:28 +0200 Subject: [PATCH 1/2] feat(M12): CLI entry point with dry-run, log-level, redacted error display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the CLI entry point for pronote-sync: cli/main.py: - main() entry point with --dry-run (tri-state: None defers to settings, True overrides) and --log-level (choices: DEBUG/INFO/WARNING/ERROR/CRITICAL) - setup_logging called before settings load (to capture config errors), then reconfigured with settings.app.log_level - PipelineRunner.from_settings() as composition root, runner.run() - Return codes: 0 success, 1 failure, 2 argparse rejection - _safe_traceback: strips exception messages, replaces with "erreur expurgée", walks __cause__/__context__ with cycle protection - _settings_secrets: collects redaction_secrets() + usernames + JID/recipient - All error messages redacted via redact_secrets() with configured secrets - DEBUG-level traceback only shown when DEBUG is enabled cli/__init__.py: - Module docstring added (French, Sphinx/reST) tests/e2e/test_cli.py (8 tests): - Dry-run and log-level propagation to composition root - Configured dry-run preserved (tri-state None) - Success with warnings returns 0 - Pipeline error redaction at DEBUG (sentinel secret) - Configuration failure redacted traceback at DEBUG - Pronote username non-disclosure - Unexpected pipeline exception: redacted traceback at DEBUG, no traceback at INFO - Argparse rejection of unknown log level (exit code 2) Coverage: cli/ 94.74%, 627 total tests pass. Co-authored-by: opencode/coder Co-authored-by: opencode/test-engineer --- TODO.md | 8 +- pronote_sync/cli/__init__.py | 1 + pronote_sync/cli/main.py | 153 ++++++++++++++++++++++++++++ tests/e2e/__init__.py | 1 + tests/e2e/test_cli.py | 189 +++++++++++++++++++++++++++++++++++ 5 files changed, 348 insertions(+), 4 deletions(-) create mode 100644 pronote_sync/cli/main.py create mode 100644 tests/e2e/__init__.py create mode 100644 tests/e2e/test_cli.py diff --git a/TODO.md b/TODO.md index d8ba6de..533cba2 100644 --- a/TODO.md +++ b/TODO.md @@ -241,10 +241,10 @@ Composer et orchestrer toutes les étapes avec gestion d'erreurs dégradée et m Exposer le lancement du pipeline via une interface en ligne de commande. -- [ ] Créer `cli/main.py` : `main()` (point d'entrée `pronote-sync`), args `--dry-run`, `--log-level`. -- [ ] Initialiser les logs (`setup_logging`) et charger `settings` au démarrage. -- [ ] Construire la composition root et lancer `PipelineRunner.run()`. -- [ ] Gérer le code de retour et l'affichage des erreurs (redactées). +- [x] Créer `cli/main.py` : `main()` (point d'entrée `pronote-sync`), args `--dry-run`, `--log-level`. +- [x] Initialiser les logs (`setup_logging`) et charger `settings` au démarrage. +- [x] Construire la composition root et lancer `PipelineRunner.run()`. +- [x] Gérer le code de retour et l'affichage des erreurs (redactées). ### Critères d'acceptation - `pronote-sync --dry-run --log-level DEBUG` s'exécute sans effet de bord. diff --git a/pronote_sync/cli/__init__.py b/pronote_sync/cli/__init__.py index e69de29..ccd59e7 100644 --- a/pronote_sync/cli/__init__.py +++ b/pronote_sync/cli/__init__.py @@ -0,0 +1 @@ +"""Interface en ligne de commande du pipeline ``pronote-sync``.""" diff --git a/pronote_sync/cli/main.py b/pronote_sync/cli/main.py new file mode 100644 index 0000000..f91591b --- /dev/null +++ b/pronote_sync/cli/main.py @@ -0,0 +1,153 @@ +"""Point d'entrée en ligne de commande du pipeline Pronote → CalDAV → XMPP.""" + +from __future__ import annotations + +import argparse +import logging +import traceback +from collections.abc import Sequence + +from pydantic import SecretStr + +from pronote_sync.config.env import load_settings +from pronote_sync.config.settings import Settings +from pronote_sync.pipeline.run import PipelineRunner +from pronote_sync.utils.logging import setup_logging +from pronote_sync.utils.redaction import redact_secrets + +logger = logging.getLogger(__name__) + +_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL") + + +def _parse_arguments(arguments: Sequence[str] | None = None) -> argparse.Namespace: + """Analyse les options de lancement du programme. + + :param arguments: Arguments à analyser, ou ``None`` pour ceux du processus. + :return: Options de ligne de commande validées. + :rtype: argparse.Namespace + """ + parser = argparse.ArgumentParser(description="Synchronise Pronote vers CalDAV et XMPP.") + parser.add_argument( + "--dry-run", + action="store_true", + default=None, + help="Simule la synchronisation sans écrire vers CalDAV ni XMPP.", + ) + parser.add_argument( + "--log-level", + choices=_LOG_LEVELS, + type=str.upper, + help="Niveau de verbosité des journaux.", + ) + return parser.parse_args(arguments) + + +def _settings_secrets(settings: Settings) -> tuple[SecretStr | str, ...]: + """Retourne les valeurs sensibles connues pour la rédaction des messages. + + Centraliser ces valeurs garantit que les diagnostics CLI ne divulguent pas + les secrets configurés, y compris lorsque le niveau ``DEBUG`` est demandé. + + :param settings: Configuration validée de l'application. + :return: Secrets connus à transmettre au mécanisme de rédaction. + :rtype: tuple[SecretStr | str, ...] + """ + candidates = ( + *settings.redaction_secrets(), + settings.pronote.username, + settings.caldav.username, + settings.xmpp.jid, + settings.xmpp.to, + ) + return tuple(dict.fromkeys(secret for secret in candidates if secret is not None)) + + +def _safe_traceback( + exception: BaseException, *, extra_secrets: Sequence[SecretStr | str] = () +) -> str: + """Construit une pile complète sans inclure les messages d'exception bruts. + + Les noms de fichiers, lignes et fonctions conservent la valeur de diagnostic + de la pile. Les messages et les chaînes de causes sont volontairement + remplacés, car ils peuvent provenir d'une bibliothèque externe. + + :param exception: Exception à représenter sans divulguer son contenu. + :param extra_secrets: Valeurs sensibles configurées à rédiger dans les cadres. + :return: Représentation de la pile et de ses causes, expurgée. + :rtype: str + """ + lines = ["Traceback (most recent call last):"] + current: BaseException | None = exception + seen: set[int] = set() + while current is not None and id(current) not in seen: + seen.add(id(current)) + for frame in traceback.extract_tb(current.__traceback__): + lines.append(f' File "{frame.filename}", line {frame.lineno}, in {frame.name}') + lines.append(f"{type(current).__name__}: erreur expurgée") + next_exception = current.__cause__ or current.__context__ + if next_exception is not None and id(next_exception) not in seen: + lines.append("La cause ou le contexte précédent est le suivant :") + current = next_exception + return redact_secrets("\n".join(lines), extra_secrets=extra_secrets) + + +def _log_failure( + message: str, + exception: BaseException, + *, + extra_secrets: Sequence[SecretStr | str] = (), +) -> None: + """Journalise une erreur et sa pile expurgée uniquement en niveau DEBUG. + + :param message: Message public déjà sûr à afficher hors DEBUG. + :param exception: Exception dont la pile doit être présentée de façon sûre. + :param extra_secrets: Valeurs sensibles configurées à rédiger. + :rtype: None + """ + logger.error("%s", redact_secrets(message, extra_secrets=extra_secrets)) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("%s", _safe_traceback(exception, extra_secrets=extra_secrets)) + + +def main(arguments: Sequence[str] | None = None) -> int: + """Lance le pipeline configuré et retourne son code de sortie. + + En niveau ``DEBUG``, les piles sont affichées sans leurs messages externes + bruts afin de préserver le diagnostic sans exposer de secret. + + :param arguments: Arguments optionnels, principalement utiles aux appels programmatiques. + :return: ``0`` en cas de succès, ``1`` sinon (après analyse des arguments). + :rtype: int + :raises SystemExit: Si argparse rejette les arguments (code de sortie 2). + """ + parsed_arguments = _parse_arguments(arguments) + setup_logging(parsed_arguments.log_level or "INFO") + try: + settings = load_settings() + except Exception as exception: + _log_failure("Configuration invalide ou indisponible.", exception) + return 1 + + setup_logging(parsed_arguments.log_level or settings.app.log_level) + try: + runner = PipelineRunner.from_settings(settings, dry_run=parsed_arguments.dry_run) + data, errors = runner.run() + except Exception as exception: + _log_failure( + "Échec inattendu du pipeline.", + exception, + extra_secrets=_settings_secrets(settings), + ) + return 1 + + secrets = _settings_secrets(settings) + for error in errors: + logger.error("%s", redact_secrets(error.message, extra_secrets=secrets)) + if data is None: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..613b96f --- /dev/null +++ b/tests/e2e/__init__.py @@ -0,0 +1 @@ +"""Tests end-to-end de l'interface en ligne de commande.""" diff --git a/tests/e2e/test_cli.py b/tests/e2e/test_cli.py new file mode 100644 index 0000000..6428916 --- /dev/null +++ b/tests/e2e/test_cli.py @@ -0,0 +1,189 @@ +"""Tests de l'interface en ligne de commande ``pronote-sync``.""" + +from __future__ import annotations + +import pytest +from pydantic import SecretStr +from pytest_mock import MockerFixture + +from pronote_sync.config.settings import AISettings, AppSettings, PronoteSettings, Settings +from pronote_sync.errors import PipelineCriticalError, PipelineWarning +from pronote_sync.models.pronote import PronoteData + + +def test_main_runs_composition_root_in_dry_run_with_requested_log_level( + mocker: MockerFixture, +) -> None: + """La CLI propage les options au logger et au runner injecté.""" + from pronote_sync.cli.main import main + + settings = Settings(app=AppSettings(log_level="WARNING")) + load_settings = mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings) + setup_logging = mocker.patch("pronote_sync.cli.main.setup_logging") + runner = mocker.Mock() + runner.run.return_value = (mocker.Mock(spec=PronoteData), []) + composition_root = mocker.patch( + "pronote_sync.cli.main.PipelineRunner.from_settings", return_value=runner + ) + + exit_code = main(["--dry-run", "--log-level", "DEBUG"]) + + assert exit_code == 0 + load_settings.assert_called_once_with() + assert setup_logging.call_args_list == [mocker.call("DEBUG"), mocker.call("DEBUG")] + composition_root.assert_called_once_with(settings, dry_run=True) + runner.run.assert_called_once_with() + + +def test_main_preserves_configured_dry_run_and_returns_success_with_warnings( + mocker: MockerFixture, +) -> None: + """Sans option, la CLI préserve le dry-run configuré et accepte les avertissements.""" + from pronote_sync.cli.main import main + + settings = Settings(app=AppSettings(dry_run=True, log_level="WARNING")) + mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings) + setup_logging = mocker.patch("pronote_sync.cli.main.setup_logging") + runner = mocker.Mock() + runner.run.return_value = ( + mocker.Mock(spec=PronoteData), + [PipelineWarning("Avertissement non bloquant")], + ) + composition_root = mocker.patch( + "pronote_sync.cli.main.PipelineRunner.from_settings", return_value=runner + ) + + exit_code = main([]) + + assert exit_code == 0 + assert setup_logging.call_args_list == [mocker.call("INFO"), mocker.call("WARNING")] + composition_root.assert_called_once_with(settings, dry_run=None) + runner.run.assert_called_once_with() + + +def test_main_returns_failure_and_redacts_pipeline_secrets_at_debug_level( + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], +) -> None: + """Les diagnostics de pipeline restent expurgés, même au niveau DEBUG.""" + from pronote_sync.cli.main import main + + secret = "M12_PIPELINE_SECRET" # pragma: allowlist secret + settings = Settings(ai=AISettings(api_key=SecretStr(secret))) + mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings) + runner = mocker.Mock() + runner.run.return_value = ( + None, + [PipelineCriticalError(f"Échec distant avec le secret {secret}")], + ) + mocker.patch("pronote_sync.cli.main.PipelineRunner.from_settings", return_value=runner) + + exit_code = main(["--log-level", "DEBUG"]) + + output = capsys.readouterr().out + assert exit_code == 1 + assert secret not in output + assert "REDACTED" in output + assert "Traceback" not in output + + +def test_main_displays_a_redacted_configuration_traceback_at_debug_level( + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], +) -> None: + """Une erreur de configuration DEBUG conserve son traceback sans son secret.""" + from pronote_sync.cli.main import main + + secret = "M12_CONFIGURATION_SECRET" # pragma: allowlist secret + mocker.patch( + "pronote_sync.cli.main.load_settings", + side_effect=ValueError(f"configuration invalide: {secret}"), + ) + + exit_code = main(["--log-level", "DEBUG"]) + + output = capsys.readouterr().out + assert exit_code == 1 + assert secret not in output + assert "Configuration invalide ou indisponible." in output + assert "Traceback" in output + + +def test_main_does_not_disclose_a_configured_pronote_username( + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], +) -> None: + """Les erreurs critiques ne divulguent pas un identifiant Pronote configuré.""" + from pronote_sync.cli.main import main + + username = "m12-parent-identifier" + settings = Settings(pronote=PronoteSettings(username=username)) + mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings) + runner = mocker.Mock() + runner.run.return_value = ( + None, + [PipelineCriticalError(f"Échec distant pour l'identifiant {username}")], + ) + mocker.patch("pronote_sync.cli.main.PipelineRunner.from_settings", return_value=runner) + + exit_code = main([]) + + output = capsys.readouterr().out + assert exit_code == 1 + assert username not in output + assert "REDACTED" in output + + +def test_main_rejects_an_unknown_log_level() -> None: + """La CLI rejette les niveaux de journalisation hors contrat.""" + from pronote_sync.cli.main import main + + with pytest.raises(SystemExit) as error: + main(["--log-level", "VERBOSE"]) + + assert error.value.code == 2 + + +def test_main_logs_redacted_traceback_when_pipeline_raises_unexpectedly( + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], +) -> None: + """Une exception inattendue du pipeline produit un traceback expurgé en DEBUG.""" + from pronote_sync.cli.main import main + + secret = "M12_UNEXPECTED_SECRET" # pragma: allowlist secret + settings = Settings(ai=AISettings(api_key=SecretStr(secret))) + mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings) + mocker.patch( + "pronote_sync.cli.main.PipelineRunner.from_settings", + side_effect=RuntimeError(f"Erreur interne avec {secret}"), + ) + + exit_code = main(["--log-level", "DEBUG"]) + + output = capsys.readouterr().out + assert exit_code == 1 + assert secret not in output + assert "Traceback" in output + assert "erreur expurgée" in output + + +def test_main_does_not_show_traceback_at_info_level( + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], +) -> None: + """En niveau INFO, aucune pile n'est affichée pour une erreur inattendue.""" + from pronote_sync.cli.main import main + + mocker.patch("pronote_sync.cli.main.load_settings", return_value=Settings()) + mocker.patch( + "pronote_sync.cli.main.PipelineRunner.from_settings", + side_effect=RuntimeError("Erreur interne"), + ) + + exit_code = main([]) + + output = capsys.readouterr().out + assert exit_code == 1 + assert "Traceback" not in output + assert "Échec inattendu du pipeline." in output From 000416f24e0271c62c813625f02b0544f666b75a Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Tue, 8 Sep 2026 16:19:27 +0200 Subject: [PATCH 2/2] feat(M13): complete test fixtures, shared conftest, and coverage >= 90% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finalize M13 test and coverage milestone: tests/fixtures/pronote-6e.ics (new): - Anonymized iCal fixture for Classe de 6e (3 VEVENTs: SVT lesson with homework block, Histoire-Géo modified lesson, all-day school outing) - Same structure as pronote-4e.ics, no secrets or real data tests/conftest.py: - Added sample_message fixture (Message with MessageType.INFORMATION) - Integrated sample_message into pronote_data fixture (messages=[sample_message]) - Sphinx/reST docstring with :return: and :rtype: .gitignore: - Fixed typo: .worktress/ -> .worktrees/ (line 56) pyproject.toml: - Added ".worktrees" to ruff extend-exclude to prevent ruff format --check . from scanning worktree files TODO.md: - Checked M13 items: tests/fixtures/ and tests/conftest.py Validation: 627 tests pass, coverage 95.67% (threshold 90%), ruff/mypy/ bandit/pre-commit all green. Co-authored-by: opencode/coder --- .gitignore | 2 +- TODO.md | 4 +-- pyproject.toml | 2 +- tests/conftest.py | 24 +++++++++++++-- tests/fixtures/pronote-6e.ics | 56 +++++++++++++++++++++++++++++++++++ 5 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/pronote-6e.ics diff --git a/.gitignore b/.gitignore index eaf59cb..16a6a24 100644 --- a/.gitignore +++ b/.gitignore @@ -53,7 +53,7 @@ Thumbs.db # --- Local scratch / WIP files --- FIXME_* TEST_* -.worktress/ +.worktrees/ # --- Logs --- *.log diff --git a/TODO.md b/TODO.md index 533cba2..073cbb4 100644 --- a/TODO.md +++ b/TODO.md @@ -257,8 +257,8 @@ 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.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…). +- [x] Créer `tests/fixtures/` : `pronote-4e.ics`, `pronote-6e.ics`, `theoretical.json`, `school_holidays.json`, `blog_rss.xml` (anonymisés, sans `icalsecurise`). +- [x] Créer `tests/conftest.py` : fixtures partagées (sample_lesson, sample_cancelled_lesson, sample_homework, sample_school_event, sample_message, sample_pronote_data…). - [x] Écrire `tests/unit/` : `test_models`, `test_parsing` (iCal), `test_uid`, `test_redaction`, `test_diff`, `test_sync`. - [x] 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. - [x] Écrire `tests/integration/` : `test_pipeline`, `test_caldav` (mocké), `test_xmpp` (mocké). diff --git a/pyproject.toml b/pyproject.toml index 9b17fd5..6d124b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,7 +94,7 @@ skips = ["B101"] # Ignorer les assertions (utilisées dans les tests) line-length = 100 target-version = "py313" # Exclure la documentation markdown (ruff format ne doit pas toucher aux blocs de code Python inclus) -extend-exclude = ["GUIDE_DEV_PYTHON.md"] +extend-exclude = ["GUIDE_DEV_PYTHON.md", ".worktrees"] [tool.ruff.lint] select = [ diff --git a/tests/conftest.py b/tests/conftest.py index d92059a..a94e91c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,7 @@ from pydantic import SecretStr from pronote_sync.config.settings import CalDAVSettings from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent, SchoolEventKind from pronote_sync.models.homework import Homework +from pronote_sync.models.message import Message, MessageType from pronote_sync.models.pronote import PronoteData @@ -85,19 +86,38 @@ def caldav_settings() -> CalDAVSettings: ) +@pytest.fixture +def sample_message() -> Message: + """Message Pronote pour les tests. + + :return: Message Pronote de test. + :rtype: Message + """ + return Message( + id="msg-001", + type=MessageType.INFORMATION, + title="Information de rentrée", + content="La rentrée est prévue le 1er septembre.", + author="Administration", + date=datetime(2026, 1, 15, 9, 0), + read=False, + ) + + @pytest.fixture def pronote_data( sample_lesson: Lesson, sample_cancelled_lesson: Lesson, sample_homework: Homework, sample_school_event: SchoolEvent, + sample_message: Message, ) -> PronoteData: - """Données Pronote de test avec des cours, devoirs et événements.""" + """Données Pronote de test avec des cours, devoirs, événements et messages.""" return PronoteData( lessons=[sample_lesson, sample_cancelled_lesson], homeworks=[sample_homework], school_events=[sample_school_event], - messages=[], + messages=[sample_message], target_date=date(2026, 1, 15), generated_at=datetime(2026, 1, 15, 0, 0), ) diff --git a/tests/fixtures/pronote-6e.ics b/tests/fixtures/pronote-6e.ics new file mode 100644 index 0000000..a0ef5a9 --- /dev/null +++ b/tests/fixtures/pronote-6e.ics @@ -0,0 +1,56 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Index Education//Pronote//FR +X-WR-CALNAME:Classe de 6e +BEGIN:VEVENT +UID:Edt_22222@index-education.net-20260908T140000Z-Index-Education +DTSTAMP:20260908T140000Z +DTSTART:20260908T140000Z +DTEND:20260908T150000Z +SUMMARY:SVT +CATEGORIES:Cours +DESCRIPTION:
+ Matière : SVT + Professeur : M. Dubois + Salle : 104 + Groupe : Classe entière + + Contenu pédagogique : + + Découverte de la cellule et de ses constituants. + Pour le 15/09/2026 : + + Lire le chapitre 2 et schématiser une cellule végétale. + Donné le 08/09/2026 : + + Lire le chapitre 2 et schématiser une cellule végétale. +
+END:VEVENT +BEGIN:VEVENT +UID:Edt_33333@index-education.net-20260908T140000Z-Index-Education +DTSTAMP:20260908T140000Z +DTSTART:20260909T100000Z +DTEND:20260909T110000Z +SUMMARY:Histoire-Géographie +CATEGORIES:Cours - Cours modifié +DESCRIPTION:
+ Matière : Histoire-Géographie + Professeur : Mme Lefevre + Salle : 203 + Groupe : Classe entière + + Contenu pédagogique : + + Les grands repères du temps long : la Préhistoire. +
+END:VEVENT +BEGIN:VEVENT +UID:Edt_44444@index-education.net-20260908T140000Z-Index-Education +DTSTAMP:20260908T140000Z +DTSTART;VALUE=DATE:20260928 +DTEND;VALUE=DATE:20260929 +SUMMARY:Sortie pédagogique +CATEGORIES:Sortie scolaire +DESCRIPTION:Journée de sortie pédagogique au musée d'histoire naturelle. +END:VEVENT +END:VCALENDAR