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:
@@ -140,10 +140,10 @@
|
||||
"filename": "GUIDE_DEV_PYTHON.md",
|
||||
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
||||
"is_verified": true,
|
||||
"line_number": 5046,
|
||||
"line_number": 5065,
|
||||
"is_secret": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_at": "2026-09-06T21:06:14Z"
|
||||
"generated_at": "2026-09-06T21:30:39Z"
|
||||
}
|
||||
|
||||
@@ -3505,7 +3505,7 @@ def week_parity(
|
||||
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.
|
||||
|
||||
**Exemple de tri** :
|
||||
**Exemple de tri** :
|
||||
```python
|
||||
# Tri des cours théoriques par ID stable (pour un matching déterministe)
|
||||
theoretical_lessons_sorted = sorted(
|
||||
@@ -3517,24 +3517,43 @@ theoretical_lessons_sorted = sorted(
|
||||
lesson.subject.lower(),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
**Exemple de matching avec départage déterministe** :
|
||||
```python
|
||||
def match_theoretical_lesson(
|
||||
real_lesson: PronoteLesson,
|
||||
theoretical_lessons: list[TheoreticalLesson],
|
||||
real_lesson: Lesson,
|
||||
theoretical_events: list[TheoreticalLesson],
|
||||
tolerance_minutes: int = 15,
|
||||
) -> 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 = [
|
||||
t for t in theoretical_lessons
|
||||
if t.day_of_week == real_lesson.start.weekday()
|
||||
and abs((t.start_time - real_lesson.start.time()).total_seconds()) <= tolerance_minutes * 60
|
||||
t
|
||||
for t in theoretical_events
|
||||
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)
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
# Tri déterministe par ID stable, puis par créneau
|
||||
candidates.sort(key=lambda t: (t.id, t.start_time))
|
||||
return candidates[0]
|
||||
```
|
||||
|
||||
16
TODO.md
16
TODO.md
@@ -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.
|
||||
|
||||
- [ ] 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).
|
||||
- [ ] 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)`.
|
||||
- [ ] 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`.
|
||||
- [ ] 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 `sources/theoretical/provider.py` : protocole `TheoreticalAgendaProvider` (§8.2).
|
||||
- [x] Créer `sources/theoretical/file.py` : parser JSON → liste de `TheoreticalLesson` avec filtrage par parité de semaine (paire/impaire/toutes).
|
||||
- [x] Créer `sources/theoretical/parity.py` : service `WeekParityService` déterminant la parité d'une date à partir d'une date de référence configurée.
|
||||
- [x] Créer `sources/theoretical/holidays.py` : service `SchoolHolidayCalendar` lisant un fichier JSON de vacances scolaires (zone A) et exposant `is_holiday(date)`.
|
||||
- [x] Implémenter le provider JSON : filtrage par parité + vacances, génération d'identifiants déterministes incluant le type de semaine.
|
||||
- [x] Ajouter la configuration : `SCHOOL_HOLIDAYS_PATH`, `THEORETICAL_WEEK_ANCHOR_DATE`, `THEORETICAL_WEEK_ANCHOR_TYPE` dans `AppSettings`.
|
||||
- [x] Normaliser les matières et créneaux pour le matching déterministe.
|
||||
- [x] Créer les fixtures : `tests/fixtures/theoretical.json` et `tests/fixtures/school_holidays.json`.
|
||||
|
||||
### Critères d'acceptation
|
||||
- `file.py` lit `tests/fixtures/theoretical.json` en `TheoreticalLesson` avec filtrage par parité.
|
||||
|
||||
@@ -10,6 +10,8 @@ plage de dates. Le filtrage tient compte du jour de la semaine, de la parité de
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
from datetime import date, time, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
@@ -24,6 +26,25 @@ from pronote_sync.utils.redaction import redact_exception, redact_secrets
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def normalize_subject(subject: str) -> str:
|
||||
"""Normalise une matière pour le matching déterministe.
|
||||
|
||||
Applique la normalisation Unicode NFKC, unifie les espaces (y compris
|
||||
tabulations et espaces insécables), supprime la ponctuation et met la
|
||||
chaîne en minuscules. Deux représentations visuellement identiques d'une
|
||||
même matière produisent ainsi la même forme normalisée.
|
||||
|
||||
:param subject: La matière brute.
|
||||
:return: La forme normalisée (NFKC, espaces unifiés, sans ponctuation, minuscule).
|
||||
:rtype: str
|
||||
"""
|
||||
normalized = unicodedata.normalize("NFKC", subject)
|
||||
normalized = re.sub(r"\s+", " ", normalized).strip()
|
||||
normalized = re.sub(r"[^\w\s]", "", normalized)
|
||||
normalized = re.sub(r"\s+", " ", normalized).strip()
|
||||
return normalized.lower()
|
||||
|
||||
|
||||
def _generate_id(entry: TheoreticalLessonEntry) -> str:
|
||||
"""Génère un identifiant déterministe pour une entrée de cours.
|
||||
|
||||
@@ -36,7 +57,7 @@ def _generate_id(entry: TheoreticalLessonEntry) -> str:
|
||||
:return: Identifiant déterministe unique.
|
||||
: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}"
|
||||
|
||||
|
||||
@@ -111,7 +132,7 @@ class JsonTheoreticalAgendaProvider:
|
||||
if effective_id in seen_ids:
|
||||
raise PronoteSyncError(
|
||||
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."
|
||||
) from None
|
||||
seen_ids.add(effective_id)
|
||||
|
||||
@@ -13,7 +13,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
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.parity import WeekParityService
|
||||
|
||||
@@ -232,10 +232,10 @@ class TestJsonTheoreticalAgendaProvider:
|
||||
lessons2 = provider_no_parity_no_holidays.get_lessons(target_date)
|
||||
assert lessons1 == lessons2
|
||||
|
||||
def test_even_odd_same_slot_different_ids(
|
||||
def test_even_odd_different_subjects_different_ids(
|
||||
self, provider_with_parity: JsonTheoreticalAgendaProvider
|
||||
) -> None:
|
||||
"""Teste que les cours even/odd sur le même créneau 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.
|
||||
"""
|
||||
@@ -257,6 +257,59 @@ class TestJsonTheoreticalAgendaProvider:
|
||||
odd_ids = {lesson.id for lesson in odd_lessons}
|
||||
assert even_ids.isdisjoint(odd_ids)
|
||||
|
||||
def test_even_odd_same_subject_different_ids(self, tmp_path: Path) -> None:
|
||||
"""Vérifie que les leçons paire/impaire sur le même créneau avec la même matière ont des IDs distincts.
|
||||
|
||||
Utilise la même matière pour isoler la parité comme seul différenciateur.
|
||||
|
||||
:assert: Les IDs des cours even et odd sont différents, avec la même matière.
|
||||
"""
|
||||
json_content = json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"lessons": [
|
||||
{
|
||||
"week": "even",
|
||||
"day_of_week": 1,
|
||||
"start_time": "10:00",
|
||||
"end_time": "11:00",
|
||||
"subject": "Langue vivante",
|
||||
"teachers": [],
|
||||
"rooms": [],
|
||||
},
|
||||
{
|
||||
"week": "odd",
|
||||
"day_of_week": 1,
|
||||
"start_time": "10:00",
|
||||
"end_time": "11:00",
|
||||
"subject": "Langue vivante",
|
||||
"teachers": [],
|
||||
"rooms": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
file_path = tmp_path / "theoretical.json"
|
||||
file_path.write_text(json_content, encoding="utf-8")
|
||||
|
||||
anchor_date = date(2026, 9, 1) # Tuesday
|
||||
parity = WeekParityService(anchor_date, "even")
|
||||
provider = JsonTheoreticalAgendaProvider(file_path=str(file_path), parity_service=parity)
|
||||
|
||||
# Tuesday in even week
|
||||
even_tuesday = date(2026, 9, 1) # Same week as anchor (even)
|
||||
odd_tuesday = date(2026, 9, 8) # One week later (odd)
|
||||
|
||||
even_lessons = provider.get_lessons(even_tuesday)
|
||||
odd_lessons = provider.get_lessons(odd_tuesday)
|
||||
|
||||
assert len(even_lessons) == 1
|
||||
assert len(odd_lessons) == 1
|
||||
assert even_lessons[0].id != odd_lessons[0].id
|
||||
# The only difference in the ID should be the week type
|
||||
assert "even" in even_lessons[0].id
|
||||
assert "odd" in odd_lessons[0].id
|
||||
|
||||
def test_explicit_id_preserved(
|
||||
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
|
||||
) -> None:
|
||||
@@ -589,3 +642,81 @@ class TestJsonTheoreticalAgendaProvider:
|
||||
# Seule la leçon du 15 octobre (jeudi) devrait être retournée
|
||||
assert len(lessons) == 1
|
||||
assert lessons[0].subject == "Sciences"
|
||||
|
||||
def test_normalize_subject_variants_produce_same_id(self, tmp_path: Path) -> None:
|
||||
"""Vérifie que des variantes de casse, d'espacement et d'Unicode produisent le même ID.
|
||||
|
||||
:assert: Les variantes de la même matière produisent le même ID normalisé.
|
||||
"""
|
||||
# Test the normalize_subject function directly
|
||||
# Note: hyphens are removed entirely (not replaced with spaces) by normalize_subject
|
||||
variants = [
|
||||
"Mathématiques avancées",
|
||||
"mathématiques avancées",
|
||||
"Mathématiques avancées",
|
||||
"MATHÉMATIQUES AVANCÉES",
|
||||
]
|
||||
normalized = [normalize_subject(variant) for variant in variants]
|
||||
# All should normalize to the same value
|
||||
assert all(n == normalized[0] for n in normalized)
|
||||
# Should be lowercase, no extra spaces, no punctuation
|
||||
assert normalized[0] == "mathématiques avancées"
|
||||
|
||||
def test_collision_error_no_secret_leak(
|
||||
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Vérifie qu'une sentinelle dans un ID dupliqué n'apparaît pas dans l'erreur.
|
||||
|
||||
:assert: PronoteSyncError est levée et la sentinelle n'apparaît pas dans l'erreur.
|
||||
"""
|
||||
# Use a secret pattern that the redaction system will actually catch
|
||||
# The _ISOLATED_SECRET_PATTERN looks for things like secret=value or secret: value
|
||||
sentinel_value = "SENTINELLE_M6_SECRET"
|
||||
# Create an ID that contains a pattern like "secret=value" which will be redacted
|
||||
secret_pattern = f"secret={sentinel_value}"
|
||||
|
||||
# Créer un fichier JSON avec 2 entrées ayant le même ID explicite contenant la sentinelle
|
||||
data = {
|
||||
"version": 1,
|
||||
"lessons": [
|
||||
{
|
||||
"id": f"theoretical-{secret_pattern}-1",
|
||||
"week": "all",
|
||||
"day_of_week": 0,
|
||||
"start_time": "08:00",
|
||||
"end_time": "09:00",
|
||||
"subject": "Maths",
|
||||
"teachers": [],
|
||||
"rooms": [],
|
||||
},
|
||||
{
|
||||
"id": f"theoretical-{secret_pattern}-1",
|
||||
"week": "all",
|
||||
"day_of_week": 1,
|
||||
"start_time": "09:00",
|
||||
"end_time": "10:00",
|
||||
"subject": "Français",
|
||||
"teachers": [],
|
||||
"rooms": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
file_path = tmp_path / "collision_test.json"
|
||||
file_path.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
with caplog.at_level("ERROR"):
|
||||
with pytest.raises(PronoteSyncError) as exc_info:
|
||||
JsonTheoreticalAgendaProvider(
|
||||
file_path=str(file_path),
|
||||
parity_service=None,
|
||||
holiday_calendar=None,
|
||||
)
|
||||
# Check that the sentinel value does not appear in the error message
|
||||
assert sentinel_value not in str(exc_info.value)
|
||||
# Check that the sentinel value does not appear in the logs
|
||||
assert sentinel_value not in caplog.text
|
||||
# Check that the secret pattern was redacted (should contain REDACTED)
|
||||
assert "REDACTED" in str(exc_info.value)
|
||||
# Check that the sentinel does not appear in the cause
|
||||
if exc_info.value.__cause__ is not None:
|
||||
assert sentinel_value not in repr(exc_info.value.__cause__)
|
||||
|
||||
Reference in New Issue
Block a user