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.
- agent2learn/__init__.py +3 -0
- agent2learn/_release.py +19 -0
- agent2learn/aipolicy.py +182 -0
- agent2learn/api.py +590 -0
- agent2learn/audit.py +358 -0
- agent2learn/auth/__init__.py +282 -0
- agent2learn/auth/cdp.py +1067 -0
- agent2learn/auth/paste.py +378 -0
- agent2learn/calendar.py +525 -0
- agent2learn/calibrate.py +347 -0
- agent2learn/check.py +1091 -0
- agent2learn/cli.py +2039 -0
- agent2learn/clock.py +39 -0
- agent2learn/config.py +205 -0
- agent2learn/console.py +229 -0
- agent2learn/convert.py +1223 -0
- agent2learn/doctor.py +1167 -0
- agent2learn/errors.py +32 -0
- agent2learn/ground.py +735 -0
- agent2learn/index.py +614 -0
- agent2learn/ingest.py +3229 -0
- agent2learn/locations.py +247 -0
- agent2learn/outlines.py +754 -0
- agent2learn/paths.py +683 -0
- agent2learn/pipeline.py +392 -0
- agent2learn/privacy.py +1123 -0
- agent2learn/schools/__init__.py +29 -0
- agent2learn/schools/_base.py +194 -0
- agent2learn/schools/generic.py +78 -0
- agent2learn/schools/uwaterloo.py +66 -0
- agent2learn/session.py +373 -0
- agent2learn/skills.py +1081 -0
- agent2learn/snapshot.py +399 -0
- agent2learn/submit.py +1047 -0
- agent2learn/transactions.py +157 -0
- agent2learn/upgrade.py +288 -0
- agent2learn/vault.py +1134 -0
- agent2learn-0.1.2.data/data/a2l-coursework/SKILL.md +52 -0
- agent2learn-0.1.2.data/data/a2l-setup/SKILL.md +27 -0
- agent2learn-0.1.2.data/data/a2l-study/SKILL.md +27 -0
- agent2learn-0.1.2.data/data/a2l-sync/SKILL.md +30 -0
- agent2learn-0.1.2.dist-info/METADATA +186 -0
- agent2learn-0.1.2.dist-info/RECORD +46 -0
- agent2learn-0.1.2.dist-info/WHEEL +4 -0
- agent2learn-0.1.2.dist-info/entry_points.txt +3 -0
- agent2learn-0.1.2.dist-info/licenses/LICENSE +202 -0
agent2learn/calendar.py
ADDED
|
@@ -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
|
+
]
|