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
|
@@ -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
|
+
]
|