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
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Pure cookie-blob parsing plus a cross-platform hidden multiline TTY reader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Iterable, Mapping
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from io import StringIO
|
|
13
|
+
from typing import Any, TextIO
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
from agent2learn.errors import AuthenticationError
|
|
17
|
+
from agent2learn.session import Session, SessionCookie
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import termios as _termios
|
|
21
|
+
except ImportError: # pragma: no cover - only Windows lacks termios
|
|
22
|
+
termios: Any = None
|
|
23
|
+
else:
|
|
24
|
+
termios = _termios
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
import msvcrt as _msvcrt
|
|
28
|
+
except ImportError: # pragma: no cover - only POSIX lacks msvcrt
|
|
29
|
+
msvcrt: Any = None
|
|
30
|
+
else:
|
|
31
|
+
msvcrt = _msvcrt
|
|
32
|
+
|
|
33
|
+
REQUIRED_COOKIE_NAMES = ("d2lSessionVal", "d2lSecureSessionVal")
|
|
34
|
+
_ALLOWED_COOKIE_NAMES = frozenset(
|
|
35
|
+
{
|
|
36
|
+
"d2lsessionval",
|
|
37
|
+
"d2lsecuresessionval",
|
|
38
|
+
"xsrf-token",
|
|
39
|
+
"xsrf_token",
|
|
40
|
+
"d2lxsrf-token",
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
_SESSION_COOKIE_NAMES = frozenset({"d2lsessionval", "d2lsecuresessionval"})
|
|
44
|
+
_XSRF_COOKIE_NAMES = frozenset({"xsrf-token", "xsrf_token", "d2lxsrf-token"})
|
|
45
|
+
_SESSION_PATH = "/d2l"
|
|
46
|
+
_DEFAULT_PATH = "/"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PasteError(AuthenticationError):
|
|
50
|
+
"""A pasted cookie blob was unavailable, malformed, or incomplete."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def session_from_blob(
|
|
54
|
+
blob: str,
|
|
55
|
+
*,
|
|
56
|
+
base_url: str,
|
|
57
|
+
harvested_at: datetime | None = None,
|
|
58
|
+
) -> Session:
|
|
59
|
+
"""Parse one supported export shape into the same minimal session projection."""
|
|
60
|
+
|
|
61
|
+
host = _configured_host(base_url)
|
|
62
|
+
records = _records_from_blob(blob, host)
|
|
63
|
+
return _session_from_records(records, base_url=base_url, host=host, harvested_at=harvested_at)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def session_from_cookie_records(
|
|
67
|
+
records: Iterable[Mapping[str, object]],
|
|
68
|
+
*,
|
|
69
|
+
base_url: str,
|
|
70
|
+
harvested_at: datetime,
|
|
71
|
+
user_id: str | None = None,
|
|
72
|
+
) -> Session:
|
|
73
|
+
"""Reduce CDP's full profile cookie list to the same paste-session projection."""
|
|
74
|
+
|
|
75
|
+
host = _configured_host(base_url)
|
|
76
|
+
return _session_from_records(
|
|
77
|
+
records,
|
|
78
|
+
base_url=base_url,
|
|
79
|
+
host=host,
|
|
80
|
+
harvested_at=harvested_at,
|
|
81
|
+
user_id=user_id,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _session_from_records(
|
|
86
|
+
records: Iterable[Mapping[str, object]],
|
|
87
|
+
*,
|
|
88
|
+
base_url: str,
|
|
89
|
+
host: str,
|
|
90
|
+
harvested_at: datetime | None,
|
|
91
|
+
user_id: str | None = None,
|
|
92
|
+
) -> Session:
|
|
93
|
+
cookies = _filter_records(records, host)
|
|
94
|
+
_require_minimum(cookies)
|
|
95
|
+
xsrf = next(
|
|
96
|
+
(cookie.value for cookie in cookies if cookie.name.casefold() in _XSRF_COOKIE_NAMES),
|
|
97
|
+
None,
|
|
98
|
+
)
|
|
99
|
+
return Session(
|
|
100
|
+
base_url=base_url,
|
|
101
|
+
cookies=tuple(cookies),
|
|
102
|
+
xsrf=xsrf,
|
|
103
|
+
harvested_at=harvested_at or datetime.now(UTC),
|
|
104
|
+
user_id=user_id,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def read_hidden_multiline(
|
|
109
|
+
*, input_stream: TextIO | None = None, output_stream: TextIO | None = None
|
|
110
|
+
) -> str:
|
|
111
|
+
"""Read pasted secrets only from a controlling TTY with terminal echo disabled."""
|
|
112
|
+
|
|
113
|
+
input_value = sys.stdin if input_stream is None else input_stream
|
|
114
|
+
output_value = sys.stderr if output_stream is None else output_stream
|
|
115
|
+
if not _is_tty(input_value) or not _is_tty(output_value):
|
|
116
|
+
raise PasteError("cookie paste requires a controlling TTY; piped input is refused")
|
|
117
|
+
|
|
118
|
+
if os.name == "nt":
|
|
119
|
+
return _read_windows_hidden(output_value)
|
|
120
|
+
if termios is None: # pragma: no cover - defensive platform branch
|
|
121
|
+
raise PasteError("hidden cookie input is unavailable on this platform")
|
|
122
|
+
return _read_posix_hidden(input_value, output_value)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _read_posix_hidden(input_stream: TextIO, output_stream: TextIO) -> str:
|
|
126
|
+
if termios is None: # pragma: no cover - guarded by read_hidden_multiline
|
|
127
|
+
raise PasteError("hidden cookie input is unavailable on this platform")
|
|
128
|
+
try:
|
|
129
|
+
fd = input_stream.fileno()
|
|
130
|
+
original = termios.tcgetattr(fd)
|
|
131
|
+
except (AttributeError, OSError, termios.error) as exc:
|
|
132
|
+
raise PasteError("cookie paste requires a usable controlling TTY") from exc
|
|
133
|
+
|
|
134
|
+
hidden = original.copy()
|
|
135
|
+
hidden[3] &= ~termios.ECHO
|
|
136
|
+
changed = False
|
|
137
|
+
try:
|
|
138
|
+
termios.tcsetattr(fd, termios.TCSANOW, hidden)
|
|
139
|
+
changed = True
|
|
140
|
+
output_stream.write("Paste cookies, then press Ctrl-D to finish:\n")
|
|
141
|
+
output_stream.flush()
|
|
142
|
+
return input_stream.read()
|
|
143
|
+
finally:
|
|
144
|
+
if changed:
|
|
145
|
+
termios.tcsetattr(fd, termios.TCSANOW, original)
|
|
146
|
+
output_stream.write("\n")
|
|
147
|
+
output_stream.flush()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _read_windows_hidden(output_stream: TextIO) -> str:
|
|
151
|
+
if msvcrt is None: # pragma: no cover - defensive platform branch
|
|
152
|
+
raise PasteError("hidden cookie input is unavailable on this platform")
|
|
153
|
+
|
|
154
|
+
output_stream.write("Paste cookies, then press Ctrl-Z and Enter to finish:\n")
|
|
155
|
+
output_stream.flush()
|
|
156
|
+
characters: list[str] = []
|
|
157
|
+
while True:
|
|
158
|
+
character = msvcrt.getwch()
|
|
159
|
+
if character in {"\x03"}:
|
|
160
|
+
raise KeyboardInterrupt
|
|
161
|
+
if character in {"\x1a", "\x04"}:
|
|
162
|
+
output_stream.write("\n")
|
|
163
|
+
output_stream.flush()
|
|
164
|
+
return "".join(characters)
|
|
165
|
+
if character in {"\r", "\n"}:
|
|
166
|
+
if not characters or characters[-1] != "\n":
|
|
167
|
+
characters.append("\n")
|
|
168
|
+
continue
|
|
169
|
+
if character == "\b":
|
|
170
|
+
if characters and characters[-1] != "\n":
|
|
171
|
+
characters.pop()
|
|
172
|
+
continue
|
|
173
|
+
characters.append(character)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _records_from_blob(blob: str, host: str) -> list[dict[str, object]]:
|
|
177
|
+
if not isinstance(blob, str) or not blob.strip():
|
|
178
|
+
raise PasteError("cookie paste is empty")
|
|
179
|
+
|
|
180
|
+
stripped = blob.strip()
|
|
181
|
+
if stripped.startswith("{") or stripped.startswith("["):
|
|
182
|
+
return _records_from_json(stripped)
|
|
183
|
+
if _looks_like_table(stripped):
|
|
184
|
+
return _records_from_table(stripped)
|
|
185
|
+
return _records_from_lines(stripped, host)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _records_from_json(blob: str) -> list[dict[str, object]]:
|
|
189
|
+
try:
|
|
190
|
+
raw: Any = json.loads(blob)
|
|
191
|
+
except json.JSONDecodeError:
|
|
192
|
+
raise PasteError("cookie JSON export is malformed") from None
|
|
193
|
+
|
|
194
|
+
raw_records = raw.get("cookies") if isinstance(raw, dict) else raw
|
|
195
|
+
if not isinstance(raw_records, list):
|
|
196
|
+
raise PasteError("cookie JSON export has no cookie list")
|
|
197
|
+
|
|
198
|
+
records: list[dict[str, object]] = []
|
|
199
|
+
for record in raw_records:
|
|
200
|
+
if not isinstance(record, dict):
|
|
201
|
+
continue
|
|
202
|
+
records.append({str(key): value for key, value in record.items()})
|
|
203
|
+
return records
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _records_from_table(blob: str) -> list[dict[str, object]]:
|
|
207
|
+
lines = [line for line in blob.splitlines() if line.strip()]
|
|
208
|
+
delimiter = "\t" if any("\t" in line for line in lines) else None
|
|
209
|
+
if delimiter is None:
|
|
210
|
+
rows = [re.split(r"\s{2,}", line.strip()) for line in lines]
|
|
211
|
+
else:
|
|
212
|
+
rows = list(csv.reader(StringIO("\n".join(lines)), delimiter=delimiter))
|
|
213
|
+
header_index = next(
|
|
214
|
+
(
|
|
215
|
+
index
|
|
216
|
+
for index, row in enumerate(rows)
|
|
217
|
+
if {cell.strip().casefold() for cell in row} >= {"name", "value"}
|
|
218
|
+
),
|
|
219
|
+
None,
|
|
220
|
+
)
|
|
221
|
+
if header_index is None:
|
|
222
|
+
raise PasteError("cookie table must contain Name and Value columns")
|
|
223
|
+
header = [cell.strip().casefold() for cell in rows[header_index]]
|
|
224
|
+
records: list[dict[str, object]] = []
|
|
225
|
+
for row in rows[header_index + 1 :]:
|
|
226
|
+
if len(row) < 2:
|
|
227
|
+
continue
|
|
228
|
+
record: dict[str, object] = {}
|
|
229
|
+
for key in ("name", "value", "domain", "path", "secure"):
|
|
230
|
+
if key in header:
|
|
231
|
+
position = header.index(key)
|
|
232
|
+
record[key] = row[position].strip() if position < len(row) else ""
|
|
233
|
+
records.append(record)
|
|
234
|
+
return records
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _records_from_lines(blob: str, host: str) -> list[dict[str, object]]:
|
|
238
|
+
records: list[dict[str, object]] = []
|
|
239
|
+
for line in blob.splitlines():
|
|
240
|
+
candidate = line.strip()
|
|
241
|
+
if not candidate:
|
|
242
|
+
continue
|
|
243
|
+
if candidate.casefold().startswith("cookie:"):
|
|
244
|
+
candidate = candidate.split(":", 1)[1].strip()
|
|
245
|
+
pieces = candidate.split(";") if ";" in candidate else [candidate]
|
|
246
|
+
for piece in pieces:
|
|
247
|
+
if "=" not in piece:
|
|
248
|
+
continue
|
|
249
|
+
name, value = piece.split("=", 1)
|
|
250
|
+
name = name.strip()
|
|
251
|
+
if not name:
|
|
252
|
+
continue
|
|
253
|
+
records.append(
|
|
254
|
+
{
|
|
255
|
+
"name": name,
|
|
256
|
+
"value": value.strip(),
|
|
257
|
+
"domain": f".{host}",
|
|
258
|
+
"path": (
|
|
259
|
+
_SESSION_PATH if name.casefold() in _SESSION_COOKIE_NAMES else _DEFAULT_PATH
|
|
260
|
+
),
|
|
261
|
+
"secure": True,
|
|
262
|
+
}
|
|
263
|
+
)
|
|
264
|
+
return records
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _filter_records(records: Iterable[Mapping[str, object]], host: str) -> list[SessionCookie]:
|
|
268
|
+
selected: dict[str, SessionCookie] = {}
|
|
269
|
+
for record in records:
|
|
270
|
+
name_value = record.get("name")
|
|
271
|
+
value = record.get("value")
|
|
272
|
+
domain_value = record.get("domain")
|
|
273
|
+
path_value = record.get("path")
|
|
274
|
+
if not isinstance(name_value, str) or not isinstance(value, str):
|
|
275
|
+
continue
|
|
276
|
+
folded_name = name_value.casefold()
|
|
277
|
+
if folded_name not in _ALLOWED_COOKIE_NAMES:
|
|
278
|
+
continue
|
|
279
|
+
if not isinstance(domain_value, str) or not _same_host(domain_value, host):
|
|
280
|
+
continue
|
|
281
|
+
path = (
|
|
282
|
+
path_value
|
|
283
|
+
if isinstance(path_value, str) and path_value.startswith("/")
|
|
284
|
+
else (_SESSION_PATH if folded_name in _SESSION_COOKIE_NAMES else _DEFAULT_PATH)
|
|
285
|
+
)
|
|
286
|
+
selected[folded_name] = SessionCookie(
|
|
287
|
+
name=_canonical_cookie_name(name_value),
|
|
288
|
+
value=value,
|
|
289
|
+
domain=_canonical_domain(domain_value),
|
|
290
|
+
path=path,
|
|
291
|
+
secure=_as_bool(record.get("secure"), default=True),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
ordered: list[SessionCookie] = []
|
|
295
|
+
for name in (*REQUIRED_COOKIE_NAMES, "XSRF-TOKEN", "XSRF_TOKEN", "d2lXSRF-Token"):
|
|
296
|
+
cookie = selected.get(name.casefold())
|
|
297
|
+
if cookie is not None:
|
|
298
|
+
ordered.append(cookie)
|
|
299
|
+
return ordered
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _require_minimum(cookies: Iterable[SessionCookie]) -> None:
|
|
303
|
+
names = {cookie.name.casefold() for cookie in cookies}
|
|
304
|
+
missing = [name for name in REQUIRED_COOKIE_NAMES if name.casefold() not in names]
|
|
305
|
+
if missing:
|
|
306
|
+
raise PasteError("missing required LEARN cookies: " + ", ".join(missing))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _configured_host(value: str) -> str:
|
|
310
|
+
try:
|
|
311
|
+
parsed = urlsplit(value if "://" in value else f"https://{value}")
|
|
312
|
+
except ValueError as exc:
|
|
313
|
+
raise PasteError("configured LEARN host is invalid") from exc
|
|
314
|
+
if parsed.hostname is None:
|
|
315
|
+
raise PasteError("configured LEARN host is invalid")
|
|
316
|
+
return _canonical_hostname(parsed.hostname)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _same_host(domain: str, host: str) -> bool:
|
|
320
|
+
candidate = domain.lstrip(".").rstrip(".")
|
|
321
|
+
try:
|
|
322
|
+
return _canonical_hostname(candidate) == host
|
|
323
|
+
except ValueError:
|
|
324
|
+
return False
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _canonical_hostname(value: str) -> str:
|
|
328
|
+
return value.encode("idna").decode("ascii").casefold().rstrip(".")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _canonical_domain(value: str) -> str:
|
|
332
|
+
prefix = "." if value.startswith(".") else ""
|
|
333
|
+
return prefix + _canonical_hostname(value.lstrip(".").rstrip("."))
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _canonical_cookie_name(value: str) -> str:
|
|
337
|
+
folded = value.casefold()
|
|
338
|
+
if folded == "d2lxsrf-token":
|
|
339
|
+
return "d2lXSRF-Token"
|
|
340
|
+
if folded == "xsrf_token":
|
|
341
|
+
return "XSRF_TOKEN"
|
|
342
|
+
if folded == "xsrf-token":
|
|
343
|
+
return "XSRF-TOKEN"
|
|
344
|
+
if folded == "d2lsessionval":
|
|
345
|
+
return "d2lSessionVal"
|
|
346
|
+
if folded == "d2lsecuresessionval":
|
|
347
|
+
return "d2lSecureSessionVal"
|
|
348
|
+
return value
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _as_bool(value: object, *, default: bool) -> bool:
|
|
352
|
+
if isinstance(value, bool):
|
|
353
|
+
return value
|
|
354
|
+
if isinstance(value, str):
|
|
355
|
+
return value.strip().casefold() in {"1", "true", "yes", "on", "✓", "secure"}
|
|
356
|
+
return default
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _looks_like_table(blob: str) -> bool:
|
|
360
|
+
first_lines = blob.splitlines()[:3]
|
|
361
|
+
return any(
|
|
362
|
+
{cell.strip().casefold() for cell in re.split(r"\t|\s{2,}", line)} >= {"name", "value"}
|
|
363
|
+
for line in first_lines
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _is_tty(stream: object) -> bool:
|
|
368
|
+
isatty = getattr(stream, "isatty", None)
|
|
369
|
+
return bool(callable(isatty) and isatty())
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
__all__ = [
|
|
373
|
+
"PasteError",
|
|
374
|
+
"REQUIRED_COOKIE_NAMES",
|
|
375
|
+
"read_hidden_multiline",
|
|
376
|
+
"session_from_blob",
|
|
377
|
+
"session_from_cookie_records",
|
|
378
|
+
]
|