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/calibrate.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Instance calibration and metadata-only enrolment discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
from agent2learn import config, paths
|
|
13
|
+
from agent2learn.errors import A2LError, NotConfigured
|
|
14
|
+
from agent2learn.schools import School
|
|
15
|
+
|
|
16
|
+
_CALIBRATION_FILENAME = "calibration.json"
|
|
17
|
+
_SCHEMA_VERSION = 1
|
|
18
|
+
_MAX_PAGES = 1000
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class CourseRef:
|
|
23
|
+
"""The typed, metadata-only projection needed to list one course offering."""
|
|
24
|
+
|
|
25
|
+
org_unit_id: int
|
|
26
|
+
code: str
|
|
27
|
+
name: str
|
|
28
|
+
term: str | None
|
|
29
|
+
is_active: bool
|
|
30
|
+
start_date: str | None = None
|
|
31
|
+
end_date: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Calibration:
|
|
36
|
+
"""The discovered API versions and metadata-only course inventory."""
|
|
37
|
+
|
|
38
|
+
lp: str
|
|
39
|
+
le: str
|
|
40
|
+
download_template: str | None
|
|
41
|
+
courses: list[CourseRef]
|
|
42
|
+
calibrated_at: str | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CalibrationClient(Protocol):
|
|
46
|
+
"""The small mutable client surface needed during calibration."""
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def school(self) -> School: ...
|
|
50
|
+
|
|
51
|
+
lp_version: str | None
|
|
52
|
+
le_version: str | None
|
|
53
|
+
download_template: str | None
|
|
54
|
+
|
|
55
|
+
def get_json(self, path: str) -> object: ...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def calibrate(client: CalibrationClient) -> Calibration:
|
|
59
|
+
"""Discover versions, verify the session, enumerate enrolments, and persist the result.
|
|
60
|
+
|
|
61
|
+
Calibration intentionally makes no content, file, grades, or discussion request. A download
|
|
62
|
+
route is learned later, during a student-approved file transfer, rather than by a hidden body
|
|
63
|
+
probe during authentication or onboarding.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
versions = client.get_json("/d2l/api/versions/")
|
|
67
|
+
lp, le = _versions(versions)
|
|
68
|
+
client.lp_version = lp
|
|
69
|
+
client.le_version = le
|
|
70
|
+
|
|
71
|
+
whoami = client.get_json(f"/d2l/api/lp/{lp}/users/whoami")
|
|
72
|
+
if not isinstance(whoami, dict) or not isinstance(whoami.get("Identifier"), str):
|
|
73
|
+
raise A2LError("calibration could not verify the authenticated user")
|
|
74
|
+
|
|
75
|
+
courses = _enumerate_courses(client, lp)
|
|
76
|
+
previous_template = _previous_template()
|
|
77
|
+
calibrated_at = datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
|
78
|
+
result = Calibration(
|
|
79
|
+
lp=lp,
|
|
80
|
+
le=le,
|
|
81
|
+
download_template=previous_template,
|
|
82
|
+
courses=courses,
|
|
83
|
+
calibrated_at=calibrated_at,
|
|
84
|
+
)
|
|
85
|
+
client.download_template = previous_template
|
|
86
|
+
_write(result)
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def load_calibration() -> Calibration:
|
|
91
|
+
"""Load calibration or give the single safe next action required to create it."""
|
|
92
|
+
|
|
93
|
+
destination = config.state_dir() / _CALIBRATION_FILENAME
|
|
94
|
+
try:
|
|
95
|
+
with open(os.fspath(paths.long_path(destination)), encoding="utf-8", newline="") as handle:
|
|
96
|
+
raw: Any = json.load(handle)
|
|
97
|
+
except FileNotFoundError as exc:
|
|
98
|
+
raise NotConfigured("calibration unavailable · run: a2l auth") from exc
|
|
99
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
100
|
+
raise NotConfigured("calibration is unreadable · run: a2l auth") from exc
|
|
101
|
+
try:
|
|
102
|
+
return _decode(raw)
|
|
103
|
+
except (TypeError, ValueError, KeyError, A2LError) as exc:
|
|
104
|
+
raise NotConfigured("calibration is invalid · run: a2l auth") from exc
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def display_courses(value: Calibration, *, all_terms: bool = False) -> list[CourseRef]:
|
|
108
|
+
"""Return deterministic course offerings for the ``courses`` command.
|
|
109
|
+
|
|
110
|
+
The default is deliberately conservative: only active, term-classified academic offerings
|
|
111
|
+
are shown. ``--all-terms`` exposes every discovered course offering, including inactive or
|
|
112
|
+
not-yet-classified records, without making another network request.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
selected = (
|
|
116
|
+
value.courses
|
|
117
|
+
if all_terms
|
|
118
|
+
else [course for course in value.courses if course.is_active and course.term is not None]
|
|
119
|
+
)
|
|
120
|
+
return sorted(
|
|
121
|
+
selected,
|
|
122
|
+
key=lambda course: (
|
|
123
|
+
course.term is None,
|
|
124
|
+
course.term or "",
|
|
125
|
+
course.code.casefold(),
|
|
126
|
+
course.org_unit_id,
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _versions(raw: Any) -> tuple[str, str]:
|
|
132
|
+
if not isinstance(raw, list):
|
|
133
|
+
raise A2LError("version discovery returned an invalid response")
|
|
134
|
+
found: dict[str, str] = {}
|
|
135
|
+
for item in raw:
|
|
136
|
+
if not isinstance(item, dict):
|
|
137
|
+
continue
|
|
138
|
+
product = item.get("ProductCode")
|
|
139
|
+
version = item.get("LatestVersion")
|
|
140
|
+
if isinstance(product, str) and isinstance(version, str) and version:
|
|
141
|
+
found[product.casefold()] = version
|
|
142
|
+
lp = found.get("lp")
|
|
143
|
+
le = found.get("le")
|
|
144
|
+
if lp is None or le is None:
|
|
145
|
+
raise A2LError("version discovery did not advertise both lp and le")
|
|
146
|
+
return lp, le
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _enumerate_courses(client: CalibrationClient, lp: str) -> list[CourseRef]:
|
|
150
|
+
courses: list[CourseRef] = []
|
|
151
|
+
bookmark: str | None = None
|
|
152
|
+
seen_bookmarks: set[str] = set()
|
|
153
|
+
for _page in range(_MAX_PAGES):
|
|
154
|
+
path = f"/d2l/api/lp/{lp}/enrollments/myenrollments/"
|
|
155
|
+
if bookmark is not None:
|
|
156
|
+
path += f"?bookmark={quote(bookmark, safe='')}"
|
|
157
|
+
payload = client.get_json(path)
|
|
158
|
+
if not isinstance(payload, dict):
|
|
159
|
+
raise A2LError("enrolment discovery returned an invalid response")
|
|
160
|
+
items = payload.get("Items")
|
|
161
|
+
paging = payload.get("PagingInfo")
|
|
162
|
+
if not isinstance(items, list) or not isinstance(paging, dict):
|
|
163
|
+
raise A2LError("enrolment discovery returned an invalid page")
|
|
164
|
+
for item in items:
|
|
165
|
+
course = _course_from_item(client, item)
|
|
166
|
+
if course is not None:
|
|
167
|
+
courses.append(course)
|
|
168
|
+
|
|
169
|
+
has_more = paging.get("HasMoreItems")
|
|
170
|
+
if has_more is False:
|
|
171
|
+
break
|
|
172
|
+
if has_more is not True:
|
|
173
|
+
raise A2LError("enrolment discovery returned an invalid paging marker")
|
|
174
|
+
next_bookmark = paging.get("Bookmark")
|
|
175
|
+
if not isinstance(next_bookmark, str) or not next_bookmark:
|
|
176
|
+
raise A2LError("enrolment discovery advertised more pages without a bookmark")
|
|
177
|
+
if next_bookmark in seen_bookmarks:
|
|
178
|
+
raise A2LError("enrolment discovery pagination repeated a bookmark")
|
|
179
|
+
seen_bookmarks.add(next_bookmark)
|
|
180
|
+
bookmark = next_bookmark
|
|
181
|
+
else:
|
|
182
|
+
raise A2LError("enrolment discovery exceeded the pagination limit")
|
|
183
|
+
|
|
184
|
+
return sorted(courses, key=lambda course: (course.code.casefold(), course.org_unit_id))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _course_from_item(client: CalibrationClient, raw: object) -> CourseRef | None:
|
|
188
|
+
if not isinstance(raw, dict):
|
|
189
|
+
raise A2LError("enrolment item is invalid")
|
|
190
|
+
org_unit = raw.get("OrgUnit")
|
|
191
|
+
if not isinstance(org_unit, dict):
|
|
192
|
+
raise A2LError("enrolment item has no organization unit")
|
|
193
|
+
type_info = org_unit.get("Type")
|
|
194
|
+
type_code = type_info.get("Code") if isinstance(type_info, dict) else None
|
|
195
|
+
if not isinstance(type_code, str) or type_code.casefold() != "course offering":
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
org_unit_id = org_unit.get("Id")
|
|
199
|
+
code = org_unit.get("Code")
|
|
200
|
+
name = org_unit.get("Name")
|
|
201
|
+
if isinstance(org_unit_id, bool) or not isinstance(org_unit_id, int):
|
|
202
|
+
raise A2LError("course offering has an invalid organization-unit ID")
|
|
203
|
+
if not isinstance(code, str) or not code or not isinstance(name, str) or not name:
|
|
204
|
+
raise A2LError("course offering has invalid metadata")
|
|
205
|
+
|
|
206
|
+
access = raw.get("Access")
|
|
207
|
+
if not isinstance(access, dict):
|
|
208
|
+
raise A2LError("course offering has invalid access metadata")
|
|
209
|
+
is_active = access.get("IsActive")
|
|
210
|
+
if not isinstance(is_active, bool):
|
|
211
|
+
raise A2LError("course offering has invalid active metadata")
|
|
212
|
+
start_date = _optional_string(access.get("StartDate"))
|
|
213
|
+
end_date = _optional_string(access.get("EndDate"))
|
|
214
|
+
term = client.school.term_from_offering(code)
|
|
215
|
+
return CourseRef(
|
|
216
|
+
org_unit_id=org_unit_id,
|
|
217
|
+
code=code,
|
|
218
|
+
name=name,
|
|
219
|
+
term=term,
|
|
220
|
+
is_active=is_active,
|
|
221
|
+
start_date=start_date,
|
|
222
|
+
end_date=end_date,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _previous_template() -> str | None:
|
|
227
|
+
destination = config.state_dir() / _CALIBRATION_FILENAME
|
|
228
|
+
try:
|
|
229
|
+
with open(os.fspath(paths.long_path(destination)), encoding="utf-8", newline="") as handle:
|
|
230
|
+
raw: Any = json.load(handle)
|
|
231
|
+
except (FileNotFoundError, OSError, UnicodeError, json.JSONDecodeError):
|
|
232
|
+
# This is only an optional route hint; a corrupt hint must not block fresh calibration.
|
|
233
|
+
return None
|
|
234
|
+
if not isinstance(raw, dict):
|
|
235
|
+
return None
|
|
236
|
+
template = raw.get("download_template")
|
|
237
|
+
return template if isinstance(template, str) and template else None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _write(value: Calibration) -> None:
|
|
241
|
+
payload = {
|
|
242
|
+
"calibrated_at": value.calibrated_at,
|
|
243
|
+
"courses": [
|
|
244
|
+
{
|
|
245
|
+
"code": course.code,
|
|
246
|
+
"end_date": course.end_date,
|
|
247
|
+
"is_active": course.is_active,
|
|
248
|
+
"name": course.name,
|
|
249
|
+
"org_unit_id": course.org_unit_id,
|
|
250
|
+
"start_date": course.start_date,
|
|
251
|
+
"term": course.term,
|
|
252
|
+
}
|
|
253
|
+
for course in value.courses
|
|
254
|
+
],
|
|
255
|
+
"download_template": value.download_template,
|
|
256
|
+
"le": value.le,
|
|
257
|
+
"lp": value.lp,
|
|
258
|
+
"schema_version": _SCHEMA_VERSION,
|
|
259
|
+
}
|
|
260
|
+
text = json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
|
|
261
|
+
paths.atomic_write_text(config.state_dir() / _CALIBRATION_FILENAME, text)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _decode(raw: Any) -> Calibration:
|
|
265
|
+
if not isinstance(raw, dict) or set(raw) != {
|
|
266
|
+
"calibrated_at",
|
|
267
|
+
"courses",
|
|
268
|
+
"download_template",
|
|
269
|
+
"le",
|
|
270
|
+
"lp",
|
|
271
|
+
"schema_version",
|
|
272
|
+
}:
|
|
273
|
+
raise ValueError("calibration schema is invalid")
|
|
274
|
+
if raw["schema_version"] != _SCHEMA_VERSION:
|
|
275
|
+
raise ValueError("calibration schema is unsupported")
|
|
276
|
+
lp = raw["lp"]
|
|
277
|
+
le = raw["le"]
|
|
278
|
+
template = raw["download_template"]
|
|
279
|
+
calibrated_at = raw["calibrated_at"]
|
|
280
|
+
courses_raw = raw["courses"]
|
|
281
|
+
if (
|
|
282
|
+
not isinstance(lp, str)
|
|
283
|
+
or not lp
|
|
284
|
+
or not isinstance(le, str)
|
|
285
|
+
or not le
|
|
286
|
+
or template is not None
|
|
287
|
+
and not isinstance(template, str)
|
|
288
|
+
or calibrated_at is not None
|
|
289
|
+
and not isinstance(calibrated_at, str)
|
|
290
|
+
or not isinstance(courses_raw, list)
|
|
291
|
+
):
|
|
292
|
+
raise ValueError("calibration schema is invalid")
|
|
293
|
+
courses = [_decode_course(value) for value in courses_raw]
|
|
294
|
+
return Calibration(
|
|
295
|
+
lp=lp,
|
|
296
|
+
le=le,
|
|
297
|
+
download_template=template,
|
|
298
|
+
courses=courses,
|
|
299
|
+
calibrated_at=calibrated_at,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _decode_course(raw: Any) -> CourseRef:
|
|
304
|
+
if not isinstance(raw, dict) or set(raw) != {
|
|
305
|
+
"code",
|
|
306
|
+
"end_date",
|
|
307
|
+
"is_active",
|
|
308
|
+
"name",
|
|
309
|
+
"org_unit_id",
|
|
310
|
+
"start_date",
|
|
311
|
+
"term",
|
|
312
|
+
}:
|
|
313
|
+
raise ValueError("calibration course schema is invalid")
|
|
314
|
+
org_unit_id = raw["org_unit_id"]
|
|
315
|
+
code = raw["code"]
|
|
316
|
+
name = raw["name"]
|
|
317
|
+
is_active = raw["is_active"]
|
|
318
|
+
if (
|
|
319
|
+
isinstance(org_unit_id, bool)
|
|
320
|
+
or not isinstance(org_unit_id, int)
|
|
321
|
+
or not isinstance(code, str)
|
|
322
|
+
or not code
|
|
323
|
+
or not isinstance(name, str)
|
|
324
|
+
or not name
|
|
325
|
+
or not isinstance(is_active, bool)
|
|
326
|
+
):
|
|
327
|
+
raise ValueError("calibration course schema is invalid")
|
|
328
|
+
return CourseRef(
|
|
329
|
+
org_unit_id=org_unit_id,
|
|
330
|
+
code=code,
|
|
331
|
+
name=name,
|
|
332
|
+
term=_optional_string(raw["term"]),
|
|
333
|
+
is_active=is_active,
|
|
334
|
+
start_date=_optional_string(raw["start_date"]),
|
|
335
|
+
end_date=_optional_string(raw["end_date"]),
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _optional_string(value: object) -> str | None:
|
|
340
|
+
if value is None:
|
|
341
|
+
return None
|
|
342
|
+
if not isinstance(value, str):
|
|
343
|
+
raise A2LError("calibration metadata contains a non-string optional value")
|
|
344
|
+
return value
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
__all__ = ["Calibration", "CourseRef", "calibrate", "display_courses", "load_calibration"]
|