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,157 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from collections.abc import Mapping
6
+ from hashlib import sha256
7
+ from pathlib import Path
8
+
9
+ from agent2learn import clock, paths
10
+ from agent2learn.errors import A2LError
11
+ from agent2learn.vault import (
12
+ ManifestEntry,
13
+ Vault,
14
+ _copy_verified,
15
+ _entry_from_json,
16
+ _entry_to_json,
17
+ _hash_file,
18
+ _validate_source_entry,
19
+ _validate_source_key,
20
+ )
21
+
22
+
23
+ def _directory(vault: Vault, key: str) -> Path:
24
+ _validate_source_key(key)
25
+ result = vault.state() / "pending-generated" / sha256(key.encode()).hexdigest()
26
+ if paths.has_link_component(result, root=vault.root):
27
+ raise A2LError("generated revision state contains a link component")
28
+ return result
29
+
30
+
31
+ def _files(entry: ManifestEntry) -> list[tuple[str, str]]:
32
+ return [
33
+ (entry.path, entry.sha256),
34
+ *((artifact.path, artifact.sha256) for _, artifact in sorted(entry.derived.items())),
35
+ ]
36
+
37
+
38
+ def _fingerprint(path: Path) -> str | None:
39
+ fingerprint, size = _hash_file(path)
40
+ if size < 0:
41
+ if paths.collides(path):
42
+ raise A2LError("generated revision destination is unreadable")
43
+ return None
44
+ return fingerprint
45
+
46
+
47
+ def recover_generated(vault: Vault, key: str) -> None:
48
+ directory = _directory(vault, key)
49
+ if not paths.long_path(directory).exists():
50
+ return
51
+ journal = directory / "entry.json"
52
+ if not paths.long_path(journal).exists():
53
+ for child in paths.long_path(directory).iterdir():
54
+ if (
55
+ child.suffix != ".part"
56
+ or not child.stem.isdigit()
57
+ or paths.is_link(child)
58
+ or not paths.long_path(child).is_file()
59
+ ):
60
+ raise A2LError("unrecognized pending generated revision")
61
+ paths.remove_tree(directory)
62
+ return
63
+ if paths.has_link_component(journal, root=vault.root):
64
+ raise A2LError("generated revision journal contains a link component")
65
+ with open(os.fspath(paths.long_path(journal)), encoding="utf-8") as handle:
66
+ raw = json.load(handle)
67
+ if not isinstance(raw, dict) or set(raw) != {"version", "key", "prior", "entry", "before"}:
68
+ raise A2LError("generated revision journal is invalid")
69
+ if type(raw["version"]) is not int or raw["version"] != 1 or raw["key"] != key:
70
+ raise A2LError("generated revision journal is invalid")
71
+ entry = _entry_from_json(key, raw["entry"])
72
+ prior = _entry_from_json(key, raw["prior"]) if raw["prior"] is not None else None
73
+ current = Vault(vault.root).entry(key)
74
+ if current not in (prior, entry):
75
+ raise A2LError("generated revision conflicts with the current manifest")
76
+ files = _files(entry)
77
+ before = raw["before"]
78
+ if not isinstance(before, dict) or set(before) != {path for path, _ in files}:
79
+ raise A2LError("generated revision journal paths are invalid")
80
+ for fingerprint in before.values():
81
+ if fingerprint is not None and (
82
+ not isinstance(fingerprint, str)
83
+ or len(fingerprint) != 64
84
+ or any(char not in "0123456789abcdef" for char in fingerprint)
85
+ ):
86
+ raise A2LError("generated revision journal digests are invalid")
87
+ staged: list[tuple[Path, Path, str, str | None]] = []
88
+ for index, (relative, fingerprint) in enumerate(files):
89
+ destination = vault._materialized_path(relative)
90
+ part = directory / f"{index}.part"
91
+ if paths.has_link_component(part, root=vault.root):
92
+ raise A2LError("generated revision part contains a link component")
93
+ actual = _fingerprint(destination)
94
+ if actual not in (before[relative], fingerprint):
95
+ raise A2LError("pending generated revision has locally modified destination bytes")
96
+ if paths.long_path(part).exists():
97
+ if _fingerprint(part) != fingerprint:
98
+ raise A2LError("pending generated revision part failed integrity validation")
99
+ if actual != fingerprint:
100
+ staged.append((destination, part, fingerprint, before[relative]))
101
+ elif actual != fingerprint:
102
+ raise A2LError("pending generated revision has missing bytes")
103
+ for destination, part, fingerprint, previous in staged:
104
+ if _fingerprint(destination) not in (previous, fingerprint):
105
+ raise A2LError("pending generated revision changed during recovery")
106
+ if not _copy_verified(
107
+ part, destination, root=vault.root, expected_sha256=fingerprint, expected_size=None
108
+ ):
109
+ raise A2LError("pending generated revision could not be installed")
110
+ vault.mark(key, entry)
111
+ vault.save_manifest()
112
+ paths.remove_tree(directory)
113
+
114
+
115
+ def install_generated(
116
+ vault: Vault,
117
+ key: str,
118
+ entry: ManifestEntry,
119
+ source: bytes,
120
+ derived: Mapping[str, bytes],
121
+ *,
122
+ preserve: bool,
123
+ ) -> None:
124
+ recover_generated(vault, key)
125
+ entry = _validate_source_entry(key, entry)
126
+ if set(derived) != set(entry.derived) or len(source) != entry.size:
127
+ raise A2LError("generated revision content does not match its manifest")
128
+ contents = [source, *(derived[name] for name in sorted(derived))]
129
+ files = _files(entry)
130
+ if any(
131
+ sha256(content).hexdigest() != fingerprint
132
+ for content, (_, fingerprint) in zip(contents, files, strict=True)
133
+ ):
134
+ raise A2LError("generated revision content failed integrity validation")
135
+ before = {relative: _fingerprint(vault._materialized_path(relative)) for relative, _ in files}
136
+ prior = vault.entry(key)
137
+ if preserve and prior is not None:
138
+ saved = vault.preserve_revision(key, changed_at=clock.now())
139
+ if saved is None and any(value is not None for value in before.values()):
140
+ raise A2LError("current generated revision could not be preserved")
141
+ directory = _directory(vault, key)
142
+ paths.ensure_dir(directory, root=vault.root)
143
+ for index, content in enumerate(contents):
144
+ paths.atomic_write_bytes(directory / f"{index}.part", content, root=vault.root)
145
+ journal = {
146
+ "version": 1,
147
+ "key": key,
148
+ "prior": _entry_to_json(prior) if prior is not None else None,
149
+ "entry": _entry_to_json(entry),
150
+ "before": before,
151
+ }
152
+ paths.atomic_write_text(
153
+ directory / "entry.json",
154
+ json.dumps(journal, ensure_ascii=False, sort_keys=True) + "\n",
155
+ root=vault.root,
156
+ )
157
+ recover_generated(vault, key)
agent2learn/upgrade.py ADDED
@@ -0,0 +1,288 @@
1
+ """Check for and install a newer Agent2Learn, only when the user asks.
2
+
3
+ Agent2Learn makes no passive network requests. There is no background version check, no
4
+ telemetry, and therefore no opt-out flag or environment variable to disable one — a switch that
5
+ turns something off implies it was on. ``a2l upgrade`` is the single command that talks to PyPI,
6
+ it names the URL it will read in its own help, and ``--check`` reports without changing anything.
7
+
8
+ Two things here are load-bearing:
9
+
10
+ **The version is untrusted input.** It arrives over the network and would otherwise be handed to a
11
+ package installer, so it is validated against a strict PEP 440 subset before it becomes an
12
+ argument, and every subprocess call passes an argument list — never a shell string. A version like
13
+ ``0.2.0; rm -rf /`` is refused, not escaped.
14
+
15
+ **A failure is never a guess.** An unreachable index, a malformed answer, or a missing version
16
+ field is reported as "could not determine", not silently treated as "you are up to date". The
17
+ underlying exception text is deliberately not echoed, because it can carry local network detail.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import re
24
+ import subprocess
25
+ from collections.abc import Callable
26
+ from dataclasses import dataclass
27
+ from urllib.request import Request, urlopen
28
+
29
+ # Typer's completion helper is intentionally imported from its public completion module. The
30
+ # completion tests render every supported shell, so a Typer change that removes it fails loudly.
31
+ from typer.completion import get_completion_script
32
+
33
+ from agent2learn import __version__
34
+ from agent2learn.errors import A2LError
35
+
36
+ PYPI_METADATA_URL = "https://pypi.org/pypi/agent2learn/json"
37
+ REQUEST_TIMEOUT = 15.0
38
+ PACKAGE_NAME = "agent2learn"
39
+
40
+ # A deliberately narrow PEP 440 subset: release segments, optional pre/post/dev suffixes. Local
41
+ # versions and arbitrary text are refused rather than sanitised.
42
+ _VERSION = re.compile(
43
+ r"^(?:(?P<epoch>[0-9]+)!)?"
44
+ r"(?P<release>[0-9]+(?:\.[0-9]+)*)"
45
+ r"(?:(?P<pre_kind>a|b|rc)(?P<pre_number>[0-9]+))?"
46
+ r"(?:\.post(?P<post_number>[0-9]+))?"
47
+ r"(?:\.dev(?P<dev_number>[0-9]+))?$"
48
+ )
49
+
50
+ MetadataFetcher = Callable[[str], object]
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class UpgradePlan:
55
+ """What an upgrade would do, described before anything is installed."""
56
+
57
+ installed: str
58
+ latest: str
59
+ needed: bool
60
+
61
+ @property
62
+ def requirement(self) -> str:
63
+ return f"{PACKAGE_NAME}=={self.latest}"
64
+
65
+
66
+ def latest_version(*, fetch: MetadataFetcher | None = None) -> str:
67
+ """Return the newest published version, or raise rather than assume."""
68
+
69
+ reader = fetch or _read_metadata
70
+ try:
71
+ payload = reader(PYPI_METADATA_URL)
72
+ except A2LError:
73
+ raise
74
+ except Exception:
75
+ # The exception text can name local hosts, proxies, and paths; the user needs the
76
+ # actionable part only.
77
+ raise A2LError(
78
+ f"could not reach {PYPI_METADATA_URL} to check for a newer version"
79
+ ) from None
80
+ if not isinstance(payload, dict):
81
+ raise A2LError("the package index returned an unexpected answer")
82
+ info = payload.get("info")
83
+ if not isinstance(info, dict):
84
+ raise A2LError("the package index answer had no version information")
85
+ value = info.get("version")
86
+ if not isinstance(value, str) or _VERSION.fullmatch(value) is None:
87
+ raise A2LError("the package index reported an unreadable version")
88
+ return value
89
+
90
+
91
+ def resolve_target(version: str) -> str:
92
+ """Validate a network-sourced version and return one exact pinned requirement."""
93
+
94
+ if not isinstance(version, str) or _VERSION.fullmatch(version) is None:
95
+ raise A2LError("refusing to install an unrecognised version string")
96
+ return f"{PACKAGE_NAME}=={version}"
97
+
98
+
99
+ def plan_upgrade(*, installed: str, latest: str) -> UpgradePlan:
100
+ """Describe the upgrade without performing it."""
101
+
102
+ resolve_target(latest)
103
+ return UpgradePlan(installed=installed, latest=latest, needed=_is_newer(latest, installed))
104
+
105
+
106
+ def render_plan(plan: UpgradePlan) -> str:
107
+ """Render the plan, always naming both versions."""
108
+
109
+ lines = [
110
+ f"installed: {plan.installed}",
111
+ f"latest: {plan.latest} (from {PYPI_METADATA_URL})",
112
+ "",
113
+ ]
114
+ if not plan.needed:
115
+ lines.append("Already up to date. Nothing to do.")
116
+ else:
117
+ lines.extend(
118
+ [
119
+ f"An upgrade is available: {plan.installed} -> {plan.latest}",
120
+ f"This would run: uv tool install --force {plan.requirement}",
121
+ ]
122
+ )
123
+ return "\n".join(lines) + "\n"
124
+
125
+
126
+ def apply_upgrade(plan: UpgradePlan) -> None:
127
+ """Install the exact pinned target through uv, using an argument list only."""
128
+
129
+ if not plan.needed:
130
+ return
131
+ requirement = resolve_target(plan.latest)
132
+ command = ["uv", "tool", "install", "--force", requirement]
133
+ try:
134
+ completed = subprocess.run(command, check=False, capture_output=True, text=True)
135
+ except FileNotFoundError:
136
+ raise A2LError(
137
+ "uv was not found; reinstall with the Agent2Learn installer or run: "
138
+ f"uv tool install --force {requirement}"
139
+ ) from None
140
+ except OSError:
141
+ raise A2LError("the upgrade could not be started") from None
142
+ if completed.returncode != 0:
143
+ raise A2LError(
144
+ f"upgrade to {plan.latest} failed; run this yourself to see why: "
145
+ f"uv tool install --force {requirement}"
146
+ )
147
+
148
+
149
+ def verify_installation(version: str) -> None:
150
+ """Verify the command and public command surface after a tool replacement.
151
+
152
+ ``uv tool install`` can succeed while an old executable remains earlier on ``PATH`` or while
153
+ a broken entry point is selected. Running the installed command as a child process catches
154
+ both cases without importing the current development checkout. The help probe is a small
155
+ schema check for the v0.1 command surface; it prevents reporting success for a package whose
156
+ entry point exists but no longer exposes the commands the installer promises.
157
+ """
158
+
159
+ resolve_target(version)
160
+ try:
161
+ version_result = subprocess.run(
162
+ ["a2l", "--version"], check=False, capture_output=True, text=True
163
+ )
164
+ except FileNotFoundError:
165
+ raise A2LError("upgrade installed no runnable a2l command; run a2l --version") from None
166
+ except OSError:
167
+ raise A2LError("could not verify the installed a2l command") from None
168
+ version_output = f"{version_result.stdout}\n{version_result.stderr}"
169
+ version_pattern = rf"(?<![0-9A-Za-z]){re.escape(version)}(?![0-9A-Za-z])"
170
+ if version_result.returncode != 0 or re.search(version_pattern, version_output) is None:
171
+ raise A2LError(
172
+ f"installed a2l did not report version {version}; run a2l --version to inspect it"
173
+ )
174
+
175
+ try:
176
+ help_result = subprocess.run(["a2l", "--help"], check=False, capture_output=True, text=True)
177
+ except (FileNotFoundError, OSError):
178
+ raise A2LError("could not verify the installed a2l command surface") from None
179
+ help_output = f"{help_result.stdout}\n{help_result.stderr}".casefold()
180
+ if help_result.returncode != 0 or not all(
181
+ marker in help_output for marker in ("courses", "sync", "skills")
182
+ ):
183
+ raise A2LError("installed a2l command surface is incompatible; run a2l --help")
184
+
185
+
186
+ def current_version() -> str:
187
+ """Return the running version."""
188
+
189
+ return __version__
190
+
191
+
192
+ def _is_newer(candidate: str, installed: str) -> bool:
193
+ return _sort_key(candidate) > _sort_key(installed)
194
+
195
+
196
+ def _sort_key(version: str) -> tuple[object, ...]:
197
+ """Return a PEP 440 ordering key for the accepted version subset.
198
+
199
+ The sentinel ranks mirror the relevant PEP 440 rules without making ``packaging`` a runtime
200
+ dependency: development-only releases precede alpha/beta/rc releases, final releases follow
201
+ all pre-releases, post releases follow the final release, and a post-development release is
202
+ below the corresponding post release. Trailing release zeros are insignificant (``1.0`` and
203
+ ``1.0.0`` therefore compare equal).
204
+ """
205
+
206
+ match = _VERSION.fullmatch(version)
207
+ if match is None:
208
+ raise A2LError("refusing to compare an unrecognised version string")
209
+ groups = match.groupdict()
210
+ release_parts = [int(part) for part in groups["release"].split(".")]
211
+ while len(release_parts) > 1 and release_parts[-1] == 0:
212
+ release_parts.pop()
213
+ release = tuple(release_parts)
214
+ pre_kind = groups["pre_kind"]
215
+ if pre_kind is None and groups["post_number"] is None and groups["dev_number"] is not None:
216
+ # A dev-only release is earlier than any named pre-release. A post-development release
217
+ # (``1.0.post1.dev1``) is different: PEP 440 places it after the corresponding final.
218
+ pre = (-1, 0, 0)
219
+ elif pre_kind is None:
220
+ # Final releases sort after alpha, beta, and release candidates.
221
+ pre = (1, 0, 0)
222
+ else:
223
+ pre = (0, {"a": 0, "b": 1, "rc": 2}[pre_kind], int(groups["pre_number"]))
224
+ post = -1 if groups["post_number"] is None else int(groups["post_number"])
225
+ dev = (1, 0) if groups["dev_number"] is None else (0, int(groups["dev_number"]))
226
+ return (int(groups["epoch"] or 0), release, pre, post, dev)
227
+
228
+
229
+ def _read_metadata(url: str) -> object:
230
+ request = Request( # noqa: S310 - the URL is a module constant, never user input
231
+ url,
232
+ headers={
233
+ "Accept": "application/json",
234
+ "User-Agent": f"agent2learn/{__version__} (+https://github.com/ManagementMO/agent2learn)",
235
+ },
236
+ method="GET",
237
+ )
238
+ if not url.startswith("https://pypi.org/"):
239
+ raise A2LError("refusing to read package metadata from an unexpected host")
240
+ with urlopen(request, timeout=REQUEST_TIMEOUT) as response: # noqa: S310 - constant https URL
241
+ if response.status != 200:
242
+ raise A2LError("the package index did not answer with metadata")
243
+ raw = response.read(2 * 1024 * 1024)
244
+ return json.loads(raw.decode("utf-8"))
245
+
246
+
247
+ def completion_shells() -> tuple[str, ...]:
248
+ """Return the shells ``a2l completions`` can emit for."""
249
+
250
+ return ("bash", "zsh", "fish", "powershell", "pwsh")
251
+
252
+
253
+ def completion_script(shell: str, *, prog_name: str = "a2l") -> str:
254
+ """Return the completion script for one shell without installing anything.
255
+
256
+ This goes through Typer's own generator rather than Click's registry: Typer vendors its Click
257
+ and registers the PowerShell completer only there, and Windows is a first-class target.
258
+ """
259
+
260
+ if shell not in completion_shells():
261
+ raise A2LError(
262
+ f"unsupported shell: {shell}; choose one of {', '.join(completion_shells())}"
263
+ )
264
+ try:
265
+ return get_completion_script(
266
+ prog_name=prog_name,
267
+ complete_var="_A2L_COMPLETE",
268
+ shell=shell,
269
+ )
270
+ except Exception:
271
+ raise A2LError(f"no completion support is available for {shell}") from None
272
+
273
+
274
+ __all__ = [
275
+ "PACKAGE_NAME",
276
+ "PYPI_METADATA_URL",
277
+ "REQUEST_TIMEOUT",
278
+ "UpgradePlan",
279
+ "apply_upgrade",
280
+ "completion_script",
281
+ "completion_shells",
282
+ "current_version",
283
+ "latest_version",
284
+ "plan_upgrade",
285
+ "render_plan",
286
+ "resolve_target",
287
+ "verify_installation",
288
+ ]