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/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Content-addressed execution environments for Lean 4."""
|
|
2
|
+
|
|
3
|
+
from .environments import (
|
|
4
|
+
Environment,
|
|
5
|
+
EnvironmentInfo,
|
|
6
|
+
ExecutionCapture,
|
|
7
|
+
ExecutionJob,
|
|
8
|
+
InteractiveSession,
|
|
9
|
+
)
|
|
10
|
+
from .errors import (
|
|
11
|
+
EnvironmentError,
|
|
12
|
+
LeanRuntimeError,
|
|
13
|
+
MaterializationError,
|
|
14
|
+
PolicyError,
|
|
15
|
+
ProjectError,
|
|
16
|
+
ResolutionError,
|
|
17
|
+
SpecificationError,
|
|
18
|
+
ToolchainError,
|
|
19
|
+
)
|
|
20
|
+
from .events import EventCallback, RuntimeEvent
|
|
21
|
+
from .health import DoctorCheck, DoctorReport
|
|
22
|
+
from .lockfiles import EnvironmentLock, LockedPackage
|
|
23
|
+
from .models import Diagnostic, ExecutionProvenance, ExecutionResult, PackageProvenance
|
|
24
|
+
from .policies import ExecutionPolicy
|
|
25
|
+
from .references import DiscoveredPackage, PackageReference
|
|
26
|
+
from .runtime import Runtime, project_toolchain
|
|
27
|
+
from .specs import EnvironmentSpec, GitPackage, Package
|
|
28
|
+
from .store import GarbageCollectionReport, StoreStatus
|
|
29
|
+
from .toolchains import ToolchainManager, normalize_toolchain
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"Diagnostic",
|
|
33
|
+
"DiscoveredPackage",
|
|
34
|
+
"DoctorCheck",
|
|
35
|
+
"DoctorReport",
|
|
36
|
+
"Environment",
|
|
37
|
+
"EnvironmentError",
|
|
38
|
+
"EnvironmentInfo",
|
|
39
|
+
"EnvironmentLock",
|
|
40
|
+
"EnvironmentSpec",
|
|
41
|
+
"ExecutionCapture",
|
|
42
|
+
"ExecutionJob",
|
|
43
|
+
"InteractiveSession",
|
|
44
|
+
"ExecutionPolicy",
|
|
45
|
+
"ExecutionProvenance",
|
|
46
|
+
"ExecutionResult",
|
|
47
|
+
"EventCallback",
|
|
48
|
+
"GarbageCollectionReport",
|
|
49
|
+
"GitPackage",
|
|
50
|
+
"LeanRuntimeError",
|
|
51
|
+
"LockedPackage",
|
|
52
|
+
"MaterializationError",
|
|
53
|
+
"Package",
|
|
54
|
+
"PackageReference",
|
|
55
|
+
"PackageProvenance",
|
|
56
|
+
"PolicyError",
|
|
57
|
+
"ProjectError",
|
|
58
|
+
"ResolutionError",
|
|
59
|
+
"Runtime",
|
|
60
|
+
"RuntimeEvent",
|
|
61
|
+
"SpecificationError",
|
|
62
|
+
"StoreStatus",
|
|
63
|
+
"ToolchainError",
|
|
64
|
+
"ToolchainManager",
|
|
65
|
+
"normalize_toolchain",
|
|
66
|
+
"project_toolchain",
|
|
67
|
+
]
|
lean_runtime/__main__.py
ADDED
lean_runtime/backends.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""Execution backends and the trusted local implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
11
|
+
from contextlib import suppress
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, BinaryIO, Protocol, TextIO, cast
|
|
15
|
+
|
|
16
|
+
from .errors import PolicyError
|
|
17
|
+
from .policies import ExecutionPolicy
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class BackendResult:
|
|
22
|
+
exit_code: int
|
|
23
|
+
stdout: str
|
|
24
|
+
stderr: str
|
|
25
|
+
elapsed_seconds: float
|
|
26
|
+
timed_out: bool
|
|
27
|
+
cancelled: bool
|
|
28
|
+
output_truncated: bool
|
|
29
|
+
enforced_policy_fields: tuple[str, ...]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Backend(Protocol):
|
|
33
|
+
name: str
|
|
34
|
+
|
|
35
|
+
def execute(
|
|
36
|
+
self,
|
|
37
|
+
command: Sequence[str],
|
|
38
|
+
*,
|
|
39
|
+
cwd: Path,
|
|
40
|
+
environment: Mapping[str, str],
|
|
41
|
+
policy: ExecutionPolicy,
|
|
42
|
+
cancel: threading.Event | None = None,
|
|
43
|
+
) -> BackendResult: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class InteractiveTextReader(Protocol):
|
|
47
|
+
@property
|
|
48
|
+
def closed(self) -> bool: ...
|
|
49
|
+
|
|
50
|
+
def read(self, size: int = -1) -> str: ...
|
|
51
|
+
|
|
52
|
+
def readline(self, size: int = -1) -> str: ...
|
|
53
|
+
|
|
54
|
+
def fileno(self) -> int: ...
|
|
55
|
+
|
|
56
|
+
def close(self) -> None: ...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class InteractiveProcess(Protocol):
|
|
60
|
+
"""Live standard-I/O streams plus managed process finalization."""
|
|
61
|
+
|
|
62
|
+
stdin: TextIO
|
|
63
|
+
stdout: InteractiveTextReader
|
|
64
|
+
stderr: InteractiveTextReader
|
|
65
|
+
|
|
66
|
+
def poll(self) -> int | None: ...
|
|
67
|
+
|
|
68
|
+
def finish(self) -> BackendResult: ...
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _OutputBudget:
|
|
72
|
+
def __init__(self, limit: int) -> None:
|
|
73
|
+
self.remaining = limit
|
|
74
|
+
self.lock = threading.Lock()
|
|
75
|
+
self.truncated = False
|
|
76
|
+
|
|
77
|
+
def take(self, chunk: bytes) -> bytes:
|
|
78
|
+
with self.lock:
|
|
79
|
+
size = min(len(chunk), self.remaining)
|
|
80
|
+
self.remaining -= size
|
|
81
|
+
if size < len(chunk):
|
|
82
|
+
self.truncated = True
|
|
83
|
+
return chunk[:size]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _drain(stream: BinaryIO, budget: _OutputBudget, chunks: list[bytes]) -> None:
|
|
87
|
+
while True:
|
|
88
|
+
chunk = stream.read(65_536)
|
|
89
|
+
if not chunk:
|
|
90
|
+
return
|
|
91
|
+
kept = budget.take(chunk)
|
|
92
|
+
if kept:
|
|
93
|
+
chunks.append(kept)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class _TranscriptReader:
|
|
97
|
+
"""Mirror caller-consumed text into the bounded execution transcript."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, stream: TextIO, budget: _OutputBudget, chunks: list[bytes]) -> None:
|
|
100
|
+
self._stream = stream
|
|
101
|
+
self._budget = budget
|
|
102
|
+
self._chunks = chunks
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def closed(self) -> bool:
|
|
106
|
+
return self._stream.closed
|
|
107
|
+
|
|
108
|
+
def fileno(self) -> int:
|
|
109
|
+
return self._stream.fileno()
|
|
110
|
+
|
|
111
|
+
def _record(self, value: str) -> str:
|
|
112
|
+
if value:
|
|
113
|
+
kept = self._budget.take(value.encode("utf-8"))
|
|
114
|
+
if kept:
|
|
115
|
+
self._chunks.append(kept)
|
|
116
|
+
return value
|
|
117
|
+
|
|
118
|
+
def read(self, size: int = -1) -> str:
|
|
119
|
+
return self._record(self._stream.read(size))
|
|
120
|
+
|
|
121
|
+
def readline(self, size: int = -1) -> str:
|
|
122
|
+
return self._record(self._stream.readline(size))
|
|
123
|
+
|
|
124
|
+
def close(self) -> None:
|
|
125
|
+
self._stream.close()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class _LocalInteractiveProcess:
|
|
129
|
+
def __init__(
|
|
130
|
+
self,
|
|
131
|
+
process: subprocess.Popen[str],
|
|
132
|
+
*,
|
|
133
|
+
policy: ExecutionPolicy,
|
|
134
|
+
enforced_policy_fields: tuple[str, ...],
|
|
135
|
+
) -> None:
|
|
136
|
+
assert process.stdin is not None
|
|
137
|
+
assert process.stdout is not None
|
|
138
|
+
assert process.stderr is not None
|
|
139
|
+
self._process = process
|
|
140
|
+
self._policy = policy
|
|
141
|
+
self._enforced_policy_fields = enforced_policy_fields
|
|
142
|
+
self._started = time.monotonic()
|
|
143
|
+
self._timed_out = threading.Event()
|
|
144
|
+
self._finished = threading.Event()
|
|
145
|
+
self._budget = _OutputBudget(policy.max_output_bytes)
|
|
146
|
+
self._stdout_chunks: list[bytes] = []
|
|
147
|
+
self._stderr_chunks: list[bytes] = []
|
|
148
|
+
self.stdin = cast(TextIO, process.stdin)
|
|
149
|
+
self.stdout: InteractiveTextReader = _TranscriptReader(
|
|
150
|
+
cast(TextIO, process.stdout), self._budget, self._stdout_chunks
|
|
151
|
+
)
|
|
152
|
+
self.stderr: InteractiveTextReader = _TranscriptReader(
|
|
153
|
+
cast(TextIO, process.stderr), self._budget, self._stderr_chunks
|
|
154
|
+
)
|
|
155
|
+
self._monitor = threading.Thread(
|
|
156
|
+
target=self._enforce_timeout,
|
|
157
|
+
name=f"lean-runtime-process-{process.pid}",
|
|
158
|
+
daemon=True,
|
|
159
|
+
)
|
|
160
|
+
self._monitor.start()
|
|
161
|
+
|
|
162
|
+
def _enforce_timeout(self) -> None:
|
|
163
|
+
remaining = self._policy.timeout_seconds - (time.monotonic() - self._started)
|
|
164
|
+
if remaining > 0 and self._finished.wait(remaining):
|
|
165
|
+
return
|
|
166
|
+
if self._process.poll() is not None:
|
|
167
|
+
return
|
|
168
|
+
self._timed_out.set()
|
|
169
|
+
LocalBackend._stop(self._process)
|
|
170
|
+
try:
|
|
171
|
+
self._process.wait(timeout=2)
|
|
172
|
+
except subprocess.TimeoutExpired:
|
|
173
|
+
LocalBackend._kill(self._process)
|
|
174
|
+
|
|
175
|
+
def poll(self) -> int | None:
|
|
176
|
+
return self._process.poll()
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _remaining(reader: InteractiveTextReader) -> None:
|
|
180
|
+
with suppress(OSError, ValueError):
|
|
181
|
+
reader.read()
|
|
182
|
+
|
|
183
|
+
def finish(self) -> BackendResult:
|
|
184
|
+
if not self.stdin.closed:
|
|
185
|
+
self.stdin.close()
|
|
186
|
+
cancelled = False
|
|
187
|
+
try:
|
|
188
|
+
self._process.wait(timeout=2)
|
|
189
|
+
except subprocess.TimeoutExpired:
|
|
190
|
+
cancelled = True
|
|
191
|
+
LocalBackend._stop(self._process)
|
|
192
|
+
try:
|
|
193
|
+
self._process.wait(timeout=2)
|
|
194
|
+
except subprocess.TimeoutExpired:
|
|
195
|
+
LocalBackend._kill(self._process)
|
|
196
|
+
self._process.wait()
|
|
197
|
+
self._finished.set()
|
|
198
|
+
self._monitor.join(timeout=3)
|
|
199
|
+
self._remaining(self.stdout)
|
|
200
|
+
self._remaining(self.stderr)
|
|
201
|
+
self.stdout.close()
|
|
202
|
+
self.stderr.close()
|
|
203
|
+
timed_out = self._timed_out.is_set()
|
|
204
|
+
return BackendResult(
|
|
205
|
+
exit_code=124 if timed_out else 130 if cancelled else int(self._process.returncode),
|
|
206
|
+
stdout=b"".join(self._stdout_chunks).decode("utf-8", errors="replace"),
|
|
207
|
+
stderr=b"".join(self._stderr_chunks).decode("utf-8", errors="replace"),
|
|
208
|
+
elapsed_seconds=time.monotonic() - self._started,
|
|
209
|
+
timed_out=timed_out,
|
|
210
|
+
cancelled=cancelled and not timed_out,
|
|
211
|
+
output_truncated=self._budget.truncated,
|
|
212
|
+
enforced_policy_fields=self._enforced_policy_fields,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class LocalBackend:
|
|
217
|
+
"""Trusted local subprocess execution with bounded captured output."""
|
|
218
|
+
|
|
219
|
+
name = "local"
|
|
220
|
+
|
|
221
|
+
@staticmethod
|
|
222
|
+
def _process_options(
|
|
223
|
+
policy: ExecutionPolicy,
|
|
224
|
+
) -> tuple[list[str], Callable[[], object] | None, int]:
|
|
225
|
+
if policy.network == "disabled":
|
|
226
|
+
raise PolicyError("the local backend cannot enforce network isolation")
|
|
227
|
+
enforced = ["timeout_seconds", "max_output_bytes"]
|
|
228
|
+
preexec = None
|
|
229
|
+
if os.name != "nt" and (policy.memory_mb or policy.cpu_seconds):
|
|
230
|
+
memory_mb = policy.memory_mb
|
|
231
|
+
cpu_seconds = policy.cpu_seconds
|
|
232
|
+
|
|
233
|
+
def apply_limits() -> None:
|
|
234
|
+
import resource
|
|
235
|
+
|
|
236
|
+
if memory_mb is not None:
|
|
237
|
+
limit = memory_mb * 1024 * 1024
|
|
238
|
+
getattr(resource, "setrlimit")( # noqa: B009
|
|
239
|
+
getattr(resource, "RLIMIT_AS"), # noqa: B009
|
|
240
|
+
(limit, limit),
|
|
241
|
+
)
|
|
242
|
+
if cpu_seconds is not None:
|
|
243
|
+
getattr(resource, "setrlimit")( # noqa: B009
|
|
244
|
+
getattr(resource, "RLIMIT_CPU"), # noqa: B009
|
|
245
|
+
(cpu_seconds, cpu_seconds),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
preexec = apply_limits
|
|
249
|
+
if memory_mb is not None:
|
|
250
|
+
enforced.append("memory_mb")
|
|
251
|
+
if cpu_seconds is not None:
|
|
252
|
+
enforced.append("cpu_seconds")
|
|
253
|
+
elif os.name == "nt" and (policy.memory_mb or policy.cpu_seconds):
|
|
254
|
+
raise PolicyError("the local Windows backend cannot enforce memory or CPU limits")
|
|
255
|
+
creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0
|
|
256
|
+
return enforced, preexec, creationflags
|
|
257
|
+
|
|
258
|
+
def spawn_interactive(
|
|
259
|
+
self,
|
|
260
|
+
command: Sequence[str],
|
|
261
|
+
*,
|
|
262
|
+
cwd: Path,
|
|
263
|
+
environment: Mapping[str, str],
|
|
264
|
+
policy: ExecutionPolicy,
|
|
265
|
+
) -> InteractiveProcess:
|
|
266
|
+
"""Spawn a trusted local process with live text pipes."""
|
|
267
|
+
enforced, preexec, creationflags = self._process_options(policy)
|
|
268
|
+
process = subprocess.Popen(
|
|
269
|
+
list(command),
|
|
270
|
+
cwd=cwd,
|
|
271
|
+
env=dict(environment),
|
|
272
|
+
stdin=subprocess.PIPE,
|
|
273
|
+
stdout=subprocess.PIPE,
|
|
274
|
+
stderr=subprocess.PIPE,
|
|
275
|
+
text=True,
|
|
276
|
+
encoding="utf-8",
|
|
277
|
+
errors="replace",
|
|
278
|
+
bufsize=1,
|
|
279
|
+
start_new_session=os.name != "nt",
|
|
280
|
+
creationflags=creationflags,
|
|
281
|
+
preexec_fn=preexec,
|
|
282
|
+
)
|
|
283
|
+
return _LocalInteractiveProcess(
|
|
284
|
+
process,
|
|
285
|
+
policy=policy,
|
|
286
|
+
enforced_policy_fields=tuple(enforced),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
def execute(
|
|
290
|
+
self,
|
|
291
|
+
command: Sequence[str],
|
|
292
|
+
*,
|
|
293
|
+
cwd: Path,
|
|
294
|
+
environment: Mapping[str, str],
|
|
295
|
+
policy: ExecutionPolicy,
|
|
296
|
+
cancel: threading.Event | None = None,
|
|
297
|
+
) -> BackendResult:
|
|
298
|
+
enforced, preexec, creationflags = self._process_options(policy)
|
|
299
|
+
|
|
300
|
+
started = time.monotonic()
|
|
301
|
+
process = subprocess.Popen(
|
|
302
|
+
list(command),
|
|
303
|
+
cwd=cwd,
|
|
304
|
+
env=dict(environment),
|
|
305
|
+
stdout=subprocess.PIPE,
|
|
306
|
+
stderr=subprocess.PIPE,
|
|
307
|
+
start_new_session=os.name != "nt",
|
|
308
|
+
creationflags=creationflags,
|
|
309
|
+
preexec_fn=preexec,
|
|
310
|
+
)
|
|
311
|
+
assert process.stdout is not None and process.stderr is not None
|
|
312
|
+
budget = _OutputBudget(policy.max_output_bytes)
|
|
313
|
+
stdout_chunks: list[bytes] = []
|
|
314
|
+
stderr_chunks: list[bytes] = []
|
|
315
|
+
readers = [
|
|
316
|
+
threading.Thread(target=_drain, args=(process.stdout, budget, stdout_chunks)),
|
|
317
|
+
threading.Thread(target=_drain, args=(process.stderr, budget, stderr_chunks)),
|
|
318
|
+
]
|
|
319
|
+
for reader in readers:
|
|
320
|
+
reader.start()
|
|
321
|
+
timed_out = False
|
|
322
|
+
cancelled = False
|
|
323
|
+
while process.poll() is None:
|
|
324
|
+
if cancel is not None and cancel.is_set():
|
|
325
|
+
cancelled = True
|
|
326
|
+
self._stop(process)
|
|
327
|
+
break
|
|
328
|
+
if time.monotonic() - started >= policy.timeout_seconds:
|
|
329
|
+
timed_out = True
|
|
330
|
+
self._stop(process)
|
|
331
|
+
break
|
|
332
|
+
time.sleep(0.02)
|
|
333
|
+
try:
|
|
334
|
+
process.wait(timeout=2)
|
|
335
|
+
except subprocess.TimeoutExpired:
|
|
336
|
+
self._kill(process)
|
|
337
|
+
process.wait()
|
|
338
|
+
for reader in readers:
|
|
339
|
+
reader.join()
|
|
340
|
+
exit_code = 130 if cancelled else 124 if timed_out else int(process.returncode)
|
|
341
|
+
return BackendResult(
|
|
342
|
+
exit_code=exit_code,
|
|
343
|
+
stdout=b"".join(stdout_chunks).decode("utf-8", errors="replace"),
|
|
344
|
+
stderr=b"".join(stderr_chunks).decode("utf-8", errors="replace"),
|
|
345
|
+
elapsed_seconds=time.monotonic() - started,
|
|
346
|
+
timed_out=timed_out,
|
|
347
|
+
cancelled=cancelled,
|
|
348
|
+
output_truncated=budget.truncated,
|
|
349
|
+
enforced_policy_fields=tuple(enforced),
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
@staticmethod
|
|
353
|
+
def _stop(process: subprocess.Popen[Any]) -> None:
|
|
354
|
+
try:
|
|
355
|
+
if os.name == "nt":
|
|
356
|
+
process.terminate()
|
|
357
|
+
else:
|
|
358
|
+
getattr(os, "killpg")(process.pid, signal.SIGTERM) # noqa: B009
|
|
359
|
+
except ProcessLookupError:
|
|
360
|
+
pass
|
|
361
|
+
|
|
362
|
+
@staticmethod
|
|
363
|
+
def _kill(process: subprocess.Popen[Any]) -> None:
|
|
364
|
+
try:
|
|
365
|
+
if os.name == "nt":
|
|
366
|
+
process.kill()
|
|
367
|
+
else:
|
|
368
|
+
getattr(os, "killpg")( # noqa: B009
|
|
369
|
+
process.pid,
|
|
370
|
+
getattr(signal, "SIGKILL"), # noqa: B009
|
|
371
|
+
)
|
|
372
|
+
except ProcessLookupError:
|
|
373
|
+
pass
|
lean_runtime/cli.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Minimal command-line interface around the environment compiler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .environments import ExecutionCapture
|
|
12
|
+
from .errors import LeanRuntimeError, MaterializationError, ResolutionError
|
|
13
|
+
from .events import RuntimeEvent
|
|
14
|
+
from .lockfiles import EnvironmentLock
|
|
15
|
+
from .models import ExecutionResult
|
|
16
|
+
from .policies import ExecutionPolicy
|
|
17
|
+
from .runtime import Runtime
|
|
18
|
+
from .specs import EnvironmentSpec
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _json(value: Any) -> None:
|
|
22
|
+
print(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _progress(event: RuntimeEvent) -> None:
|
|
26
|
+
package = f" [{event.data['package']}]" if "package" in event.data else ""
|
|
27
|
+
print(f"lean-runtime: {event.kind}{package}: {event.message}", file=sys.stderr)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _cli_source_name(path: Path) -> str:
|
|
31
|
+
if path.is_absolute():
|
|
32
|
+
return path.name
|
|
33
|
+
return path.as_posix()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _emit_result(result: ExecutionResult, as_json: bool) -> None:
|
|
37
|
+
if as_json:
|
|
38
|
+
_json(result.to_dict())
|
|
39
|
+
return
|
|
40
|
+
if result.stdout:
|
|
41
|
+
print(result.stdout, end="" if result.stdout.endswith("\n") else "\n")
|
|
42
|
+
if result.stderr:
|
|
43
|
+
print(result.stderr, end="" if result.stderr.endswith("\n") else "\n", file=sys.stderr)
|
|
44
|
+
status = "accepted" if result.ok else "rejected"
|
|
45
|
+
environment = f" environment={result.environment_id}" if result.environment_id else ""
|
|
46
|
+
print(
|
|
47
|
+
f"{status}:{environment} toolchain={result.toolchain} "
|
|
48
|
+
f"exit={result.exit_code} elapsed={result.elapsed_seconds:.3f}s"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _policy(arguments: argparse.Namespace) -> ExecutionPolicy:
|
|
53
|
+
return ExecutionPolicy(
|
|
54
|
+
timeout_seconds=arguments.timeout,
|
|
55
|
+
max_output_bytes=arguments.max_output,
|
|
56
|
+
memory_mb=arguments.memory,
|
|
57
|
+
cpu_seconds=arguments.cpu,
|
|
58
|
+
network=arguments.network,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _add_policy(parser: argparse.ArgumentParser, *, timeout: float = 120) -> None:
|
|
63
|
+
parser.add_argument("--timeout", type=float, default=timeout)
|
|
64
|
+
parser.add_argument("--max-output", type=int, default=1_000_000)
|
|
65
|
+
parser.add_argument("--memory", type=int, help="memory limit in MiB")
|
|
66
|
+
parser.add_argument("--cpu", type=int, help="CPU time limit in seconds")
|
|
67
|
+
parser.add_argument("--network", choices=("inherit", "disabled"), default="inherit")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parser() -> argparse.ArgumentParser:
|
|
71
|
+
root = argparse.ArgumentParser(prog="lean-runtime")
|
|
72
|
+
root.add_argument("--home", help="runtime store root")
|
|
73
|
+
root.add_argument("--quiet", action="store_true", help="suppress progress events")
|
|
74
|
+
commands = root.add_subparsers(dest="command", required=True)
|
|
75
|
+
|
|
76
|
+
resolve = commands.add_parser("resolve", help="compile a TOML/JSON spec into a lock")
|
|
77
|
+
resolve.add_argument("spec", type=Path)
|
|
78
|
+
resolve.add_argument("--output", type=Path)
|
|
79
|
+
resolve.add_argument("--timeout", type=float, default=900)
|
|
80
|
+
|
|
81
|
+
ensure = commands.add_parser("ensure", help="build or reopen a locked environment")
|
|
82
|
+
ensure.add_argument("lock", type=Path)
|
|
83
|
+
ensure.add_argument("--name")
|
|
84
|
+
|
|
85
|
+
check = commands.add_parser(
|
|
86
|
+
"check", help="check with --with packages or in a published environment"
|
|
87
|
+
)
|
|
88
|
+
check.add_argument(
|
|
89
|
+
"inputs",
|
|
90
|
+
nargs="+",
|
|
91
|
+
help="FILE with --with, otherwise ENVIRONMENT FILE; FILE may be - for stdin",
|
|
92
|
+
)
|
|
93
|
+
check.add_argument(
|
|
94
|
+
"--with",
|
|
95
|
+
dest="package_refs",
|
|
96
|
+
action="append",
|
|
97
|
+
default=[],
|
|
98
|
+
metavar="REFERENCE",
|
|
99
|
+
help="repeatable github:owner/repository@tag-or-commit package reference",
|
|
100
|
+
)
|
|
101
|
+
check.add_argument("--toolchain", help="override the toolchain discovered from --with packages")
|
|
102
|
+
check.add_argument(
|
|
103
|
+
"--include", action="append", default=[], type=Path, help="additional Lean source file"
|
|
104
|
+
)
|
|
105
|
+
check.add_argument("--json", action="store_true")
|
|
106
|
+
_add_policy(check)
|
|
107
|
+
|
|
108
|
+
inspect = commands.add_parser("inspect", help="inspect a published environment")
|
|
109
|
+
inspect.add_argument("environment")
|
|
110
|
+
inspect.add_argument("--packages", action="store_true", help="include exact package locks")
|
|
111
|
+
|
|
112
|
+
commands.add_parser("env-list", help="list published environments")
|
|
113
|
+
commands.add_parser("cache-status", help="show cache counts and disk usage")
|
|
114
|
+
commands.add_parser("doctor", help="check local prerequisites and cache health")
|
|
115
|
+
|
|
116
|
+
replay = commands.add_parser("replay", help="replay a canonical execution capture")
|
|
117
|
+
replay.add_argument("capture", type=Path)
|
|
118
|
+
replay.add_argument("--json", action="store_true")
|
|
119
|
+
|
|
120
|
+
gc = commands.add_parser("gc", help="collect old unreferenced environments")
|
|
121
|
+
gc.add_argument("--execute", action="store_true", help="remove candidates; default is dry-run")
|
|
122
|
+
gc.add_argument("--minimum-age-hours", type=float, default=24 * 30)
|
|
123
|
+
|
|
124
|
+
raw = commands.add_parser("raw-check", help="check without a managed environment")
|
|
125
|
+
raw.add_argument("file", type=Path, help="Lean source file, or - for stdin")
|
|
126
|
+
raw.add_argument("--toolchain")
|
|
127
|
+
raw.add_argument("--project", type=Path)
|
|
128
|
+
raw.add_argument("--json", action="store_true")
|
|
129
|
+
_add_policy(raw)
|
|
130
|
+
|
|
131
|
+
build = commands.add_parser("project-build", help="build an existing Lake project")
|
|
132
|
+
build.add_argument("project", type=Path)
|
|
133
|
+
build.add_argument("targets", nargs="*")
|
|
134
|
+
build.add_argument("--toolchain")
|
|
135
|
+
build.add_argument("--timeout", type=float, default=900)
|
|
136
|
+
build.add_argument("--json", action="store_true")
|
|
137
|
+
|
|
138
|
+
install = commands.add_parser("install", help="install a Lean toolchain")
|
|
139
|
+
install.add_argument("toolchain")
|
|
140
|
+
return root
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def main(argv: list[str] | None = None) -> int:
|
|
144
|
+
args = parser().parse_args(argv)
|
|
145
|
+
runtime = Runtime(home=args.home, on_event=None if args.quiet else _progress)
|
|
146
|
+
try:
|
|
147
|
+
if args.command == "install":
|
|
148
|
+
print(runtime.toolchains.ensure(args.toolchain))
|
|
149
|
+
return 0
|
|
150
|
+
if args.command == "resolve":
|
|
151
|
+
lock = runtime.resolve(EnvironmentSpec.load(args.spec), timeout=args.timeout)
|
|
152
|
+
if args.output:
|
|
153
|
+
lock.write(args.output)
|
|
154
|
+
print(lock.lock_id)
|
|
155
|
+
else:
|
|
156
|
+
_json(lock.to_dict())
|
|
157
|
+
return 0
|
|
158
|
+
if args.command == "ensure":
|
|
159
|
+
environment = runtime.ensure(EnvironmentLock.load(args.lock), name=args.name)
|
|
160
|
+
_json(environment.inspect().to_dict())
|
|
161
|
+
return 0
|
|
162
|
+
if args.command == "inspect":
|
|
163
|
+
environment = runtime.open(args.environment)
|
|
164
|
+
payload = environment.inspect().to_dict()
|
|
165
|
+
if args.packages:
|
|
166
|
+
payload["package_locks"] = [
|
|
167
|
+
package.to_dict() for package in environment.lock.packages
|
|
168
|
+
]
|
|
169
|
+
_json(payload)
|
|
170
|
+
return 0
|
|
171
|
+
if args.command == "env-list":
|
|
172
|
+
_json(list(runtime.list_environments()))
|
|
173
|
+
return 0
|
|
174
|
+
if args.command == "cache-status":
|
|
175
|
+
_json(runtime.store_status().to_dict())
|
|
176
|
+
return 0
|
|
177
|
+
if args.command == "doctor":
|
|
178
|
+
doctor_report = runtime.doctor()
|
|
179
|
+
_json(doctor_report.to_dict())
|
|
180
|
+
return 0 if doctor_report.ok else 2
|
|
181
|
+
if args.command == "replay":
|
|
182
|
+
capture = ExecutionCapture.load(args.capture)
|
|
183
|
+
result = runtime.replay_capture(capture)
|
|
184
|
+
_emit_result(result, args.json)
|
|
185
|
+
if capture.expected_ok is not None and result.ok != capture.expected_ok:
|
|
186
|
+
return 1
|
|
187
|
+
return 0 if result.ok else 1
|
|
188
|
+
if args.command == "gc":
|
|
189
|
+
gc_report = runtime.gc(
|
|
190
|
+
dry_run=not args.execute,
|
|
191
|
+
minimum_age_seconds=args.minimum_age_hours * 3600,
|
|
192
|
+
)
|
|
193
|
+
_json(gc_report.to_dict())
|
|
194
|
+
return 0
|
|
195
|
+
if args.command == "check":
|
|
196
|
+
if args.package_refs:
|
|
197
|
+
if len(args.inputs) != 1:
|
|
198
|
+
raise ValueError("check with --with expects exactly one FILE")
|
|
199
|
+
environment = runtime.ensure_references(args.package_refs, toolchain=args.toolchain)
|
|
200
|
+
source_file = Path(args.inputs[0])
|
|
201
|
+
else:
|
|
202
|
+
if len(args.inputs) != 2:
|
|
203
|
+
raise ValueError("check expects ENVIRONMENT FILE, or FILE with --with")
|
|
204
|
+
if args.toolchain:
|
|
205
|
+
raise ValueError("check --toolchain is only valid with --with")
|
|
206
|
+
environment = runtime.open(args.inputs[0])
|
|
207
|
+
source_file = Path(args.inputs[1])
|
|
208
|
+
if str(source_file) == "-":
|
|
209
|
+
if args.include:
|
|
210
|
+
raise ValueError("stdin entrypoints cannot be combined with --include")
|
|
211
|
+
result = environment.check(sys.stdin.read(), policy=_policy(args))
|
|
212
|
+
else:
|
|
213
|
+
source_paths = [source_file, *args.include]
|
|
214
|
+
files = {_cli_source_name(path): path.read_text() for path in source_paths}
|
|
215
|
+
result = environment.check_files(
|
|
216
|
+
files,
|
|
217
|
+
entrypoint=_cli_source_name(source_file),
|
|
218
|
+
policy=_policy(args),
|
|
219
|
+
)
|
|
220
|
+
elif args.command == "raw-check":
|
|
221
|
+
source = sys.stdin.read() if str(args.file) == "-" else args.file.read_text()
|
|
222
|
+
result = runtime.check(
|
|
223
|
+
source,
|
|
224
|
+
toolchain=args.toolchain,
|
|
225
|
+
project=args.project,
|
|
226
|
+
policy=_policy(args),
|
|
227
|
+
)
|
|
228
|
+
else:
|
|
229
|
+
result = runtime.build(
|
|
230
|
+
args.project,
|
|
231
|
+
targets=args.targets,
|
|
232
|
+
toolchain=args.toolchain,
|
|
233
|
+
timeout=args.timeout,
|
|
234
|
+
)
|
|
235
|
+
except (ResolutionError, MaterializationError) as exc:
|
|
236
|
+
_json(
|
|
237
|
+
{
|
|
238
|
+
"error": str(exc),
|
|
239
|
+
"phase": exc.phase,
|
|
240
|
+
"command": list(exc.command),
|
|
241
|
+
"exit_code": exc.exit_code,
|
|
242
|
+
"output": exc.output,
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
return 2
|
|
246
|
+
except (LeanRuntimeError, OSError, UnicodeError, ValueError, json.JSONDecodeError) as exc:
|
|
247
|
+
print(f"lean-runtime: {exc}", file=sys.stderr)
|
|
248
|
+
return 2
|
|
249
|
+
_emit_result(result, args.json)
|
|
250
|
+
return 0 if result.ok else 1
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
if __name__ == "__main__":
|
|
254
|
+
raise SystemExit(main())
|