agent2learn 0.1.6__tar.gz → 0.1.7__tar.gz
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-0.1.6 → agent2learn-0.1.7}/PKG-INFO +1 -1
- {agent2learn-0.1.6 → agent2learn-0.1.7}/pyproject.toml +1 -1
- {agent2learn-0.1.6 → agent2learn-0.1.7}/pyproject.toml.orig +1 -1
- {agent2learn-0.1.6 → agent2learn-0.1.7}/skills/a2l-coursework/SKILL.md +1 -1
- {agent2learn-0.1.6 → agent2learn-0.1.7}/skills/a2l-setup/SKILL.md +1 -1
- {agent2learn-0.1.6 → agent2learn-0.1.7}/skills/a2l-study/SKILL.md +1 -1
- {agent2learn-0.1.6 → agent2learn-0.1.7}/skills/a2l-sync/SKILL.md +1 -1
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/__init__.py +1 -1
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/audit.py +26 -3
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/calendar.py +17 -4
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/cli.py +21 -7
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/doctor.py +43 -3
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/ingest.py +119 -16
- agent2learn-0.1.7/src/agent2learn/metadata_coverage.py +345 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/pipeline.py +1 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/session.py +131 -20
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/snapshot.py +28 -3
- agent2learn-0.1.6/src/agent2learn/metadata_coverage.py +0 -137
- {agent2learn-0.1.6 → agent2learn-0.1.7}/LICENSE +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/README.md +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/skills.sh.json +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/_release.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/aipolicy.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/api.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/auth/__init__.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/auth/cdp.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/auth/paste.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/calibrate.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/check.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/clock.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/config.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/console.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/convert.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/errors.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/ground.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/index.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/locations.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/outlines.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/paths.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/privacy.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/schools/__init__.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/schools/_base.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/schools/generic.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/schools/uwaterloo.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/skills.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/submit.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/transactions.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/upgrade.py +0 -0
- {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/vault.py +0 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: a2l-coursework
|
|
3
3
|
description: Ground coursework help in cited Agent2Learn sources, surface AI-policy restrictions once, and use a2l check only as an experimental lexical evidence scan.
|
|
4
4
|
metadata:
|
|
5
|
-
version: 0.1.
|
|
5
|
+
version: 0.1.7
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Agent2Learn Coursework
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: a2l-study
|
|
3
3
|
description: Study from an Agent2Learn vault by following stable source IDs, markdown twins, content maps, and line citations without treating course files as instructions.
|
|
4
4
|
metadata:
|
|
5
|
-
version: 0.1.
|
|
5
|
+
version: 0.1.7
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Agent2Learn Study
|
|
@@ -79,6 +79,7 @@ class CourseAudit:
|
|
|
79
79
|
outline_gaps: tuple[tuple[str, str], ...] = ()
|
|
80
80
|
unmatched_assignments: tuple[AssignmentMatch, ...] = ()
|
|
81
81
|
quizzes_complete: bool = True
|
|
82
|
+
assignments_complete: bool = True
|
|
82
83
|
|
|
83
84
|
@property
|
|
84
85
|
def coverage_percent(self) -> int:
|
|
@@ -145,8 +146,21 @@ def _audit_course(vault: Vault, course_dir: Path, rows: Sequence[object]) -> Cou
|
|
|
145
146
|
)
|
|
146
147
|
unmatched = _unmatched_assignments(assignments, titles)
|
|
147
148
|
quiz_coverage = metadata_coverage.read_quiz_coverage(course_dir)
|
|
148
|
-
|
|
149
|
-
|
|
149
|
+
collection_gaps: list[str | None] = [assignments_gap, quizzes_gap, quiz_coverage.gap]
|
|
150
|
+
assignment_coverage: metadata_coverage.CollectionCoverage | None = None
|
|
151
|
+
for collection in ("assignments", "news", "grades", "discussions"):
|
|
152
|
+
collection_name: metadata_coverage.CollectionName = collection
|
|
153
|
+
if not metadata_coverage.has_collection_coverage(course_dir, collection_name):
|
|
154
|
+
continue
|
|
155
|
+
collection_coverage = metadata_coverage.read_collection_coverage(
|
|
156
|
+
course_dir, collection_name
|
|
157
|
+
)
|
|
158
|
+
if collection == "assignments":
|
|
159
|
+
assignment_coverage = collection_coverage
|
|
160
|
+
collection_gaps.append(collection_coverage.gap(collection))
|
|
161
|
+
metadata_gaps = tuple(gap for gap in collection_gaps if gap is not None)
|
|
162
|
+
assignments_complete = assignments_gap is None and (
|
|
163
|
+
assignment_coverage is None or assignment_coverage.status == "complete"
|
|
150
164
|
)
|
|
151
165
|
|
|
152
166
|
first = next((row for row in rows if isinstance(row, Mapping)), {})
|
|
@@ -166,6 +180,7 @@ def _audit_course(vault: Vault, course_dir: Path, rows: Sequence[object]) -> Cou
|
|
|
166
180
|
outline_gaps=outline_gaps,
|
|
167
181
|
unmatched_assignments=unmatched,
|
|
168
182
|
quizzes_complete=quiz_coverage.status == "complete" and quizzes_gap is None,
|
|
183
|
+
assignments_complete=assignments_complete,
|
|
169
184
|
)
|
|
170
185
|
|
|
171
186
|
|
|
@@ -339,8 +354,16 @@ def _render(audits: Sequence[CourseAudit], stamp: str) -> str:
|
|
|
339
354
|
else f"- Quiz inventory not confirmed; {audit.quizzes} cached quiz record(s)"
|
|
340
355
|
f" ({audit.quizzes_with_due_dates} with due dates)"
|
|
341
356
|
)
|
|
357
|
+
assignment_inventory = (
|
|
358
|
+
f"- {_plural(audit.assignments, 'assignment')}"
|
|
359
|
+
if audit.assignments_complete
|
|
360
|
+
else (
|
|
361
|
+
f"- Assignment inventory not confirmed; {audit.assignments} cached assignment "
|
|
362
|
+
"record(s)"
|
|
363
|
+
)
|
|
364
|
+
)
|
|
342
365
|
counts = [
|
|
343
|
-
|
|
366
|
+
assignment_inventory,
|
|
344
367
|
quiz_inventory,
|
|
345
368
|
f"- {_plural(audit.media, 'media file')}",
|
|
346
369
|
]
|
|
@@ -87,6 +87,18 @@ QUIZ_COVERAGE_WARNING = (
|
|
|
87
87
|
"Quiz coverage is incomplete or unknown; deadlines may be missing and cached quiz dates "
|
|
88
88
|
"are not confirmed current. Check LEARN."
|
|
89
89
|
)
|
|
90
|
+
METADATA_COVERAGE_WARNING = (
|
|
91
|
+
"Metadata coverage is incomplete or unavailable; deadlines may be missing and cached "
|
|
92
|
+
"dates are not confirmed current. Check LEARN."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def coverage_warning(gaps: Sequence[str]) -> str:
|
|
97
|
+
"""Return a stable warning without echoing server-provided diagnostics."""
|
|
98
|
+
|
|
99
|
+
if gaps and all(gap.startswith("quizzes ") for gap in gaps):
|
|
100
|
+
return QUIZ_COVERAGE_WARNING
|
|
101
|
+
return METADATA_COVERAGE_WARNING
|
|
90
102
|
|
|
91
103
|
|
|
92
104
|
def collect_events(vault: Vault, school: School) -> tuple[CalendarEvent, ...]:
|
|
@@ -153,8 +165,9 @@ def render_ics(vault: Vault, school: School, *, now: datetime | None = None) ->
|
|
|
153
165
|
"METHOD:PUBLISH",
|
|
154
166
|
f"X-WR-TIMEZONE:{_escape_text(school.timezone)}",
|
|
155
167
|
]
|
|
156
|
-
|
|
157
|
-
|
|
168
|
+
metadata_gaps = metadata_coverage.vault_metadata_gaps(vault)
|
|
169
|
+
if metadata_gaps:
|
|
170
|
+
lines.append(f"X-A2L-COVERAGE-WARNING:{_escape_text(coverage_warning(metadata_gaps))}")
|
|
158
171
|
for event in collect_events(vault, school):
|
|
159
172
|
lines.extend(_event_lines(event, timezone, stamp))
|
|
160
173
|
lines.append("END:VCALENDAR")
|
|
@@ -236,7 +249,7 @@ def build_today(
|
|
|
236
249
|
overdue=tuple(overdue),
|
|
237
250
|
exam_countdowns=tuple(exam_countdowns),
|
|
238
251
|
changes=snapshot.diff_vault(vault, include_grades=include_grades),
|
|
239
|
-
metadata_gaps=metadata_coverage.
|
|
252
|
+
metadata_gaps=metadata_coverage.vault_metadata_gaps(vault),
|
|
240
253
|
)
|
|
241
254
|
|
|
242
255
|
|
|
@@ -252,7 +265,7 @@ def render_today(report: TodayReport, *, include_grades: bool = False) -> str:
|
|
|
252
265
|
[
|
|
253
266
|
"Metadata coverage is incomplete:",
|
|
254
267
|
*[f"- {gap}" for gap in report.metadata_gaps],
|
|
255
|
-
"Deadlines may be missing, and cached
|
|
268
|
+
"Deadlines may be missing, and cached metadata dates may be outdated.",
|
|
256
269
|
"",
|
|
257
270
|
]
|
|
258
271
|
)
|
|
@@ -390,8 +390,9 @@ def calendar(
|
|
|
390
390
|
else:
|
|
391
391
|
written = calendar_module.write_ics(vault, school, output)
|
|
392
392
|
typer.echo(f"calendar exported: {_display_path(written)}")
|
|
393
|
-
|
|
394
|
-
|
|
393
|
+
metadata_gaps = metadata_coverage.vault_metadata_gaps(vault)
|
|
394
|
+
if metadata_gaps:
|
|
395
|
+
typer.echo(f"Warning: {calendar_module.coverage_warning(metadata_gaps)}", err=True)
|
|
395
396
|
except A2LError as exc:
|
|
396
397
|
typer.echo(str(exc), err=True)
|
|
397
398
|
raise typer.Exit(code=exc.exit_code) from None
|
|
@@ -1862,14 +1863,14 @@ def _print_metadata_summary(
|
|
|
1862
1863
|
include_grades: bool,
|
|
1863
1864
|
) -> None:
|
|
1864
1865
|
gaps = (
|
|
1865
|
-
metadata_coverage.
|
|
1866
|
+
metadata_coverage.vault_metadata_gaps(Vault(root))
|
|
1866
1867
|
if report is None
|
|
1867
1868
|
else getattr(report, "gaps", ())
|
|
1868
1869
|
)
|
|
1869
1870
|
for gap in gaps:
|
|
1870
1871
|
typer.echo(
|
|
1871
|
-
f"{console.GLYPH['warn']} {gap};
|
|
1872
|
-
"See .a2l/AUDIT.md and check
|
|
1872
|
+
f"{console.GLYPH['warn']} {gap}; metadata/deadline coverage is not confirmed. "
|
|
1873
|
+
"See .a2l/AUDIT.md and check the affected collection in LEARN.",
|
|
1873
1874
|
err=True,
|
|
1874
1875
|
)
|
|
1875
1876
|
if report is None:
|
|
@@ -1888,6 +1889,7 @@ def _print_metadata_summary(
|
|
|
1888
1889
|
|
|
1889
1890
|
assignment_count = 0
|
|
1890
1891
|
quiz_count = 0
|
|
1892
|
+
assignments_complete = True
|
|
1891
1893
|
quizzes_complete = True
|
|
1892
1894
|
deadlines: list[tuple[str, str, str]] = []
|
|
1893
1895
|
if isinstance(reports, Sequence):
|
|
@@ -1900,6 +1902,13 @@ def _print_metadata_summary(
|
|
|
1900
1902
|
assignments = _read_metadata_rows(directory / "_meta" / "assignments.json")
|
|
1901
1903
|
quizzes = _read_metadata_rows(directory / "_meta" / "quizzes.json")
|
|
1902
1904
|
coverage = metadata_coverage.read_quiz_coverage(directory)
|
|
1905
|
+
assignment_coverage = metadata_coverage.read_collection_coverage(
|
|
1906
|
+
directory, "assignments"
|
|
1907
|
+
)
|
|
1908
|
+
if metadata_coverage.has_collection_coverage(directory, "assignments"):
|
|
1909
|
+
assignments_complete = assignments_complete and (
|
|
1910
|
+
assignment_coverage.status == "complete"
|
|
1911
|
+
)
|
|
1903
1912
|
quizzes_complete = quizzes_complete and coverage.status == "complete"
|
|
1904
1913
|
assignment_count += len(assignments)
|
|
1905
1914
|
quiz_count += len(quizzes)
|
|
@@ -1911,11 +1920,16 @@ def _print_metadata_summary(
|
|
|
1911
1920
|
|
|
1912
1921
|
grade_text = "grades synced" if include_grades else "grades not synced"
|
|
1913
1922
|
detail = f"{deadline_count} {'deadlines' if quizzes_complete else 'known deadlines'}"
|
|
1914
|
-
if assignment_count or quiz_count:
|
|
1923
|
+
if assignment_count or quiz_count or not assignments_complete:
|
|
1924
|
+
assignment_inventory = (
|
|
1925
|
+
f"{assignment_count} assignments"
|
|
1926
|
+
if assignments_complete
|
|
1927
|
+
else "assignment inventory unconfirmed"
|
|
1928
|
+
)
|
|
1915
1929
|
quiz_inventory = (
|
|
1916
1930
|
f"{quiz_count} quizzes" if quizzes_complete else "quiz inventory unconfirmed"
|
|
1917
1931
|
)
|
|
1918
|
-
detail = f"{
|
|
1932
|
+
detail = f"{assignment_inventory} · {quiz_inventory} · {detail}"
|
|
1919
1933
|
typer.echo(
|
|
1920
1934
|
f"{console.GLYPH['ok']} {course_count} courses · {topic_count} topics · "
|
|
1921
1935
|
f"{detail} · {grade_text}"
|
|
@@ -92,7 +92,10 @@ _COURSE_SOURCE_DIRECTORIES = frozenset(
|
|
|
92
92
|
)
|
|
93
93
|
_SAFE_PUBLIC_NOTES = {
|
|
94
94
|
"fs.vault": "vault root `~`",
|
|
95
|
-
"metadata.
|
|
95
|
+
"metadata.collections": (
|
|
96
|
+
"One or more optional metadata collections are unavailable or incomplete."
|
|
97
|
+
),
|
|
98
|
+
"metadata.quizzes": "Quiz collection coverage is incomplete, unavailable, or unknown.",
|
|
96
99
|
"metadata.quizzes.forbidden": "Quiz collection unavailable (HTTP 403 Forbidden).",
|
|
97
100
|
}
|
|
98
101
|
_PUBLIC_CHECK_NAMES = frozenset(
|
|
@@ -110,6 +113,7 @@ _PUBLIC_CHECK_NAMES = frozenset(
|
|
|
110
113
|
"fs.longest_path",
|
|
111
114
|
"fs.unavailable",
|
|
112
115
|
"fs.vault",
|
|
116
|
+
"metadata.collections",
|
|
113
117
|
"metadata.quizzes",
|
|
114
118
|
"metadata.quizzes.forbidden",
|
|
115
119
|
"session.age",
|
|
@@ -640,8 +644,10 @@ def _vault(vault: Vault | None) -> list[Check]:
|
|
|
640
644
|
unusable_twins = 0
|
|
641
645
|
unreadable_maps = 0
|
|
642
646
|
quiz_gaps: set[str] = set()
|
|
647
|
+
collection_gaps: set[str] = set()
|
|
643
648
|
quizzes_forbidden = False
|
|
644
649
|
quiz_sync_needed = False
|
|
650
|
+
collection_sync_needed = False
|
|
645
651
|
term_stats: dict[str, list[int]] = {}
|
|
646
652
|
try:
|
|
647
653
|
map_paths = sorted(
|
|
@@ -665,8 +671,25 @@ def _vault(vault: Vault | None) -> list[Check]:
|
|
|
665
671
|
coverage = metadata_coverage.read_quiz_coverage(map_path.parent.parent)
|
|
666
672
|
if coverage.gap is not None:
|
|
667
673
|
quiz_gaps.add(coverage.gap)
|
|
668
|
-
quizzes_forbidden = quizzes_forbidden or
|
|
674
|
+
quizzes_forbidden = quizzes_forbidden or (
|
|
675
|
+
coverage.status == "unavailable" and coverage.http_status == 403
|
|
676
|
+
)
|
|
669
677
|
quiz_sync_needed = quiz_sync_needed or coverage.status in {"unknown", "incomplete"}
|
|
678
|
+
for collection in ("assignments", "news", "grades", "discussions"):
|
|
679
|
+
collection_name: metadata_coverage.CollectionName = collection
|
|
680
|
+
if not metadata_coverage.has_collection_coverage(
|
|
681
|
+
map_path.parent.parent, collection_name
|
|
682
|
+
):
|
|
683
|
+
continue
|
|
684
|
+
collection_coverage = metadata_coverage.read_collection_coverage(
|
|
685
|
+
map_path.parent.parent, collection_name
|
|
686
|
+
)
|
|
687
|
+
if (gap := collection_coverage.gap(collection)) is not None:
|
|
688
|
+
collection_gaps.add(gap)
|
|
689
|
+
collection_sync_needed = collection_sync_needed or collection_coverage.status in {
|
|
690
|
+
"unknown",
|
|
691
|
+
"incomplete",
|
|
692
|
+
}
|
|
670
693
|
term = map_path.parent.parent.parent.name
|
|
671
694
|
stats = term_stats.setdefault(term, [0, 0, 0, 0, 0, 0])
|
|
672
695
|
stats[0] += 1
|
|
@@ -786,6 +809,22 @@ def _vault(vault: Vault | None) -> list[Check]:
|
|
|
786
809
|
public=_SAFE_PUBLIC_NOTES[quiz_check] if quiz_gaps else None,
|
|
787
810
|
)
|
|
788
811
|
)
|
|
812
|
+
if collection_gaps:
|
|
813
|
+
collection_fix = (
|
|
814
|
+
"run: a2l sync"
|
|
815
|
+
if collection_sync_needed
|
|
816
|
+
else "see .a2l/AUDIT.md; check the affected collection in LEARN"
|
|
817
|
+
)
|
|
818
|
+
checks.append(
|
|
819
|
+
Check(
|
|
820
|
+
"Vault",
|
|
821
|
+
"metadata.collections",
|
|
822
|
+
"warn",
|
|
823
|
+
"; ".join(sorted(collection_gaps)),
|
|
824
|
+
collection_fix,
|
|
825
|
+
public=_SAFE_PUBLIC_NOTES["metadata.collections"],
|
|
826
|
+
)
|
|
827
|
+
)
|
|
789
828
|
checks.append(
|
|
790
829
|
Check(
|
|
791
830
|
"Vault",
|
|
@@ -927,7 +966,8 @@ def next_command(checks: Sequence[Check]) -> str | None:
|
|
|
927
966
|
return f"run: {candidate}"
|
|
928
967
|
if any(
|
|
929
968
|
check.status == "warn"
|
|
930
|
-
and check.name
|
|
969
|
+
and check.name
|
|
970
|
+
in {"vault.terms", "vault.citable", "metadata.quizzes.forbidden", "metadata.collections"}
|
|
931
971
|
for check in checks
|
|
932
972
|
):
|
|
933
973
|
# All actionable fixes were considered above. Viewing cached information is useful;
|
|
@@ -155,6 +155,7 @@ class FileReport:
|
|
|
155
155
|
interrupted: bool = False
|
|
156
156
|
errors: tuple[str, ...] = ()
|
|
157
157
|
exit_code: int = 0
|
|
158
|
+
gaps: tuple[str, ...] = ()
|
|
158
159
|
|
|
159
160
|
|
|
160
161
|
@dataclass(frozen=True)
|
|
@@ -223,6 +224,53 @@ class IngestClient(Protocol):
|
|
|
223
224
|
) -> DownloadResult: ...
|
|
224
225
|
|
|
225
226
|
|
|
227
|
+
def _record_optional_collection(
|
|
228
|
+
course_dir: Path,
|
|
229
|
+
vault: Vault,
|
|
230
|
+
collection: metadata_coverage.CollectionName,
|
|
231
|
+
*,
|
|
232
|
+
complete: bool,
|
|
233
|
+
error: BaseException | None,
|
|
234
|
+
errors: list[str],
|
|
235
|
+
gaps: list[str],
|
|
236
|
+
) -> metadata_coverage.CollectionCoverage:
|
|
237
|
+
"""Persist safe coverage for an optional collection and classify its failure.
|
|
238
|
+
|
|
239
|
+
A 404 means that the optional D2L tool or collection is not available for this course. It is
|
|
240
|
+
not evidence of an empty list, but it also must not prevent independent content from syncing.
|
|
241
|
+
All other failures remain metadata errors. A successful retry clears a prior recorded gap.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
coverage = metadata_coverage.collection_coverage(collection, complete, error)
|
|
245
|
+
if error is not None:
|
|
246
|
+
if coverage.status == "unavailable":
|
|
247
|
+
if (gap := coverage.gap(collection)) is not None:
|
|
248
|
+
gaps.append(gap)
|
|
249
|
+
else:
|
|
250
|
+
errors.append(_safe_error(collection, error))
|
|
251
|
+
if error is not None or metadata_coverage.has_collection_coverage(course_dir, collection):
|
|
252
|
+
metadata_coverage.write_collection_coverage(
|
|
253
|
+
course_dir, collection, coverage, root=vault.root
|
|
254
|
+
)
|
|
255
|
+
return coverage
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _discussion_failure(
|
|
259
|
+
course_dir: Path,
|
|
260
|
+
vault: Vault,
|
|
261
|
+
error: BaseException,
|
|
262
|
+
) -> tuple[str | None, str | None]:
|
|
263
|
+
"""Persist a discussion failure and return ``(error, recorded_gap)``."""
|
|
264
|
+
|
|
265
|
+
coverage = metadata_coverage.collection_coverage("discussions", False, error)
|
|
266
|
+
metadata_coverage.write_collection_coverage(
|
|
267
|
+
course_dir, "discussions", coverage, root=vault.root
|
|
268
|
+
)
|
|
269
|
+
if coverage.status == "unavailable":
|
|
270
|
+
return None, coverage.gap("discussions")
|
|
271
|
+
return _safe_error("discussions", error), None
|
|
272
|
+
|
|
273
|
+
|
|
226
274
|
def ingest_metadata(
|
|
227
275
|
client: IngestClient,
|
|
228
276
|
vault: Vault,
|
|
@@ -272,8 +320,15 @@ def ingest_metadata(
|
|
|
272
320
|
assignments, assignments_complete, assignments_error = _fetch_collection(
|
|
273
321
|
client, _endpoint_path(client, course, "dropbox/folders/")
|
|
274
322
|
)
|
|
275
|
-
|
|
276
|
-
|
|
323
|
+
_record_optional_collection(
|
|
324
|
+
course_dir,
|
|
325
|
+
vault,
|
|
326
|
+
"assignments",
|
|
327
|
+
complete=assignments_complete,
|
|
328
|
+
error=assignments_error,
|
|
329
|
+
errors=errors,
|
|
330
|
+
gaps=gaps,
|
|
331
|
+
)
|
|
277
332
|
existing_map = _read_content_map(course_dir)
|
|
278
333
|
attachment_topics = _assignment_attachment_topics(assignments, course=course, school=school)
|
|
279
334
|
merged_topics = _merge_topic_records(
|
|
@@ -334,8 +389,15 @@ def ingest_metadata(
|
|
|
334
389
|
news, news_complete, news_error = _fetch_collection(
|
|
335
390
|
client, _endpoint_path(client, course, "news/")
|
|
336
391
|
)
|
|
337
|
-
|
|
338
|
-
|
|
392
|
+
_record_optional_collection(
|
|
393
|
+
course_dir,
|
|
394
|
+
vault,
|
|
395
|
+
"news",
|
|
396
|
+
complete=news_complete,
|
|
397
|
+
error=news_error,
|
|
398
|
+
errors=errors,
|
|
399
|
+
gaps=gaps,
|
|
400
|
+
)
|
|
339
401
|
news_rows = _project_news(news)
|
|
340
402
|
news_rows = _merge_rows(
|
|
341
403
|
_read_list(course_dir / "_meta" / "news.json"),
|
|
@@ -370,8 +432,15 @@ def ingest_metadata(
|
|
|
370
432
|
grades, grades_complete, grades_error = _fetch_collection(
|
|
371
433
|
client, _endpoint_path(client, course, "grades/values/myGradeValues/")
|
|
372
434
|
)
|
|
373
|
-
|
|
374
|
-
|
|
435
|
+
_record_optional_collection(
|
|
436
|
+
course_dir,
|
|
437
|
+
vault,
|
|
438
|
+
"grades",
|
|
439
|
+
complete=grades_complete,
|
|
440
|
+
error=grades_error,
|
|
441
|
+
errors=errors,
|
|
442
|
+
gaps=gaps,
|
|
443
|
+
)
|
|
375
444
|
grades_path = course_dir / "_meta" / "my_grades.json"
|
|
376
445
|
if grades_complete:
|
|
377
446
|
grade_rows = _merge_rows(
|
|
@@ -393,7 +462,8 @@ def ingest_metadata(
|
|
|
393
462
|
complete=False,
|
|
394
463
|
)
|
|
395
464
|
_write_list(grades_path, grade_rows, root=vault.root)
|
|
396
|
-
|
|
465
|
+
if grades_error is None:
|
|
466
|
+
errors.append("grades: incomplete response")
|
|
397
467
|
|
|
398
468
|
merged_topics = course_index.reconcile_content_map(vault, merged_topics)
|
|
399
469
|
_write_content_map(course_dir, merged_topics, root=vault.root)
|
|
@@ -469,6 +539,16 @@ def load_metadata_report(
|
|
|
469
539
|
coverage = metadata_coverage.read_quiz_coverage(course_dir)
|
|
470
540
|
if coverage.gap is not None:
|
|
471
541
|
(errors if coverage.status == "incomplete" else gaps).append(coverage.gap)
|
|
542
|
+
for collection in ("assignments", "news", "grades", "discussions"):
|
|
543
|
+
collection_name: metadata_coverage.CollectionName = collection
|
|
544
|
+
if not metadata_coverage.has_collection_coverage(course_dir, collection_name):
|
|
545
|
+
continue
|
|
546
|
+
collection_state = metadata_coverage.read_collection_coverage(
|
|
547
|
+
course_dir, collection_name
|
|
548
|
+
)
|
|
549
|
+
collection_gap = collection_state.gap(collection_name)
|
|
550
|
+
if collection_gap is not None:
|
|
551
|
+
(errors if collection_state.status == "incomplete" else gaps).append(collection_gap)
|
|
472
552
|
deadline_count += sum(
|
|
473
553
|
1
|
|
474
554
|
for row in [
|
|
@@ -546,6 +626,7 @@ def ingest_files(
|
|
|
546
626
|
selected = _selected_courses(client, term=term, only=only)
|
|
547
627
|
downloaded = skipped = failed = metadata_only = download_gaps = 0
|
|
548
628
|
errors: list[str] = []
|
|
629
|
+
gaps: list[str] = []
|
|
549
630
|
|
|
550
631
|
for course in selected:
|
|
551
632
|
course_dir = _course_directory(vault, school, course)
|
|
@@ -569,7 +650,7 @@ def ingest_files(
|
|
|
569
650
|
else list(planned)
|
|
570
651
|
)
|
|
571
652
|
if include_discussions:
|
|
572
|
-
discussion_error = _ingest_discussions(
|
|
653
|
+
discussion_error, discussion_gap = _ingest_discussions(
|
|
573
654
|
client,
|
|
574
655
|
course,
|
|
575
656
|
course_dir,
|
|
@@ -578,6 +659,8 @@ def ingest_files(
|
|
|
578
659
|
)
|
|
579
660
|
if discussion_error is not None:
|
|
580
661
|
errors.append(discussion_error)
|
|
662
|
+
if discussion_gap is not None:
|
|
663
|
+
gaps.append(discussion_gap)
|
|
581
664
|
|
|
582
665
|
for topic in chosen:
|
|
583
666
|
if topic.availability == "external_link":
|
|
@@ -651,6 +734,7 @@ def ingest_files(
|
|
|
651
734
|
interrupted=True,
|
|
652
735
|
errors=tuple(errors),
|
|
653
736
|
exit_code=130,
|
|
737
|
+
gaps=tuple(sorted(set(gaps))),
|
|
654
738
|
)
|
|
655
739
|
except SessionExpired:
|
|
656
740
|
raise
|
|
@@ -695,6 +779,7 @@ def ingest_files(
|
|
|
695
779
|
metadata_only=metadata_only,
|
|
696
780
|
download_gaps=download_gaps,
|
|
697
781
|
errors=tuple(errors),
|
|
782
|
+
gaps=tuple(sorted(set(gaps))),
|
|
698
783
|
)
|
|
699
784
|
|
|
700
785
|
|
|
@@ -2982,27 +3067,39 @@ def _ingest_discussions(
|
|
|
2982
3067
|
vault: Vault,
|
|
2983
3068
|
*,
|
|
2984
3069
|
include_authors: bool,
|
|
2985
|
-
) -> str | None:
|
|
2986
|
-
"""Fetch opt-in discussions while keeping raw author identity out of the vault.
|
|
3070
|
+
) -> tuple[str | None, str | None]:
|
|
3071
|
+
"""Fetch opt-in discussions while keeping raw author identity out of the vault.
|
|
3072
|
+
|
|
3073
|
+
The return value is ``(error, gap)``. A missing optional discussion tool is a recorded gap;
|
|
3074
|
+
malformed data, authentication failures, and other unexpected failures remain errors.
|
|
3075
|
+
"""
|
|
2987
3076
|
|
|
2988
3077
|
values, complete, error = _fetch_collection(
|
|
2989
3078
|
client, _endpoint_path(client, course, "discussions/forums/")
|
|
2990
3079
|
)
|
|
2991
3080
|
if error is not None:
|
|
2992
|
-
return
|
|
3081
|
+
return _discussion_failure(course_dir, vault, error)
|
|
2993
3082
|
if not complete:
|
|
2994
|
-
return
|
|
3083
|
+
return _discussion_failure(course_dir, vault, A2LError("incomplete response"))
|
|
2995
3084
|
if any(
|
|
2996
3085
|
not isinstance(value, dict) or not _discussion_forum_is_valid(value) for value in values
|
|
2997
3086
|
):
|
|
2998
|
-
return
|
|
3087
|
+
return _discussion_failure(
|
|
3088
|
+
course_dir,
|
|
3089
|
+
vault,
|
|
3090
|
+
A2LError("metadata endpoint returned an invalid forum"),
|
|
3091
|
+
)
|
|
2999
3092
|
forum_ids = [value["ForumId"] for value in values if isinstance(value, dict)]
|
|
3000
3093
|
if len({str(value) for value in forum_ids}) != len(forum_ids):
|
|
3001
|
-
return
|
|
3094
|
+
return _discussion_failure(
|
|
3095
|
+
course_dir,
|
|
3096
|
+
vault,
|
|
3097
|
+
A2LError("metadata endpoint returned duplicate forums"),
|
|
3098
|
+
)
|
|
3002
3099
|
try:
|
|
3003
3100
|
existing_rows = _read_discussion_rows(course_dir / "_meta" / "discussions.json")
|
|
3004
3101
|
except A2LError as exc:
|
|
3005
|
-
return
|
|
3102
|
+
return _discussion_failure(course_dir, vault, exc)
|
|
3006
3103
|
key = _discussion_key(vault)
|
|
3007
3104
|
posts = [
|
|
3008
3105
|
post for value in values if isinstance(value, dict) for post in _discussion_posts(value)
|
|
@@ -3067,7 +3164,13 @@ def _ingest_discussions(
|
|
|
3067
3164
|
paths.atomic_write_text(
|
|
3068
3165
|
discussion_dir / "discussions.md", "\n".join(markdown_lines), root=vault.root
|
|
3069
3166
|
)
|
|
3070
|
-
|
|
3167
|
+
metadata_coverage.write_collection_coverage(
|
|
3168
|
+
course_dir,
|
|
3169
|
+
"discussions",
|
|
3170
|
+
metadata_coverage.CollectionCoverage("complete"),
|
|
3171
|
+
root=vault.root,
|
|
3172
|
+
)
|
|
3173
|
+
return None, None
|
|
3071
3174
|
|
|
3072
3175
|
|
|
3073
3176
|
def _discussion_key(vault: Vault) -> bytes:
|