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

@@ -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)