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/audit.py ADDED
@@ -0,0 +1,358 @@
1
+ """Structural coverage audit over what the vault actually holds.
2
+
3
+ The audit answers one question honestly: *what is missing, and why?* It never guesses that
4
+ coverage is complete, never treats a title match as proof, and never reports a number it
5
+ cannot substantiate from the manifest and the reconciled content map.
6
+
7
+ Its most important output is the part users dislike: assignments with no matching course
8
+ content, conversion gaps, integrity gaps, and links that were deliberately not fetched. A
9
+ report that only counted successes would let a silent ingest failure look like a finished
10
+ archive.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ from collections import Counter
19
+ from collections.abc import Mapping, Sequence
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+
23
+ from agent2learn import clock, paths
24
+ from agent2learn import index as course_index
25
+ from agent2learn.vault import Vault
26
+
27
+ AUDIT_VERSION = 1
28
+
29
+ _MEDIA_EXTENSIONS = frozenset(
30
+ {".mp3", ".mp4", ".m4a", ".m4v", ".mov", ".avi", ".wav", ".webm", ".mkv", ".flv"}
31
+ )
32
+ _CITABLE = "markdown_ready"
33
+ _GAP_LABELS: dict[str, str] = {
34
+ "metadata_only": "known but not fetched",
35
+ "source_only": "fetched, no markdown twin",
36
+ "unsupported_format": "no converter for this format",
37
+ "conversion_gap": "fetched, but conversion produced no markdown twin",
38
+ "integrity_gap": "on-disk bytes do not match the manifest",
39
+ "download_gap": "the server did not serve this file; retry the fetch",
40
+ "external_link": "external link, deliberately not fetched",
41
+ }
42
+ # Tokens that appear in nearly every coursework title and so carry no matching signal.
43
+ # Shared in spirit with ground.py's GENERIC set; kept local because changing one must not
44
+ # silently change the other's scoring.
45
+ _GENERIC = frozenset(
46
+ {
47
+ "a", "an", "and", "activity", "assignment", "class", "copy", "for", "home", "in",
48
+ "lab", "of", "part", "solution", "take", "the", "to", "week",
49
+ }
50
+ ) # fmt: skip
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class AssignmentMatch:
55
+ """One assignment and the single best content guess, always labelled as a guess."""
56
+
57
+ title: str
58
+ due_date: str | None
59
+ best_match: str | None
60
+ overlap: int
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class CourseAudit:
65
+ """Everything the report says about one course, computed from local evidence only."""
66
+
67
+ course: str
68
+ code: str
69
+ name: str
70
+ topics: int
71
+ citable: int
72
+ coverage: dict[str, int] = field(default_factory=dict)
73
+ links: dict[str, int] = field(default_factory=dict)
74
+ media: int = 0
75
+ quizzes: int = 0
76
+ quizzes_with_due_dates: int = 0
77
+ assignments: int = 0
78
+ metadata_gaps: tuple[str, ...] = ()
79
+ outline_gaps: tuple[tuple[str, str], ...] = ()
80
+ unmatched_assignments: tuple[AssignmentMatch, ...] = ()
81
+
82
+ @property
83
+ def coverage_percent(self) -> int:
84
+ """Citable share, floored so a partial archive never rounds up to 100%."""
85
+ if self.topics == 0:
86
+ return 0
87
+ return int(self.citable * 100 // self.topics)
88
+
89
+
90
+ def audit_vault(vault: Vault) -> list[CourseAudit]:
91
+ """Compute a per-course structural audit from the manifest and content maps."""
92
+ results: list[CourseAudit] = []
93
+ for map_path in sorted(
94
+ path for path in paths.walk(vault.root) if path.name == "content_map.json"
95
+ ):
96
+ course_dir = map_path.parent.parent
97
+ rows = course_index.read_content_map(course_dir)["topics"]
98
+ if not isinstance(rows, list):
99
+ continue
100
+ results.append(_audit_course(vault, course_dir, rows))
101
+ return results
102
+
103
+
104
+ def write_audit(vault: Vault, *, timestamp: str | None = None) -> Path:
105
+ """Write ``.a2l/AUDIT.md`` and return its path."""
106
+ audits = audit_vault(vault)
107
+ stamp = timestamp if timestamp is not None else clock.stamp()
108
+ destination = vault.state() / "AUDIT.md"
109
+ paths.atomic_write_text(destination, _render(audits, stamp), root=vault.root)
110
+ return destination
111
+
112
+
113
+ def _audit_course(vault: Vault, course_dir: Path, rows: Sequence[object]) -> CourseAudit:
114
+ coverage: Counter[str] = Counter()
115
+ links: Counter[str] = Counter()
116
+ media = 0
117
+ citable = 0
118
+ topics = 0
119
+ titles: list[tuple[str, set[str]]] = []
120
+
121
+ for raw in rows:
122
+ if not isinstance(raw, Mapping):
123
+ continue
124
+ topics += 1
125
+ availability = str(raw.get("availability", "metadata_only"))
126
+ coverage[availability] += 1
127
+ if availability == _CITABLE:
128
+ citable += 1
129
+ if availability == "external_link":
130
+ links[_link_kind(raw)] += 1
131
+ if _is_media(raw):
132
+ media += 1
133
+ title = str(raw.get("title") or "")
134
+ if title:
135
+ titles.append((title, _terms(title)))
136
+
137
+ assignments, assignments_gap = _read_json_list(course_dir / "_meta" / "assignments.json")
138
+ quizzes, quizzes_gap = _read_json_list(course_dir / "_meta" / "quizzes.json")
139
+ outline_rows, _ = _read_json_list(course_dir / "_meta" / "outlines.json")
140
+ outline_gaps = tuple(
141
+ (str(row.get("source_key") or "unknown"), str(row.get("reason") or "unknown"))
142
+ for row in outline_rows
143
+ if row.get("status") == "outline_unavailable"
144
+ )
145
+ unmatched = _unmatched_assignments(assignments, titles)
146
+ metadata_gaps = tuple(gap for gap in (assignments_gap, quizzes_gap) if gap is not None)
147
+
148
+ first = next((row for row in rows if isinstance(row, Mapping)), {})
149
+ return CourseAudit(
150
+ course=paths.rel_posix(course_dir, vault.root),
151
+ code=str(first.get("course_code") or course_dir.name),
152
+ name=str(first.get("course_name") or course_dir.name),
153
+ topics=topics,
154
+ citable=citable,
155
+ coverage=dict(sorted(coverage.items())),
156
+ links=dict(sorted(links.items())),
157
+ media=media,
158
+ quizzes=len(quizzes),
159
+ quizzes_with_due_dates=sum(1 for row in quizzes if row.get("due_date")),
160
+ assignments=len(assignments),
161
+ metadata_gaps=metadata_gaps,
162
+ outline_gaps=outline_gaps,
163
+ unmatched_assignments=unmatched,
164
+ )
165
+
166
+
167
+ def _unmatched_assignments(
168
+ assignments: Sequence[Mapping[str, object]], titles: Sequence[tuple[str, set[str]]]
169
+ ) -> tuple[AssignmentMatch, ...]:
170
+ """Return assignments sharing no distinguishing term with any topic in the course.
171
+
172
+ This is deliberately a weak, lexical signal, and it is reported as a prompt to look
173
+ rather than as a finding. An assignment with no overlap usually means the brief lives
174
+ somewhere the API does not expose — a quiz description, an announcement, or a slide.
175
+ The best-overlapping title is carried along so the student has somewhere to start.
176
+ """
177
+ unmatched: list[AssignmentMatch] = []
178
+ for row in assignments:
179
+ title = str(row.get("title") or "")
180
+ wanted = _terms(title)
181
+ if not wanted:
182
+ continue
183
+ # Highest overlap wins; ties break on the title so the report is stable everywhere.
184
+ ranked = sorted(
185
+ ((len(wanted & terms), candidate) for candidate, terms in titles),
186
+ key=lambda pair: (-pair[0], pair[1]),
187
+ )
188
+ best_overlap, best_title = ranked[0] if ranked else (0, None)
189
+ if best_overlap == 0:
190
+ unmatched.append(
191
+ AssignmentMatch(
192
+ title=title,
193
+ due_date=_optional_text(row.get("due_date")),
194
+ best_match=best_title,
195
+ overlap=0,
196
+ )
197
+ )
198
+ return tuple(sorted(unmatched, key=lambda item: (item.due_date or "", item.title)))
199
+
200
+
201
+ def _terms(value: str) -> set[str]:
202
+ """Tokenise a title into distinguishing terms, dropping generic coursework words."""
203
+ tokens: set[str] = set()
204
+ for word in re.findall(r"[a-z0-9]+", value.casefold()):
205
+ if len(word) > 1 or word.isdigit():
206
+ tokens.add(word)
207
+ parts = re.findall(r"[a-z]+|[0-9]+", word)
208
+ if len(parts) > 1:
209
+ tokens.update(part for part in parts if len(part) > 1 or part.isdigit())
210
+ return tokens - _GENERIC
211
+
212
+
213
+ def _link_kind(row: Mapping[str, object]) -> str:
214
+ kind = str(row.get("kind") or "").casefold()
215
+ if kind in {"lti", "externallink", "link"}:
216
+ return {"lti": "LTI tool", "externallink": "external link", "link": "external link"}[kind]
217
+ return kind or "unknown"
218
+
219
+
220
+ def _is_media(row: Mapping[str, object]) -> bool:
221
+ candidate = str(row.get("source_path") or row.get("url_path") or "")
222
+ return _suffix(candidate) in _MEDIA_EXTENSIONS
223
+
224
+
225
+ def _suffix(value: str) -> str:
226
+ _, _, tail = value.rpartition("/")
227
+ _, dot, extension = tail.rpartition(".")
228
+ return f".{extension.casefold()}" if dot else ""
229
+
230
+
231
+ def _read_json_list(destination: Path) -> tuple[list[Mapping[str, object]], str | None]:
232
+ try:
233
+ with open(os.fspath(paths.long_path(destination)), encoding="utf-8", newline="") as handle:
234
+ raw = json.load(handle)
235
+ except FileNotFoundError:
236
+ return [], None
237
+ except (OSError, UnicodeError, json.JSONDecodeError):
238
+ return [], f"{destination.name} is unreadable"
239
+ if not isinstance(raw, list):
240
+ return [], f"{destination.name} has an invalid root"
241
+ rows = [row for row in raw if isinstance(row, Mapping)]
242
+ if len(rows) != len(raw):
243
+ return rows, f"{destination.name} contains invalid item(s)"
244
+ return rows, None
245
+
246
+
247
+ def _optional_text(value: object) -> str | None:
248
+ return value if isinstance(value, str) and value else None
249
+
250
+
251
+ def _plural(count: int, singular: str, plural: str | None = None) -> str:
252
+ return f"{count} {singular}" if count == 1 else f"{count} {plural or singular + 's'}"
253
+
254
+
255
+ def _render(audits: Sequence[CourseAudit], stamp: str) -> str:
256
+ lines = [
257
+ "# Coverage audit",
258
+ "",
259
+ f"Generated {stamp} · audit schema {AUDIT_VERSION}",
260
+ "",
261
+ "This report describes only what is in this vault. A gap here means the material was "
262
+ "not retrievable through the API, not that it does not exist in the course.",
263
+ "",
264
+ ]
265
+
266
+ if not audits:
267
+ lines.extend(["No courses have been ingested yet. Run `a2l sync` to begin.", ""])
268
+ return "\n".join(lines)
269
+
270
+ lines.extend(
271
+ ["## Summary", "", "| Course | Citable | Topics | Coverage |", "| --- | --- | --- | --- |"]
272
+ )
273
+ for audit in audits:
274
+ lines.append(
275
+ f"| {audit.code} | {audit.citable} | {audit.topics} | {audit.coverage_percent}% |"
276
+ )
277
+ lines.append("")
278
+
279
+ for audit in audits:
280
+ lines.extend([f"## {audit.code} — {audit.name}", ""])
281
+ lines.append(
282
+ f"{audit.citable} of {audit.topics} topics are citable ({audit.coverage_percent}%)."
283
+ )
284
+ lines.append("")
285
+
286
+ gaps = [(state, count) for state, count in audit.coverage.items() if state != _CITABLE]
287
+ if gaps:
288
+ lines.extend(["### Gaps", "", "| State | Count | Meaning |", "| --- | --- | --- |"])
289
+ for state, count in gaps:
290
+ lines.append(f"| `{state}` | {count} | {_GAP_LABELS.get(state, 'unclassified')} |")
291
+ lines.append("")
292
+
293
+ if audit.metadata_gaps:
294
+ lines.extend(
295
+ [
296
+ "### Metadata gaps",
297
+ "",
298
+ "The local metadata projection could not be read completely; inventory "
299
+ "counts below may be incomplete.",
300
+ "",
301
+ *[f"- {gap}" for gap in audit.metadata_gaps],
302
+ "",
303
+ ]
304
+ )
305
+
306
+ if audit.outline_gaps:
307
+ lines.extend(
308
+ [
309
+ "### Outline gaps",
310
+ "",
311
+ "These outlines could not be rendered from LEARN. They are retried on "
312
+ "every sync.",
313
+ "",
314
+ "| Outline | Reason |",
315
+ "| --- | --- |",
316
+ ]
317
+ )
318
+ for source_key, reason in audit.outline_gaps:
319
+ lines.append(f"| `{source_key}` | {reason} |")
320
+ lines.append("")
321
+
322
+ if audit.links:
323
+ lines.extend(["### Links not fetched", "", "| Kind | Count |", "| --- | --- |"])
324
+ for kind, count in audit.links.items():
325
+ lines.append(f"| {kind} | {count} |")
326
+ lines.extend(
327
+ ["", "These are external or licensed targets. Open them in LEARN directly.", ""]
328
+ )
329
+
330
+ counts = [
331
+ f"- {_plural(audit.assignments, 'assignment')}",
332
+ f"- {_plural(audit.quizzes, 'quiz', 'quizzes')}"
333
+ f" ({audit.quizzes_with_due_dates} with due dates)",
334
+ f"- {_plural(audit.media, 'media file')}",
335
+ ]
336
+ lines.extend(["### Inventory", "", *counts, ""])
337
+
338
+ if audit.unmatched_assignments:
339
+ lines.extend(
340
+ [
341
+ "### Assignments with no matching content",
342
+ "",
343
+ "No topic in this course shares a distinguishing term with these "
344
+ "assignments. The brief may have been posted somewhere the API does not "
345
+ "expose, such as a quiz description or an announcement.",
346
+ "",
347
+ "| Assignment | Due |",
348
+ "| --- | --- |",
349
+ ]
350
+ )
351
+ for item in audit.unmatched_assignments:
352
+ lines.append(f"| {item.title} | {item.due_date or '—'} |")
353
+ lines.append("")
354
+
355
+ return "\n".join(lines)
356
+
357
+
358
+ __all__ = ["AUDIT_VERSION", "AssignmentMatch", "CourseAudit", "audit_vault", "write_audit"]
@@ -0,0 +1,282 @@
1
+ """Same-device authentication with a CDP path and a universal hidden-TTY fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import sys
7
+ from pathlib import Path
8
+ from urllib.parse import urljoin, urlsplit
9
+
10
+ import requests
11
+
12
+ from agent2learn import __version__, config, paths, session
13
+ from agent2learn.errors import AuthenticationError
14
+ from agent2learn.schools import School
15
+
16
+ from . import paste
17
+
18
+ _CONNECT_TIMEOUT = 10.0
19
+ _READ_TIMEOUT = 30.0
20
+
21
+
22
+ def authenticate(school: School, *, backend: str = "auto") -> session.Session:
23
+ """Harvest, verify, and persist a minimum same-device LEARN API session.
24
+
25
+ ``auto`` deliberately has only one fallback: the tested hidden-TTY paste flow. It does not
26
+ opportunistically select another browser automation stack or copy cookies from an everyday
27
+ browser profile.
28
+ """
29
+
30
+ normalized_backend = backend.casefold()
31
+ if normalized_backend == "paste":
32
+ return _authenticate_from_paste(school)
33
+ if normalized_backend not in {"auto", "cdp"}:
34
+ raise AuthenticationError(f"unknown authentication backend: {backend}")
35
+
36
+ from . import cdp
37
+
38
+ try:
39
+ harvested = cdp.authenticate_browser(school)
40
+ except AuthenticationError as exc:
41
+ safe_message = _safe_cdp_error(exc)
42
+ if normalized_backend == "auto":
43
+ raise AuthenticationError(f"{safe_message}; fallback: a2l auth --paste") from None
44
+ raise AuthenticationError(safe_message) from None
45
+ except (OSError, ValueError) as exc:
46
+ message = "dedicated browser authentication could not access local state"
47
+ if normalized_backend == "auto":
48
+ message += "; fallback: a2l auth --paste"
49
+ raise AuthenticationError(message) from exc
50
+
51
+ stable_id = _verified_id_from_cdp_result(harvested)
52
+ if stable_id is None:
53
+ raise AuthenticationError("login could not be verified; try: a2l auth --paste")
54
+ verified = _with_user_id(harvested, stable_id)
55
+ _store_verified(verified)
56
+ return verified
57
+
58
+
59
+ def verify(value: session.Session, school: School) -> str | None:
60
+ """Return the stable D2L identifier after an authenticated, same-origin API check.
61
+
62
+ The response is reduced immediately to ``Identifier``. Display names and the rest of the
63
+ response never enter the returned value or the persisted ``Session`` projection.
64
+ """
65
+
66
+ if _origin(value.base_url) != _origin(school.base_url):
67
+ raise AuthenticationError("session and school must use the same HTTPS origin")
68
+
69
+ transport = requests.Session()
70
+ transport.trust_env = False
71
+ transport.cookies.update(value.requests_cookies())
72
+ headers = {
73
+ "User-Agent": f"agent2learn/{__version__} (+https://github.com/ManagementMO/agent2learn)",
74
+ }
75
+ if value.xsrf:
76
+ headers["X-Csrf-Token"] = value.xsrf
77
+
78
+ versions_url = urljoin(value.base_url.rstrip("/") + "/", "d2l/api/versions/")
79
+ try:
80
+ versions_response = transport.get(
81
+ versions_url,
82
+ headers=headers,
83
+ timeout=(_CONNECT_TIMEOUT, _READ_TIMEOUT),
84
+ allow_redirects=False,
85
+ )
86
+ except requests.RequestException:
87
+ return None
88
+ try:
89
+ if not 200 <= versions_response.status_code < 300:
90
+ return None
91
+ if "text/html" in versions_response.headers.get("Content-Type", "").casefold():
92
+ return None
93
+ versions = versions_response.json()
94
+ except (ValueError, requests.RequestException):
95
+ return None
96
+ finally:
97
+ versions_response.close()
98
+
99
+ candidates = _lp_versions(versions)
100
+ for version in candidates:
101
+ whoami_url = urljoin(
102
+ value.base_url.rstrip("/") + "/",
103
+ f"d2l/api/lp/{version}/users/whoami",
104
+ )
105
+ try:
106
+ response = transport.get(
107
+ whoami_url,
108
+ headers=headers,
109
+ timeout=(_CONNECT_TIMEOUT, _READ_TIMEOUT),
110
+ allow_redirects=False,
111
+ )
112
+ except requests.RequestException:
113
+ continue
114
+ try:
115
+ if not 200 <= response.status_code < 300:
116
+ continue
117
+ if "text/html" in response.headers.get("Content-Type", "").casefold():
118
+ continue
119
+ payload = response.json()
120
+ except (ValueError, requests.RequestException):
121
+ continue
122
+ finally:
123
+ response.close()
124
+
125
+ identifier = _stable_identifier(payload)
126
+ if identifier is not None:
127
+ return identifier
128
+ return None
129
+
130
+
131
+ def clear_profile() -> None:
132
+ """Clear the exported session and remove only the dedicated browser profile after consent."""
133
+
134
+ try:
135
+ session.clear()
136
+ except OSError as exc:
137
+ raise AuthenticationError(
138
+ "saved API session could not be cleared; close it or fix its permissions and retry"
139
+ ) from exc
140
+ profile = config.data_dir() / "browser-profile"
141
+ if paths.is_link(profile):
142
+ raise AuthenticationError(f"refusing to remove symlinked profile: {profile}")
143
+ if _profile_is_locked(profile):
144
+ raise AuthenticationError(
145
+ f"dedicated browser profile is in use; close it normally before removing: {profile}"
146
+ )
147
+ if not paths.long_path(profile).exists():
148
+ return
149
+ if not paths.long_path(profile).is_dir():
150
+ raise AuthenticationError(f"dedicated browser profile is not a directory: {profile}")
151
+ if not _tty(sys.stdin) or not _tty(sys.stdout):
152
+ raise AuthenticationError(
153
+ f"profile removal requires an interactive confirmation for: {profile}"
154
+ )
155
+
156
+ sys.stderr.write(
157
+ "This removes the dedicated Agent2Learn browser profile and its Waterloo/Duo "
158
+ "remembered state:\n"
159
+ f" {profile}\n"
160
+ "Type 'yes' to continue: "
161
+ )
162
+ sys.stderr.flush()
163
+ answer = sys.stdin.readline().strip().casefold()
164
+ sys.stderr.write("\n")
165
+ if answer != "yes":
166
+ raise AuthenticationError("profile removal cancelled")
167
+ try:
168
+ paths.remove_tree(profile)
169
+ except OSError as exc:
170
+ raise AuthenticationError(
171
+ "dedicated browser profile could not be removed; close it and retry"
172
+ ) from exc
173
+
174
+
175
+ def _authenticate_from_paste(school: School) -> session.Session:
176
+ blob = paste.read_hidden_multiline()
177
+ pending = paste.session_from_blob(blob, base_url=school.base_url)
178
+ stable_id = verify(pending, school)
179
+ if stable_id is None:
180
+ raise AuthenticationError("login could not be verified; try: a2l auth --paste")
181
+ verified = _with_user_id(pending, stable_id)
182
+ _store_verified(verified)
183
+ return verified
184
+
185
+
186
+ def _store_verified(value: session.Session) -> None:
187
+ """Translate local session-storage failures into the CLI's stable auth taxonomy."""
188
+ try:
189
+ session.store(value)
190
+ except (OSError, ValueError) as exc:
191
+ raise AuthenticationError(
192
+ "verified session could not be saved; check local permissions"
193
+ ) from exc
194
+
195
+
196
+ def _with_user_id(value: session.Session, stable_id: str) -> session.Session:
197
+ return session.Session(
198
+ base_url=value.base_url,
199
+ cookies=value.cookies,
200
+ xsrf=value.xsrf,
201
+ harvested_at=value.harvested_at,
202
+ user_id=stable_id,
203
+ )
204
+
205
+
206
+ def _verified_id_from_cdp_result(value: object) -> str | None:
207
+ if isinstance(value, session.Session):
208
+ return value.user_id
209
+ if isinstance(value, str) and value:
210
+ return value
211
+ return None
212
+
213
+
214
+ def _safe_cdp_error(exc: AuthenticationError) -> str:
215
+ """Keep the public auth error generic while retaining a sanitized blocked hostname."""
216
+
217
+ prefix = "authentication stopped at undeclared host "
218
+ raw = str(exc)
219
+ if raw.startswith(prefix):
220
+ host = raw[len(prefix) :].split(";", 1)[0].strip()
221
+ if re.fullmatch(r"[A-Za-z0-9.:-]{1,253}", host):
222
+ return f"{prefix}{host}"
223
+ return "dedicated browser authentication failed"
224
+
225
+
226
+ def _lp_versions(payload: object) -> tuple[str, ...]:
227
+ if not isinstance(payload, list):
228
+ return ()
229
+ found: list[str] = []
230
+ for product in payload:
231
+ if not isinstance(product, dict) or product.get("ProductCode") != "lp":
232
+ continue
233
+ versions: list[str] = []
234
+ latest = product.get("LatestVersion")
235
+ if isinstance(latest, str):
236
+ versions.append(latest)
237
+ supported = product.get("SupportedVersions")
238
+ if isinstance(supported, list):
239
+ versions.extend(value for value in supported if isinstance(value, str))
240
+ for version in versions:
241
+ if version and version not in found:
242
+ found.append(version)
243
+ return tuple(found)
244
+
245
+
246
+ def _stable_identifier(payload: object) -> str | None:
247
+ if not isinstance(payload, dict):
248
+ return None
249
+ value = payload.get("Identifier")
250
+ if isinstance(value, bool):
251
+ return None
252
+ if isinstance(value, int):
253
+ return str(value)
254
+ if isinstance(value, str) and value.strip():
255
+ return value
256
+ return None
257
+
258
+
259
+ def _origin(value: str) -> tuple[str, str, int]:
260
+ parsed = urlsplit(value)
261
+ if parsed.scheme.casefold() != "https" or parsed.hostname is None:
262
+ raise AuthenticationError("authentication requires an HTTPS school origin")
263
+ try:
264
+ port = parsed.port or 443
265
+ except ValueError as exc:
266
+ raise AuthenticationError("school origin has an invalid port") from exc
267
+ return parsed.scheme.casefold(), parsed.hostname.rstrip(".").casefold(), port
268
+
269
+
270
+ def _profile_is_locked(profile: Path) -> bool:
271
+ return any(
272
+ paths.long_path(profile / marker).exists()
273
+ for marker in ("SingletonLock", "SingletonSocket", "SingletonCookie")
274
+ )
275
+
276
+
277
+ def _tty(stream: object) -> bool:
278
+ isatty = getattr(stream, "isatty", None)
279
+ return bool(callable(isatty) and isatty())
280
+
281
+
282
+ __all__ = ["authenticate", "clear_profile", "verify"]