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,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"]