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,399 @@
1
+ # ruff: noqa: E501
2
+ """Atomic, privacy-bounded sync snapshots consumed by a later diff command."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ from collections.abc import Mapping, Sequence
9
+ from dataclasses import dataclass
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+ from typing import Any, TypedDict, cast
13
+
14
+ from agent2learn import paths
15
+ from agent2learn.vault import Vault
16
+
17
+ SNAPSHOT_SCHEMA_VERSION = 1
18
+
19
+
20
+ class _CourseSnapshot(TypedDict):
21
+ topic_ids: set[int]
22
+ announcement_ids: set[str]
23
+ due_dates: tuple[str, ...]
24
+ grades: dict[str, dict[str, object]]
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class SnapshotDiff:
29
+ """The privacy-bounded result of comparing two completed sync snapshots.
30
+
31
+ The snapshot format intentionally stores stable IDs rather than source text. A diff can
32
+ therefore say what changed without copying announcements, grade comments, or course content
33
+ into a report. ``new_grades`` is populated only when the caller explicitly opts in.
34
+ """
35
+
36
+ previous_timestamp: str | None
37
+ current_timestamp: str | None
38
+ new_content: tuple[dict[str, object], ...] = ()
39
+ new_announcements: tuple[dict[str, object], ...] = ()
40
+ changed_due_dates: tuple[dict[str, object], ...] = ()
41
+ new_grades: tuple[dict[str, object], ...] = ()
42
+
43
+ @property
44
+ def has_baseline(self) -> bool:
45
+ """Return whether the current snapshot had an earlier snapshot to compare against."""
46
+
47
+ return self.previous_timestamp is not None
48
+
49
+
50
+ def write_snapshot(
51
+ vault: Vault, course_dirs: Sequence[Path], *, include_grades: bool, timestamp: str
52
+ ) -> Path:
53
+ """Write one deterministic local snapshot; omitted grades are never copied forward."""
54
+ courses: list[dict[str, object]] = []
55
+ canonical_timestamp, filename_timestamp = _snapshot_timestamp(timestamp)
56
+ for course in sorted(course_dirs, key=lambda value: paths.rel_posix(value, vault.root)):
57
+ meta = course / "_meta"
58
+ content = _read_json(meta / "content_map.json", None)
59
+ if not isinstance(content, dict) or not isinstance(content.get("topics"), list):
60
+ raise ValueError("content_map metadata must contain a topics list")
61
+ topics = _object_rows(content["topics"], "content_map topics")
62
+ assignments = _object_rows(_read_json(meta / "assignments.json", None), "assignments")
63
+ quizzes = _object_rows(_read_json(meta / "quizzes.json", None), "quizzes")
64
+ news = _object_rows(_read_json(meta / "news.json", None), "news")
65
+ topic_ids: list[int] = []
66
+ for row in topics:
67
+ topic_id = row.get("topic_id")
68
+ if isinstance(topic_id, bool) or not isinstance(topic_id, int):
69
+ raise ValueError("content_map topics contain an invalid topic_id")
70
+ topic_ids.append(topic_id)
71
+ item: dict[str, object] = {
72
+ "course": paths.rel_posix(course, vault.root),
73
+ "topic_ids": sorted(topic_ids),
74
+ "due_dates": sorted(
75
+ {
76
+ str(row["due_date"])
77
+ for row in [*assignments, *quizzes]
78
+ if isinstance(row, dict) and row.get("due_date")
79
+ }
80
+ ),
81
+ "announcement_ids": sorted(
82
+ str(row["id"])
83
+ for row in news
84
+ if isinstance(row, dict) and row.get("id") is not None
85
+ ),
86
+ }
87
+ if include_grades:
88
+ item["grades"] = _object_rows(_read_json(meta / "my_grades.json", None), "grades")
89
+ courses.append(item)
90
+ payload = {
91
+ "schema_version": SNAPSHOT_SCHEMA_VERSION,
92
+ "created_at": canonical_timestamp,
93
+ "courses": courses,
94
+ }
95
+ filename = f"{filename_timestamp}.json"
96
+ destination = vault.state() / "snapshots" / filename
97
+ paths.ensure_dir(destination.parent, root=vault.root)
98
+ paths.atomic_write_text(
99
+ destination,
100
+ json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2, separators=(",", ": "))
101
+ + "\n",
102
+ root=vault.root,
103
+ )
104
+ return destination
105
+
106
+
107
+ def _read_json(path: Path, default: Any) -> Any:
108
+ try:
109
+ with open(os.fspath(paths.long_path(path)), encoding="utf-8", newline="") as handle:
110
+ return json.load(handle)
111
+ except FileNotFoundError:
112
+ return default
113
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
114
+ raise ValueError("snapshot metadata is unreadable") from exc
115
+
116
+
117
+ def _object_rows(value: Any, label: str) -> list[dict[str, object]]:
118
+ if not isinstance(value, list):
119
+ raise ValueError(f"{label} metadata must be a list")
120
+ if any(not isinstance(row, dict) for row in value):
121
+ raise ValueError(f"{label} metadata contains an invalid item")
122
+ return value
123
+
124
+
125
+ def _snapshot_timestamp(value: str) -> tuple[str, str]:
126
+ if not isinstance(value, str) or not value:
127
+ raise ValueError("snapshot timestamp must be an ISO-8601 string")
128
+ candidate = value[:-1] + "+00:00" if value.endswith("Z") else value
129
+ try:
130
+ parsed = datetime.fromisoformat(candidate)
131
+ except ValueError as exc:
132
+ raise ValueError("snapshot timestamp is invalid") from exc
133
+ if parsed.tzinfo is None or parsed.utcoffset() is None:
134
+ raise ValueError("snapshot timestamp must be timezone-aware")
135
+ utc = parsed.astimezone(UTC)
136
+ canonical = _canonical_timestamp(utc)
137
+ filename = canonical.replace("-", "").replace(":", "").replace(".", "")
138
+ return canonical, filename
139
+
140
+
141
+ def diff_vault(
142
+ vault: Vault, *, since: str | None = None, include_grades: bool = False
143
+ ) -> SnapshotDiff:
144
+ """Compare the latest local snapshot with its previous snapshot.
145
+
146
+ ``since`` may be an exact snapshot filename/stem or an exact ``created_at`` value. Requiring
147
+ an exact match avoids silently comparing against the wrong sync when a vault contains several
148
+ snapshots from the same day. An empty snapshot directory is a valid first-sync state and
149
+ returns an honest no-baseline result; malformed snapshots remain errors rather than becoming
150
+ empty data.
151
+ """
152
+
153
+ snapshots = _read_snapshots(vault)
154
+ if not snapshots:
155
+ return SnapshotDiff(None, None)
156
+
157
+ ordered = sorted(snapshots, key=lambda item: (item[0], item[1].name))
158
+ current_timestamp, current_path, current = ordered[-1]
159
+ del current_path
160
+ previous: tuple[str, Path, dict[str, object]] | None = None
161
+ if since is None:
162
+ if len(ordered) > 1:
163
+ previous = ordered[-2]
164
+ else:
165
+ previous = _select_snapshot(ordered, since, current_timestamp)
166
+
167
+ return compare_snapshots(
168
+ previous[2] if previous is not None else None,
169
+ current,
170
+ include_grades=include_grades,
171
+ )
172
+
173
+
174
+ def compare_snapshots(
175
+ previous: Mapping[str, object] | None,
176
+ current: Mapping[str, object],
177
+ *,
178
+ include_grades: bool = False,
179
+ ) -> SnapshotDiff:
180
+ """Compare two validated snapshot-shaped mappings without reading the filesystem."""
181
+
182
+ current_timestamp = _snapshot_created_at(current)
183
+ previous_timestamp = _snapshot_created_at(previous) if previous is not None else None
184
+ current_courses = _course_map(current)
185
+ previous_courses = _course_map(previous) if previous is not None else {}
186
+
187
+ new_content: list[dict[str, object]] = []
188
+ new_announcements: list[dict[str, object]] = []
189
+ changed_due_dates: list[dict[str, object]] = []
190
+ new_grades: list[dict[str, object]] = []
191
+
192
+ for course in sorted(current_courses):
193
+ current_row = current_courses[course]
194
+ previous_row = previous_courses.get(course)
195
+ if previous_row is None:
196
+ # A first snapshot for a newly selected course is not presented as a pile of changes;
197
+ # the next sync can establish a meaningful baseline for it.
198
+ continue
199
+
200
+ for topic_id in sorted(current_row["topic_ids"] - previous_row["topic_ids"]):
201
+ new_content.append({"course": course, "topic_id": topic_id})
202
+ for announcement_id in sorted(
203
+ current_row["announcement_ids"] - previous_row["announcement_ids"]
204
+ ):
205
+ new_announcements.append({"course": course, "announcement_id": announcement_id})
206
+ if current_row["due_dates"] != previous_row["due_dates"]:
207
+ changed_due_dates.append(
208
+ {
209
+ "course": course,
210
+ "previous": previous_row["due_dates"],
211
+ "current": current_row["due_dates"],
212
+ }
213
+ )
214
+
215
+ if include_grades:
216
+ old_grades = previous_row["grades"]
217
+ for grade_id, grade in sorted(current_row["grades"].items()):
218
+ if old_grades.get(grade_id) != grade:
219
+ new_grades.append({"course": course, "grade": grade})
220
+
221
+ return SnapshotDiff(
222
+ previous_timestamp=previous_timestamp,
223
+ current_timestamp=current_timestamp,
224
+ new_content=tuple(new_content),
225
+ new_announcements=tuple(new_announcements),
226
+ changed_due_dates=tuple(changed_due_dates),
227
+ new_grades=tuple(new_grades) if include_grades else (),
228
+ )
229
+
230
+
231
+ def render_diff(diff: SnapshotDiff, *, include_grades: bool = False) -> str:
232
+ """Render a stable human-readable diff without exposing opt-in grades by default."""
233
+
234
+ if not diff.has_baseline:
235
+ return "No previous sync snapshot; changes will appear after the next sync.\n"
236
+
237
+ lines = [
238
+ f"Changes since {diff.previous_timestamp} (latest sync {diff.current_timestamp}):",
239
+ "",
240
+ ]
241
+ if diff.new_content:
242
+ lines.extend(["New content:"])
243
+ lines.extend(f"- {row['course']} · topic {row['topic_id']}" for row in diff.new_content)
244
+ lines.append("")
245
+ if diff.new_announcements:
246
+ lines.extend(["New announcements:"])
247
+ lines.extend(
248
+ f"- {row['course']} · announcement {row['announcement_id']}"
249
+ for row in diff.new_announcements
250
+ )
251
+ lines.append("")
252
+ if diff.changed_due_dates:
253
+ lines.extend(["Changed due dates:"])
254
+ for row in diff.changed_due_dates:
255
+ previous = ", ".join(cast(tuple[str, ...], row["previous"])) or "none"
256
+ current = ", ".join(cast(tuple[str, ...], row["current"])) or "none"
257
+ lines.append(f"- {row['course']} · {previous} → {current}")
258
+ lines.append("")
259
+ if include_grades and diff.new_grades:
260
+ lines.extend(["New grades:"])
261
+ for row in diff.new_grades:
262
+ grade = cast(dict[str, object], row["grade"])
263
+ lines.append(
264
+ f"- {row['course']} · {grade.get('name') or grade.get('id') or 'grade'}: "
265
+ f"{grade.get('displayed', 'posted')}"
266
+ )
267
+ lines.append("")
268
+ visible_grades = diff.new_grades if include_grades else ()
269
+ if not any((diff.new_content, diff.new_announcements, diff.changed_due_dates, visible_grades)):
270
+ lines.append("No changes recorded.")
271
+ return "\n".join(lines).rstrip() + "\n"
272
+
273
+
274
+ def _read_snapshots(vault: Vault) -> list[tuple[str, Path, dict[str, object]]]:
275
+ directory = vault.state() / "snapshots"
276
+ if paths.is_link(directory):
277
+ raise ValueError("snapshot directory must not be a symlink")
278
+ if not paths.long_path(directory).is_dir():
279
+ return []
280
+ results: list[tuple[str, Path, dict[str, object]]] = []
281
+ for path in sorted(paths.walk(directory)):
282
+ if paths.is_link(path):
283
+ raise ValueError("snapshot must not be a symlink")
284
+ if path.suffix.casefold() != ".json":
285
+ continue
286
+ raw = _read_json(path, None)
287
+ if not isinstance(raw, dict):
288
+ raise ValueError("snapshot must contain an object")
289
+ if raw.get("schema_version") != SNAPSHOT_SCHEMA_VERSION:
290
+ raise ValueError("snapshot has an unsupported schema")
291
+ created_at = _snapshot_created_at(raw)
292
+ if not isinstance(raw.get("courses"), list):
293
+ raise ValueError("snapshot courses must be a list")
294
+ _course_map(raw) # validate the small comparison surface before selecting a baseline
295
+ results.append((created_at, path, raw))
296
+ return results
297
+
298
+
299
+ def _select_snapshot(
300
+ ordered: Sequence[tuple[str, Path, dict[str, object]]], since: str, current: str
301
+ ) -> tuple[str, Path, dict[str, object]]:
302
+ normalized = since.strip()
303
+ matches = [
304
+ item
305
+ for item in ordered
306
+ if item[0] == normalized or item[1].stem == normalized or item[0].startswith(normalized)
307
+ ]
308
+ if len(matches) != 1:
309
+ raise ValueError("--since must identify exactly one prior snapshot")
310
+ selected = matches[0]
311
+ if selected[0] >= current:
312
+ raise ValueError("--since must identify a snapshot before the latest sync")
313
+ return selected
314
+
315
+
316
+ def _snapshot_created_at(value: Mapping[str, object] | None) -> str:
317
+ if value is None:
318
+ raise ValueError("snapshot is missing created_at")
319
+ created_at = value.get("created_at")
320
+ if not isinstance(created_at, str) or not created_at:
321
+ raise ValueError("snapshot created_at must be an ISO-8601 string")
322
+ candidate = created_at[:-1] + "+00:00" if created_at.endswith("Z") else created_at
323
+ try:
324
+ parsed = datetime.fromisoformat(candidate)
325
+ except ValueError as exc:
326
+ raise ValueError("snapshot created_at is invalid") from exc
327
+ if parsed.tzinfo is None or parsed.utcoffset() is None:
328
+ raise ValueError("snapshot created_at must be timezone-aware")
329
+ return _canonical_timestamp(parsed.astimezone(UTC))
330
+
331
+
332
+ def _canonical_timestamp(value: datetime) -> str:
333
+ return value.isoformat(timespec="microseconds" if value.microsecond else "seconds").replace(
334
+ "+00:00", "Z"
335
+ )
336
+
337
+
338
+ def _course_map(
339
+ value: Mapping[str, object] | None,
340
+ ) -> dict[str, _CourseSnapshot]:
341
+ if value is None:
342
+ return {}
343
+ raw_courses = value.get("courses")
344
+ if not isinstance(raw_courses, list):
345
+ raise ValueError("snapshot courses must be a list")
346
+ courses: dict[str, _CourseSnapshot] = {}
347
+ for raw in raw_courses:
348
+ if not isinstance(raw, dict):
349
+ raise ValueError("snapshot contains an invalid course")
350
+ course = raw.get("course")
351
+ if not isinstance(course, str) or not course or course in courses:
352
+ raise ValueError("snapshot contains an invalid or duplicate course")
353
+ courses[course] = {
354
+ "topic_ids": _int_set(raw.get("topic_ids"), "topic_ids"),
355
+ "announcement_ids": _str_set(raw.get("announcement_ids"), "announcement_ids"),
356
+ "due_dates": tuple(sorted(_str_set(raw.get("due_dates"), "due_dates"))),
357
+ "grades": _grade_map(raw.get("grades")),
358
+ }
359
+ return courses
360
+
361
+
362
+ def _int_set(value: object, label: str) -> set[int]:
363
+ if not isinstance(value, list) or any(
364
+ isinstance(item, bool) or not isinstance(item, int) for item in value
365
+ ):
366
+ raise ValueError(f"snapshot {label} must be an integer list")
367
+ return set(value)
368
+
369
+
370
+ def _str_set(value: object, label: str) -> set[str]:
371
+ if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
372
+ raise ValueError(f"snapshot {label} must be a string list")
373
+ return set(value)
374
+
375
+
376
+ def _grade_map(value: object) -> dict[str, dict[str, object]]:
377
+ if value is None:
378
+ return {}
379
+ if not isinstance(value, list):
380
+ raise ValueError("snapshot grades must be a list")
381
+ result: dict[str, dict[str, object]] = {}
382
+ for raw in value:
383
+ if not isinstance(raw, dict) or not isinstance(raw.get("id"), str):
384
+ raise ValueError("snapshot contains an invalid grade")
385
+ identifier = raw["id"]
386
+ if identifier in result:
387
+ raise ValueError("snapshot contains duplicate grades")
388
+ result[identifier] = dict(raw)
389
+ return result
390
+
391
+
392
+ __all__ = [
393
+ "SNAPSHOT_SCHEMA_VERSION",
394
+ "SnapshotDiff",
395
+ "compare_snapshots",
396
+ "diff_vault",
397
+ "render_diff",
398
+ "write_snapshot",
399
+ ]