agent2learn 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. agent2learn/__init__.py +3 -0
  2. agent2learn/_release.py +19 -0
  3. agent2learn/aipolicy.py +182 -0
  4. agent2learn/api.py +590 -0
  5. agent2learn/audit.py +358 -0
  6. agent2learn/auth/__init__.py +282 -0
  7. agent2learn/auth/cdp.py +1067 -0
  8. agent2learn/auth/paste.py +378 -0
  9. agent2learn/calendar.py +525 -0
  10. agent2learn/calibrate.py +347 -0
  11. agent2learn/check.py +1091 -0
  12. agent2learn/cli.py +2039 -0
  13. agent2learn/clock.py +39 -0
  14. agent2learn/config.py +205 -0
  15. agent2learn/console.py +229 -0
  16. agent2learn/convert.py +1223 -0
  17. agent2learn/doctor.py +1167 -0
  18. agent2learn/errors.py +32 -0
  19. agent2learn/ground.py +735 -0
  20. agent2learn/index.py +614 -0
  21. agent2learn/ingest.py +3229 -0
  22. agent2learn/locations.py +247 -0
  23. agent2learn/outlines.py +754 -0
  24. agent2learn/paths.py +683 -0
  25. agent2learn/pipeline.py +392 -0
  26. agent2learn/privacy.py +1123 -0
  27. agent2learn/schools/__init__.py +29 -0
  28. agent2learn/schools/_base.py +194 -0
  29. agent2learn/schools/generic.py +78 -0
  30. agent2learn/schools/uwaterloo.py +66 -0
  31. agent2learn/session.py +373 -0
  32. agent2learn/skills.py +1081 -0
  33. agent2learn/snapshot.py +399 -0
  34. agent2learn/submit.py +1047 -0
  35. agent2learn/transactions.py +157 -0
  36. agent2learn/upgrade.py +288 -0
  37. agent2learn/vault.py +1134 -0
  38. agent2learn-0.1.2.data/data/a2l-coursework/SKILL.md +52 -0
  39. agent2learn-0.1.2.data/data/a2l-setup/SKILL.md +27 -0
  40. agent2learn-0.1.2.data/data/a2l-study/SKILL.md +27 -0
  41. agent2learn-0.1.2.data/data/a2l-sync/SKILL.md +30 -0
  42. agent2learn-0.1.2.dist-info/METADATA +186 -0
  43. agent2learn-0.1.2.dist-info/RECORD +46 -0
  44. agent2learn-0.1.2.dist-info/WHEEL +4 -0
  45. agent2learn-0.1.2.dist-info/entry_points.txt +3 -0
  46. agent2learn-0.1.2.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,525 @@
1
+ """Local calendar export and the read-only ``today`` study view.
2
+
3
+ Calendar input is deliberately limited to typed metadata already written in a vault. The
4
+ module never contacts LEARN, follows URLs, or copies announcement/grade bodies into an iCalendar
5
+ file. Assignment and quiz due dates come from their normalized ``_meta`` projections; optional
6
+ ``exams.json`` and ``office_hours.json`` projections use the same small date-field vocabulary.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ from collections.abc import Mapping, Sequence
15
+ from dataclasses import dataclass
16
+ from datetime import UTC, date, datetime, timedelta
17
+ from hashlib import sha256
18
+ from pathlib import Path
19
+ from typing import Any, Literal, cast
20
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
21
+
22
+ from agent2learn import clock, paths, snapshot
23
+ from agent2learn.errors import A2LError
24
+ from agent2learn.schools import School, parse_api_timestamp
25
+ from agent2learn.vault import Vault
26
+
27
+ CalendarKind = Literal["assignment", "quiz", "exam", "office_hour"]
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class CalendarEvent:
32
+ """One normalized event before it is serialized to iCalendar."""
33
+
34
+ uid: str
35
+ course: str
36
+ course_name: str
37
+ kind: CalendarKind
38
+ source_id: str
39
+ summary: str
40
+ start: datetime | date
41
+ end: datetime | date | None = None
42
+ location: str | None = None
43
+
44
+ @property
45
+ def all_day(self) -> bool:
46
+ """Return whether this event came from a date-only value."""
47
+
48
+ return isinstance(self.start, date) and not isinstance(self.start, datetime)
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class ExamCountdown:
53
+ """A future exam's local date and whole-day countdown."""
54
+
55
+ event: CalendarEvent
56
+ days_remaining: int
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class TodayReport:
61
+ """Structured local information shown by ``a2l today``."""
62
+
63
+ as_of: datetime
64
+ timezone: str
65
+ due_soon: tuple[CalendarEvent, ...]
66
+ overdue: tuple[CalendarEvent, ...]
67
+ exam_countdowns: tuple[ExamCountdown, ...]
68
+ changes: snapshot.SnapshotDiff
69
+
70
+
71
+ _DATE_ONLY = re.compile(r"^\d{4}-\d{2}-\d{2}$")
72
+ _SOURCE_FILES: tuple[tuple[str, CalendarKind], ...] = (
73
+ ("assignments.json", "assignment"),
74
+ ("quizzes.json", "quiz"),
75
+ ("exams.json", "exam"),
76
+ ("office_hours.json", "office_hour"),
77
+ )
78
+ _CALENDAR_CONTAINER_KEYS = {
79
+ "assignment": "assignments",
80
+ "quiz": "quizzes",
81
+ "exam": "exams",
82
+ "office_hour": "office_hours",
83
+ }
84
+
85
+
86
+ def collect_events(vault: Vault, school: School) -> tuple[CalendarEvent, ...]:
87
+ """Collect deterministic assignment, quiz, exam, and office-hour events.
88
+
89
+ A missing optional exam or office-hour projection is a valid partial calendar: this function
90
+ cannot invent events from a course end date or from a web URL. Malformed present metadata is
91
+ surfaced as an error, matching the rest of the vault's typed metadata readers.
92
+ """
93
+
94
+ if not paths.long_path(vault.root).is_dir():
95
+ return ()
96
+
97
+ events: list[CalendarEvent] = []
98
+ seen_uids: set[str] = set()
99
+ for content_map in sorted(
100
+ (
101
+ candidate
102
+ for candidate in paths.walk(vault.root)
103
+ if candidate.name == "content_map.json" and ".a2l" not in candidate.parts
104
+ ),
105
+ key=lambda candidate: paths.rel_posix(candidate, vault.root),
106
+ ):
107
+ if paths.is_link(content_map):
108
+ raise A2LError("content_map.json must not be a symlink")
109
+ course_dir = content_map.parent.parent
110
+ context = _course_context(content_map, course_dir)
111
+ for filename, kind in _SOURCE_FILES:
112
+ rows = _read_rows(course_dir / "_meta" / filename)
113
+ for row in rows:
114
+ event = _event_from_row(row, kind, context, school)
115
+ if event is None or event.uid in seen_uids:
116
+ continue
117
+ seen_uids.add(event.uid)
118
+ events.append(event)
119
+
120
+ # A single normalized container is convenient for adapters that receive exams and office
121
+ # hours together. The dedicated files above remain the canonical simple form.
122
+ for kind in ("exam", "office_hour"):
123
+ rows = _read_container_rows(course_dir / "_meta" / "calendar.json", kind)
124
+ for row in rows:
125
+ event = _event_from_row(row, kind, context, school)
126
+ if event is None or event.uid in seen_uids:
127
+ continue
128
+ seen_uids.add(event.uid)
129
+ events.append(event)
130
+
131
+ return tuple(sorted(events, key=lambda event: _event_sort_key(event, school.timezone)))
132
+
133
+
134
+ def render_ics(vault: Vault, school: School, *, now: datetime | None = None) -> str:
135
+ """Render a CRLF-terminated RFC 5545 calendar with stable event UIDs."""
136
+
137
+ timezone = _zone(school.timezone)
138
+ stamp = _aware_utc(now if now is not None else clock.now(), "calendar timestamp")
139
+ lines = [
140
+ "BEGIN:VCALENDAR",
141
+ "VERSION:2.0",
142
+ "PRODID:-//Agent2Learn//Calendar//EN",
143
+ "CALSCALE:GREGORIAN",
144
+ "METHOD:PUBLISH",
145
+ f"X-WR-TIMEZONE:{_escape_text(school.timezone)}",
146
+ ]
147
+ for event in collect_events(vault, school):
148
+ lines.extend(_event_lines(event, timezone, stamp))
149
+ lines.append("END:VCALENDAR")
150
+ return "\r\n".join(line for raw in lines for line in _fold_line(raw)) + "\r\n"
151
+
152
+
153
+ def write_ics(
154
+ vault: Vault,
155
+ school: School,
156
+ destination: Path,
157
+ *,
158
+ now: datetime | None = None,
159
+ ) -> Path:
160
+ """Atomically write a calendar export to an explicit local destination."""
161
+
162
+ if paths.is_link(destination) or paths.has_link_component(destination.parent):
163
+ raise A2LError("calendar destination must not contain a symlink")
164
+ try:
165
+ destination.relative_to(vault.root)
166
+ except ValueError:
167
+ bound_root = None
168
+ else:
169
+ bound_root = vault.root
170
+ paths.ensure_dir(destination.parent, root=bound_root)
171
+ paths.atomic_write_text(destination, render_ics(vault, school, now=now), root=bound_root)
172
+ return destination
173
+
174
+
175
+ def build_today(
176
+ vault: Vault,
177
+ school: School,
178
+ *,
179
+ now: datetime | None = None,
180
+ include_grades: bool = False,
181
+ ) -> TodayReport:
182
+ """Build the local daily view using explicit school-zone date arithmetic."""
183
+
184
+ as_of = _aware_utc(now if now is not None else clock.now(), "today timestamp")
185
+ timezone = _zone(school.timezone)
186
+ local_now = as_of.astimezone(timezone)
187
+ local_today = local_now.date()
188
+ week_end = local_today + timedelta(days=7)
189
+ due_soon: list[CalendarEvent] = []
190
+ overdue: list[CalendarEvent] = []
191
+ exam_countdowns: list[ExamCountdown] = []
192
+
193
+ for event in collect_events(vault, school):
194
+ if event.kind in {"assignment", "quiz"}:
195
+ if event.all_day:
196
+ event_date = event.start
197
+ if event_date < local_today:
198
+ overdue.append(event)
199
+ elif event_date <= week_end:
200
+ due_soon.append(event)
201
+ else:
202
+ start = cast(datetime, event.start)
203
+ if start < as_of:
204
+ overdue.append(event)
205
+ elif start.astimezone(timezone).date() <= week_end:
206
+ due_soon.append(event)
207
+ elif event.kind == "exam":
208
+ event_date = (
209
+ event.start
210
+ if event.all_day
211
+ else cast(datetime, event.start).astimezone(timezone).date()
212
+ )
213
+ if event_date >= local_today and event_date <= local_today + timedelta(days=30):
214
+ exam_countdowns.append(
215
+ ExamCountdown(event, max(0, (event_date - local_today).days))
216
+ )
217
+
218
+ due_soon.sort(key=lambda event: _event_sort_key(event, school.timezone))
219
+ overdue.sort(key=lambda event: _event_sort_key(event, school.timezone))
220
+ exam_countdowns.sort(key=lambda item: _event_sort_key(item.event, school.timezone))
221
+ return TodayReport(
222
+ as_of=as_of,
223
+ timezone=school.timezone,
224
+ due_soon=tuple(due_soon),
225
+ overdue=tuple(overdue),
226
+ exam_countdowns=tuple(exam_countdowns),
227
+ changes=snapshot.diff_vault(vault, include_grades=include_grades),
228
+ )
229
+
230
+
231
+ def render_today(report: TodayReport, *, include_grades: bool = False) -> str:
232
+ """Render a concise daily view; sensitive grades remain opt-in at presentation time too."""
233
+
234
+ lines = [
235
+ f"Today · {report.as_of.astimezone(_zone(report.timezone)).date()} ({report.timezone})",
236
+ "",
237
+ ]
238
+ if report.exam_countdowns:
239
+ lines.append("Exam countdown:")
240
+ for countdown in report.exam_countdowns:
241
+ lines.append(
242
+ f"- {countdown.event.summary} · {countdown.days_remaining} day(s) remaining"
243
+ )
244
+ lines.append("")
245
+ if report.overdue:
246
+ lines.append("Overdue:")
247
+ lines.extend(f"- {_display_event(event)}" for event in report.overdue)
248
+ lines.append("")
249
+ if report.due_soon:
250
+ lines.append("Due within 7 days:")
251
+ lines.extend(f"- {_display_event(event)}" for event in report.due_soon)
252
+ lines.append("")
253
+ if not report.overdue and not report.due_soon:
254
+ lines.extend(["No assignments or quizzes due within 7 days.", ""])
255
+
256
+ change_text = snapshot.render_diff(report.changes, include_grades=include_grades).rstrip()
257
+ if report.changes.has_baseline:
258
+ lines.extend(["Changes since last sync:", change_text, ""])
259
+ else:
260
+ lines.extend(["Changes since last sync:", "- No previous sync snapshot.", ""])
261
+ return "\n".join(lines).rstrip() + "\n"
262
+
263
+
264
+ def _event_lines(event: CalendarEvent, timezone: ZoneInfo, stamp: datetime) -> list[str]:
265
+ lines = [
266
+ "BEGIN:VEVENT",
267
+ f"UID:{event.uid}",
268
+ f"DTSTAMP:{stamp.strftime('%Y%m%dT%H%M%SZ')}",
269
+ f"SUMMARY:{_escape_text(event.summary)}",
270
+ f"X-A2L-KIND:{event.kind}",
271
+ "SEQUENCE:0",
272
+ "STATUS:CONFIRMED",
273
+ "TRANSP:TRANSPARENT",
274
+ ]
275
+ if event.all_day:
276
+ lines.append(f"DTSTART;VALUE=DATE:{event.start.strftime('%Y%m%d')}")
277
+ if isinstance(event.end, date) and not isinstance(event.end, datetime):
278
+ lines.append(f"DTEND;VALUE=DATE:{event.end.strftime('%Y%m%d')}")
279
+ else:
280
+ lines.append(f"DTSTART;TZID={timezone.key}:{_local_datetime(event.start, timezone)}")
281
+ if isinstance(event.end, datetime):
282
+ lines.append(f"DTEND;TZID={timezone.key}:{_local_datetime(event.end, timezone)}")
283
+ if event.location:
284
+ lines.append(f"LOCATION:{_escape_text(event.location)}")
285
+ lines.append("END:VEVENT")
286
+ return lines
287
+
288
+
289
+ def _local_datetime(value: datetime | date, timezone: ZoneInfo) -> str:
290
+ if isinstance(value, date) and not isinstance(value, datetime):
291
+ raise ValueError("a date-only event cannot use a timed iCalendar field")
292
+ return value.astimezone(timezone).strftime("%Y%m%dT%H%M%S")
293
+
294
+
295
+ def _event_from_row(
296
+ row: Mapping[str, object], kind: CalendarKind, context: Mapping[str, str], school: School
297
+ ) -> CalendarEvent | None:
298
+ if row.get("withdrawn_at"):
299
+ return None
300
+ value = _first_value(row, _date_keys(kind))
301
+ if value is None:
302
+ return None
303
+ start = _parse_event_time(value, school.timezone)
304
+ end_value = _first_value(row, ("end_date", "end", "end_time", "EndDate"))
305
+ end = _parse_event_time(end_value, school.timezone) if end_value is not None else None
306
+ if end is not None and _event_sort_key_values(end, school.timezone) < _event_sort_key_values(
307
+ start, school.timezone
308
+ ):
309
+ raise A2LError("calendar event end precedes its start")
310
+ identifier = _event_id(row, kind)
311
+ course_key = context["course_key"]
312
+ event_key = f"{school.id}:{course_key}:{kind}:{identifier}"
313
+ uid = f"{sha256(event_key.encode('utf-8')).hexdigest()}@agent2learn"
314
+ title = (
315
+ _first_text(row, ("title", "name", "Name", "Title"))
316
+ or f"{kind.replace('_', ' ').title()} {identifier}"
317
+ )
318
+ course_code = context["course_code"]
319
+ return CalendarEvent(
320
+ uid=uid,
321
+ course=course_code,
322
+ course_name=context["course_name"],
323
+ kind=kind,
324
+ source_id=identifier,
325
+ summary=f"[{course_code}] {title}",
326
+ start=start,
327
+ end=end,
328
+ location=_first_text(row, ("location", "Location", "room", "Room")),
329
+ )
330
+
331
+
332
+ def _date_keys(kind: CalendarKind) -> tuple[str, ...]:
333
+ if kind in {"assignment", "quiz"}:
334
+ return ("due_date", "DueDate", "date")
335
+ return ("start_date", "start", "exam_date", "date", "Date", "StartDate")
336
+
337
+
338
+ def _event_id(row: Mapping[str, object], kind: CalendarKind) -> str:
339
+ for key in ("id", "Id", "QuizId", "exam_id", "office_hour_id"):
340
+ value = row.get(key)
341
+ if isinstance(value, (str, int)) and not isinstance(value, bool) and str(value):
342
+ return str(value)
343
+ canonical = json.dumps(dict(row), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
344
+ return sha256(f"{kind}:{canonical}".encode()).hexdigest()[:24]
345
+
346
+
347
+ def _course_context(content_map: Path, course_dir: Path) -> dict[str, str]:
348
+ raw = _read_json(content_map)
349
+ topics = raw.get("topics")
350
+ if not isinstance(topics, list):
351
+ raise A2LError("content_map.json topics must be an array")
352
+ for item in topics:
353
+ if not isinstance(item, dict):
354
+ continue
355
+ code = item.get("course_code")
356
+ name = item.get("course_name")
357
+ term = item.get("term")
358
+ if isinstance(code, str) and code:
359
+ course_key = f"{code}|{term}" if isinstance(term, str) and term else code
360
+ offering_id = item.get("course_org_unit_id", item.get("org_unit_id"))
361
+ if isinstance(offering_id, (str, int)) and not isinstance(offering_id, bool):
362
+ course_key = f"{course_key}|{offering_id}"
363
+ return {
364
+ "course_key": course_key,
365
+ "course_code": code,
366
+ "course_name": name if isinstance(name, str) and name else code,
367
+ }
368
+ relative = paths.rel_posix(course_dir, course_dir.parents[1])
369
+ return {"course_key": relative, "course_code": course_dir.name, "course_name": course_dir.name}
370
+
371
+
372
+ def _read_rows(path: Path) -> list[dict[str, object]]:
373
+ if not paths.long_path(path).exists():
374
+ return []
375
+ if paths.is_link(path):
376
+ raise A2LError("calendar metadata must not be a symlink")
377
+ raw = _read_json_value(path)
378
+ if not isinstance(raw, list) or any(not isinstance(item, dict) for item in raw):
379
+ raise A2LError(f"{path.name} must contain a list of objects")
380
+ return [cast(dict[str, object], item) for item in raw]
381
+
382
+
383
+ def _read_container_rows(path: Path, kind: str) -> list[dict[str, object]]:
384
+ if not paths.long_path(path).exists():
385
+ return []
386
+ if paths.is_link(path):
387
+ raise A2LError("calendar metadata must not be a symlink")
388
+ if not paths.long_path(path).is_file():
389
+ raise A2LError("calendar.json must be a regular file")
390
+ raw = _read_json(path)
391
+ value = raw.get(_CALENDAR_CONTAINER_KEYS[kind])
392
+ if value is None:
393
+ return []
394
+ if not isinstance(value, list) or any(not isinstance(item, dict) for item in value):
395
+ raise A2LError(f"calendar.json {_CALENDAR_CONTAINER_KEYS[kind]} must be a list")
396
+ return [cast(dict[str, object], item) for item in value]
397
+
398
+
399
+ def _read_json(path: Path) -> dict[str, object]:
400
+ raw = _read_json_value(path)
401
+ if not isinstance(raw, dict):
402
+ raise A2LError(f"{path.name} must contain an object")
403
+ return cast(dict[str, object], raw)
404
+
405
+
406
+ def _read_json_value(path: Path) -> Any:
407
+ try:
408
+ with open(os.fspath(paths.long_path(path)), encoding="utf-8", newline="") as handle:
409
+ return json.load(handle)
410
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
411
+ raise A2LError(f"{path.name} is unreadable") from exc
412
+
413
+
414
+ def _parse_event_time(value: object, timezone_name: str) -> datetime | date:
415
+ if isinstance(value, datetime):
416
+ return _normalize_datetime(value, timezone_name)
417
+ if isinstance(value, date):
418
+ return value
419
+ if not isinstance(value, str) or not value:
420
+ raise A2LError("calendar event date must be an ISO-8601 string")
421
+ if _DATE_ONLY.fullmatch(value):
422
+ try:
423
+ return date.fromisoformat(value)
424
+ except ValueError as exc:
425
+ raise A2LError("calendar event date is invalid") from exc
426
+ try:
427
+ return _normalize_datetime(parse_api_timestamp(value), timezone_name)
428
+ except ValueError as exc:
429
+ # Adapter-authored local calendar rows may omit an offset. They are interpreted in the
430
+ # school zone explicitly; machine TZ is never consulted.
431
+ try:
432
+ parsed = datetime.fromisoformat(value)
433
+ except ValueError as inner:
434
+ raise A2LError("calendar event timestamp is invalid") from inner
435
+ if parsed.tzinfo is not None and parsed.utcoffset() is not None:
436
+ raise A2LError("calendar event timestamp is invalid") from exc
437
+ return parsed.replace(tzinfo=_zone(timezone_name))
438
+
439
+
440
+ def _normalize_datetime(value: datetime, timezone_name: str) -> datetime:
441
+ return _aware_utc(value, "calendar event timestamp").astimezone(_zone(timezone_name))
442
+
443
+
444
+ def _event_sort_key(event: CalendarEvent, timezone_name: str) -> tuple[datetime, str]:
445
+ return (*_event_sort_key_values(event.start, timezone_name), event.uid)
446
+
447
+
448
+ def _event_sort_key_values(value: datetime | date, timezone_name: str) -> tuple[datetime]:
449
+ timezone = _zone(timezone_name)
450
+ if isinstance(value, datetime):
451
+ return (value.astimezone(timezone),)
452
+ return (datetime.combine(value, datetime.min.time(), tzinfo=timezone),)
453
+
454
+
455
+ def _aware_utc(value: datetime, label: str) -> datetime:
456
+ if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() is None:
457
+ raise ValueError(f"{label} must be timezone-aware")
458
+ return value.astimezone(UTC)
459
+
460
+
461
+ def _zone(value: str) -> ZoneInfo:
462
+ try:
463
+ return ZoneInfo(value)
464
+ except ZoneInfoNotFoundError as exc:
465
+ raise A2LError("school timezone is unavailable") from exc
466
+
467
+
468
+ def _first_value(row: Mapping[str, object], keys: Sequence[str]) -> object | None:
469
+ for key in keys:
470
+ value = row.get(key)
471
+ if value is not None and value != "":
472
+ return value
473
+ return None
474
+
475
+
476
+ def _first_text(row: Mapping[str, object], keys: Sequence[str]) -> str | None:
477
+ value = _first_value(row, keys)
478
+ return value if isinstance(value, str) and value.strip() else None
479
+
480
+
481
+ def _escape_text(value: str) -> str:
482
+ return (
483
+ value.replace("\\", "\\\\")
484
+ .replace(";", "\\;")
485
+ .replace(",", "\\,")
486
+ .replace("\r\n", "\\n")
487
+ .replace("\n", "\\n")
488
+ .replace("\r", "\\n")
489
+ )
490
+
491
+
492
+ def _fold_line(line: str) -> list[str]:
493
+ """Fold an iCalendar content line at 75 UTF-8 octets."""
494
+
495
+ chunks: list[str] = []
496
+ current = ""
497
+ for char in line:
498
+ candidate = current + char
499
+ if len(candidate.encode("utf-8")) > 75 and current:
500
+ chunks.append(current)
501
+ current = " " + char
502
+ else:
503
+ current = candidate
504
+ chunks.append(current)
505
+ return chunks
506
+
507
+
508
+ def _display_event(event: CalendarEvent) -> str:
509
+ if event.all_day:
510
+ when = event.start.isoformat()
511
+ else:
512
+ when = cast(datetime, event.start).isoformat(timespec="minutes")
513
+ return f"{when} · {event.summary}"
514
+
515
+
516
+ __all__ = [
517
+ "CalendarEvent",
518
+ "ExamCountdown",
519
+ "TodayReport",
520
+ "build_today",
521
+ "collect_events",
522
+ "render_ics",
523
+ "render_today",
524
+ "write_ics",
525
+ ]