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>
This commit is contained in:
2026-09-06 23:30:47 +02:00
parent 29270427ef
commit 1d26d49e74
5 changed files with 194 additions and 23 deletions

View File

@@ -140,10 +140,10 @@
"filename": "GUIDE_DEV_PYTHON.md", "filename": "GUIDE_DEV_PYTHON.md",
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
"is_verified": true, "is_verified": true,
"line_number": 5046, "line_number": 5065,
"is_secret": false "is_secret": false
} }
] ]
}, },
"generated_at": "2026-09-06T21:06:14Z" "generated_at": "2026-09-06T21:30:39Z"
} }

View File

@@ -3505,7 +3505,7 @@ def week_parity(
2. **Comparaison exacte** : Les créneaux horaires et la matière normalisée doivent correspondre. 2. **Comparaison exacte** : Les créneaux horaires et la matière normalisée doivent correspondre.
3. **Choix de la première correspondance** : En cas de multiples correspondances admissibles, choisir la **première** après tri déterministe. 3. **Choix de la première correspondance** : En cas de multiples correspondances admissibles, choisir la **première** après tri déterministe.
**Exemple de tri** : **Exemple de tri** :
```python ```python
# Tri des cours théoriques par ID stable (pour un matching déterministe) # Tri des cours théoriques par ID stable (pour un matching déterministe)
theoretical_lessons_sorted = sorted( theoretical_lessons_sorted = sorted(
@@ -3517,24 +3517,43 @@ theoretical_lessons_sorted = sorted(
lesson.subject.lower(), lesson.subject.lower(),
), ),
) )
```
**Exemple de matching avec départage déterministe** : **Exemple de matching avec départage déterministe** :
```python ```python
def match_theoretical_lesson( def match_theoretical_lesson(
real_lesson: PronoteLesson, real_lesson: Lesson,
theoretical_lessons: list[TheoreticalLesson], theoretical_events: list[TheoreticalLesson],
tolerance_minutes: int = 15, tolerance_minutes: int = 15,
) -> TheoreticalLesson | None: ) -> TheoreticalLesson | None:
"""Trouve le cours théorique correspondant, avec départage déterministe.""" """Trouve la leçon théorique correspondant à une leçon réelle.
:param real_lesson: Leçon réelle depuis Pronote.
:param theoretical_events: Liste des leçons théoriques candidates.
:param tolerance_minutes: Tolérance en minutes pour le créneau horaire.
:return: La leçon théorique correspondante, ou None.
:rtype: TheoreticalLesson | None
"""
real_start = real_lesson.start
real_day = real_start.weekday()
def to_minutes(t: time) -> int:
return t.hour * 60 + t.minute
# ``normalize_subject`` sera défini dans ``sync/diff.py`` (M8) ou dans le
# module théorique ; il normalise les matières pour un matching déterministe.
start_minutes = real_start.hour * 60 + real_start.minute
end_minutes = real_lesson.end.hour * 60 + real_lesson.end.minute
candidates = [ candidates = [
t for t in theoretical_lessons t
if t.day_of_week == real_lesson.start.weekday() for t in theoretical_events
and abs((t.start_time - real_lesson.start.time()).total_seconds()) <= tolerance_minutes * 60 if t.day_of_week == real_day
and abs(to_minutes(t.start_time) - start_minutes) <= tolerance_minutes
and abs(to_minutes(t.end_time) - end_minutes) <= tolerance_minutes
and normalize_subject(t.subject) == normalize_subject(real_lesson.subject) and normalize_subject(t.subject) == normalize_subject(real_lesson.subject)
] ]
if not candidates: if not candidates:
return None return None
# Tri déterministe par ID stable, puis par créneau
candidates.sort(key=lambda t: (t.id, t.start_time)) candidates.sort(key=lambda t: (t.id, t.start_time))
return candidates[0] return candidates[0]
``` ```

16
TODO.md
View File

@@ -114,14 +114,14 @@ Récupérer le flux RSS du blog du collège, parser et dédupliquer les articles
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. 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). - [x] Créer `sources/theoretical/provider.py` : protocole `TheoreticalAgendaProvider` (§8.2).
- [ ] 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/file.py` : parser JSON → liste de `TheoreticalLesson` avec filtrage par parité de semaine (paire/impaire/toutes).
- [ ] 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/parity.py` : service `WeekParityService` déterminant la parité d'une date à partir d'une date de référence configurée.
- [ ] Créer `sources/theoretical/holidays.py` : service `SchoolHolidayCalendar` lisant un fichier JSON de vacances scolaires (zone A) et exposant `is_holiday(date)`. - [x] Créer `sources/theoretical/holidays.py` : service `SchoolHolidayCalendar` lisant un fichier JSON de vacances scolaires (zone A) et exposant `is_holiday(date)`.
- [ ] Implémenter le provider JSON : filtrage par parité + vacances, génération d'identifiants déterministes incluant le type de semaine. - [x] Implémenter le provider JSON : filtrage par parité + vacances, génération d'identifiants déterministes incluant le type de semaine.
- [ ] Ajouter la configuration : `SCHOOL_HOLIDAYS_PATH`, `THEORETICAL_WEEK_ANCHOR_DATE`, `THEORETICAL_WEEK_ANCHOR_TYPE` dans `AppSettings`. - [x] Ajouter la configuration : `SCHOOL_HOLIDAYS_PATH`, `THEORETICAL_WEEK_ANCHOR_DATE`, `THEORETICAL_WEEK_ANCHOR_TYPE` dans `AppSettings`.
- [ ] Normaliser les matières et créneaux pour le matching déterministe. - [x] Normaliser les matières et créneaux pour le matching déterministe.
- [ ] Créer les fixtures : `tests/fixtures/theoretical.json` et `tests/fixtures/school_holidays.json`. - [x] Créer les fixtures : `tests/fixtures/theoretical.json` et `tests/fixtures/school_holidays.json`.
### Critères d'acceptation ### Critères d'acceptation
- `file.py` lit `tests/fixtures/theoretical.json` en `TheoreticalLesson` avec filtrage par parité. - `file.py` lit `tests/fixtures/theoretical.json` en `TheoreticalLesson` avec filtrage par parité.

View File

@@ -10,6 +10,8 @@ plage de dates. Le filtrage tient compte du jour de la semaine, de la parité de
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
import unicodedata
from datetime import date, time, timedelta from datetime import date, time, timedelta
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
@@ -24,6 +26,25 @@ from pronote_sync.utils.redaction import redact_exception, redact_secrets
logger = logging.getLogger(__name__) 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: def _generate_id(entry: TheoreticalLessonEntry) -> str:
"""Génère un identifiant déterministe pour une entrée de cours. """Génère un identifiant déterministe pour une entrée de cours.
@@ -36,7 +57,7 @@ def _generate_id(entry: TheoreticalLessonEntry) -> str:
:return: Identifiant déterministe unique. :return: Identifiant déterministe unique.
:rtype: str :rtype: str
""" """
subject_slug = entry.subject.lower().strip().replace(" ", "-") subject_slug = normalize_subject(entry.subject).replace(" ", "-")
return f"theoretical:{entry.week}:{entry.day_of_week}:{entry.start_time}-{entry.end_time}:{subject_slug}" return f"theoretical:{entry.week}:{entry.day_of_week}:{entry.start_time}-{entry.end_time}:{subject_slug}"
@@ -111,7 +132,7 @@ class JsonTheoreticalAgendaProvider:
if effective_id in seen_ids: if effective_id in seen_ids:
raise PronoteSyncError( raise PronoteSyncError(
f"Conflit d'identifiant dans l'agenda théorique : " f"Conflit d'identifiant dans l'agenda théorique : "
f"l'identifiant '{effective_id}' est utilisé par plusieurs leçons. " f"l'identifiant '{redact_secrets(effective_id)}' est utilisé par plusieurs leçons. "
f"Fournissez des identifiants explicites uniques." f"Fournissez des identifiants explicites uniques."
) from None ) from None
seen_ids.add(effective_id) seen_ids.add(effective_id)

View File

@@ -13,7 +13,7 @@ from pathlib import Path
import pytest import pytest
from pronote_sync.errors import PronoteSyncError from pronote_sync.errors import PronoteSyncError
from pronote_sync.sources.theoretical.file import JsonTheoreticalAgendaProvider from pronote_sync.sources.theoretical.file import JsonTheoreticalAgendaProvider, normalize_subject
from pronote_sync.sources.theoretical.holidays import SchoolHolidayCalendar from pronote_sync.sources.theoretical.holidays import SchoolHolidayCalendar
from pronote_sync.sources.theoretical.parity import WeekParityService from pronote_sync.sources.theoretical.parity import WeekParityService
@@ -232,10 +232,10 @@ class TestJsonTheoreticalAgendaProvider:
lessons2 = provider_no_parity_no_holidays.get_lessons(target_date) lessons2 = provider_no_parity_no_holidays.get_lessons(target_date)
assert lessons1 == lessons2 assert lessons1 == lessons2
def test_even_odd_same_slot_different_ids( def test_even_odd_different_subjects_different_ids(
self, provider_with_parity: JsonTheoreticalAgendaProvider self, provider_with_parity: JsonTheoreticalAgendaProvider
) -> None: ) -> None:
"""Teste que les cours even/odd sur le même créneau ont des IDs différents. """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. :assert: Les IDs des cours even et odd sont différents.
""" """
@@ -257,6 +257,59 @@ class TestJsonTheoreticalAgendaProvider:
odd_ids = {lesson.id for lesson in odd_lessons} odd_ids = {lesson.id for lesson in odd_lessons}
assert even_ids.isdisjoint(odd_ids) 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( def test_explicit_id_preserved(
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
) -> None: ) -> None:
@@ -589,3 +642,81 @@ class TestJsonTheoreticalAgendaProvider:
# Seule la leçon du 15 octobre (jeudi) devrait être retournée # Seule la leçon du 15 octobre (jeudi) devrait être retournée
assert len(lessons) == 1 assert len(lessons) == 1
assert lessons[0].subject == "Sciences" 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__)