diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 4f34eaa..bfda9b6 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -26,7 +26,7 @@ repos:
name: mypy
entry: mypy
language: python
- additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "pytest>=8.0.0"]
+ additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "pytest>=8.0.0", "types-requests>=2.31.0", "icalendar>=5.0.0"]
types: [python]
pass_filenames: true
diff --git a/pronote_sync/sources/pronote/ical.py b/pronote_sync/sources/pronote/ical.py
new file mode 100644
index 0000000..d3252f3
--- /dev/null
+++ b/pronote_sync/sources/pronote/ical.py
@@ -0,0 +1,460 @@
+"""Récupération et parsing du flux iCal Pronote.
+
+Ce module fournit le téléchargement du flux iCal Pronote (via HTTP ou
+``file://`` pour les tests) ainsi que son parsing en modèles : cours
+(:class:`~pronote_sync.models.agenda.Lesson`), événements scolaires
+(:class:`~pronote_sync.models.agenda.SchoolEvent`) et devoirs
+(:class:`~pronote_sync.models.homework.Homework`).
+
+La collecte finale des devoirs est réalisée à part, une fois la date
+cible connue, via :func:`collect_homeworks`.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import re
+import urllib.parse
+from datetime import date, datetime
+from html import unescape
+from pathlib import Path
+from typing import TypedDict
+
+import requests
+from icalendar import Calendar
+
+from ...models.agenda import (
+ HomeworkBlock,
+ Lesson,
+ LessonStatus,
+ SchoolEvent,
+ SchoolEventKind,
+)
+from ...models.homework import Homework
+from ...utils.redaction import redact_exception, redact_url
+from ...utils.uid import generate_deterministic_uid, normalize_pronote_uid
+
+_HEADER_LABEL_PATTERN = re.compile(r"\b(Matière|Professeurs?|Salles?|Groupe)\s*:\s*")
+_CALNAME_PATTERN = re.compile(r"^X-WR-CALNAME(?:;[^:]*)?:([^\r\n]*)", re.MULTILINE)
+_TAG_PATTERN = re.compile(r"<[^>]+>")
+_CONTENT_PATTERN = re.compile(
+ r"Contenu pédagogique\s*:\s*(.*?)(?=|\s*$|\Z)",
+ re.DOTALL,
+)
+_DUE_PATTERN = re.compile(
+ r"Pour le (\d{2}/\d{2}/\d{4})\s*:\s*(.*?)(?=|\s*$|\Z)",
+ re.DOTALL,
+)
+_ASSIGNED_PATTERN = re.compile(
+ r"Donné le (\d{2}/\d{2}/\d{4})\s*:\s*(.*?)(?=|\s*$|\Z)",
+ re.DOTALL,
+)
+
+_HEADERS = {
+ "accept": "text/calendar",
+ "user-agent": "pronote-sync",
+}
+
+
+class HeaderInfo(TypedDict):
+ """Métadonnées du cours extraites de l'en-tête de la DESCRIPTION."""
+
+ subject: str
+ teachers: list[str]
+ rooms: list[str]
+ group: str | None
+
+
+def fetch_ical(url: str, timeout: int = 20) -> str:
+ """Récupère le contenu brut d'un flux iCal Pronote.
+
+ Gère les URLs ``file://`` pour les tests locaux (le chemin est
+ décodé de l'échappement URI, ex. ``%20`` → espace) et valide que
+ le flux commence bien par ``BEGIN:VCALENDAR``. Toutes les erreurs
+ sont relancées avec un message dont les secrets (token
+ ``icalsecurise``) sont masqués.
+
+ :param url: URL du flux iCal (avec token ``icalsecurise``) ou chemin ``file://``.
+ :param timeout: Timeout HTTP en secondes (défaut : 20).
+ :return: Contenu brut du flux iCal.
+ :rtype: str
+ :raises OSError: Si le fichier local ``file://`` est illisible.
+ :raises requests.RequestException: Si la récupération HTTP échoue.
+ :raises ValueError: Si le flux ne commence pas par ``BEGIN:VCALENDAR``.
+ """
+ if url.startswith("file://"):
+ parsed_url = urllib.parse.urlparse(url)
+ path = Path(urllib.parse.unquote(parsed_url.path))
+ try:
+ content = path.read_text(encoding="utf-8")
+ except OSError as exc:
+ raise OSError(
+ f"Impossible de lire le fichier iCal {redact_url(url)} : {redact_exception(exc)}"
+ ) from exc
+ if not content.lstrip().startswith("BEGIN:VCALENDAR"):
+ raise ValueError(f"Fichier iCal invalide (pas de BEGIN:VCALENDAR) : {redact_url(url)}")
+ return content
+
+ try:
+ response = requests.get(url, headers=_HEADERS, timeout=timeout)
+ response.raise_for_status()
+ content = response.text
+ except Exception as exc:
+ raise requests.RequestException(
+ f"Échec de la récupération du flux iCal {redact_url(url)} : {redact_exception(exc)}"
+ ) from exc
+
+ if not content.lstrip().startswith("BEGIN:VCALENDAR"):
+ raise ValueError(f"Flux iCal invalide (pas de BEGIN:VCALENDAR) : {redact_url(url)}")
+ return str(content)
+
+
+def _unfold_ical(raw_ical: str) -> str:
+ """Déplie les lignes de continuation iCalendar.
+
+ Une ligne commençant par un espace ou une tabulation prolonge la
+ ligne précédente : le caractère d'espacement initial est retiré et
+ la suite est jointe à la ligne précédente.
+
+ :param raw_ical: Contenu brut du flux iCal.
+ :return: Contenu avec les lignes de continuation dépliées.
+ :rtype: str
+ """
+ unfolded: list[str] = []
+ for line in raw_ical.splitlines():
+ if line.startswith((" ", "\t")) and unfolded:
+ unfolded[-1] += line[1:]
+ else:
+ unfolded.append(line)
+ return "\n".join(unfolded)
+
+
+def get_calendar_name(raw_ical: str) -> str | None:
+ """Extrait le nom du calendrier depuis la propriété ``X-WR-CALNAME``.
+
+ La propriété peut comporter des paramètres (par exemple
+ ``X-WR-CALNAME;LANGUAGE=fr:Nom``) et les lignes de continuation
+ iCalendar sont dépliées avant la recherche.
+
+ :param raw_ical: Contenu brut du flux iCal.
+ :return: Nom du calendrier ou ``None`` si la propriété est absente.
+ :rtype: str | None
+ """
+ match = _CALNAME_PATTERN.search(_unfold_ical(raw_ical))
+ if match is None:
+ return None
+ return match.group(1).strip()
+
+
+def split_header_and_body(description: str) -> tuple[str, str]:
+ """Sépare l'en-tête texte du corps HTML dans la DESCRIPTION.
+
+ L'en-tête est la partie avant le premier ````, le corps
+ HTML commence à partir du premier ````.
+
+ :param description: Contenu brut de la DESCRIPTION d'un VEVENT.
+ :return: Tuple ``(en-tête, corps)`` ; le corps est vide si aucun ````.
+ :rtype: tuple[str, str]
+ """
+ strong_start = description.find("")
+ if strong_start == -1:
+ return description.strip(), ""
+ header = description[:strong_start].strip()
+ body = description[strong_start:]
+ return header, body
+
+
+def parse_header(header: str) -> HeaderInfo:
+ """Parse l'en-tête texte pour extraire les métadonnées du cours.
+
+ Les labels reconnus sont : ``Matière :``, ``Professeur(s) :``,
+ ``Salle(s) :`` et ``Groupe :``. La recherche se fait par position
+ des labels, ce qui supporte aussi bien un en-tête multi-lignes
+ qu'un en-tête dont les lignes sont jointes sur une seule ligne.
+
+ :param header: En-tête texte (avant le premier ````).
+ :return: Dictionnaire typé avec les champs subject, teachers, rooms, group.
+ :rtype: HeaderInfo
+ """
+ info: HeaderInfo = {"subject": "", "teachers": [], "rooms": [], "group": None}
+ matches = list(_HEADER_LABEL_PATTERN.finditer(header))
+ for index, match in enumerate(matches):
+ value_start = match.end()
+ value_end = matches[index + 1].start() if index + 1 < len(matches) else len(header)
+ value = unescape(header[value_start:value_end].strip())
+ label = match.group(1).lower()
+ if label == "matière":
+ info["subject"] = value
+ elif label in ("professeur", "professeurs"):
+ info["teachers"] = [part.strip() for part in value.split(",") if part.strip()]
+ elif label in ("salle", "salles"):
+ info["rooms"] = [part.strip() for part in value.split(",") if part.strip()]
+ elif label == "groupe":
+ info["group"] = value
+ return info
+
+
+def _strip_html(text: str) -> str:
+ """Retire les balises HTML d'un texte et nettoie les espaces.
+
+ :param text: Texte pouvant contenir des balises HTML.
+ :return: Texte brut sans balises, entités HTML décodées.
+ :rtype: str
+ """
+ cleaned = _TAG_PATTERN.sub("", text)
+ return unescape(cleaned).strip()
+
+
+def _parse_french_date(value: str) -> date | None:
+ """Convertit une date au format ``JJ/MM/AAAA`` en :class:`date`.
+
+ :param value: Date au format ``JJ/MM/AAAA``.
+ :return: Date parsée, ou ``None`` si le format est invalide.
+ :rtype: date | None
+ """
+ try:
+ return datetime.strptime(value, "%d/%m/%Y").date()
+ except ValueError:
+ return None
+
+
+def parse_body(body: str) -> tuple[str | None, dict[date, str], dict[date, str]]:
+ """Parse le corps HTML pour extraire contenu pédagogique et devoirs.
+
+ Le contenu est extrait de la section ``Contenu pédagogique :``.
+ Les devoirs à faire sont extraits des sections ``Pour le JJ/MM/AAAA :``
+ (dict date → texte) et les devoirs donnés des sections
+ ``Donné le JJ/MM/AAAA :`` (dict date → texte).
+
+ :param body: Corps HTML (à partir du premier ````).
+ :return: Tuple ``(contenu pédagogique, devoirs dus, devoirs donnés)``.
+ :rtype: tuple[str | None, dict[date, str], dict[date, str]]
+ """
+ content: str | None = None
+ due_blocks: dict[date, str] = {}
+ assigned_blocks: dict[date, str] = {}
+
+ content_match = _CONTENT_PATTERN.search(body)
+ if content_match is not None:
+ content = _strip_html(content_match.group(1))
+
+ for match in _DUE_PATTERN.finditer(body):
+ due_date = _parse_french_date(match.group(1))
+ if due_date is not None:
+ due_blocks[due_date] = _strip_html(match.group(2))
+
+ for match in _ASSIGNED_PATTERN.finditer(body):
+ assigned_date = _parse_french_date(match.group(1))
+ if assigned_date is not None:
+ assigned_blocks[assigned_date] = _strip_html(match.group(2))
+
+ return content, due_blocks, assigned_blocks
+
+
+def parse_homework_blocks(
+ due_blocks: dict[date, str],
+ assigned_blocks: dict[date, str],
+) -> tuple[HomeworkBlock, ...]:
+ """Construit les :class:`HomeworkBlock` depuis les dicts de devoirs.
+
+ Les blocs dus (``kind="due"``) précèdent les blocs donnés
+ (``kind="assigned"``), dans l'ordre d'insertion des dicts.
+
+ :param due_blocks: Dict date → texte des devoirs à faire.
+ :param assigned_blocks: Dict date → texte des devoirs donnés.
+ :return: Tuple de blocs de devoirs pour le cours.
+ :rtype: tuple[HomeworkBlock, ...]
+ """
+ blocks: list[HomeworkBlock] = []
+ for due_date, text in due_blocks.items():
+ blocks.append(HomeworkBlock(kind="due", date=due_date, text=text, html=text))
+ for assigned_date, text in assigned_blocks.items():
+ blocks.append(HomeworkBlock(kind="assigned", date=assigned_date, text=text, html=text))
+ return tuple(blocks)
+
+
+def normalize_homework_text(text: str) -> str:
+ """Normalise le texte d'un devoir pour la déduplication.
+
+ Unifie les espaces multiples, supprime les balises HTML, puis
+ applique trim et minuscules.
+
+ :param text: Texte brut du devoir.
+ :return: Texte normalisé.
+ :rtype: str
+ """
+ normalized = re.sub(r"\s+", " ", text)
+ normalized = _TAG_PATTERN.sub("", normalized)
+ return normalized.strip().lower()
+
+
+def generate_homework_id(due_on: date, normalized_text: str) -> str:
+ """Génère un ID stable pour un devoir.
+
+ L'ID est la clé ``AAAA-MM-JJ|texte_normalisé`` hachée en SHA-1 dont
+ on garde les 12 premiers caractères hexadécimaux. Le hachage n'est
+ pas utilisé à des fins de sécurité (``usedforsecurity=False``).
+
+ :param due_on: Date d'échéance du devoir.
+ :param normalized_text: Texte normalisé du devoir.
+ :return: ID stable (12 caractères hexadécimaux).
+ :rtype: str
+ """
+ payload = f"{due_on.isoformat()}|{normalized_text}".encode()
+ return hashlib.sha1(payload, usedforsecurity=False).hexdigest()[:12]
+
+
+def collect_homeworks(lessons: list[Lesson], target_date: date) -> list[Homework]:
+ """Collecte et déduplique les devoirs en deux passes globales.
+
+ Passe 1 : les blocs ``due`` (devoirs à faire pour ``target_date``)
+ de tous les cours. Passe 2 : les blocs ``assigned`` (devoirs donnés
+ le jour cible) des cours du jour ``target_date``. La déduplication
+ se fait par texte normalisé (premier venu, premier servi) et le
+ résultat est trié par matière puis texte.
+
+ :param lessons: Liste de tous les cours (VEVENT) parsés.
+ :param target_date: Date cible pour laquelle collecter les devoirs.
+ :return: Liste unique de devoirs, triée par matière puis texte.
+ :rtype: list[Homework]
+ """
+ by_text: dict[str, Homework] = {}
+
+ for lesson in lessons:
+ for block in lesson.homework_blocks:
+ if block.kind == "due" and block.date == target_date:
+ key = normalize_homework_text(block.text)
+ if key not in by_text:
+ by_text[key] = Homework(
+ id=generate_homework_id(target_date, key),
+ subject=lesson.subject,
+ teachers=lesson.teachers,
+ assigned_on=lesson.start.date(),
+ due_on=block.date,
+ text=block.text,
+ html=block.html,
+ )
+
+ for lesson in lessons:
+ if lesson.start.date() != target_date:
+ continue
+ for block in lesson.homework_blocks:
+ if block.kind == "assigned":
+ key = normalize_homework_text(block.text)
+ if key not in by_text:
+ by_text[key] = Homework(
+ id=generate_homework_id(target_date, key),
+ subject=lesson.subject,
+ teachers=lesson.teachers,
+ assigned_on=block.date,
+ due_on=target_date,
+ text=block.text,
+ html=block.html,
+ )
+
+ return sorted(by_text.values(), key=lambda hw: (hw.subject.lower(), hw.text.lower()))
+
+
+def parse_ical(raw_ical: str) -> tuple[list[Lesson], list[Homework], list[SchoolEvent]]:
+ """Parse un flux iCal Pronote en événements typés.
+
+ Les VEVENT de vacances/congés (tout le jour) deviennent des
+ :class:`SchoolEvent` de type ``holiday``. Les VEVENT horodatés
+ deviennent des :class:`Lesson` dont le statut dérive de la
+ catégorie (``Cours - Cours annulé`` → ``CANCELLED``,
+ ``Cours - Cours déplacé`` → ``MOVED``). Les UID sont normalisés ;
+ un événement sans UID reçoit un UID déterministe généré à partir
+ de ses champs clés (début, fin, matière, enseignants, salles, groupe).
+
+ :param raw_ical: Contenu brut du flux iCal.
+ :return: Tuple ``(lessons, homeworks, school_events)`` où ``homeworks``
+ est **toujours vide** : la collecte/déduplication se fait plus tard
+ via :func:`collect_homeworks` une fois la date cible connue.
+ :rtype: tuple[list[Lesson], list[Homework], list[SchoolEvent]]
+ """
+ calendar = Calendar.from_ical(raw_ical)
+
+ lessons: list[Lesson] = []
+ homeworks: list[Homework] = []
+ school_events: list[SchoolEvent] = []
+
+ for component in calendar.walk():
+ if component.name != "VEVENT":
+ continue
+
+ dtstart = component.get("dtstart")
+ dtend = component.get("dtend")
+ if dtstart is None or dtend is None:
+ continue
+ start = dtstart.dt
+ end = dtend.dt
+
+ categories_obj = component.get("categories")
+ if categories_obj is None:
+ categories: list[str] = []
+ else:
+ categories = [str(category) for category in categories_obj.cats]
+
+ # Événements de type vacances/congés (tout le jour).
+ if any(cat in ("Congés", "Vacances") for cat in categories):
+ from_date = start.date() if isinstance(start, datetime) else start
+ to_date = end.date() if isinstance(end, datetime) else end
+ summary = component.get("summary")
+ label = str(summary) if summary is not None else ""
+ school_events.append(
+ SchoolEvent(
+ kind=SchoolEventKind.HOLIDAY,
+ label=label,
+ from_date=from_date,
+ to_date=to_date,
+ )
+ )
+ continue
+
+ # Les cours sont des événements horodatés ; les événements tout
+ # le jour non scolaires sont ignorés.
+ if not isinstance(start, datetime) or not isinstance(end, datetime):
+ continue
+
+ status = LessonStatus.NORMAL
+ if "Cours - Cours annulé" in categories:
+ status = LessonStatus.CANCELLED
+ elif "Cours - Cours déplacé" in categories:
+ status = LessonStatus.MOVED
+
+ description = component.get("description")
+ description_str = str(description) if description is not None else ""
+ header, body = split_header_and_body(description_str)
+ lesson_data = parse_header(header)
+ content, due_blocks, assigned_blocks = parse_body(body)
+ homework_blocks = parse_homework_blocks(due_blocks, assigned_blocks)
+
+ uid_value = component.get("uid")
+ if uid_value is None or str(uid_value).strip() == "":
+ uid = generate_deterministic_uid(
+ start=start,
+ end=end,
+ subject=lesson_data["subject"],
+ teachers=lesson_data["teachers"],
+ rooms=lesson_data["rooms"],
+ group=lesson_data["group"],
+ )
+ else:
+ uid = normalize_pronote_uid(str(uid_value))
+
+ lessons.append(
+ Lesson(
+ id=uid,
+ start=start,
+ end=end,
+ subject=lesson_data["subject"],
+ teachers=tuple(lesson_data["teachers"]),
+ rooms=tuple(lesson_data["rooms"]),
+ group=lesson_data["group"],
+ status=status,
+ content=content,
+ homework_blocks=homework_blocks,
+ )
+ )
+
+ return lessons, homeworks, school_events