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/locations.py
ADDED
|
@@ -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()
|