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