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,247 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import unicodedata
6
+ from collections.abc import Collection, Iterator, Mapping, Sequence
7
+ from dataclasses import asdict, dataclass
8
+ from pathlib import Path, PurePosixPath
9
+
10
+ from agent2learn import paths
11
+ from agent2learn.calibrate import CourseRef
12
+ from agent2learn.errors import A2LError
13
+ from agent2learn.schools import School
14
+ from agent2learn.vault import Vault
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class CourseIdentity:
19
+ school: str
20
+ org_unit_id: int
21
+ code: str
22
+ name: str
23
+ term: str | None
24
+
25
+
26
+ def read_course_identity(course_dir: Path) -> CourseIdentity | None:
27
+ path = course_dir / "_meta" / "course.json"
28
+ raw = _read_json(path, course_dir.parent.parent)
29
+ if raw is None:
30
+ if paths.long_path(path).exists():
31
+ raise A2LError("course directory ownership is invalid")
32
+ return None
33
+ if not isinstance(raw, dict) or set(raw) != {
34
+ "schema_version",
35
+ "school",
36
+ "org_unit_id",
37
+ "code",
38
+ "name",
39
+ "term",
40
+ }:
41
+ raise A2LError("course directory ownership is invalid")
42
+ identifier = raw.get("org_unit_id")
43
+ if (
44
+ isinstance(raw.get("schema_version"), bool)
45
+ or not isinstance(raw.get("schema_version"), int)
46
+ or raw.get("schema_version") != 1
47
+ or not isinstance(raw.get("school"), str)
48
+ or not raw["school"]
49
+ or isinstance(identifier, bool)
50
+ or not isinstance(identifier, int)
51
+ or identifier <= 0
52
+ or not isinstance(raw.get("code"), str)
53
+ or not isinstance(raw.get("name"), str)
54
+ or raw.get("term") is not None
55
+ and not isinstance(raw.get("term"), str)
56
+ ):
57
+ raise A2LError("course directory ownership is invalid")
58
+ return CourseIdentity(raw["school"], identifier, raw["code"], raw["name"], raw["term"])
59
+
60
+
61
+ def course_directory(vault: Vault, school: School, course: CourseRef, preferred: Path) -> Path:
62
+ from agent2learn.index import read_content_map
63
+
64
+ wanted = (school.id, course.org_unit_id)
65
+ owners: dict[Path, set[tuple[str, int]]] = {}
66
+ for key, entry in vault.manifest().items():
67
+ parts = PurePosixPath(entry.path).parts
68
+ owner = _source_owner(key)
69
+ if owner is not None and len(parts) >= 3 and not parts[0].startswith("."):
70
+ directory = vault.root / parts[0] / parts[1]
71
+ owners.setdefault(directory, set()).add(owner)
72
+ candidates = set(owners)
73
+ for term in _directories(vault.root):
74
+ for directory in _directories(term):
75
+ if paths.long_path(directory / "_meta").is_dir():
76
+ candidates.add(directory)
77
+ matches: list[Path] = []
78
+ empty_legacy: set[Path] = set()
79
+ for directory in sorted(candidates):
80
+ if paths.has_link_component(directory, root=vault.root):
81
+ raise A2LError("course directory ownership contains a link component")
82
+ identity = read_course_identity(directory)
83
+ known = set(owners.get(directory, set()))
84
+ if identity is not None:
85
+ known.add((identity.school, identity.org_unit_id))
86
+ content_map = directory / "_meta" / "content_map.json"
87
+ if paths.has_link_component(content_map, root=vault.root):
88
+ raise A2LError("course metadata contains a link component")
89
+ if paths.long_path(content_map).is_file():
90
+ topics = read_content_map(directory)["topics"]
91
+ assert isinstance(topics, list)
92
+ for row in topics:
93
+ if isinstance(row, Mapping):
94
+ owner = _source_owner(row.get("source_key"))
95
+ if owner is not None:
96
+ known.add(owner)
97
+ if not topics and identity is None and not known:
98
+ empty_legacy.add(directory)
99
+ if wanted in known:
100
+ if known != {wanted}:
101
+ raise A2LError("ambiguous course directory ownership")
102
+ matches.append(directory)
103
+ if len(matches) > 1:
104
+ raise A2LError("ambiguous course directory ownership")
105
+ if matches:
106
+ destination = matches[0]
107
+ elif preferred in empty_legacy:
108
+ destination = preferred
109
+ else:
110
+ if paths.has_link_component(preferred, root=vault.root):
111
+ raise A2LError("course directory contains a link component")
112
+ paths.ensure_dir(preferred.parent, root=vault.root)
113
+ destination = paths.unique_path(preferred)
114
+ identity = CourseIdentity(school.id, course.org_unit_id, course.code, course.name, course.term)
115
+ destination_file = destination / "_meta" / "course.json"
116
+ payload = {"schema_version": 1, **asdict(identity)}
117
+ if _read_json(destination_file, vault.root) != payload:
118
+ paths.ensure_dir(destination_file.parent, root=vault.root)
119
+ paths.atomic_write_text(
120
+ destination_file,
121
+ json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
122
+ root=vault.root,
123
+ )
124
+ return destination
125
+
126
+
127
+ def assignment_row_directory(course_dir: Path, row: Mapping[str, object]) -> Path | None:
128
+ value = row.get("directory")
129
+ if value is None:
130
+ instructions = row.get("instructions_html")
131
+ if isinstance(instructions, str):
132
+ value = PurePosixPath(instructions).parent.as_posix()
133
+ if value is None:
134
+ return None
135
+ if (
136
+ not isinstance(value, str)
137
+ or "\\" in value
138
+ or ":" in value
139
+ or PurePosixPath(value).is_absolute()
140
+ or any(part in {"", ".", ".."} for part in value.split("/"))
141
+ ):
142
+ raise A2LError("assignment directory ownership is invalid")
143
+ root = course_dir.parent.parent
144
+ destination = root / PurePosixPath(value)
145
+ if destination.parent != course_dir / "assignments" or paths.has_link_component(
146
+ destination, root=root
147
+ ):
148
+ raise A2LError("assignment directory ownership escapes its course")
149
+ return destination
150
+
151
+
152
+ def assignment_directories(
153
+ vault: Vault,
154
+ course_dir: Path,
155
+ school: School,
156
+ course: CourseRef,
157
+ rows: Sequence[dict[str, object]],
158
+ previous: Sequence[Mapping[str, object]],
159
+ active: Collection[str],
160
+ ) -> dict[str, Path]:
161
+ old = {str(row.get("id")): row for row in previous}
162
+ claimed: dict[str, str] = {}
163
+ reserved: list[Path] = []
164
+ for row in previous:
165
+ directory = assignment_row_directory(course_dir, row)
166
+ if directory is not None:
167
+ name = _path_key(directory)
168
+ identifier = str(row.get("id"))
169
+ if name in claimed and claimed[name] != identifier:
170
+ raise A2LError("ambiguous assignment directory ownership")
171
+ claimed[name] = identifier
172
+ reserved.append(directory)
173
+ result: dict[str, Path] = {}
174
+ for row in sorted(rows, key=lambda value: str(value.get("id"))):
175
+ identifier = str(row.get("id"))
176
+ if identifier not in active:
177
+ continue
178
+ directory = assignment_row_directory(course_dir, row)
179
+ entry = vault.entry(f"{school.id}:{course.org_unit_id}:dropbox:{identifier}")
180
+ if entry is not None:
181
+ source_directory = vault.materialized(entry).parent
182
+ if directory is not None and directory != source_directory:
183
+ raise A2LError("assignment directory disagrees with its source manifest")
184
+ directory = source_directory
185
+ if directory is None and identifier in old:
186
+ title = str(old[identifier].get("title") or f"Assignment {identifier}")
187
+ for name in (f"{title} {identifier}", title):
188
+ candidate = course_dir / "assignments" / paths.safe_name(name)
189
+ if paths.is_link(candidate):
190
+ raise A2LError("assignment directory contains a link component")
191
+ if paths.long_path(candidate).is_dir():
192
+ directory = candidate
193
+ break
194
+ if directory is None:
195
+ title = str(row.get("title") or f"Assignment {identifier}")
196
+ preferred = course_dir / "assignments" / paths.safe_name(f"{title} {identifier}")
197
+ directory = paths.unique_path(preferred, reserved=reserved)
198
+ relative = paths.rel_posix(directory, vault.root)
199
+ checked = assignment_row_directory(course_dir, {"directory": relative})
200
+ assert checked is not None
201
+ name = _path_key(checked)
202
+ if name in claimed and claimed[name] != identifier:
203
+ raise A2LError("ambiguous assignment directory ownership")
204
+ claimed[name] = identifier
205
+ reserved.append(checked)
206
+ paths.ensure_dir(checked, root=vault.root)
207
+ row["directory"] = relative
208
+ result[identifier] = checked
209
+ return result
210
+
211
+
212
+ def _source_owner(value: object) -> tuple[str, int] | None:
213
+ if not isinstance(value, str):
214
+ return None
215
+ parts = value.split(":")
216
+ if len(parts) == 4 and parts[0] and parts[1].isdigit() and int(parts[1]) > 0:
217
+ return parts[0], int(parts[1])
218
+ return None
219
+
220
+
221
+ def _directories(parent: Path) -> Iterator[Path]:
222
+ if not paths.long_path(parent).is_dir():
223
+ return
224
+ for child in sorted(paths.long_path(parent).iterdir()):
225
+ candidate = parent / child.name
226
+ if (
227
+ not candidate.name.startswith(".")
228
+ and not paths.is_link(candidate)
229
+ and paths.long_path(candidate).is_dir()
230
+ ):
231
+ yield candidate
232
+
233
+
234
+ def _read_json(path: Path, root: Path) -> object:
235
+ if paths.has_link_component(path, root=root):
236
+ raise A2LError("directory ownership metadata contains a link component")
237
+ try:
238
+ with open(os.fspath(paths.long_path(path)), encoding="utf-8", newline="") as handle:
239
+ return json.load(handle)
240
+ except FileNotFoundError:
241
+ return None
242
+ except (OSError, ValueError) as exc:
243
+ raise A2LError("directory ownership metadata is unreadable") from exc
244
+
245
+
246
+ def _path_key(path: Path) -> str:
247
+ return unicodedata.normalize("NFC", path.as_posix()).casefold()