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/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
|
+
]
|