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
agent2learn/session.py ADDED
@@ -0,0 +1,373 @@
1
+ """Validated local session storage with a keyring-or-file fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime, timedelta
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from urllib.parse import urlsplit, urlunsplit
12
+
13
+ import requests
14
+ from requests.cookies import RequestsCookieJar
15
+
16
+ from agent2learn import config, paths
17
+
18
+ try:
19
+ import keyring as _keyring
20
+ except Exception: # pragma: no cover - exercised by environments without keyring installed
21
+ keyring: Any = None
22
+ else:
23
+ keyring = _keyring
24
+
25
+ _KEYRING_SERVICE = "agent2learn"
26
+ _KEYRING_USERNAME = "session"
27
+ _SESSION_FILENAME = "session.json"
28
+ _SCHEMA_KEYS = frozenset({"base_url", "cookies", "harvested_at", "user_id", "xsrf"})
29
+ _COOKIE_KEYS = frozenset({"domain", "name", "path", "secure", "value"})
30
+ _ALLOWED_COOKIE_NAMES = frozenset(
31
+ {"d2lsessionval", "d2lsecuresessionval", "xsrf-token", "xsrf_token", "d2lxsrf-token"}
32
+ )
33
+ _last_backend: str | None = None
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class SessionCookie:
38
+ """One browser-exported cookie with its original scope retained."""
39
+
40
+ name: str
41
+ value: str
42
+ domain: str
43
+ path: str
44
+ secure: bool
45
+
46
+
47
+ @dataclass
48
+ class Session:
49
+ """The minimum local API session projection.
50
+
51
+ Browser identity-provider cookies and display names are intentionally absent. Cookies are
52
+ filtered to the configured LEARN host at construction and again when a request jar is made,
53
+ so later mutation of this non-frozen dataclass cannot widen the request scope accidentally.
54
+ """
55
+
56
+ base_url: str
57
+ cookies: tuple[SessionCookie, ...]
58
+ xsrf: str | None
59
+ harvested_at: datetime
60
+ user_id: str | None
61
+
62
+ def __post_init__(self) -> None:
63
+ self.base_url = _normalize_base_url(self.base_url)
64
+ self.cookies = tuple(self.cookies)
65
+ if self.xsrf is not None and not isinstance(self.xsrf, str):
66
+ raise ValueError("session xsrf must be a string or null")
67
+ if self.user_id is not None and not isinstance(self.user_id, str):
68
+ raise ValueError("session user_id must be a string or null")
69
+ if not isinstance(self.harvested_at, datetime):
70
+ raise ValueError("session harvested_at must be a datetime")
71
+ if self.harvested_at.tzinfo is None or self.harvested_at.utcoffset() is None:
72
+ raise ValueError("session harvested_at must be timezone-aware")
73
+ self.harvested_at = self.harvested_at.astimezone(UTC)
74
+
75
+ host = _base_host(self.base_url)
76
+ scoped: list[SessionCookie] = []
77
+ for cookie in self.cookies:
78
+ _validate_cookie(cookie)
79
+ if cookie.name.casefold() in _ALLOWED_COOKIE_NAMES and _cookie_belongs_to_host(
80
+ cookie.domain, host
81
+ ):
82
+ scoped.append(cookie)
83
+ self.cookies = tuple(scoped)
84
+
85
+ def age(self) -> timedelta:
86
+ """Return the age of the harvest as measured by the current UTC clock."""
87
+
88
+ return datetime.now(UTC) - self.harvested_at
89
+
90
+ def requests_cookies(self) -> RequestsCookieJar:
91
+ """Return a requests jar containing only cookies scoped to this session's host."""
92
+
93
+ jar = requests.cookies.RequestsCookieJar()
94
+ host = _base_host(self.base_url)
95
+ for cookie in self.cookies:
96
+ _validate_cookie(cookie)
97
+ if cookie.name.casefold() not in _ALLOWED_COOKIE_NAMES or not _cookie_belongs_to_host(
98
+ cookie.domain, host
99
+ ):
100
+ continue
101
+ jar.set(
102
+ cookie.name,
103
+ cookie.value,
104
+ domain=cookie.domain,
105
+ path=cookie.path,
106
+ secure=cookie.secure,
107
+ )
108
+ return jar
109
+
110
+
111
+ def store(value: Session) -> str:
112
+ """Persist a validated session and return the backend that accepted it."""
113
+
114
+ global _last_backend
115
+ if not isinstance(value, Session):
116
+ raise ValueError("session value has an invalid schema")
117
+ validated = Session(
118
+ base_url=value.base_url,
119
+ cookies=value.cookies,
120
+ xsrf=value.xsrf,
121
+ harvested_at=value.harvested_at,
122
+ user_id=value.user_id,
123
+ )
124
+ blob = _encode(validated)
125
+
126
+ if keyring is not None:
127
+ try:
128
+ keyring.set_password(_KEYRING_SERVICE, _KEYRING_USERNAME, blob)
129
+ except Exception:
130
+ # Keyring backends commonly fail because SecretService/D-Bus is unavailable. This
131
+ # is an expected storage choice, not an error to surface or log. Remove a stale
132
+ # keyring value when possible so a later process cannot prefer it over the fallback.
133
+ _delete_keyring_quietly()
134
+ else:
135
+ _remove_file_quietly(_session_path())
136
+ _last_backend = "keyring"
137
+ return _last_backend
138
+
139
+ paths.atomic_write_text(_session_path(), blob)
140
+ _last_backend = "file"
141
+ return _last_backend
142
+
143
+
144
+ def load() -> Session | None:
145
+ """Load the keyring session, then the protected local-file fallback."""
146
+
147
+ global _last_backend
148
+ if keyring is not None:
149
+ try:
150
+ blob = keyring.get_password(_KEYRING_SERVICE, _KEYRING_USERNAME)
151
+ except Exception:
152
+ blob = None
153
+ if blob is not None:
154
+ loaded = _decode(blob)
155
+ _last_backend = "keyring"
156
+ return loaded
157
+
158
+ blob = _read_file()
159
+ if blob is None:
160
+ _last_backend = "file"
161
+ return None
162
+ loaded = _decode(blob)
163
+ _last_backend = "file"
164
+ return loaded
165
+
166
+
167
+ def clear() -> None:
168
+ """Remove the exported session from both storage backends without surfacing keyring errors."""
169
+
170
+ global _last_backend
171
+ _delete_keyring_quietly()
172
+ # An explicit clear must not claim success while a file-backed session remains on disk. The
173
+ # keyring is best-effort because its backend is outside our control, but a failed local unlink
174
+ # is actionable and must reach the caller.
175
+ _remove_file(_session_path())
176
+ _last_backend = None
177
+
178
+
179
+ def backend_name() -> str:
180
+ """Return only the active storage backend name, never its contents."""
181
+
182
+ if _last_backend is not None:
183
+ return _last_backend
184
+
185
+ if _file_is_present():
186
+ return "file"
187
+ if keyring is None:
188
+ return "file"
189
+ try:
190
+ keyring.get_password(_KEYRING_SERVICE, _KEYRING_USERNAME)
191
+ except Exception:
192
+ return "file"
193
+ return "keyring"
194
+
195
+
196
+ def _encode(value: Session) -> str:
197
+ payload: dict[str, object] = {
198
+ "base_url": value.base_url,
199
+ "cookies": [
200
+ {
201
+ "domain": cookie.domain,
202
+ "name": cookie.name,
203
+ "path": cookie.path,
204
+ "secure": cookie.secure,
205
+ "value": cookie.value,
206
+ }
207
+ for cookie in value.cookies
208
+ ],
209
+ "harvested_at": value.harvested_at.isoformat().replace("+00:00", "Z"),
210
+ "user_id": value.user_id,
211
+ "xsrf": value.xsrf,
212
+ }
213
+ return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n"
214
+
215
+
216
+ def _decode(blob: object) -> Session:
217
+ if not isinstance(blob, str):
218
+ raise ValueError("stored session schema is not text")
219
+ try:
220
+ raw: Any = json.loads(blob)
221
+ except json.JSONDecodeError as exc:
222
+ raise ValueError("stored session is not valid JSON") from exc
223
+ if not isinstance(raw, dict) or set(raw) != _SCHEMA_KEYS:
224
+ raise ValueError("stored session schema is invalid")
225
+
226
+ base_url = raw["base_url"]
227
+ cookies_raw = raw["cookies"]
228
+ harvested_at = raw["harvested_at"]
229
+ user_id = raw["user_id"]
230
+ xsrf = raw["xsrf"]
231
+ if not isinstance(base_url, str) or not isinstance(cookies_raw, list):
232
+ raise ValueError("stored session schema is invalid")
233
+ if not isinstance(harvested_at, str):
234
+ raise ValueError("stored session schema is invalid")
235
+ if user_id is not None and not isinstance(user_id, str):
236
+ raise ValueError("stored session schema is invalid")
237
+ if xsrf is not None and not isinstance(xsrf, str):
238
+ raise ValueError("stored session schema is invalid")
239
+
240
+ cookies: list[SessionCookie] = []
241
+ for raw_cookie in cookies_raw:
242
+ if not isinstance(raw_cookie, dict) or set(raw_cookie) != _COOKIE_KEYS:
243
+ raise ValueError("stored session cookie schema is invalid")
244
+ cookie = SessionCookie(
245
+ name=raw_cookie["name"],
246
+ value=raw_cookie["value"],
247
+ domain=raw_cookie["domain"],
248
+ path=raw_cookie["path"],
249
+ secure=raw_cookie["secure"],
250
+ )
251
+ cookies.append(cookie)
252
+
253
+ parsed_at = _parse_harvested_at(harvested_at)
254
+ return Session(
255
+ base_url=base_url,
256
+ cookies=tuple(cookies),
257
+ xsrf=xsrf,
258
+ harvested_at=parsed_at,
259
+ user_id=user_id,
260
+ )
261
+
262
+
263
+ def _parse_harvested_at(value: str) -> datetime:
264
+ candidate = f"{value[:-1]}+00:00" if value.endswith("Z") else value
265
+ try:
266
+ parsed = datetime.fromisoformat(candidate)
267
+ except ValueError as exc:
268
+ raise ValueError("stored session harvested_at is invalid") from exc
269
+ if parsed.tzinfo is None or parsed.utcoffset() is None:
270
+ raise ValueError("stored session harvested_at must be timezone-aware")
271
+ return parsed.astimezone(UTC)
272
+
273
+
274
+ def _normalize_base_url(value: str) -> str:
275
+ if not isinstance(value, str):
276
+ raise ValueError("session base_url must be a string")
277
+ try:
278
+ parsed = urlsplit(value)
279
+ port = parsed.port
280
+ except ValueError as exc:
281
+ raise ValueError("session base_url has an invalid host") from exc
282
+ if parsed.scheme.casefold() != "https":
283
+ raise ValueError("session base_url must use HTTPS")
284
+ if parsed.hostname is None or parsed.username is not None or parsed.password is not None:
285
+ raise ValueError("session base_url must contain a host without credentials")
286
+ if parsed.query or parsed.fragment or port is None and ":" in parsed.netloc:
287
+ raise ValueError("session base_url must be a plain configured host")
288
+ return urlunsplit(("https", parsed.netloc, parsed.path.rstrip("/"), "", ""))
289
+
290
+
291
+ def _base_host(base_url: str) -> str:
292
+ hostname = urlsplit(base_url).hostname
293
+ if hostname is None: # pragma: no cover - _normalize_base_url rejects this first
294
+ raise ValueError("session base_url must contain a host")
295
+ return hostname.rstrip(".").casefold()
296
+
297
+
298
+ def _validate_cookie(cookie: SessionCookie) -> None:
299
+ if not isinstance(cookie, SessionCookie):
300
+ raise ValueError("session cookie schema is invalid")
301
+ if not isinstance(cookie.name, str) or not cookie.name:
302
+ raise ValueError("session cookie schema is invalid")
303
+ if not isinstance(cookie.value, str):
304
+ raise ValueError("session cookie schema is invalid")
305
+ if not isinstance(cookie.domain, str) or not cookie.domain.strip():
306
+ raise ValueError("session cookie schema is invalid")
307
+ if not isinstance(cookie.path, str) or not cookie.path.startswith("/"):
308
+ raise ValueError("session cookie schema is invalid")
309
+ if not isinstance(cookie.secure, bool):
310
+ raise ValueError("session cookie schema is invalid")
311
+
312
+
313
+ def _cookie_belongs_to_host(domain: str, host: str) -> bool:
314
+ normalized = domain.lstrip(".").rstrip(".").casefold()
315
+ return normalized == host
316
+
317
+
318
+ def _session_path() -> Path:
319
+ return config.state_dir() / _SESSION_FILENAME
320
+
321
+
322
+ def _read_file() -> str | None:
323
+ path = _session_path()
324
+ if paths.is_link(path):
325
+ raise ValueError("stored session must not be a symlink")
326
+ try:
327
+ with open(os.fspath(paths.long_path(path)), encoding="utf-8", newline="") as handle:
328
+ return handle.read()
329
+ except FileNotFoundError:
330
+ return None
331
+ except UnicodeError as exc:
332
+ raise ValueError("stored session is not valid JSON") from exc
333
+
334
+
335
+ def _file_is_present() -> bool:
336
+ try:
337
+ os.lstat(os.fspath(paths.long_path(_session_path())))
338
+ except FileNotFoundError:
339
+ return False
340
+ return True
341
+
342
+
343
+ def _remove_file_quietly(path: Path) -> None:
344
+ try:
345
+ os.unlink(os.fspath(paths.long_path(path)))
346
+ except OSError:
347
+ return
348
+
349
+
350
+ def _remove_file(path: Path) -> None:
351
+ try:
352
+ os.unlink(os.fspath(paths.long_path(path)))
353
+ except FileNotFoundError:
354
+ return
355
+
356
+
357
+ def _delete_keyring_quietly() -> None:
358
+ if keyring is None:
359
+ return
360
+ try:
361
+ keyring.delete_password(_KEYRING_SERVICE, _KEYRING_USERNAME)
362
+ except Exception:
363
+ return
364
+
365
+
366
+ __all__ = [
367
+ "Session",
368
+ "SessionCookie",
369
+ "backend_name",
370
+ "clear",
371
+ "load",
372
+ "store",
373
+ ]