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