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/snapshot.py
ADDED
|
@@ -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
|
+
]
|