Compare commits

...
Author SHA1 Message Date
Codex eef81ea323 fix(docs): corriger le répertoire de clonage
Closes #56

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

Co-authored-by: Codex <codex@antoineve.me>
2026-09-13 00:24:10 +02:00
Codex f261fed1af fix(blog): confirmer l'état mémoire après sauvegarde
Closes #49

Co-authored-by: Codex <codex@antoineve.me>
2026-09-13 00:15:07 +02:00
Codex e6f0659cbf fix(caldav): ignorer les événements gérés sans UID
Closes #47

Co-authored-by: Codex <codex@antoineve.me>
2026-09-13 00:13:29 +02:00
9 changed files with 129 additions and 23 deletions
+2 -2
View File
@@ -156,7 +156,7 @@
"filename": "tests/unit/test_caldav_gateway.py", "filename": "tests/unit/test_caldav_gateway.py",
"hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f", "hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f",
"is_verified": false, "is_verified": false,
"line_number": 763 "line_number": 794
} }
], ],
"tests/unit/test_caldav_security.py": [ "tests/unit/test_caldav_security.py": [
@@ -185,5 +185,5 @@
} }
] ]
}, },
"generated_at": "2026-09-12T17:57:39Z" "generated_at": "2026-09-12T22:12:56Z"
} }
+2 -3
View File
@@ -40,7 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.1.0] - 2026-09-08 ## [0.1.0] - 2026-09-08
Initial release covering milestones M1 through M15. Initial release covering milestones M1 through M15, except the optional Gitea Actions workflow.
### Added ### Added
- **M1 (Scaffolding)**: Python project structure with `pyproject.toml`, and tooling configuration for `ruff`, `mypy`, `bandit`, and `pre-commit`. - **M1 (Scaffolding)**: Python project structure with `pyproject.toml`, and tooling configuration for `ruff`, `mypy`, `bandit`, and `pre-commit`.
@@ -57,5 +57,4 @@ Initial release covering milestones M1 through M15.
- **M12 (CLI entry point)**: `pronote-sync` command with `--dry-run` and `--log-level` options, redacted error display, and safe traceback in DEBUG mode. - **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. - **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. - **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). - **M15 (Documentation)**: README, README.LLM.md (AI agent setup guide), MIT LICENSE, CHANGELOG, and local validation procedures. Gitea Actions CI/CD remains optional and is not delivered in this release.
- **Other**: MIT License. Gitea Actions CI/CD reference for LXC/VPS (Debian/CentOS) is planned and optional, not delivered in this release.
+19 -2
View File
@@ -12,8 +12,8 @@ Synchronise l'agenda et les devoirs de **Pronote** vers un calendrier **CalDAV**
```bash ```bash
# Cloner le dépôt # Cloner le dépôt
git clone <repo-url> git clone https://git.antoineve.me/AntoineVe/college-infos
cd pronote-sync cd college-infos
# Créer l'environnement virtuel # Créer l'environnement virtuel
python3.13 -m venv .venv python3.13 -m venv .venv
@@ -48,6 +48,23 @@ pas garantir un état persistant cohérent pendant une simulation.
--- ---
## Validation et CI
Aucun workflow Gitea Actions n'est livré actuellement. Les validations du projet sont donc
exécutées localement avec les commandes suivantes :
```bash
pytest
ruff check .
mypy .
bandit -r pronote_sync/
```
`pre-commit run --all-files` regroupe également les contrôles de formatage, typage, sécurité et
détection de secrets.
---
## 🛠️ Déploiement ## 🛠️ 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. Les artefacts pour **systemd/timer** et **logrotate** sont fournis dans `deploy/`. Voir [docs/exploitation.md](docs/exploitation.md) pour plus de détails.
+1 -1
View File
@@ -308,5 +308,5 @@ Rédiger la documentation utilisateur et finaliser le projet.
### Critères d'acceptation ### Critères d'acceptation
- `README.md` permet d'installer et de lancer le projet sans le guide. - `README.md` permet d'installer et de lancer le projet sans le guide.
- Gitea Actions exécute tests + lint + sécurité. - Les procédures locales de test, lint et sécurité sont documentées et exécutables.
- Aucun secret dans la documentation. - Aucun secret dans la documentation.
+26 -9
View File
@@ -97,7 +97,10 @@ class BlogRSSState:
redact_exception(exc), redact_exception(exc),
) )
def _save(self) -> None: def _save(
self,
state: tuple[set[str], str | None, str | None] | None = None,
) -> bool:
"""Sauvegarde l'état dans le fichier JSON de manière atomique. """Sauvegarde l'état dans le fichier JSON de manière atomique.
La sortie est déterministe : ``known_guids`` est trié La sortie est déterministe : ``known_guids`` est trié
@@ -107,20 +110,30 @@ class BlogRSSState:
jamais laisser un fichier partiel en cas d'interruption. En cas jamais laisser un fichier partiel en cas d'interruption. En cas
d'erreur d'écriture, une erreur est journalisée sans être d'erreur d'écriture, une erreur est journalisée sans être
propagée et le fichier temporaire est supprimé. propagée et le fichier temporaire est supprimé.
:param state: État à sauvegarder ; l'état courant est utilisé par défaut.
:return: ``True`` si l'état a été sauvegardé ou si la persistance est désactivée.
:rtype: bool
""" """
if not self._persistence_enabled: if not self._persistence_enabled:
return return True
known_guids, etag, last_modified = state or (
self._known_guids,
self._etag,
self._last_modified,
)
payload = { payload = {
"version": _STATE_VERSION, "version": _STATE_VERSION,
"known_guids": sorted(self._known_guids), "known_guids": sorted(known_guids),
"etag": self._etag, "etag": etag,
"last_modified": self._last_modified, "last_modified": last_modified,
} }
tmp_file = self._state_file.with_suffix(".tmp") tmp_file = self._state_file.with_suffix(".tmp")
try: try:
with open(tmp_file, "w", encoding="utf-8") as handle: with open(tmp_file, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2) json.dump(payload, handle, indent=2)
tmp_file.replace(self._state_file) tmp_file.replace(self._state_file)
return True
except Exception as exc: except Exception as exc:
logger.error( logger.error(
"Impossible d'écrire le fichier d'état blog RSS %s : %s.", "Impossible d'écrire le fichier d'état blog RSS %s : %s.",
@@ -134,6 +147,7 @@ class BlogRSSState:
"Nettoyage du fichier temporaire échoué : %s", "Nettoyage du fichier temporaire échoué : %s",
redact_exception(cleanup_exc), redact_exception(cleanup_exc),
) )
return False
def get_known_guids(self) -> frozenset[str]: def get_known_guids(self) -> frozenset[str]:
"""Renvoie une copie immuable des GUID d'articles déjà connus. """Renvoie une copie immuable des GUID d'articles déjà connus.
@@ -168,10 +182,13 @@ class BlogRSSState:
""" """
if result.not_modified: if result.not_modified:
return return
self._known_guids.update(article.id for article in result.articles) new_state = (
self._etag = result.etag self._known_guids | {article.id for article in result.articles},
self._last_modified = result.last_modified result.etag,
self._save() result.last_modified,
)
if self._save(new_state):
self._known_guids, self._etag, self._last_modified = new_state
def get_cache_headers(self) -> tuple[str | None, str | None]: def get_cache_headers(self) -> tuple[str | None, str | None]:
"""Renvoie les en-têtes de cache HTTP mémorisés. """Renvoie les en-têtes de cache HTTP mémorisés.
+5 -1
View File
@@ -191,7 +191,11 @@ class CalDAVGateway:
for vevent in component.walk("VEVENT"): for vevent in component.walk("VEVENT"):
managed = vevent.get(MANAGED_PROPERTY) managed = vevent.get(MANAGED_PROPERTY)
if managed is not None and str(managed) == MANAGED_VALUE: if managed is not None and str(managed) == MANAGED_VALUE:
raw_uid = str(vevent.get("UID")) raw_uid_value = vevent.get("UID")
if raw_uid_value is None or not str(raw_uid_value).strip():
logger.warning("Événement CalDAV géré sans UID ignoré.")
continue
raw_uid = str(raw_uid_value)
canonical_uid = normalize_pronote_uid(raw_uid) canonical_uid = normalize_pronote_uid(raw_uid)
result.append((raw_uid, canonical_uid, vevent)) result.append((raw_uid, canonical_uid, vevent))
except Exception as exc: except Exception as exc:
+6 -5
View File
@@ -7,9 +7,10 @@ name = "pronote-sync"
version = "0.1.2" version = "0.1.2"
description = "Synchronisation Pronote → CalDAV + XMPP" description = "Synchronisation Pronote → CalDAV + XMPP"
license = {text = "MIT"} license = {text = "MIT"}
readme = "README.md"
requires-python = ">=3.13.5" requires-python = ">=3.13.5"
authors = [ authors = [
{name = "Votre Nom", email = "votre@email.com"} {name = "Antoine Van Elstraete", email = "antoine@van-elstraete.net"}
] ]
keywords = ["pronote", "caldav", "xmpp", "sync", "school"] keywords = ["pronote", "caldav", "xmpp", "sync", "school"]
classifiers = [ classifiers = [
@@ -57,10 +58,10 @@ dev = [
pronote-sync = "pronote_sync.cli.main:main" pronote-sync = "pronote_sync.cli.main:main"
[project.urls] [project.urls]
Homepage = "https://github.com/votre-utilisateur/pronote-sync" Homepage = "https://git.antoineve.me/AntoineVe/college-infos"
Documentation = "https://github.com/votre-utilisateur/pronote-sync#readme" Documentation = "https://git.antoineve.me/AntoineVe/college-infos/wiki"
Repository = "https://github.com/votre-utilisateur/pronote-sync" Repository = "https://git.antoineve.me/AntoineVe/college-infos"
Issues = "https://github.com/votre-utilisateur/pronote-sync/issues" Issues = "https://git.antoineve.me/AntoineVe/college-infos/issues"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["."] where = ["."]
+37
View File
@@ -425,4 +425,41 @@ def test_atomic_save_preserves_on_error(tmp_path: Path) -> None:
assert state.get_known_guids() == frozenset({"original-guid-1", "original-guid-2", "new-guid"}) assert state.get_known_guids() == frozenset({"original-guid-1", "original-guid-2", "new-guid"})
def test_acknowledge_does_not_advance_memory_when_save_fails(
tmp_path: Path,
) -> None:
"""Conserve l'état précédent en mémoire si l'acquittement ne peut pas être 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(["existing-guid"])
state.update_cache_headers("old-etag", "old-last-modified")
article = BlogArticle(
id="new-guid",
title="Article",
url="https://example.com/article",
published_at=datetime(2026, 9, 12, 8, 0, tzinfo=UTC),
updated_at=None,
category=None,
author=None,
content_html="<p>Contenu</p>",
content_text="Contenu",
)
with patch.object(Path, "replace", side_effect=OSError("replace failed")):
state.acknowledge(
BlogRSSFetchResult(
articles=(article,),
etag="new-etag",
last_modified="new-last-modified",
)
)
assert state.get_known_guids() == frozenset({"existing-guid"})
assert state.get_cache_headers() == ("old-etag", "old-last-modified")
# Ensure trailing newline # Ensure trailing newline
+31
View File
@@ -327,6 +327,37 @@ def test_list_managed_events_returns_only_managed(
assert str(vevent.get("UID")) == "test-uid-123" assert str(vevent.get("UID")) == "test-uid-123"
def test_list_managed_events_ignores_managed_event_without_uid(
caldav_settings: CalDAVSettings,
mock_client_factory: MagicMock,
caplog: LogCaptureFixture,
) -> None:
"""Ignore un événement géré sans UID et ne le transmet pas au planificateur.
:param caldav_settings: Paramètres CalDAV valides.
:param mock_client_factory: Usine de clients CalDAV mockée.
:param caplog: Capture des journaux de diagnostic.
:return: None
"""
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory)
gateway.connect()
malformed_event = Event()
malformed_event.add("SUMMARY", "Événement sans identifiant")
malformed_event.add(MANAGED_PROPERTY, MANAGED_VALUE)
remote_event = MagicMock()
remote_event.icalendar_component = Calendar()
remote_event.icalendar_component.add_component(malformed_event)
calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[0]
calendar.search.return_value = [remote_event]
with caplog.at_level(logging.WARNING):
result = gateway.list_managed_events(datetime(2026, 1, 1), datetime(2026, 12, 31))
assert result == []
assert "sans UID ignoré" in caplog.text
def test_list_managed_events_not_connected_raises( def test_list_managed_events_not_connected_raises(
caldav_settings: CalDAVSettings, caldav_settings: CalDAVSettings,
) -> None: ) -> None: