"""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, list[tuple[date, str]], list[tuple[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 :`` (liste de tuples ``(date, texte)`` dans l'ordre du flux) et les devoirs donnés des sections ``Donné le JJ/MM/AAAA :`` (liste de tuples ``(date, texte)``). Les listes préservent tous les blocs, même lorsque plusieurs sections partagent la même date. :param body: Corps HTML (à partir du premier ````). :return: Tuple ``(contenu pédagogique, devoirs dus, devoirs donnés)``. :rtype: tuple[str | None, list[tuple[date, str]], list[tuple[date, str]]] """ content: str | None = None due_blocks: list[tuple[date, str]] = [] assigned_blocks: list[tuple[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.append((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.append((assigned_date, _strip_html(match.group(2)))) return content, due_blocks, assigned_blocks def parse_homework_blocks( due_blocks: list[tuple[date, str]], assigned_blocks: list[tuple[date, str]], ) -> tuple[HomeworkBlock, ...]: """Construit les :class:`HomeworkBlock` depuis les listes de devoirs. Les blocs dus (``kind="due"``) précèdent les blocs donnés (``kind="assigned"``), dans l'ordre des listes. Tous les blocs sont préservés, y compris lorsque plusieurs partagent la même date. :param due_blocks: Liste de tuples ``(date, texte)`` des devoirs à faire. :param assigned_blocks: Liste de tuples ``(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: blocks.append(HomeworkBlock(kind="due", date=due_date, text=text, html=text)) for assigned_date, text in assigned_blocks: 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 propriété ``STATUS`` (``CANCELLED`` → ``CANCELLED``) et 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_obj = component.get("status") status_value = str(status_obj).strip().upper() if status_obj is not None else "" if status_value == "CANCELLED" or "Cours - Cours annulé" in categories: status = LessonStatus.CANCELLED elif "Cours - Cours déplacé" in categories: status = LessonStatus.MOVED else: status = LessonStatus.NORMAL 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