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/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]