godcode-engine 4.0.0__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.
- godcode/__init__.py +86 -0
- godcode/__main__.py +7 -0
- godcode/agentics.py +235 -0
- godcode/ast.py +227 -0
- godcode/chain.py +210 -0
- godcode/cli.py +698 -0
- godcode/environment.py +74 -0
- godcode/errors.py +69 -0
- godcode/interpreter.py +1104 -0
- godcode/ledger.py +88 -0
- godcode/lexer.py +218 -0
- godcode/lsp.py +677 -0
- godcode/parser.py +525 -0
- godcode/plugins.py +255 -0
- godcode/registry.py +515 -0
- godcode/sandbox.py +385 -0
- godcode/scrolls/covenant.god +10 -0
- godcode/scrolls/covenant.toml +6 -0
- godcode/scrolls/lists.god +53 -0
- godcode/scrolls/lists.toml +6 -0
- godcode/scrolls/math.god +64 -0
- godcode/scrolls/math.toml +6 -0
- godcode/scrolls/prophecy.god +14 -0
- godcode/scrolls/prophecy.toml +6 -0
- godcode/scrolls/strings.god +32 -0
- godcode/scrolls/strings.toml +6 -0
- godcode/scrolls/time.god +10 -0
- godcode/scrolls/time.toml +6 -0
- godcode/spirit.py +180 -0
- godcode/tokens.py +91 -0
- godcode/tools.py +378 -0
- godcode/values.py +56 -0
- godcode_engine-4.0.0.dist-info/METADATA +212 -0
- godcode_engine-4.0.0.dist-info/RECORD +38 -0
- godcode_engine-4.0.0.dist-info/WHEEL +5 -0
- godcode_engine-4.0.0.dist-info/entry_points.txt +2 -0
- godcode_engine-4.0.0.dist-info/licenses/LICENSE +201 -0
- godcode_engine-4.0.0.dist-info/top_level.txt +1 -0
godcode/registry.py
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
"""Pillar 2 — the Scroll Registry: installable, versioned God Code scrolls.
|
|
2
|
+
|
|
3
|
+
A scroll is a directory holding a ``scroll.toml`` manifest beside its entry
|
|
4
|
+
``.god`` file. The local registry (``registry/`` at the repo root) is the
|
|
5
|
+
source of truth: ``publish`` copies a scroll into
|
|
6
|
+
``registry/scrolls/<name>/<version>/`` and records it in
|
|
7
|
+
``registry/index.json``; ``install`` copies from there into
|
|
8
|
+
``~/.godcode/scrolls/<name>/<version>/`` (user-global) or
|
|
9
|
+
``.godcode/scrolls/`` (project-local, wins over user-global).
|
|
10
|
+
|
|
11
|
+
requires-python is >=3.10, so ``tomllib`` (3.11+) is off the table: the
|
|
12
|
+
manifest parser below is a hand-rolled TOML subset covering exactly the
|
|
13
|
+
keys this registry defines.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import shutil
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Optional
|
|
25
|
+
|
|
26
|
+
try: # the package version is the engine version; tolerate odd import orders
|
|
27
|
+
from godcode import __version__ as ENGINE_VERSION
|
|
28
|
+
except Exception: # pragma: no cover - defensive
|
|
29
|
+
ENGINE_VERSION = "2.0.0"
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# errors
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ScrollError(Exception):
|
|
36
|
+
"""Raised when a manifest, publish, install, or lookup fails."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# manifest parsing (TOML subset: key = "value" pairs, # comments)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
MANIFEST_KEYS = frozenset(
|
|
44
|
+
{"name", "version", "author", "description", "entry", "godcode"}
|
|
45
|
+
)
|
|
46
|
+
REQUIRED_KEYS = MANIFEST_KEYS # every key is required for now
|
|
47
|
+
_NAME_RE = re.compile(r"^[a-z][a-z0-9-]*[a-z0-9]$|^[a-z]$")
|
|
48
|
+
_SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
|
|
49
|
+
_REQ_CLAUSE_RE = re.compile(r"^(>=|<=|==|!=|>|<)\s*(\d+(?:\.\d+){0,2})$")
|
|
50
|
+
|
|
51
|
+
# Human-readable blurbs shown in errors; keep them short.
|
|
52
|
+
_KEY_HINTS = {
|
|
53
|
+
"name": 'e.g. name = "json-tools"',
|
|
54
|
+
"version": 'e.g. version = "1.0.0"',
|
|
55
|
+
"author": 'e.g. author = "Ama Seretse"',
|
|
56
|
+
"description": 'e.g. description = "JSON helpers written in God Code"',
|
|
57
|
+
"entry": 'e.g. entry = "json-tools.god"',
|
|
58
|
+
"godcode": 'e.g. godcode = ">=2.0"',
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _strip_comment(line: str) -> str:
|
|
63
|
+
"""Cut a trailing ``#`` comment, ignoring ``#`` inside quoted strings."""
|
|
64
|
+
in_quote: str | None = None
|
|
65
|
+
i = 0
|
|
66
|
+
while i < len(line):
|
|
67
|
+
ch = line[i]
|
|
68
|
+
if in_quote:
|
|
69
|
+
if ch == "\\" and in_quote == '"':
|
|
70
|
+
i += 2
|
|
71
|
+
continue
|
|
72
|
+
if ch == in_quote:
|
|
73
|
+
in_quote = None
|
|
74
|
+
elif ch in ('"', "'"):
|
|
75
|
+
in_quote = ch
|
|
76
|
+
elif ch == "#":
|
|
77
|
+
return line[:i]
|
|
78
|
+
i += 1
|
|
79
|
+
return line
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_toml_value(raw: str, source: str, lineno: int) -> str:
|
|
83
|
+
"""Parse a quoted TOML string value (double- or single-quoted)."""
|
|
84
|
+
if len(raw) >= 2 and raw[0] == '"' and raw[-1] == '"':
|
|
85
|
+
out: list[str] = []
|
|
86
|
+
i = 1
|
|
87
|
+
while i < len(raw) - 1:
|
|
88
|
+
ch = raw[i]
|
|
89
|
+
if ch == "\\":
|
|
90
|
+
i += 1
|
|
91
|
+
if i >= len(raw) - 1:
|
|
92
|
+
raise ScrollError(
|
|
93
|
+
f"{source}:{lineno}: dangling backslash in manifest value"
|
|
94
|
+
)
|
|
95
|
+
esc = raw[i]
|
|
96
|
+
out.append({"n": "\n", "t": "\t", "r": "\r"}.get(esc, esc))
|
|
97
|
+
else:
|
|
98
|
+
out.append(ch)
|
|
99
|
+
i += 1
|
|
100
|
+
return "".join(out)
|
|
101
|
+
if len(raw) >= 2 and raw[0] == "'" and raw[-1] == "'":
|
|
102
|
+
return raw[1:-1] # literal string: no escapes
|
|
103
|
+
raise ScrollError(
|
|
104
|
+
f"{source}:{lineno}: manifest values must be quoted strings "
|
|
105
|
+
f"(got {raw!r})"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def parse_manifest(text: str, source: str = "<scroll.toml>") -> dict[str, str]:
|
|
110
|
+
"""Parse and validate a ``scroll.toml`` manifest. Returns a plain dict."""
|
|
111
|
+
data: dict[str, str] = {}
|
|
112
|
+
for lineno, raw_line in enumerate(text.splitlines(), 1):
|
|
113
|
+
line = _strip_comment(raw_line).strip()
|
|
114
|
+
if not line:
|
|
115
|
+
continue
|
|
116
|
+
if "=" not in line:
|
|
117
|
+
raise ScrollError(
|
|
118
|
+
f"{source}:{lineno}: expected `key = \"value\"`, got {line!r}"
|
|
119
|
+
)
|
|
120
|
+
key, _, value = line.partition("=")
|
|
121
|
+
key, value = key.strip(), value.strip()
|
|
122
|
+
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_-]*", key):
|
|
123
|
+
raise ScrollError(f"{source}:{lineno}: bad manifest key {key!r}")
|
|
124
|
+
if key in data:
|
|
125
|
+
raise ScrollError(
|
|
126
|
+
f"{source}:{lineno}: duplicate manifest key {key!r}"
|
|
127
|
+
)
|
|
128
|
+
data[key] = _parse_toml_value(value, source, lineno)
|
|
129
|
+
|
|
130
|
+
unknown = sorted(set(data) - MANIFEST_KEYS)
|
|
131
|
+
if unknown:
|
|
132
|
+
raise ScrollError(
|
|
133
|
+
f"{source}: unknown manifest key(s) {', '.join(unknown)}; "
|
|
134
|
+
"known keys: " + ", ".join(sorted(MANIFEST_KEYS))
|
|
135
|
+
)
|
|
136
|
+
missing = sorted(REQUIRED_KEYS - set(data))
|
|
137
|
+
if missing:
|
|
138
|
+
hints = "; ".join(_KEY_HINTS[k] for k in missing)
|
|
139
|
+
raise ScrollError(
|
|
140
|
+
f"{source}: missing required manifest key(s) "
|
|
141
|
+
f"{', '.join(missing)} ({hints})"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
name = data["name"]
|
|
145
|
+
if not _NAME_RE.match(name):
|
|
146
|
+
raise ScrollError(
|
|
147
|
+
f"{source}: name {name!r} must be lowercase letters, digits and "
|
|
148
|
+
"hyphens, starting with a letter"
|
|
149
|
+
)
|
|
150
|
+
parse_semver(data["version"], source=source) # validates
|
|
151
|
+
entry = data["entry"]
|
|
152
|
+
if not entry or "/" in entry or "\\" in entry or entry.startswith("."):
|
|
153
|
+
raise ScrollError(
|
|
154
|
+
f"{source}: entry must be a plain filename, got {entry!r}"
|
|
155
|
+
)
|
|
156
|
+
req = data["godcode"]
|
|
157
|
+
if not requirement_satisfied(req):
|
|
158
|
+
raise ScrollError(
|
|
159
|
+
f"{source}: this scroll needs God Code {req}, "
|
|
160
|
+
f"but the engine is {ENGINE_VERSION}"
|
|
161
|
+
)
|
|
162
|
+
return data
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def read_manifest(path: str | os.PathLike) -> dict[str, str]:
|
|
166
|
+
"""Read, parse, and validate the manifest at ``path``."""
|
|
167
|
+
path = Path(path)
|
|
168
|
+
try:
|
|
169
|
+
text = path.read_text(encoding="utf-8")
|
|
170
|
+
except OSError as exc:
|
|
171
|
+
raise ScrollError(f"cannot read manifest {path}: {exc}") from exc
|
|
172
|
+
return parse_manifest(text, source=str(path))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
# semver-ish versions (X.Y.Z only)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def parse_semver(text: str, source: str = "<manifest>") -> tuple[int, int, int]:
|
|
180
|
+
"""Parse ``X.Y.Z``; raise ScrollError on anything else."""
|
|
181
|
+
match = _SEMVER_RE.match(text.strip())
|
|
182
|
+
if not match:
|
|
183
|
+
raise ScrollError(
|
|
184
|
+
f"{source}: version must look like X.Y.Z, got {text!r}"
|
|
185
|
+
)
|
|
186
|
+
return int(match.group(1)), int(match.group(2)), int(match.group(3))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def compare_versions(a: str, b: str) -> int:
|
|
190
|
+
"""-1 if a < b, 0 if equal, 1 if a > b (numeric per component)."""
|
|
191
|
+
ta, tb = parse_semver(a), parse_semver(b)
|
|
192
|
+
return (ta > tb) - (ta < tb)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def latest_version(versions: list[str]) -> str:
|
|
196
|
+
"""The highest version; ties are impossible (versions are unique)."""
|
|
197
|
+
if not versions:
|
|
198
|
+
raise ScrollError("no versions to choose from")
|
|
199
|
+
best = versions[0]
|
|
200
|
+
for v in versions[1:]:
|
|
201
|
+
if compare_versions(v, best) > 0:
|
|
202
|
+
best = v
|
|
203
|
+
return best
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
# godcode version requirements, e.g. ">=2.0, <4"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _parse_requirement(req: str) -> list[tuple[str, tuple[int, ...]]]:
|
|
211
|
+
clauses: list[tuple[str, tuple[int, ...]]] = []
|
|
212
|
+
for part in req.split(","):
|
|
213
|
+
part = part.strip()
|
|
214
|
+
if not part:
|
|
215
|
+
raise ScrollError(f"bad godcode requirement {req!r}: empty clause")
|
|
216
|
+
match = _REQ_CLAUSE_RE.match(part)
|
|
217
|
+
if not match:
|
|
218
|
+
raise ScrollError(
|
|
219
|
+
f"bad godcode requirement {req!r}: expected e.g. "
|
|
220
|
+
f"'>=2.0', got {part!r}"
|
|
221
|
+
)
|
|
222
|
+
op, ver = match.group(1), match.group(2)
|
|
223
|
+
clauses.append((op, tuple(int(x) for x in ver.split("."))))
|
|
224
|
+
return clauses
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _pad(parts: tuple[int, ...], length: int) -> tuple[int, ...]:
|
|
228
|
+
return parts + (0,) * (length - len(parts))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _clauses_satisfied(
|
|
232
|
+
req: str, engine: str, clauses: list[tuple[str, tuple[int, ...]]]
|
|
233
|
+
) -> bool:
|
|
234
|
+
engine_parts = _pad(tuple(int(x) for x in engine.split(".")), 3)
|
|
235
|
+
for op, want in clauses:
|
|
236
|
+
want = _pad(want, 3)
|
|
237
|
+
if op == ">=" and not (engine_parts >= want):
|
|
238
|
+
return False
|
|
239
|
+
if op == "<=" and not (engine_parts <= want):
|
|
240
|
+
return False
|
|
241
|
+
if op == "==" and not (engine_parts == want):
|
|
242
|
+
return False
|
|
243
|
+
if op == "!=" and not (engine_parts != want):
|
|
244
|
+
return False
|
|
245
|
+
if op == ">" and not (engine_parts > want):
|
|
246
|
+
return False
|
|
247
|
+
if op == "<" and not (engine_parts < want):
|
|
248
|
+
return False
|
|
249
|
+
return True
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def requirement_satisfied(req: str, engine: str = ENGINE_VERSION) -> bool:
|
|
253
|
+
"""True when ``engine`` satisfies a requirement like ``>=2.0``."""
|
|
254
|
+
return _clauses_satisfied(req, engine, _parse_requirement(req))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
# the registry
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _default_registry_root() -> Path:
|
|
262
|
+
return Path(__file__).resolve().parent.parent / "registry"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class ScrollRegistry:
|
|
266
|
+
"""Local scroll registry: publish into ``registry/``, install from it."""
|
|
267
|
+
|
|
268
|
+
def __init__(
|
|
269
|
+
self,
|
|
270
|
+
registry_root: str | os.PathLike | None = None,
|
|
271
|
+
home: str | os.PathLike | None = None,
|
|
272
|
+
project_dir: str | os.PathLike | None = None,
|
|
273
|
+
) -> None:
|
|
274
|
+
self.registry_root = Path(registry_root or _default_registry_root())
|
|
275
|
+
self.scrolls_root = self.registry_root / "scrolls"
|
|
276
|
+
self.index_path = self.registry_root / "index.json"
|
|
277
|
+
self.home = Path(home or Path.home()) # honors $HOME, monkeypatchable
|
|
278
|
+
self.user_dir = self.home / ".godcode" / "scrolls"
|
|
279
|
+
self.project_dir = Path(project_dir or Path.cwd())
|
|
280
|
+
self.project_scrolls_dir = self.project_dir / ".godcode" / "scrolls"
|
|
281
|
+
|
|
282
|
+
# ------------------------------------------------------------- index ---
|
|
283
|
+
|
|
284
|
+
def load_index(self) -> dict[str, Any]:
|
|
285
|
+
"""The published index; {} when nothing has been published yet."""
|
|
286
|
+
if not self.index_path.is_file():
|
|
287
|
+
return {}
|
|
288
|
+
try:
|
|
289
|
+
return json.loads(self.index_path.read_text(encoding="utf-8"))
|
|
290
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
291
|
+
raise ScrollError(
|
|
292
|
+
f"cannot read registry index {self.index_path}: {exc}"
|
|
293
|
+
) from exc
|
|
294
|
+
|
|
295
|
+
def _save_index(self, index: dict[str, Any]) -> None:
|
|
296
|
+
self.index_path.parent.mkdir(parents=True, exist_ok=True)
|
|
297
|
+
self.index_path.write_text(
|
|
298
|
+
json.dumps(index, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# ----------------------------------------------------------- publish ---
|
|
302
|
+
|
|
303
|
+
def publish(self, scroll_dir: str | os.PathLike) -> dict[str, str]:
|
|
304
|
+
"""Validate a scroll directory and publish it into the registry.
|
|
305
|
+
|
|
306
|
+
Copies ``scroll_dir`` to ``registry/scrolls/<name>/<version>/`` and
|
|
307
|
+
updates ``index.json``. Refuses when that exact version is already
|
|
308
|
+
published. Returns the validated manifest.
|
|
309
|
+
"""
|
|
310
|
+
src = Path(scroll_dir)
|
|
311
|
+
if not src.is_dir():
|
|
312
|
+
raise ScrollError(f"publish: {src} is not a directory")
|
|
313
|
+
manifest_path = src / "scroll.toml"
|
|
314
|
+
if not manifest_path.is_file():
|
|
315
|
+
raise ScrollError(f"publish: {src} holds no scroll.toml manifest")
|
|
316
|
+
manifest = read_manifest(manifest_path)
|
|
317
|
+
|
|
318
|
+
name, version = manifest["name"], manifest["version"]
|
|
319
|
+
if src.name != name:
|
|
320
|
+
raise ScrollError(
|
|
321
|
+
f"publish: manifest name {name!r} does not match directory "
|
|
322
|
+
f"name {src.name!r}"
|
|
323
|
+
)
|
|
324
|
+
entry_path = src / manifest["entry"]
|
|
325
|
+
if not entry_path.is_file():
|
|
326
|
+
raise ScrollError(
|
|
327
|
+
f"publish: entry file {manifest['entry']!r} not found in {src}"
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
index = self.load_index()
|
|
331
|
+
published = index.get(name, {}).get("versions", [])
|
|
332
|
+
if version in published:
|
|
333
|
+
raise ScrollError(
|
|
334
|
+
f"publish: {name} {version} is already in the registry — "
|
|
335
|
+
"bump the version to publish again"
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
dest = self.scrolls_root / name / version
|
|
339
|
+
if dest.exists():
|
|
340
|
+
raise ScrollError(
|
|
341
|
+
f"publish: {dest} already exists on disk but is not indexed; "
|
|
342
|
+
"remove it or repair the index first"
|
|
343
|
+
)
|
|
344
|
+
shutil.copytree(src, dest)
|
|
345
|
+
|
|
346
|
+
versions = sorted(set(published) | {version},
|
|
347
|
+
key=lambda v: parse_semver(v))
|
|
348
|
+
index[name] = {
|
|
349
|
+
"name": name,
|
|
350
|
+
"versions": versions,
|
|
351
|
+
"latest": latest_version(versions),
|
|
352
|
+
"manifest": manifest,
|
|
353
|
+
}
|
|
354
|
+
self._save_index(index)
|
|
355
|
+
return manifest
|
|
356
|
+
|
|
357
|
+
# ----------------------------------------------------------- install ---
|
|
358
|
+
|
|
359
|
+
def resolve_version(self, name: str, version: str | None = None) -> str:
|
|
360
|
+
"""The version to install: ``version`` if given, else the latest."""
|
|
361
|
+
index = self.load_index()
|
|
362
|
+
entry = index.get(name)
|
|
363
|
+
if entry is None:
|
|
364
|
+
raise ScrollError(
|
|
365
|
+
f"install: {name!r} is not in the registry "
|
|
366
|
+
f"({self.registry_root})"
|
|
367
|
+
)
|
|
368
|
+
versions: list[str] = entry["versions"]
|
|
369
|
+
if version is None:
|
|
370
|
+
return entry["latest"]
|
|
371
|
+
if version not in versions:
|
|
372
|
+
raise ScrollError(
|
|
373
|
+
f"install: {name} has no version {version!r}; "
|
|
374
|
+
f"published: {', '.join(versions)}"
|
|
375
|
+
)
|
|
376
|
+
return version
|
|
377
|
+
|
|
378
|
+
def install(
|
|
379
|
+
self,
|
|
380
|
+
name: str,
|
|
381
|
+
version: str | None = None,
|
|
382
|
+
project: bool = False,
|
|
383
|
+
) -> dict[str, Any]:
|
|
384
|
+
"""Install a scroll from the registry into an install directory.
|
|
385
|
+
|
|
386
|
+
``project=True`` installs under ``.godcode/scrolls/`` in the current
|
|
387
|
+
(or given) project directory; otherwise under ``~/.godcode/scrolls/``.
|
|
388
|
+
Returns the install receipt.
|
|
389
|
+
"""
|
|
390
|
+
resolved = self.resolve_version(name, version)
|
|
391
|
+
src = self.scrolls_root / name / resolved
|
|
392
|
+
if not src.is_dir():
|
|
393
|
+
raise ScrollError(
|
|
394
|
+
f"install: registry is missing files for {name} {resolved} "
|
|
395
|
+
f"(expected {src})"
|
|
396
|
+
)
|
|
397
|
+
manifest = read_manifest(src / "scroll.toml")
|
|
398
|
+
|
|
399
|
+
base = self.project_scrolls_dir if project else self.user_dir
|
|
400
|
+
dest = base / name / resolved
|
|
401
|
+
if dest.exists():
|
|
402
|
+
shutil.rmtree(dest)
|
|
403
|
+
shutil.copytree(src, dest)
|
|
404
|
+
|
|
405
|
+
receipt = {
|
|
406
|
+
"name": name,
|
|
407
|
+
"version": resolved,
|
|
408
|
+
"location": "project" if project else "user",
|
|
409
|
+
"installed_at": datetime.now(timezone.utc).isoformat(),
|
|
410
|
+
"manifest": manifest,
|
|
411
|
+
}
|
|
412
|
+
(dest / "install.json").write_text(
|
|
413
|
+
json.dumps(receipt, indent=2, sort_keys=True) + "\n",
|
|
414
|
+
encoding="utf-8",
|
|
415
|
+
)
|
|
416
|
+
return receipt
|
|
417
|
+
|
|
418
|
+
# -------------------------------------------------------------- read ---
|
|
419
|
+
|
|
420
|
+
def _installed_versions(self, base: Path) -> dict[str, list[str]]:
|
|
421
|
+
"""name -> sorted installed versions found under ``base``."""
|
|
422
|
+
found: dict[str, list[str]] = {}
|
|
423
|
+
if not base.is_dir():
|
|
424
|
+
return found
|
|
425
|
+
for name_dir in sorted(base.iterdir()):
|
|
426
|
+
if not name_dir.is_dir():
|
|
427
|
+
continue
|
|
428
|
+
for ver_dir in sorted(name_dir.iterdir()):
|
|
429
|
+
if not ver_dir.is_dir():
|
|
430
|
+
continue
|
|
431
|
+
if not (ver_dir / "scroll.toml").is_file():
|
|
432
|
+
continue
|
|
433
|
+
try:
|
|
434
|
+
parse_semver(ver_dir.name)
|
|
435
|
+
except ScrollError:
|
|
436
|
+
continue
|
|
437
|
+
found.setdefault(name_dir.name, []).append(ver_dir.name)
|
|
438
|
+
return found
|
|
439
|
+
|
|
440
|
+
def list_installed(self) -> list[dict[str, Any]]:
|
|
441
|
+
"""Every installed scroll; project-local entries win over user ones."""
|
|
442
|
+
user = self._installed_versions(self.user_dir)
|
|
443
|
+
project = self._installed_versions(self.project_scrolls_dir)
|
|
444
|
+
rows: list[dict[str, Any]] = []
|
|
445
|
+
for name in sorted(set(user) | set(project)):
|
|
446
|
+
versions = sorted(
|
|
447
|
+
set(user.get(name, [])) | set(project.get(name, [])),
|
|
448
|
+
key=lambda v: parse_semver(v),
|
|
449
|
+
)
|
|
450
|
+
rows.append(
|
|
451
|
+
{
|
|
452
|
+
"name": name,
|
|
453
|
+
"versions": versions,
|
|
454
|
+
"latest": latest_version(versions),
|
|
455
|
+
"locations": sorted(
|
|
456
|
+
{
|
|
457
|
+
*(("user",) if name in user else ()),
|
|
458
|
+
*(("project",) if name in project else ()),
|
|
459
|
+
}
|
|
460
|
+
),
|
|
461
|
+
}
|
|
462
|
+
)
|
|
463
|
+
return rows
|
|
464
|
+
|
|
465
|
+
def info(self, name: str) -> dict[str, Any]:
|
|
466
|
+
"""Manifest, published versions, and install state for ``name``."""
|
|
467
|
+
index = self.load_index()
|
|
468
|
+
entry = index.get(name)
|
|
469
|
+
user = self._installed_versions(self.user_dir).get(name, [])
|
|
470
|
+
project = self._installed_versions(self.project_scrolls_dir).get(name, [])
|
|
471
|
+
installed = sorted(set(user) | set(project),
|
|
472
|
+
key=lambda v: parse_semver(v))
|
|
473
|
+
return {
|
|
474
|
+
"name": name,
|
|
475
|
+
"published": entry["versions"] if entry else [],
|
|
476
|
+
"latest": entry["latest"] if entry else None,
|
|
477
|
+
"manifest": entry["manifest"] if entry else None,
|
|
478
|
+
"installed": installed,
|
|
479
|
+
"installed_project": sorted(project, key=lambda v: parse_semver(v)),
|
|
480
|
+
"installed_user": sorted(user, key=lambda v: parse_semver(v)),
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
# ------------------------------------------- interpreter resolution ----
|
|
484
|
+
|
|
485
|
+
def resolve_entry(self, name: str) -> Optional[Path]:
|
|
486
|
+
"""Path to the installed entry ``.god`` file for ``name``, if any.
|
|
487
|
+
|
|
488
|
+
Project-local installs win; otherwise the latest user-global
|
|
489
|
+
version wins. Returns None when the scroll is not installed.
|
|
490
|
+
"""
|
|
491
|
+
for base in (self.project_scrolls_dir, self.user_dir):
|
|
492
|
+
name_dir = base / name
|
|
493
|
+
if not name_dir.is_dir():
|
|
494
|
+
continue
|
|
495
|
+
versions: list[str] = []
|
|
496
|
+
for ver_dir in name_dir.iterdir():
|
|
497
|
+
if ver_dir.is_dir() and (ver_dir / "scroll.toml").is_file():
|
|
498
|
+
try:
|
|
499
|
+
parse_semver(ver_dir.name)
|
|
500
|
+
except ScrollError:
|
|
501
|
+
continue
|
|
502
|
+
versions.append(ver_dir.name)
|
|
503
|
+
if not versions:
|
|
504
|
+
continue
|
|
505
|
+
latest = latest_version(versions)
|
|
506
|
+
scroll_dir = name_dir / latest
|
|
507
|
+
try:
|
|
508
|
+
manifest = read_manifest(scroll_dir / "scroll.toml")
|
|
509
|
+
except ScrollError:
|
|
510
|
+
continue
|
|
511
|
+
entry = scroll_dir / manifest["entry"]
|
|
512
|
+
if entry.is_file():
|
|
513
|
+
return entry.resolve()
|
|
514
|
+
# project-local dir exists but is broken: fall through to user
|
|
515
|
+
return None
|