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/runtime.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""High-level API for reproducible Lean environments and raw project execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from dataclasses import replace
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .backends import Backend, LocalBackend
|
|
14
|
+
from .diagnostics import error_diagnostic, parse_diagnostics
|
|
15
|
+
from .environments import Environment, EnvironmentManager, ExecutionCapture
|
|
16
|
+
from .errors import ProjectError, SpecificationError, ToolchainError
|
|
17
|
+
from .events import EventCallback, EventEmitter
|
|
18
|
+
from .health import DoctorReport, diagnose
|
|
19
|
+
from .lockfiles import EnvironmentLock
|
|
20
|
+
from .models import ExecutionProvenance, ExecutionResult
|
|
21
|
+
from .policies import ExecutionPolicy
|
|
22
|
+
from .references import PackageReference, discover_package, normalize_references
|
|
23
|
+
from .resolver import EnvironmentResolver
|
|
24
|
+
from .serialization import sha256_id, sha256_text
|
|
25
|
+
from .specs import EnvironmentSpec, GitPackage
|
|
26
|
+
from .store import EnvironmentStore, GarbageCollectionReport, StoreStatus, platform_record
|
|
27
|
+
from .toolchains import ToolchainManager, normalize_toolchain
|
|
28
|
+
|
|
29
|
+
EnvironmentReference = Environment | EnvironmentSpec | EnvironmentLock | str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def project_toolchain(project: str | os.PathLike[str]) -> str:
|
|
33
|
+
"""Read and normalize the toolchain pinned by a Lean project."""
|
|
34
|
+
root = Path(project).expanduser().resolve()
|
|
35
|
+
path = root / "lean-toolchain"
|
|
36
|
+
if not path.is_file():
|
|
37
|
+
raise ProjectError(f"project has no lean-toolchain file: {root}")
|
|
38
|
+
return normalize_toolchain(path.read_text(encoding="utf-8"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Runtime:
|
|
42
|
+
"""Compile environments and execute trusted Lean inputs within them."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
home: str | os.PathLike[str] | None = None,
|
|
48
|
+
toolchains: ToolchainManager | None = None,
|
|
49
|
+
backend: Backend | None = None,
|
|
50
|
+
on_event: EventCallback | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self.toolchains = toolchains or ToolchainManager(home)
|
|
53
|
+
self.home = self.toolchains.home
|
|
54
|
+
self.backend = backend or LocalBackend()
|
|
55
|
+
self.events = EventEmitter(on_event)
|
|
56
|
+
self.store = EnvironmentStore(self.home)
|
|
57
|
+
self.resolver = EnvironmentResolver(self.toolchains, self.store, self.events)
|
|
58
|
+
self.environments = EnvironmentManager(
|
|
59
|
+
self.store, self.toolchains, self.backend, self.events
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def resolve(self, spec: EnvironmentSpec, *, timeout: float = 900) -> EnvironmentLock:
|
|
63
|
+
return self.resolver.resolve(spec, timeout=timeout)
|
|
64
|
+
|
|
65
|
+
def ensure(
|
|
66
|
+
self,
|
|
67
|
+
lock: EnvironmentLock,
|
|
68
|
+
*,
|
|
69
|
+
name: str | None = None,
|
|
70
|
+
build_profile: str = "release",
|
|
71
|
+
) -> Environment:
|
|
72
|
+
return self.environments.ensure(lock, name=name, build_profile=build_profile)
|
|
73
|
+
|
|
74
|
+
def open(self, identifier: str) -> Environment:
|
|
75
|
+
"""Open a published environment without resolution or network access."""
|
|
76
|
+
return self.environments.open(identifier)
|
|
77
|
+
|
|
78
|
+
def create_environment(
|
|
79
|
+
self,
|
|
80
|
+
name: str,
|
|
81
|
+
*,
|
|
82
|
+
toolchain: str,
|
|
83
|
+
packages: Sequence[GitPackage],
|
|
84
|
+
timeout: float = 900,
|
|
85
|
+
) -> Environment:
|
|
86
|
+
spec = EnvironmentSpec(toolchain, tuple(packages))
|
|
87
|
+
return self.ensure(self.resolve(spec, timeout=timeout), name=name)
|
|
88
|
+
|
|
89
|
+
def spec_from_references(
|
|
90
|
+
self,
|
|
91
|
+
packages: Sequence[str | PackageReference],
|
|
92
|
+
*,
|
|
93
|
+
toolchain: str | None = None,
|
|
94
|
+
) -> EnvironmentSpec:
|
|
95
|
+
"""Discover GitHub-style package references and return an exact specification."""
|
|
96
|
+
references = normalize_references(tuple(packages))
|
|
97
|
+
if not references:
|
|
98
|
+
raise SpecificationError("at least one package reference is required")
|
|
99
|
+
discovery_root = self.store.home / "resolution" / "references"
|
|
100
|
+
discovered = []
|
|
101
|
+
for reference in references:
|
|
102
|
+
self.events.emit(
|
|
103
|
+
"package_reference.started",
|
|
104
|
+
f"Discovering {reference.display}",
|
|
105
|
+
reference=reference.display,
|
|
106
|
+
)
|
|
107
|
+
package = discover_package(
|
|
108
|
+
reference,
|
|
109
|
+
directory=discovery_root,
|
|
110
|
+
toolchains=self.toolchains,
|
|
111
|
+
)
|
|
112
|
+
discovered.append(package)
|
|
113
|
+
self.events.emit(
|
|
114
|
+
"package_reference.resolved",
|
|
115
|
+
f"Discovered {package.package.name}",
|
|
116
|
+
reference=reference.display,
|
|
117
|
+
package=package.package.name,
|
|
118
|
+
revision=package.package.rev,
|
|
119
|
+
toolchain=package.toolchain,
|
|
120
|
+
root_module=package.package.module,
|
|
121
|
+
)
|
|
122
|
+
declared_toolchains = {package.toolchain for package in discovered}
|
|
123
|
+
if toolchain is None:
|
|
124
|
+
if len(declared_toolchains) != 1:
|
|
125
|
+
details = ", ".join(
|
|
126
|
+
f"{package.package.name}={package.toolchain}" for package in discovered
|
|
127
|
+
)
|
|
128
|
+
raise SpecificationError(
|
|
129
|
+
"package references declare different Lean toolchains; "
|
|
130
|
+
f"select one explicitly with toolchain=... ({details})"
|
|
131
|
+
)
|
|
132
|
+
selected = next(iter(declared_toolchains))
|
|
133
|
+
else:
|
|
134
|
+
selected = normalize_toolchain(toolchain)
|
|
135
|
+
for package in discovered:
|
|
136
|
+
if package.toolchain != selected:
|
|
137
|
+
self.events.emit(
|
|
138
|
+
"compatibility.toolchain_override",
|
|
139
|
+
f"{package.package.name} declares {package.toolchain}; using {selected}",
|
|
140
|
+
package=package.package.name,
|
|
141
|
+
declared_toolchain=package.toolchain,
|
|
142
|
+
environment_toolchain=selected,
|
|
143
|
+
)
|
|
144
|
+
return EnvironmentSpec(selected, tuple(package.package for package in discovered))
|
|
145
|
+
|
|
146
|
+
def resolve_references(
|
|
147
|
+
self,
|
|
148
|
+
packages: Sequence[str | PackageReference],
|
|
149
|
+
*,
|
|
150
|
+
toolchain: str | None = None,
|
|
151
|
+
timeout: float = 900,
|
|
152
|
+
) -> EnvironmentLock:
|
|
153
|
+
"""Discover package references and resolve their exact Lake graph."""
|
|
154
|
+
return self.resolve(
|
|
155
|
+
self.spec_from_references(packages, toolchain=toolchain), timeout=timeout
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def ensure_references(
|
|
159
|
+
self,
|
|
160
|
+
packages: Sequence[str | PackageReference],
|
|
161
|
+
*,
|
|
162
|
+
toolchain: str | None = None,
|
|
163
|
+
name: str | None = None,
|
|
164
|
+
timeout: float = 900,
|
|
165
|
+
) -> Environment:
|
|
166
|
+
"""Build or reopen the environment described by package references."""
|
|
167
|
+
return self.ensure(
|
|
168
|
+
self.resolve_references(packages, toolchain=toolchain, timeout=timeout), name=name
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def check(
|
|
172
|
+
self,
|
|
173
|
+
source: str,
|
|
174
|
+
*,
|
|
175
|
+
environment: EnvironmentReference | None = None,
|
|
176
|
+
packages: Sequence[str | PackageReference] = (),
|
|
177
|
+
toolchain: str | None = None,
|
|
178
|
+
project: str | os.PathLike[str] | None = None,
|
|
179
|
+
filename: str = "Main.lean",
|
|
180
|
+
timeout: float | None = None,
|
|
181
|
+
policy: ExecutionPolicy | None = None,
|
|
182
|
+
) -> ExecutionResult:
|
|
183
|
+
"""Check source in a content-addressed environment or a raw toolchain/project."""
|
|
184
|
+
selected_policy = policy or ExecutionPolicy(timeout_seconds=timeout or 120)
|
|
185
|
+
if timeout is not None and policy is not None:
|
|
186
|
+
selected_policy = replace(policy, timeout_seconds=timeout)
|
|
187
|
+
if environment is not None and packages:
|
|
188
|
+
raise SpecificationError("check cannot combine environment= with packages=")
|
|
189
|
+
if project is not None and packages:
|
|
190
|
+
raise SpecificationError("check cannot combine project= with packages=")
|
|
191
|
+
if packages:
|
|
192
|
+
resolved = self.ensure_references(packages, toolchain=toolchain)
|
|
193
|
+
return resolved.check(source, filename=filename, policy=selected_policy)
|
|
194
|
+
if environment is not None:
|
|
195
|
+
resolved = self._environment(environment)
|
|
196
|
+
return resolved.check(source, filename=filename, policy=selected_policy)
|
|
197
|
+
return self._raw_check(
|
|
198
|
+
source,
|
|
199
|
+
toolchain=toolchain,
|
|
200
|
+
project=project,
|
|
201
|
+
filename=filename,
|
|
202
|
+
policy=selected_policy,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def check_file(
|
|
206
|
+
self,
|
|
207
|
+
path: str | os.PathLike[str],
|
|
208
|
+
*,
|
|
209
|
+
environment: EnvironmentReference | None = None,
|
|
210
|
+
packages: Sequence[str | PackageReference] = (),
|
|
211
|
+
toolchain: str | None = None,
|
|
212
|
+
project: str | os.PathLike[str] | None = None,
|
|
213
|
+
timeout: float | None = None,
|
|
214
|
+
policy: ExecutionPolicy | None = None,
|
|
215
|
+
) -> ExecutionResult:
|
|
216
|
+
source_path = Path(path).expanduser().resolve()
|
|
217
|
+
return self.check(
|
|
218
|
+
source_path.read_text(encoding="utf-8"),
|
|
219
|
+
filename=source_path.name,
|
|
220
|
+
environment=environment,
|
|
221
|
+
packages=packages,
|
|
222
|
+
toolchain=toolchain,
|
|
223
|
+
project=project,
|
|
224
|
+
timeout=timeout,
|
|
225
|
+
policy=policy,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def check_files(
|
|
229
|
+
self,
|
|
230
|
+
files: Mapping[str, str],
|
|
231
|
+
*,
|
|
232
|
+
entrypoint: str = "Main.lean",
|
|
233
|
+
environment: EnvironmentReference,
|
|
234
|
+
policy: ExecutionPolicy | None = None,
|
|
235
|
+
) -> ExecutionResult:
|
|
236
|
+
"""Check a multi-file request in a managed environment."""
|
|
237
|
+
return self._environment(environment).check_files(
|
|
238
|
+
files, entrypoint=entrypoint, policy=policy
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def build(
|
|
242
|
+
self,
|
|
243
|
+
project: str | os.PathLike[str],
|
|
244
|
+
*,
|
|
245
|
+
targets: Sequence[str] = (),
|
|
246
|
+
toolchain: str | None = None,
|
|
247
|
+
timeout: float = 900,
|
|
248
|
+
) -> ExecutionResult:
|
|
249
|
+
"""Build an existing trusted Lake project outside the environment store."""
|
|
250
|
+
root = Path(project).expanduser().resolve()
|
|
251
|
+
if not root.is_dir():
|
|
252
|
+
raise ProjectError(f"project directory does not exist: {root}")
|
|
253
|
+
selected = normalize_toolchain(toolchain) if toolchain else project_toolchain(root)
|
|
254
|
+
command = self.toolchains.command(selected, "lake", "build", *targets)
|
|
255
|
+
return self._raw_result(
|
|
256
|
+
command,
|
|
257
|
+
cwd=root,
|
|
258
|
+
toolchain=selected,
|
|
259
|
+
source_digest=sha256_text(""),
|
|
260
|
+
policy=ExecutionPolicy(timeout_seconds=timeout, max_output_bytes=10_000_000),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def gc(
|
|
264
|
+
self, *, dry_run: bool = True, minimum_age_seconds: float = 2_592_000
|
|
265
|
+
) -> GarbageCollectionReport:
|
|
266
|
+
return self.store.gc(dry_run=dry_run, minimum_age_seconds=minimum_age_seconds)
|
|
267
|
+
|
|
268
|
+
def doctor(self) -> DoctorReport:
|
|
269
|
+
return diagnose(self.toolchains, self.store)
|
|
270
|
+
|
|
271
|
+
def store_status(self) -> StoreStatus:
|
|
272
|
+
return self.store.status()
|
|
273
|
+
|
|
274
|
+
def list_environments(self) -> tuple[dict[str, object], ...]:
|
|
275
|
+
aliases = self.store.aliases()
|
|
276
|
+
names_by_id: dict[str, list[str]] = {}
|
|
277
|
+
for name, environment_id in aliases.items():
|
|
278
|
+
names_by_id.setdefault(environment_id, []).append(name)
|
|
279
|
+
records: list[dict[str, object]] = []
|
|
280
|
+
for path in sorted(self.store.environments.glob("env_*")):
|
|
281
|
+
metadata_path = path / "metadata.json"
|
|
282
|
+
if not metadata_path.is_file():
|
|
283
|
+
continue
|
|
284
|
+
try:
|
|
285
|
+
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
286
|
+
except (OSError, json.JSONDecodeError):
|
|
287
|
+
continue
|
|
288
|
+
records.append(
|
|
289
|
+
{
|
|
290
|
+
"environment_id": path.name,
|
|
291
|
+
"lock_id": metadata.get("lock_id"),
|
|
292
|
+
"toolchain": metadata.get("toolchain"),
|
|
293
|
+
"created_at": metadata.get("created_at"),
|
|
294
|
+
"status": metadata.get("status"),
|
|
295
|
+
"names": sorted(names_by_id.get(path.name, [])),
|
|
296
|
+
}
|
|
297
|
+
)
|
|
298
|
+
return tuple(records)
|
|
299
|
+
|
|
300
|
+
def replay_capture(self, capture: ExecutionCapture | str | os.PathLike[str]) -> ExecutionResult:
|
|
301
|
+
"""Materialize a capture's lock if needed and replay its check request."""
|
|
302
|
+
resolved = (
|
|
303
|
+
ExecutionCapture.load(capture) if isinstance(capture, (str, os.PathLike)) else capture
|
|
304
|
+
)
|
|
305
|
+
if resolved.operation != "check":
|
|
306
|
+
raise ProjectError(f"unsupported capture operation: {resolved.operation}")
|
|
307
|
+
environment = self.ensure(resolved.lock)
|
|
308
|
+
return environment.check_files(
|
|
309
|
+
resolved.files,
|
|
310
|
+
entrypoint=resolved.entrypoint,
|
|
311
|
+
policy=resolved.policy,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
def _environment(self, value: EnvironmentReference) -> Environment:
|
|
315
|
+
if isinstance(value, Environment):
|
|
316
|
+
return value
|
|
317
|
+
if isinstance(value, EnvironmentSpec):
|
|
318
|
+
return self.ensure(self.resolve(value))
|
|
319
|
+
if isinstance(value, EnvironmentLock):
|
|
320
|
+
return self.ensure(value)
|
|
321
|
+
return self.open(value)
|
|
322
|
+
|
|
323
|
+
def _raw_check(
|
|
324
|
+
self,
|
|
325
|
+
source: str,
|
|
326
|
+
*,
|
|
327
|
+
toolchain: str | None,
|
|
328
|
+
project: str | os.PathLike[str] | None,
|
|
329
|
+
filename: str,
|
|
330
|
+
policy: ExecutionPolicy,
|
|
331
|
+
) -> ExecutionResult:
|
|
332
|
+
project_root = Path(project).expanduser().resolve() if project else None
|
|
333
|
+
selected = normalize_toolchain(toolchain) if toolchain else None
|
|
334
|
+
if selected is None and project_root is not None:
|
|
335
|
+
selected = project_toolchain(project_root)
|
|
336
|
+
if selected is None:
|
|
337
|
+
raise ToolchainError("check requires an environment, toolchain, or pinned project")
|
|
338
|
+
safe_filename = Path(filename).name
|
|
339
|
+
if not safe_filename.endswith(".lean"):
|
|
340
|
+
safe_filename += ".lean"
|
|
341
|
+
with tempfile.TemporaryDirectory(prefix="raw-check-", dir=self.store.jobs) as raw:
|
|
342
|
+
source_path = Path(raw) / safe_filename
|
|
343
|
+
source_path.write_text(source, encoding="utf-8")
|
|
344
|
+
if project_root is None:
|
|
345
|
+
command = self.toolchains.command(selected, "lean", str(source_path))
|
|
346
|
+
cwd = source_path.parent
|
|
347
|
+
else:
|
|
348
|
+
if not project_root.is_dir():
|
|
349
|
+
raise ProjectError(f"project directory does not exist: {project_root}")
|
|
350
|
+
command = self.toolchains.command(selected, "lake", "env", "lean", str(source_path))
|
|
351
|
+
cwd = project_root
|
|
352
|
+
return self._raw_result(
|
|
353
|
+
command,
|
|
354
|
+
cwd=cwd,
|
|
355
|
+
toolchain=selected,
|
|
356
|
+
source_digest=sha256_text(source),
|
|
357
|
+
policy=policy,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
def _raw_result(
|
|
361
|
+
self,
|
|
362
|
+
command: Sequence[str],
|
|
363
|
+
*,
|
|
364
|
+
cwd: Path,
|
|
365
|
+
toolchain: str,
|
|
366
|
+
source_digest: str,
|
|
367
|
+
policy: ExecutionPolicy,
|
|
368
|
+
) -> ExecutionResult:
|
|
369
|
+
started_at = datetime.now(timezone.utc).isoformat()
|
|
370
|
+
logical_command = list(command[3:])
|
|
371
|
+
if source_digest != sha256_text("") and logical_command:
|
|
372
|
+
logical_command[-1] = Path(logical_command[-1]).name
|
|
373
|
+
request_digest = sha256_id(
|
|
374
|
+
"request",
|
|
375
|
+
{
|
|
376
|
+
"environment_id": None,
|
|
377
|
+
"toolchain": toolchain,
|
|
378
|
+
"command": logical_command,
|
|
379
|
+
"source_digest": source_digest,
|
|
380
|
+
"policy": policy.to_dict(),
|
|
381
|
+
"backend": self.backend.name,
|
|
382
|
+
},
|
|
383
|
+
)
|
|
384
|
+
execution_id = sha256_id(
|
|
385
|
+
"execution",
|
|
386
|
+
{
|
|
387
|
+
"request_digest": request_digest,
|
|
388
|
+
"started_at": started_at,
|
|
389
|
+
"nonce": os.urandom(16).hex(),
|
|
390
|
+
},
|
|
391
|
+
)
|
|
392
|
+
raw = self.backend.execute(
|
|
393
|
+
command,
|
|
394
|
+
cwd=cwd,
|
|
395
|
+
environment=self.toolchains.environment,
|
|
396
|
+
policy=policy,
|
|
397
|
+
)
|
|
398
|
+
output = "\n".join(part for part in (raw.stdout, raw.stderr) if part)
|
|
399
|
+
diagnostics = parse_diagnostics(output)
|
|
400
|
+
if raw.timed_out:
|
|
401
|
+
diagnostics += (error_diagnostic("Lean execution exceeded its time limit"),)
|
|
402
|
+
provenance = ExecutionProvenance(
|
|
403
|
+
environment_id=None,
|
|
404
|
+
execution_id=execution_id,
|
|
405
|
+
request_digest=request_digest,
|
|
406
|
+
lock_id=None,
|
|
407
|
+
toolchain=toolchain,
|
|
408
|
+
packages=(),
|
|
409
|
+
platform=platform_record(),
|
|
410
|
+
backend=self.backend.name,
|
|
411
|
+
requested_policy=policy.to_dict(),
|
|
412
|
+
enforced_policy_fields=raw.enforced_policy_fields,
|
|
413
|
+
source_digest=source_digest,
|
|
414
|
+
started_at=started_at,
|
|
415
|
+
)
|
|
416
|
+
return ExecutionResult(
|
|
417
|
+
ok=raw.exit_code == 0,
|
|
418
|
+
exit_code=raw.exit_code,
|
|
419
|
+
toolchain=toolchain,
|
|
420
|
+
command=tuple(command),
|
|
421
|
+
cwd=str(cwd),
|
|
422
|
+
stdout=raw.stdout,
|
|
423
|
+
stderr=raw.stderr,
|
|
424
|
+
elapsed_seconds=raw.elapsed_seconds,
|
|
425
|
+
timed_out=raw.timed_out,
|
|
426
|
+
cancelled=raw.cancelled,
|
|
427
|
+
output_truncated=raw.output_truncated,
|
|
428
|
+
diagnostics=diagnostics,
|
|
429
|
+
provenance=provenance,
|
|
430
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Canonical serialization and digest helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import uuid
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def canonical_json_bytes(value: Any) -> bytes:
|
|
14
|
+
"""Encode JSON data with a stable, whitespace-free representation."""
|
|
15
|
+
return json.dumps(
|
|
16
|
+
value,
|
|
17
|
+
ensure_ascii=False,
|
|
18
|
+
allow_nan=False,
|
|
19
|
+
sort_keys=True,
|
|
20
|
+
separators=(",", ":"),
|
|
21
|
+
).encode("utf-8")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def sha256_id(prefix: str, value: Any) -> str:
|
|
25
|
+
"""Return a namespaced SHA-256 identity for canonical JSON data."""
|
|
26
|
+
digest = hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
|
27
|
+
return f"{prefix}_{digest}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def sha256_text(value: str) -> str:
|
|
31
|
+
return "sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def write_json_atomic(path: Path, value: Any) -> None:
|
|
35
|
+
"""Atomically publish formatted JSON next to its destination."""
|
|
36
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
temporary = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
|
|
38
|
+
try:
|
|
39
|
+
with temporary.open("w", encoding="utf-8") as handle:
|
|
40
|
+
handle.write(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n")
|
|
41
|
+
handle.flush()
|
|
42
|
+
os.fsync(handle.fileno())
|
|
43
|
+
temporary.replace(path)
|
|
44
|
+
if os.name != "nt":
|
|
45
|
+
directory = os.open(path.parent, os.O_RDONLY)
|
|
46
|
+
try:
|
|
47
|
+
os.fsync(directory)
|
|
48
|
+
finally:
|
|
49
|
+
os.close(directory)
|
|
50
|
+
finally:
|
|
51
|
+
temporary.unlink(missing_ok=True)
|
lean_runtime/specs.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Declarative Lean environment specifications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Literal, cast
|
|
12
|
+
|
|
13
|
+
if sys.version_info >= (3, 11):
|
|
14
|
+
import tomllib
|
|
15
|
+
else: # pragma: no cover - exercised by the Python 3.10 CI job
|
|
16
|
+
import tomli as tomllib
|
|
17
|
+
|
|
18
|
+
from .errors import SpecificationError
|
|
19
|
+
from .serialization import sha256_id
|
|
20
|
+
from .toolchains import normalize_toolchain
|
|
21
|
+
|
|
22
|
+
SPEC_SCHEMA = "lean-runtime-environment-spec/1"
|
|
23
|
+
_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_'-]*")
|
|
24
|
+
_MODULE = re.compile(r"[A-Za-z_][A-Za-z0-9_']*(?:\.[A-Za-z_][A-Za-z0-9_']*)*")
|
|
25
|
+
_COMMIT = re.compile(r"[0-9a-fA-F]{40}")
|
|
26
|
+
_TAG = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/+\-]{0,199}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class GitPackage:
|
|
31
|
+
"""One exact Git dependency.
|
|
32
|
+
|
|
33
|
+
A request may use an exact commit or an explicitly marked tag. Tags are
|
|
34
|
+
resolved to full commits before they enter an environment lock.
|
|
35
|
+
``root_module`` is imported by the generated root library so Lake builds
|
|
36
|
+
the dependency's Lean artifacts before the environment is published.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
url: str
|
|
41
|
+
rev: str
|
|
42
|
+
root_module: str | None = None
|
|
43
|
+
subdir: str | None = None
|
|
44
|
+
artifact_command: tuple[str, ...] = ()
|
|
45
|
+
revision_kind: Literal["commit", "tag"] = "commit"
|
|
46
|
+
|
|
47
|
+
def __post_init__(self) -> None:
|
|
48
|
+
if _NAME.fullmatch(self.name) is None:
|
|
49
|
+
raise SpecificationError(f"invalid Lake package name: {self.name!r}")
|
|
50
|
+
if not self.url or "\n" in self.url or "\r" in self.url:
|
|
51
|
+
raise SpecificationError(f"invalid Git URL for package {self.name!r}")
|
|
52
|
+
if self.revision_kind == "commit" and _COMMIT.fullmatch(self.rev) is None:
|
|
53
|
+
raise SpecificationError(
|
|
54
|
+
f"package {self.name!r} requires a full 40-character Git commit"
|
|
55
|
+
)
|
|
56
|
+
if self.revision_kind == "tag" and _TAG.fullmatch(self.rev) is None:
|
|
57
|
+
raise SpecificationError(f"invalid Git tag for package {self.name!r}: {self.rev!r}")
|
|
58
|
+
if self.root_module is not None and _MODULE.fullmatch(self.root_module) is None:
|
|
59
|
+
raise SpecificationError(
|
|
60
|
+
f"invalid root module for package {self.name!r}: {self.root_module!r}"
|
|
61
|
+
)
|
|
62
|
+
if self.subdir is not None:
|
|
63
|
+
subdir = Path(self.subdir)
|
|
64
|
+
if subdir.is_absolute() or ".." in subdir.parts:
|
|
65
|
+
raise SpecificationError(f"package subdir must be relative: {self.subdir!r}")
|
|
66
|
+
if any(not item or "\x00" in item for item in self.artifact_command):
|
|
67
|
+
raise SpecificationError(f"invalid artifact command for package {self.name!r}")
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def module(self) -> str:
|
|
71
|
+
if self.root_module:
|
|
72
|
+
return self.root_module
|
|
73
|
+
return self.name[0].upper() + self.name[1:]
|
|
74
|
+
|
|
75
|
+
def to_dict(self) -> dict[str, Any]:
|
|
76
|
+
return {
|
|
77
|
+
"source": "git",
|
|
78
|
+
"name": self.name,
|
|
79
|
+
"url": self.url,
|
|
80
|
+
"rev": self.rev.lower() if self.revision_kind == "commit" else self.rev,
|
|
81
|
+
"revision_kind": self.revision_kind,
|
|
82
|
+
"root_module": self.module,
|
|
83
|
+
"subdir": self.subdir,
|
|
84
|
+
"artifact_command": list(self.artifact_command),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def git(
|
|
89
|
+
cls,
|
|
90
|
+
name: str,
|
|
91
|
+
url: str,
|
|
92
|
+
rev: str,
|
|
93
|
+
*,
|
|
94
|
+
root_module: str | None = None,
|
|
95
|
+
subdir: str | None = None,
|
|
96
|
+
artifact_command: tuple[str, ...] = (),
|
|
97
|
+
) -> GitPackage:
|
|
98
|
+
"""Convenience constructor allowing ``Package.git(...)``."""
|
|
99
|
+
return cls(
|
|
100
|
+
name=name,
|
|
101
|
+
url=url,
|
|
102
|
+
rev=rev,
|
|
103
|
+
revision_kind="commit",
|
|
104
|
+
root_module=root_module,
|
|
105
|
+
subdir=subdir,
|
|
106
|
+
artifact_command=artifact_command,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def tag(
|
|
111
|
+
cls,
|
|
112
|
+
name: str,
|
|
113
|
+
url: str,
|
|
114
|
+
tag: str,
|
|
115
|
+
*,
|
|
116
|
+
root_module: str | None = None,
|
|
117
|
+
subdir: str | None = None,
|
|
118
|
+
artifact_command: tuple[str, ...] = (),
|
|
119
|
+
) -> GitPackage:
|
|
120
|
+
"""Request a friendly Git tag that will be pinned during resolution."""
|
|
121
|
+
return cls(
|
|
122
|
+
name=name,
|
|
123
|
+
url=url,
|
|
124
|
+
rev=tag,
|
|
125
|
+
revision_kind="tag",
|
|
126
|
+
root_module=root_module,
|
|
127
|
+
subdir=subdir,
|
|
128
|
+
artifact_command=artifact_command,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def from_dict(cls, value: dict[str, Any]) -> GitPackage:
|
|
133
|
+
if value.get("source", "git") != "git":
|
|
134
|
+
raise SpecificationError("the initial release supports only Git packages")
|
|
135
|
+
command = value.get("artifact_command") or []
|
|
136
|
+
if not isinstance(command, list) or not all(isinstance(item, str) for item in command):
|
|
137
|
+
raise SpecificationError("artifact_command must be an array of strings")
|
|
138
|
+
tag = value.get("tag")
|
|
139
|
+
if tag is not None and "rev" in value:
|
|
140
|
+
raise SpecificationError("a Git package cannot specify both rev and tag")
|
|
141
|
+
revision_kind_value = "tag" if tag is not None else value.get("revision_kind", "commit")
|
|
142
|
+
if revision_kind_value not in {"commit", "tag"}:
|
|
143
|
+
raise SpecificationError("revision_kind must be 'commit' or 'tag'")
|
|
144
|
+
revision_kind = cast(Literal["commit", "tag"], revision_kind_value)
|
|
145
|
+
return cls(
|
|
146
|
+
name=str(value.get("name", "")),
|
|
147
|
+
url=str(value.get("url", "")),
|
|
148
|
+
rev=str(tag if tag is not None else value.get("rev", "")),
|
|
149
|
+
revision_kind=revision_kind,
|
|
150
|
+
root_module=value.get("root_module"),
|
|
151
|
+
subdir=value.get("subdir"),
|
|
152
|
+
artifact_command=tuple(command),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
Package = GitPackage
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True, slots=True)
|
|
160
|
+
class EnvironmentSpec:
|
|
161
|
+
"""Canonical inputs to the environment compiler."""
|
|
162
|
+
|
|
163
|
+
toolchain: str
|
|
164
|
+
packages: tuple[GitPackage, ...] = field(default_factory=tuple)
|
|
165
|
+
|
|
166
|
+
def __post_init__(self) -> None:
|
|
167
|
+
object.__setattr__(self, "toolchain", normalize_toolchain(self.toolchain))
|
|
168
|
+
names = [package.name for package in self.packages]
|
|
169
|
+
duplicates = sorted(name for name, count in Counter(names).items() if count > 1)
|
|
170
|
+
if duplicates:
|
|
171
|
+
raise SpecificationError("duplicate direct package names: " + ", ".join(duplicates))
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def spec_digest(self) -> str:
|
|
175
|
+
return sha256_id("spec", self.to_dict())
|
|
176
|
+
|
|
177
|
+
def to_dict(self) -> dict[str, Any]:
|
|
178
|
+
return {
|
|
179
|
+
"schema": SPEC_SCHEMA,
|
|
180
|
+
"toolchain": self.toolchain,
|
|
181
|
+
"packages": [
|
|
182
|
+
package.to_dict() for package in sorted(self.packages, key=lambda item: item.name)
|
|
183
|
+
],
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
@classmethod
|
|
187
|
+
def from_dict(cls, value: dict[str, Any]) -> EnvironmentSpec:
|
|
188
|
+
schema = value.get("schema", SPEC_SCHEMA)
|
|
189
|
+
if schema != SPEC_SCHEMA:
|
|
190
|
+
raise SpecificationError(f"unsupported environment specification schema: {schema!r}")
|
|
191
|
+
raw_packages = value.get("packages", value.get("package", []))
|
|
192
|
+
if not isinstance(raw_packages, list):
|
|
193
|
+
raise SpecificationError("packages must be an array")
|
|
194
|
+
return cls(
|
|
195
|
+
toolchain=str(value.get("toolchain", "")),
|
|
196
|
+
packages=tuple(GitPackage.from_dict(item) for item in raw_packages),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def load(cls, path: str | Path) -> EnvironmentSpec:
|
|
201
|
+
source = Path(path)
|
|
202
|
+
with source.open("rb") as handle:
|
|
203
|
+
value = (
|
|
204
|
+
tomllib.load(handle)
|
|
205
|
+
if source.suffix.lower() == ".toml"
|
|
206
|
+
else json.loads(handle.read().decode("utf-8"))
|
|
207
|
+
)
|
|
208
|
+
if not isinstance(value, dict):
|
|
209
|
+
raise SpecificationError("environment specification must be an object")
|
|
210
|
+
return cls.from_dict(value)
|