"""Apply config/course.yaml classification rules to file paths.""" from __future__ import annotations import fnmatch import re from dataclasses import dataclass from typing import Optional @dataclass(frozen=True) class Classification: kind: str topic: str visibility: str student_access: bool release_status: str order: Optional[int] = None lecture_date: Optional[str] = None def classify_path(path: str, rules: dict) -> Optional[Classification]: """Classify a repo-relative POSIX path against the loaded course.yaml rules. Returns None if no rule matches (caller should record as unmatched diagnostic). """ for rule in rules["classification_rules"]: if not fnmatch.fnmatchcase(path, rule["match"]): continue topic_raw = _extract(rule.get("topic_extract"), path) if topic_raw is None: topic = "_unknown" else: topic = rules.get("topic_name_overrides", {}).get( topic_raw, _normalize_topic(topic_raw) ) order = _extract_int(rule.get("order_extract"), path) lecture_date = _extract(rule.get("lecture_date_extract"), path) return Classification( kind=rule["kind"], topic=topic, visibility=rule["visibility"], student_access=rule["student_access"], release_status=rule["release_status"], order=order, lecture_date=lecture_date, ) return None def _extract(pattern: Optional[str], path: str) -> Optional[str]: if not pattern: return None m = re.search(pattern, path) return m.group(1) if m else None def _extract_int(pattern: Optional[str], path: str) -> Optional[int]: raw = _extract(pattern, path) return int(raw) if raw is not None else None def _normalize_topic(raw: str) -> str: """Single-cap-boundary Camel/Pascal → snake_case lowercase. Consecutive capitals (e.g., 'URL', 'XMLParser') are NOT handled — use ``topic_name_overrides`` in course.yaml to map such inputs. """ s = re.sub(r"(?