lean-runtime 0.6.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.
- lean_runtime/__init__.py +67 -0
- lean_runtime/__main__.py +4 -0
- lean_runtime/backends.py +373 -0
- lean_runtime/cli.py +254 -0
- lean_runtime/diagnostics.py +52 -0
- lean_runtime/environments.py +1170 -0
- lean_runtime/errors.py +63 -0
- lean_runtime/events.py +35 -0
- lean_runtime/health.py +86 -0
- lean_runtime/lake.py +43 -0
- lean_runtime/lockfiles.py +192 -0
- lean_runtime/locking.py +82 -0
- lean_runtime/models.py +78 -0
- lean_runtime/policies.py +36 -0
- lean_runtime/py.typed +1 -0
- lean_runtime/references.py +246 -0
- lean_runtime/resolver.py +282 -0
- lean_runtime/runtime.py +430 -0
- lean_runtime/serialization.py +51 -0
- lean_runtime/specs.py +210 -0
- lean_runtime/store.py +462 -0
- lean_runtime/toolchains.py +164 -0
- lean_runtime-0.6.0.dist-info/METADATA +332 -0
- lean_runtime-0.6.0.dist-info/RECORD +28 -0
- lean_runtime-0.6.0.dist-info/WHEEL +5 -0
- lean_runtime-0.6.0.dist-info/entry_points.txt +2 -0
- lean_runtime-0.6.0.dist-info/licenses/LICENSE +202 -0
- lean_runtime-0.6.0.dist-info/top_level.txt +1 -0
lean_runtime/store.py
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""Content-addressed storage for locks, sources, and environments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from contextlib import contextmanager, suppress
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .errors import EnvironmentError
|
|
21
|
+
from .lockfiles import EnvironmentLock
|
|
22
|
+
from .locking import FileLock
|
|
23
|
+
from .serialization import sha256_id, write_json_atomic
|
|
24
|
+
|
|
25
|
+
STORE_SCHEMA = "lean-runtime-store/1"
|
|
26
|
+
_ALIAS = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}")
|
|
27
|
+
_ENVIRONMENT_ID = re.compile(r"env_[0-9a-f]{64}")
|
|
28
|
+
ALIAS_SCHEMA = "lean-runtime-environment-alias/1"
|
|
29
|
+
SUPPORTED_BUILD_PROFILE = "release"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _snapshot_digest(root: Path) -> str:
|
|
33
|
+
"""Hash checked-out content while excluding Git and runtime metadata."""
|
|
34
|
+
digest = hashlib.sha256()
|
|
35
|
+
for directory, directories, filenames in os.walk(root, followlinks=False):
|
|
36
|
+
current = Path(directory)
|
|
37
|
+
symlink_directories = [name for name in directories if (current / name).is_symlink()]
|
|
38
|
+
directories[:] = sorted(
|
|
39
|
+
name
|
|
40
|
+
for name in directories
|
|
41
|
+
if name not in {".git", ".lake"} and name not in symlink_directories
|
|
42
|
+
)
|
|
43
|
+
for name in sorted([*filenames, *symlink_directories]):
|
|
44
|
+
path = current / name
|
|
45
|
+
relative = path.relative_to(root)
|
|
46
|
+
if relative.as_posix() == ".lean-runtime-source.json":
|
|
47
|
+
continue
|
|
48
|
+
if path.is_symlink():
|
|
49
|
+
digest.update(b"link\0" + relative.as_posix().encode() + b"\0")
|
|
50
|
+
digest.update(os.readlink(path).encode())
|
|
51
|
+
elif path.is_file():
|
|
52
|
+
mode = path.stat().st_mode & 0o111
|
|
53
|
+
digest.update(
|
|
54
|
+
b"file\0" + relative.as_posix().encode() + b"\0" + str(mode).encode() + b"\0"
|
|
55
|
+
)
|
|
56
|
+
with path.open("rb") as handle:
|
|
57
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
58
|
+
digest.update(chunk)
|
|
59
|
+
return "sha256:" + digest.hexdigest()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def platform_record() -> dict[str, str]:
|
|
63
|
+
return {
|
|
64
|
+
"system": platform.system().lower(),
|
|
65
|
+
"machine": platform.machine().lower(),
|
|
66
|
+
"python_platform": platform.platform(),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def environment_identity(lock: EnvironmentLock, build_profile: str = "release") -> str:
|
|
71
|
+
if build_profile != SUPPORTED_BUILD_PROFILE:
|
|
72
|
+
raise EnvironmentError(
|
|
73
|
+
f"unsupported build profile {build_profile!r}; only 'release' is implemented"
|
|
74
|
+
)
|
|
75
|
+
return sha256_id(
|
|
76
|
+
"env",
|
|
77
|
+
{
|
|
78
|
+
"schema": STORE_SCHEMA,
|
|
79
|
+
"lock_id": lock.lock_id,
|
|
80
|
+
"platform": platform_record(),
|
|
81
|
+
"build_profile": build_profile,
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def clone_tree(source: Path, destination: Path) -> None:
|
|
87
|
+
"""Clone a tree using copy-on-write where the host supports it."""
|
|
88
|
+
if destination.exists():
|
|
89
|
+
raise EnvironmentError(f"clone destination already exists: {destination}")
|
|
90
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
if platform.system() == "Darwin":
|
|
92
|
+
result = subprocess.run(
|
|
93
|
+
["cp", "-cR", str(source), str(destination)],
|
|
94
|
+
stdout=subprocess.DEVNULL,
|
|
95
|
+
stderr=subprocess.DEVNULL,
|
|
96
|
+
check=False,
|
|
97
|
+
)
|
|
98
|
+
if result.returncode == 0:
|
|
99
|
+
return
|
|
100
|
+
elif platform.system() == "Linux":
|
|
101
|
+
result = subprocess.run(
|
|
102
|
+
["cp", "--reflink=auto", "-a", str(source), str(destination)],
|
|
103
|
+
stdout=subprocess.DEVNULL,
|
|
104
|
+
stderr=subprocess.DEVNULL,
|
|
105
|
+
check=False,
|
|
106
|
+
)
|
|
107
|
+
if result.returncode == 0:
|
|
108
|
+
return
|
|
109
|
+
shutil.copytree(source, destination, symlinks=True)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True, slots=True)
|
|
113
|
+
class GarbageCollectionReport:
|
|
114
|
+
candidates: tuple[str, ...]
|
|
115
|
+
removed: tuple[str, ...]
|
|
116
|
+
retained: tuple[str, ...]
|
|
117
|
+
dry_run: bool
|
|
118
|
+
|
|
119
|
+
def to_dict(self) -> dict[str, Any]:
|
|
120
|
+
return {
|
|
121
|
+
"candidates": list(self.candidates),
|
|
122
|
+
"removed": list(self.removed),
|
|
123
|
+
"retained": list(self.retained),
|
|
124
|
+
"dry_run": self.dry_run,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True, slots=True)
|
|
129
|
+
class StoreStatus:
|
|
130
|
+
home: str
|
|
131
|
+
environments: int
|
|
132
|
+
locks: int
|
|
133
|
+
sources: int
|
|
134
|
+
executions: int
|
|
135
|
+
aliases: int
|
|
136
|
+
bytes_used: int
|
|
137
|
+
bytes_free: int
|
|
138
|
+
|
|
139
|
+
def to_dict(self) -> dict[str, Any]:
|
|
140
|
+
return {
|
|
141
|
+
"home": self.home,
|
|
142
|
+
"environments": self.environments,
|
|
143
|
+
"locks": self.locks,
|
|
144
|
+
"sources": self.sources,
|
|
145
|
+
"executions": self.executions,
|
|
146
|
+
"aliases": self.aliases,
|
|
147
|
+
"bytes_used": self.bytes_used,
|
|
148
|
+
"bytes_free": self.bytes_free,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class EnvironmentStore:
|
|
153
|
+
"""Filesystem-backed, atomically published content-addressed store."""
|
|
154
|
+
|
|
155
|
+
def __init__(self, home: Path) -> None:
|
|
156
|
+
self.home = home
|
|
157
|
+
self.sources = home / "sources" / "git"
|
|
158
|
+
self.locks = home / "locks"
|
|
159
|
+
self.environments = home / "environments"
|
|
160
|
+
self.names = home / "names"
|
|
161
|
+
self.jobs = home / "jobs"
|
|
162
|
+
self.executions = home / "executions"
|
|
163
|
+
self.usage = home / "usage"
|
|
164
|
+
self.leases = home / "leases"
|
|
165
|
+
self.lock_dir = home / ".locks"
|
|
166
|
+
for path in (
|
|
167
|
+
self.sources,
|
|
168
|
+
self.locks,
|
|
169
|
+
self.environments,
|
|
170
|
+
self.names,
|
|
171
|
+
self.jobs,
|
|
172
|
+
self.executions,
|
|
173
|
+
self.usage,
|
|
174
|
+
self.leases,
|
|
175
|
+
self.lock_dir,
|
|
176
|
+
):
|
|
177
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
|
|
179
|
+
def lock_path(self, lock_id: str) -> Path:
|
|
180
|
+
return self.locks / lock_id / "environment.lock.json"
|
|
181
|
+
|
|
182
|
+
def publish_lock(self, lock: EnvironmentLock) -> Path:
|
|
183
|
+
destination = self.lock_path(lock.lock_id)
|
|
184
|
+
with FileLock(self.lock_dir / f"{lock.lock_id}.lock"):
|
|
185
|
+
if destination.is_file():
|
|
186
|
+
EnvironmentLock.load(destination)
|
|
187
|
+
return destination
|
|
188
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
lock.write(destination)
|
|
190
|
+
return destination
|
|
191
|
+
|
|
192
|
+
def load_lock(self, lock_id: str) -> EnvironmentLock:
|
|
193
|
+
path = self.lock_path(lock_id)
|
|
194
|
+
if not path.is_file():
|
|
195
|
+
raise EnvironmentError(f"environment lock is not present: {lock_id}")
|
|
196
|
+
return EnvironmentLock.load(path)
|
|
197
|
+
|
|
198
|
+
def source_path(self, source_id: str) -> Path:
|
|
199
|
+
return self.sources / source_id / "source"
|
|
200
|
+
|
|
201
|
+
def validate_source(
|
|
202
|
+
self,
|
|
203
|
+
source_id: str,
|
|
204
|
+
*,
|
|
205
|
+
url: str,
|
|
206
|
+
revision: str,
|
|
207
|
+
tree_hash: str,
|
|
208
|
+
) -> Path:
|
|
209
|
+
source = self.source_path(source_id)
|
|
210
|
+
marker = source / ".lean-runtime-source.json"
|
|
211
|
+
if not source.is_dir() or not marker.is_file():
|
|
212
|
+
raise EnvironmentError(f"immutable source snapshot is incomplete: {source_id}")
|
|
213
|
+
metadata = json.loads(marker.read_text(encoding="utf-8"))
|
|
214
|
+
expected = {
|
|
215
|
+
"source_id": source_id,
|
|
216
|
+
"url": url,
|
|
217
|
+
"revision": revision,
|
|
218
|
+
"tree_hash": tree_hash,
|
|
219
|
+
}
|
|
220
|
+
if any(metadata.get(key) != value for key, value in expected.items()):
|
|
221
|
+
raise EnvironmentError(f"immutable source metadata mismatch: {source_id}")
|
|
222
|
+
content_hash = metadata.get("content_hash")
|
|
223
|
+
if isinstance(content_hash, str) and _snapshot_digest(source) != content_hash:
|
|
224
|
+
raise EnvironmentError(f"immutable source content was modified: {source_id}")
|
|
225
|
+
commands = (("rev-parse", "HEAD"), ("rev-parse", "HEAD^{tree}"))
|
|
226
|
+
observed = []
|
|
227
|
+
for arguments in commands:
|
|
228
|
+
process = subprocess.run(
|
|
229
|
+
["git", "-C", str(source), *arguments],
|
|
230
|
+
text=True,
|
|
231
|
+
capture_output=True,
|
|
232
|
+
check=False,
|
|
233
|
+
)
|
|
234
|
+
if process.returncode:
|
|
235
|
+
raise EnvironmentError(f"immutable source Git metadata is invalid: {source_id}")
|
|
236
|
+
observed.append(process.stdout.strip().lower())
|
|
237
|
+
if observed != [revision.lower(), tree_hash.lower()]:
|
|
238
|
+
raise EnvironmentError(f"immutable source content mismatch: {source_id}")
|
|
239
|
+
return source
|
|
240
|
+
|
|
241
|
+
def publish_source(self, checkout: Path, source_id: str, metadata: dict[str, Any]) -> Path:
|
|
242
|
+
destination = self.source_path(source_id)
|
|
243
|
+
with FileLock(self.lock_dir / f"{source_id}.lock"):
|
|
244
|
+
if destination.is_dir():
|
|
245
|
+
self.validate_source(
|
|
246
|
+
source_id,
|
|
247
|
+
url=str(metadata["url"]),
|
|
248
|
+
revision=str(metadata["revision"]),
|
|
249
|
+
tree_hash=str(metadata["tree_hash"]),
|
|
250
|
+
)
|
|
251
|
+
return destination
|
|
252
|
+
parent = destination.parent
|
|
253
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
254
|
+
stage = parent / f".staging-{os.getpid()}-{uuid.uuid4().hex}"
|
|
255
|
+
try:
|
|
256
|
+
command = [
|
|
257
|
+
"git",
|
|
258
|
+
"clone",
|
|
259
|
+
"--quiet",
|
|
260
|
+
"--no-local",
|
|
261
|
+
"--depth",
|
|
262
|
+
"1",
|
|
263
|
+
"--no-tags",
|
|
264
|
+
checkout.resolve().as_uri(),
|
|
265
|
+
str(stage),
|
|
266
|
+
]
|
|
267
|
+
process = subprocess.run(
|
|
268
|
+
command,
|
|
269
|
+
text=True,
|
|
270
|
+
capture_output=True,
|
|
271
|
+
check=False,
|
|
272
|
+
)
|
|
273
|
+
if process.returncode:
|
|
274
|
+
raise EnvironmentError(
|
|
275
|
+
f"could not create compact source snapshot {source_id}: "
|
|
276
|
+
+ process.stdout
|
|
277
|
+
+ process.stderr
|
|
278
|
+
)
|
|
279
|
+
remote = subprocess.run(
|
|
280
|
+
[
|
|
281
|
+
"git",
|
|
282
|
+
"-C",
|
|
283
|
+
str(stage),
|
|
284
|
+
"remote",
|
|
285
|
+
"set-url",
|
|
286
|
+
"origin",
|
|
287
|
+
str(metadata["url"]),
|
|
288
|
+
],
|
|
289
|
+
text=True,
|
|
290
|
+
capture_output=True,
|
|
291
|
+
check=False,
|
|
292
|
+
)
|
|
293
|
+
if remote.returncode:
|
|
294
|
+
raise EnvironmentError(
|
|
295
|
+
f"could not normalize source remote {source_id}: "
|
|
296
|
+
+ remote.stdout
|
|
297
|
+
+ remote.stderr
|
|
298
|
+
)
|
|
299
|
+
snapshot_metadata = {**metadata, "content_hash": _snapshot_digest(stage)}
|
|
300
|
+
write_json_atomic(stage / ".lean-runtime-source.json", snapshot_metadata)
|
|
301
|
+
stage.replace(destination)
|
|
302
|
+
finally:
|
|
303
|
+
if stage.exists():
|
|
304
|
+
shutil.rmtree(stage)
|
|
305
|
+
return destination
|
|
306
|
+
|
|
307
|
+
def environment_path(self, environment_id: str) -> Path:
|
|
308
|
+
return self.environments / environment_id
|
|
309
|
+
|
|
310
|
+
def validate_environment_id(self, environment_id: str) -> str:
|
|
311
|
+
if _ENVIRONMENT_ID.fullmatch(environment_id) is None:
|
|
312
|
+
raise EnvironmentError(f"invalid environment identity: {environment_id!r}")
|
|
313
|
+
return environment_id
|
|
314
|
+
|
|
315
|
+
def touch_environment(self, environment_id: str) -> None:
|
|
316
|
+
self.validate_environment_id(environment_id)
|
|
317
|
+
write_json_atomic(
|
|
318
|
+
self.usage / f"{environment_id}.json",
|
|
319
|
+
{"environment_id": environment_id, "last_used_at": time.time()},
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
@contextmanager
|
|
323
|
+
def execution_lease(self, environment_id: str) -> Iterator[None]:
|
|
324
|
+
"""Prevent GC during a clone without serializing concurrent clones."""
|
|
325
|
+
self.validate_environment_id(environment_id)
|
|
326
|
+
lease_directory = self.leases / environment_id
|
|
327
|
+
lease = lease_directory / f"{os.getpid()}-{uuid.uuid4().hex}.json"
|
|
328
|
+
with FileLock(self.lock_dir / f"{environment_id}.lock"):
|
|
329
|
+
if not self.environment_path(environment_id).is_dir():
|
|
330
|
+
raise EnvironmentError(
|
|
331
|
+
f"environment disappeared before execution: {environment_id}"
|
|
332
|
+
)
|
|
333
|
+
lease_directory.mkdir(parents=True, exist_ok=True)
|
|
334
|
+
self.touch_environment(environment_id)
|
|
335
|
+
write_json_atomic(
|
|
336
|
+
lease,
|
|
337
|
+
{"environment_id": environment_id, "pid": os.getpid(), "created_at": time.time()},
|
|
338
|
+
)
|
|
339
|
+
try:
|
|
340
|
+
yield None
|
|
341
|
+
finally:
|
|
342
|
+
lease.unlink(missing_ok=True)
|
|
343
|
+
with suppress(OSError):
|
|
344
|
+
lease_directory.rmdir()
|
|
345
|
+
|
|
346
|
+
def has_execution_leases(self, environment_id: str) -> bool:
|
|
347
|
+
return any((self.leases / environment_id).glob("*.json"))
|
|
348
|
+
|
|
349
|
+
def validate_alias(self, name: str) -> str:
|
|
350
|
+
if _ALIAS.fullmatch(name) is None:
|
|
351
|
+
raise EnvironmentError(f"invalid environment name: {name!r}")
|
|
352
|
+
return name
|
|
353
|
+
|
|
354
|
+
def set_alias(self, name: str, environment_id: str) -> None:
|
|
355
|
+
self.validate_alias(name)
|
|
356
|
+
self.validate_environment_id(environment_id)
|
|
357
|
+
record = {
|
|
358
|
+
"schema": ALIAS_SCHEMA,
|
|
359
|
+
"name": name,
|
|
360
|
+
"environment_id": environment_id,
|
|
361
|
+
}
|
|
362
|
+
with FileLock(self.lock_dir / f"name-{name}.lock"):
|
|
363
|
+
write_json_atomic(self.names / f"{name}.json", record)
|
|
364
|
+
|
|
365
|
+
def resolve_identifier(self, identifier: str) -> str:
|
|
366
|
+
if _ENVIRONMENT_ID.fullmatch(identifier) is not None:
|
|
367
|
+
direct = self.environment_path(identifier)
|
|
368
|
+
if direct.is_dir():
|
|
369
|
+
return identifier
|
|
370
|
+
alias_path = self.names / f"{self.validate_alias(identifier)}.json"
|
|
371
|
+
if not alias_path.is_file():
|
|
372
|
+
raise EnvironmentError(f"unknown environment: {identifier}")
|
|
373
|
+
environment_id = self._read_alias(alias_path, expected_name=identifier)
|
|
374
|
+
if not self.environment_path(environment_id).is_dir():
|
|
375
|
+
raise EnvironmentError(f"environment alias is dangling: {identifier}")
|
|
376
|
+
return environment_id
|
|
377
|
+
|
|
378
|
+
def _read_alias(self, path: Path, *, expected_name: str | None = None) -> str:
|
|
379
|
+
try:
|
|
380
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
381
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
382
|
+
raise EnvironmentError(f"invalid environment alias record: {path.name}") from exc
|
|
383
|
+
if not isinstance(value, dict) or value.get("schema") != ALIAS_SCHEMA:
|
|
384
|
+
raise EnvironmentError(f"invalid environment alias record: {path.name}")
|
|
385
|
+
name = value.get("name")
|
|
386
|
+
environment_id = value.get("environment_id")
|
|
387
|
+
if not isinstance(name, str) or name != path.stem or name != (expected_name or name):
|
|
388
|
+
raise EnvironmentError(f"environment alias name mismatch: {path.name}")
|
|
389
|
+
self.validate_alias(name)
|
|
390
|
+
if not isinstance(environment_id, str):
|
|
391
|
+
raise EnvironmentError(f"environment alias has no identity: {name}")
|
|
392
|
+
return self.validate_environment_id(environment_id)
|
|
393
|
+
|
|
394
|
+
def aliases(self) -> dict[str, str]:
|
|
395
|
+
result: dict[str, str] = {}
|
|
396
|
+
for path in sorted(self.names.glob("*.json")):
|
|
397
|
+
result[path.stem] = self._read_alias(path)
|
|
398
|
+
return result
|
|
399
|
+
|
|
400
|
+
def status(self) -> StoreStatus:
|
|
401
|
+
bytes_used = 0
|
|
402
|
+
for path in self.home.rglob("*"):
|
|
403
|
+
try:
|
|
404
|
+
if path.is_file() and not path.is_symlink():
|
|
405
|
+
bytes_used += path.stat().st_size
|
|
406
|
+
except OSError:
|
|
407
|
+
continue
|
|
408
|
+
return StoreStatus(
|
|
409
|
+
home=str(self.home),
|
|
410
|
+
environments=sum(1 for path in self.environments.glob("env_*") if path.is_dir()),
|
|
411
|
+
locks=sum(1 for path in self.locks.glob("lock_*") if path.is_dir()),
|
|
412
|
+
sources=sum(1 for path in self.sources.glob("source_*") if path.is_dir()),
|
|
413
|
+
executions=sum(1 for path in self.executions.glob("execution_*.json")),
|
|
414
|
+
aliases=len(self.aliases()),
|
|
415
|
+
bytes_used=bytes_used,
|
|
416
|
+
bytes_free=shutil.disk_usage(self.home).free,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
def gc(
|
|
420
|
+
self, *, dry_run: bool = True, minimum_age_seconds: float = 2_592_000
|
|
421
|
+
) -> GarbageCollectionReport:
|
|
422
|
+
"""Remove old environments not reachable through a name.
|
|
423
|
+
|
|
424
|
+
Locks and immutable sources are retained in the first store schema.
|
|
425
|
+
"""
|
|
426
|
+
referenced = set(self.aliases().values())
|
|
427
|
+
now = time.time()
|
|
428
|
+
candidates: list[str] = []
|
|
429
|
+
retained: list[str] = []
|
|
430
|
+
removed: list[str] = []
|
|
431
|
+
with FileLock(self.lock_dir / "gc.lock"):
|
|
432
|
+
for path in sorted(self.environments.glob("env_*")):
|
|
433
|
+
if _ENVIRONMENT_ID.fullmatch(path.name) is None:
|
|
434
|
+
retained.append(path.name)
|
|
435
|
+
continue
|
|
436
|
+
usage = self.usage / f"{path.name}.json"
|
|
437
|
+
age = now - (usage.stat().st_mtime if usage.exists() else path.stat().st_mtime)
|
|
438
|
+
if (
|
|
439
|
+
path.name in referenced
|
|
440
|
+
or age < minimum_age_seconds
|
|
441
|
+
or self.has_execution_leases(path.name)
|
|
442
|
+
):
|
|
443
|
+
retained.append(path.name)
|
|
444
|
+
continue
|
|
445
|
+
candidates.append(path.name)
|
|
446
|
+
if not dry_run:
|
|
447
|
+
with FileLock(self.lock_dir / f"{path.name}.lock"):
|
|
448
|
+
referenced = set(self.aliases().values())
|
|
449
|
+
age = now - (
|
|
450
|
+
usage.stat().st_mtime if usage.exists() else path.stat().st_mtime
|
|
451
|
+
)
|
|
452
|
+
if (
|
|
453
|
+
path.name in referenced
|
|
454
|
+
or age < minimum_age_seconds
|
|
455
|
+
or self.has_execution_leases(path.name)
|
|
456
|
+
):
|
|
457
|
+
retained.append(path.name)
|
|
458
|
+
continue
|
|
459
|
+
shutil.rmtree(path)
|
|
460
|
+
usage.unlink(missing_ok=True)
|
|
461
|
+
removed.append(path.name)
|
|
462
|
+
return GarbageCollectionReport(tuple(candidates), tuple(removed), tuple(retained), dry_run)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Private Elan bootstrap and Lean toolchain management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
import urllib.request
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .errors import ToolchainError
|
|
14
|
+
|
|
15
|
+
ELAN_VERSION = "4.2.3"
|
|
16
|
+
ELAN_INIT_URL = f"https://raw.githubusercontent.com/leanprover/elan/v{ELAN_VERSION}/elan-init.sh"
|
|
17
|
+
ELAN_INIT_SHA256 = "a620ff1641616222c8d37c54845492004bb84d6877cdbc944dd65c1aa685bf53"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def default_runtime_home() -> Path:
|
|
21
|
+
"""Return the cache root without depending on a platform-specific package."""
|
|
22
|
+
override = os.environ.get("LEAN_RUNTIME_HOME")
|
|
23
|
+
if override:
|
|
24
|
+
return Path(override).expanduser()
|
|
25
|
+
if os.name == "nt":
|
|
26
|
+
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
|
27
|
+
return base / "lean-runtime"
|
|
28
|
+
if sys_platform() == "darwin":
|
|
29
|
+
return Path.home() / "Library" / "Caches" / "lean-runtime"
|
|
30
|
+
return Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "lean-runtime"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sys_platform() -> str:
|
|
34
|
+
"""Small indirection used by tests."""
|
|
35
|
+
import sys
|
|
36
|
+
|
|
37
|
+
return sys.platform
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def normalize_toolchain(value: str) -> str:
|
|
41
|
+
"""Normalize common Lean release spellings to an Elan toolchain name."""
|
|
42
|
+
value = value.strip()
|
|
43
|
+
if not value:
|
|
44
|
+
raise ToolchainError("toolchain must not be empty")
|
|
45
|
+
if "/" in value or ":" in value:
|
|
46
|
+
return value
|
|
47
|
+
if value.startswith("v"):
|
|
48
|
+
return f"leanprover/lean4:{value}"
|
|
49
|
+
if value[0].isdigit():
|
|
50
|
+
return f"leanprover/lean4:v{value}"
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ToolchainManager:
|
|
55
|
+
"""Own an isolated Elan home and install Lean releases on demand.
|
|
56
|
+
|
|
57
|
+
Set ``LEAN_RUNTIME_ELAN`` to use an explicit existing Elan executable.
|
|
58
|
+
This is useful in CI and development; ordinary users get a private copy.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, home: str | os.PathLike[str] | None = None) -> None:
|
|
62
|
+
self.home = Path(home).expanduser().resolve() if home else default_runtime_home()
|
|
63
|
+
self.elan_home = self.home / "elan"
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def environment(self) -> dict[str, str]:
|
|
67
|
+
env = os.environ.copy()
|
|
68
|
+
elan_home = Path(os.environ.get("LEAN_RUNTIME_ELAN_HOME", self.elan_home))
|
|
69
|
+
env["ELAN_HOME"] = str(elan_home)
|
|
70
|
+
env["PATH"] = os.pathsep.join((str(elan_home / "bin"), env.get("PATH", "")))
|
|
71
|
+
return env
|
|
72
|
+
|
|
73
|
+
def elan_path(self, *, bootstrap: bool = True) -> Path:
|
|
74
|
+
override = os.environ.get("LEAN_RUNTIME_ELAN")
|
|
75
|
+
if override:
|
|
76
|
+
# Elan is commonly installed as symlinks to one multicall binary.
|
|
77
|
+
# Resolving the link changes argv[0] to `elan-init`, which selects
|
|
78
|
+
# the installer interface instead of the toolchain manager.
|
|
79
|
+
path = Path(override).expanduser().absolute()
|
|
80
|
+
if path.is_file():
|
|
81
|
+
return path
|
|
82
|
+
raise ToolchainError(f"LEAN_RUNTIME_ELAN does not name a file: {path}")
|
|
83
|
+
name = "elan.exe" if os.name == "nt" else "elan"
|
|
84
|
+
private = self.elan_home / "bin" / name
|
|
85
|
+
if private.is_file():
|
|
86
|
+
return private
|
|
87
|
+
if not bootstrap:
|
|
88
|
+
raise ToolchainError("private Elan is not installed")
|
|
89
|
+
self.bootstrap_elan()
|
|
90
|
+
if not private.is_file():
|
|
91
|
+
raise ToolchainError("Elan installer completed without creating the executable")
|
|
92
|
+
return private
|
|
93
|
+
|
|
94
|
+
def bootstrap_elan(self) -> Path:
|
|
95
|
+
"""Install Elan under the runtime cache without touching user defaults."""
|
|
96
|
+
if os.name == "nt":
|
|
97
|
+
raise ToolchainError(
|
|
98
|
+
"automatic Elan bootstrap currently supports macOS and Linux; "
|
|
99
|
+
"set LEAN_RUNTIME_ELAN on Windows"
|
|
100
|
+
)
|
|
101
|
+
self.home.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
with tempfile.TemporaryDirectory(prefix="elan-bootstrap-", dir=self.home) as raw:
|
|
103
|
+
script = Path(raw) / "elan-init.sh"
|
|
104
|
+
try:
|
|
105
|
+
with urllib.request.urlopen(ELAN_INIT_URL, timeout=30) as response:
|
|
106
|
+
installer = response.read()
|
|
107
|
+
except OSError as exc:
|
|
108
|
+
raise ToolchainError(f"could not download Elan installer: {exc}") from exc
|
|
109
|
+
observed = hashlib.sha256(installer).hexdigest()
|
|
110
|
+
if observed != ELAN_INIT_SHA256:
|
|
111
|
+
raise ToolchainError("downloaded Elan installer failed its SHA-256 integrity check")
|
|
112
|
+
script.write_bytes(installer)
|
|
113
|
+
script.chmod(script.stat().st_mode | stat.S_IXUSR)
|
|
114
|
+
env = self.environment
|
|
115
|
+
process = subprocess.run(
|
|
116
|
+
[str(script), "-y", "--no-modify-path", "--default-toolchain", "none"],
|
|
117
|
+
env=env,
|
|
118
|
+
text=True,
|
|
119
|
+
stdout=subprocess.PIPE,
|
|
120
|
+
stderr=subprocess.STDOUT,
|
|
121
|
+
check=False,
|
|
122
|
+
)
|
|
123
|
+
if process.returncode:
|
|
124
|
+
raise ToolchainError(f"Elan installer exited {process.returncode}:\n{process.stdout}")
|
|
125
|
+
return self.elan_path(bootstrap=False)
|
|
126
|
+
|
|
127
|
+
def is_installed(self, toolchain: str) -> bool:
|
|
128
|
+
name = normalize_toolchain(toolchain)
|
|
129
|
+
process = subprocess.run(
|
|
130
|
+
[str(self.elan_path()), "toolchain", "list"],
|
|
131
|
+
env=self.environment,
|
|
132
|
+
text=True,
|
|
133
|
+
stdout=subprocess.PIPE,
|
|
134
|
+
stderr=subprocess.STDOUT,
|
|
135
|
+
check=False,
|
|
136
|
+
)
|
|
137
|
+
if process.returncode:
|
|
138
|
+
raise ToolchainError(f"could not list installed Lean toolchains:\n{process.stdout}")
|
|
139
|
+
installed = {
|
|
140
|
+
fields[0] for line in process.stdout.splitlines() if (fields := line.split(maxsplit=1))
|
|
141
|
+
}
|
|
142
|
+
return name in installed
|
|
143
|
+
|
|
144
|
+
def ensure(self, toolchain: str) -> str:
|
|
145
|
+
"""Install a toolchain if necessary and return its normalized name."""
|
|
146
|
+
name = normalize_toolchain(toolchain)
|
|
147
|
+
if self.is_installed(name):
|
|
148
|
+
return name
|
|
149
|
+
process = subprocess.run(
|
|
150
|
+
[str(self.elan_path()), "toolchain", "install", name],
|
|
151
|
+
env=self.environment,
|
|
152
|
+
text=True,
|
|
153
|
+
stdout=subprocess.PIPE,
|
|
154
|
+
stderr=subprocess.STDOUT,
|
|
155
|
+
check=False,
|
|
156
|
+
)
|
|
157
|
+
if process.returncode:
|
|
158
|
+
raise ToolchainError(f"could not install Lean toolchain {name!r}:\n{process.stdout}")
|
|
159
|
+
return name
|
|
160
|
+
|
|
161
|
+
def command(self, toolchain: str, executable: str, *args: str) -> list[str]:
|
|
162
|
+
"""Construct a command pinned to one toolchain."""
|
|
163
|
+
name = self.ensure(toolchain)
|
|
164
|
+
return [str(self.elan_path()), "run", name, executable, *args]
|