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/errors.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Exceptions raised by :mod:`lean_runtime`."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LeanRuntimeError(RuntimeError):
|
|
5
|
+
"""Base class for runtime infrastructure failures."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ToolchainError(LeanRuntimeError):
|
|
9
|
+
"""A Lean toolchain could not be resolved or installed."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProjectError(LeanRuntimeError):
|
|
13
|
+
"""A Lean project is missing required configuration."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SpecificationError(LeanRuntimeError):
|
|
17
|
+
"""An environment specification is invalid."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ResolutionError(LeanRuntimeError):
|
|
21
|
+
"""Lake could not resolve an environment specification."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
message: str,
|
|
26
|
+
*,
|
|
27
|
+
phase: str = "resolution",
|
|
28
|
+
command: tuple[str, ...] = (),
|
|
29
|
+
exit_code: int | None = None,
|
|
30
|
+
output: str = "",
|
|
31
|
+
) -> None:
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.phase = phase
|
|
34
|
+
self.command = command
|
|
35
|
+
self.exit_code = exit_code
|
|
36
|
+
self.output = output
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class EnvironmentError(LeanRuntimeError):
|
|
40
|
+
"""A content-addressed environment could not be opened or built."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MaterializationError(EnvironmentError):
|
|
44
|
+
"""Locked sources or build artifacts could not be materialized."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
message: str,
|
|
49
|
+
*,
|
|
50
|
+
phase: str,
|
|
51
|
+
command: tuple[str, ...] = (),
|
|
52
|
+
exit_code: int | None = None,
|
|
53
|
+
output: str = "",
|
|
54
|
+
) -> None:
|
|
55
|
+
super().__init__(message)
|
|
56
|
+
self.phase = phase
|
|
57
|
+
self.command = command
|
|
58
|
+
self.exit_code = exit_code
|
|
59
|
+
self.output = output
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class PolicyError(LeanRuntimeError):
|
|
63
|
+
"""An execution policy cannot be enforced by the selected backend."""
|
lean_runtime/events.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Structured progress events emitted by long-running runtime operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class RuntimeEvent:
|
|
13
|
+
"""One observable lifecycle transition."""
|
|
14
|
+
|
|
15
|
+
kind: str
|
|
16
|
+
message: str
|
|
17
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
18
|
+
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
19
|
+
|
|
20
|
+
def to_dict(self) -> dict[str, Any]:
|
|
21
|
+
return asdict(self)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
EventCallback = Callable[[RuntimeEvent], None]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EventEmitter:
|
|
28
|
+
"""Null-safe adapter shared by runtime components."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, callback: EventCallback | None = None) -> None:
|
|
31
|
+
self.callback = callback
|
|
32
|
+
|
|
33
|
+
def emit(self, kind: str, message: str, **data: Any) -> None:
|
|
34
|
+
if self.callback is not None:
|
|
35
|
+
self.callback(RuntimeEvent(kind=kind, message=message, data=data))
|
lean_runtime/health.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Local installation and cache health checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from .errors import ToolchainError
|
|
14
|
+
from .store import EnvironmentStore
|
|
15
|
+
from .toolchains import ToolchainManager
|
|
16
|
+
|
|
17
|
+
CheckStatus = Literal["pass", "warning", "fail"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class DoctorCheck:
|
|
22
|
+
name: str
|
|
23
|
+
status: CheckStatus
|
|
24
|
+
message: str
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> dict[str, str]:
|
|
27
|
+
return asdict(self)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class DoctorReport:
|
|
32
|
+
checks: tuple[DoctorCheck, ...]
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def ok(self) -> bool:
|
|
36
|
+
return all(check.status != "fail" for check in self.checks)
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict[str, object]:
|
|
39
|
+
return {"ok": self.ok, "checks": [check.to_dict() for check in self.checks]}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def diagnose(toolchains: ToolchainManager, store: EnvironmentStore) -> DoctorReport:
|
|
43
|
+
"""Inspect prerequisites without downloading or building anything."""
|
|
44
|
+
checks: list[DoctorCheck] = []
|
|
45
|
+
git = shutil.which("git")
|
|
46
|
+
if git is None:
|
|
47
|
+
checks.append(DoctorCheck("git", "fail", "Git is not available on PATH"))
|
|
48
|
+
else:
|
|
49
|
+
process = subprocess.run([git, "--version"], text=True, capture_output=True, check=False)
|
|
50
|
+
status: CheckStatus = "pass" if process.returncode == 0 else "fail"
|
|
51
|
+
checks.append(DoctorCheck("git", status, process.stdout.strip() or "Git failed"))
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
with tempfile.NamedTemporaryFile(dir=store.home, prefix="doctor-", delete=True):
|
|
55
|
+
pass
|
|
56
|
+
checks.append(DoctorCheck("store", "pass", f"Store is writable: {store.home}"))
|
|
57
|
+
except OSError as exc:
|
|
58
|
+
checks.append(DoctorCheck("store", "fail", f"Store is not writable: {exc}"))
|
|
59
|
+
|
|
60
|
+
free = shutil.disk_usage(store.home).free
|
|
61
|
+
if free < 256 * 1024 * 1024:
|
|
62
|
+
checks.append(DoctorCheck("disk", "fail", f"Only {free // (1024**2)} MiB free"))
|
|
63
|
+
elif free < 2 * 1024 * 1024 * 1024:
|
|
64
|
+
checks.append(DoctorCheck("disk", "warning", f"Only {free // (1024**2)} MiB free"))
|
|
65
|
+
else:
|
|
66
|
+
checks.append(DoctorCheck("disk", "pass", f"{free // (1024**3)} GiB free"))
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
elan = toolchains.elan_path(bootstrap=False)
|
|
70
|
+
except ToolchainError:
|
|
71
|
+
if os.name != "nt" and platform.system() in {"Darwin", "Linux"}:
|
|
72
|
+
checks.append(
|
|
73
|
+
DoctorCheck("elan", "warning", "Private Elan is not installed; it will bootstrap")
|
|
74
|
+
)
|
|
75
|
+
else:
|
|
76
|
+
checks.append(
|
|
77
|
+
DoctorCheck("elan", "fail", "Set LEAN_RUNTIME_ELAN to an Elan executable")
|
|
78
|
+
)
|
|
79
|
+
else:
|
|
80
|
+
checks.append(DoctorCheck("elan", "pass", f"Elan executable: {elan}"))
|
|
81
|
+
|
|
82
|
+
staging = tuple(store.environments.glob(".staging-*"))
|
|
83
|
+
status = "warning" if staging else "pass"
|
|
84
|
+
message = f"{len(staging)} incomplete staging directories" if staging else "No stale builds"
|
|
85
|
+
checks.append(DoctorCheck("staging", status, message))
|
|
86
|
+
return DoctorReport(tuple(checks))
|
lean_runtime/lake.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Synthetic Lake root generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from .specs import EnvironmentSpec
|
|
8
|
+
|
|
9
|
+
ROOT_PACKAGE = "lean_runtime_environment"
|
|
10
|
+
ROOT_MODULE = "LeanRuntimeEnvironment"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _quote(value: str) -> str:
|
|
14
|
+
return json.dumps(value, ensure_ascii=False)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def generate_lakefile(spec: EnvironmentSpec) -> str:
|
|
18
|
+
lines = [
|
|
19
|
+
f"name = {_quote(ROOT_PACKAGE)}",
|
|
20
|
+
'version = "0.0.0"',
|
|
21
|
+
'defaultTargets = ["LeanRuntimeEnvironment"]',
|
|
22
|
+
"",
|
|
23
|
+
]
|
|
24
|
+
for package in sorted(spec.packages, key=lambda item: item.name):
|
|
25
|
+
revision = package.rev.lower() if package.revision_kind == "commit" else package.rev
|
|
26
|
+
lines.extend(
|
|
27
|
+
[
|
|
28
|
+
"[[require]]",
|
|
29
|
+
f"name = {_quote(package.name)}",
|
|
30
|
+
f"git = {_quote(package.url)}",
|
|
31
|
+
f"rev = {_quote(revision)}",
|
|
32
|
+
]
|
|
33
|
+
)
|
|
34
|
+
if package.subdir:
|
|
35
|
+
lines.append(f"subDir = {_quote(package.subdir)}")
|
|
36
|
+
lines.append("")
|
|
37
|
+
lines.extend(["[[lean_lib]]", f"name = {_quote(ROOT_MODULE)}", ""])
|
|
38
|
+
return "\n".join(lines)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def generate_root_module(spec: EnvironmentSpec) -> str:
|
|
42
|
+
imports = [f"import {package.module}" for package in spec.packages]
|
|
43
|
+
return "\n".join([*imports, "", "/- Generated by Lean Runtime. -/", ""])
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Portable environment locks produced by Lake-backed resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .errors import EnvironmentError
|
|
12
|
+
from .serialization import sha256_id, write_json_atomic
|
|
13
|
+
|
|
14
|
+
LOCK_SCHEMA = "lean-runtime-environment-lock/1"
|
|
15
|
+
_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_'-]*")
|
|
16
|
+
_MODULE = re.compile(r"[A-Za-z_][A-Za-z0-9_']*(?:\.[A-Za-z_][A-Za-z0-9_']*)*")
|
|
17
|
+
_COMMIT = re.compile(r"[0-9a-fA-F]{40}")
|
|
18
|
+
_SOURCE_ID = re.compile(r"source_[0-9a-f]{64}")
|
|
19
|
+
_SPEC_ID = re.compile(r"spec_[0-9a-f]{64}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _optional_string(value: Any, field_name: str) -> str | None:
|
|
23
|
+
if value is None:
|
|
24
|
+
return None
|
|
25
|
+
if not isinstance(value, str):
|
|
26
|
+
raise EnvironmentError(f"lock field {field_name!r} must be a string or null")
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class LockedPackage:
|
|
32
|
+
name: str
|
|
33
|
+
url: str
|
|
34
|
+
revision: str
|
|
35
|
+
source_id: str
|
|
36
|
+
tree_hash: str
|
|
37
|
+
requested_revision: str | None = None
|
|
38
|
+
inherited: bool = False
|
|
39
|
+
subdir: str | None = None
|
|
40
|
+
root_module: str | None = None
|
|
41
|
+
artifact_command: tuple[str, ...] = field(default_factory=tuple)
|
|
42
|
+
|
|
43
|
+
def __post_init__(self) -> None:
|
|
44
|
+
if _NAME.fullmatch(self.name) is None:
|
|
45
|
+
raise EnvironmentError(f"invalid locked package name: {self.name!r}")
|
|
46
|
+
if not self.url or "\n" in self.url or "\r" in self.url:
|
|
47
|
+
raise EnvironmentError(f"invalid locked Git URL for package {self.name!r}")
|
|
48
|
+
if _COMMIT.fullmatch(self.revision) is None:
|
|
49
|
+
raise EnvironmentError(f"invalid locked revision for package {self.name!r}")
|
|
50
|
+
if _COMMIT.fullmatch(self.tree_hash) is None:
|
|
51
|
+
raise EnvironmentError(f"invalid locked tree hash for package {self.name!r}")
|
|
52
|
+
if _SOURCE_ID.fullmatch(self.source_id) is None:
|
|
53
|
+
raise EnvironmentError(f"invalid source identity for package {self.name!r}")
|
|
54
|
+
if self.requested_revision is not None and not self.requested_revision:
|
|
55
|
+
raise EnvironmentError(f"empty requested revision for package {self.name!r}")
|
|
56
|
+
if self.root_module is not None and _MODULE.fullmatch(self.root_module) is None:
|
|
57
|
+
raise EnvironmentError(f"invalid root module for package {self.name!r}")
|
|
58
|
+
if self.subdir is not None:
|
|
59
|
+
subdir = Path(self.subdir)
|
|
60
|
+
if not self.subdir or subdir.is_absolute() or ".." in subdir.parts:
|
|
61
|
+
raise EnvironmentError(f"unsafe package subdir in lock: {self.subdir!r}")
|
|
62
|
+
if any(not item or "\x00" in item for item in self.artifact_command):
|
|
63
|
+
raise EnvironmentError(f"invalid artifact command for package {self.name!r}")
|
|
64
|
+
|
|
65
|
+
def to_dict(self) -> dict[str, Any]:
|
|
66
|
+
return {
|
|
67
|
+
"name": self.name,
|
|
68
|
+
"source": "git",
|
|
69
|
+
"url": self.url,
|
|
70
|
+
"requested_revision": self.requested_revision,
|
|
71
|
+
"revision": self.revision,
|
|
72
|
+
"tree_hash": self.tree_hash,
|
|
73
|
+
"source_id": self.source_id,
|
|
74
|
+
"inherited": self.inherited,
|
|
75
|
+
"subdir": self.subdir,
|
|
76
|
+
"root_module": self.root_module,
|
|
77
|
+
"artifact_command": list(self.artifact_command),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_dict(cls, value: dict[str, Any]) -> LockedPackage:
|
|
82
|
+
if value.get("source") != "git":
|
|
83
|
+
raise EnvironmentError("lock contains an unsupported package source")
|
|
84
|
+
command = value.get("artifact_command", [])
|
|
85
|
+
if not isinstance(command, list) or not all(isinstance(item, str) for item in command):
|
|
86
|
+
raise EnvironmentError("locked artifact_command must be an array of strings")
|
|
87
|
+
inherited = value.get("inherited", False)
|
|
88
|
+
if not isinstance(inherited, bool):
|
|
89
|
+
raise EnvironmentError("locked package inherited must be Boolean")
|
|
90
|
+
required = ("name", "url", "revision", "tree_hash", "source_id")
|
|
91
|
+
if any(not isinstance(value.get(key), str) for key in required):
|
|
92
|
+
raise EnvironmentError("locked package is missing a required string field")
|
|
93
|
+
return cls(
|
|
94
|
+
name=value["name"],
|
|
95
|
+
url=value["url"],
|
|
96
|
+
requested_revision=_optional_string(
|
|
97
|
+
value.get("requested_revision"), "requested_revision"
|
|
98
|
+
),
|
|
99
|
+
revision=value["revision"],
|
|
100
|
+
tree_hash=value["tree_hash"],
|
|
101
|
+
source_id=value["source_id"],
|
|
102
|
+
inherited=inherited,
|
|
103
|
+
subdir=_optional_string(value.get("subdir"), "subdir"),
|
|
104
|
+
root_module=_optional_string(value.get("root_module"), "root_module"),
|
|
105
|
+
artifact_command=tuple(command),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True, slots=True)
|
|
110
|
+
class EnvironmentLock:
|
|
111
|
+
"""A canonical, platform-independent resolved dependency graph."""
|
|
112
|
+
|
|
113
|
+
toolchain: str
|
|
114
|
+
spec_digest: str
|
|
115
|
+
root_lakefile: str
|
|
116
|
+
root_module: str
|
|
117
|
+
manifest: dict[str, Any]
|
|
118
|
+
packages: tuple[LockedPackage, ...]
|
|
119
|
+
|
|
120
|
+
def __post_init__(self) -> None:
|
|
121
|
+
if not self.toolchain or "\x00" in self.toolchain:
|
|
122
|
+
raise EnvironmentError("lock contains an invalid toolchain")
|
|
123
|
+
if _SPEC_ID.fullmatch(self.spec_digest) is None:
|
|
124
|
+
raise EnvironmentError("lock contains an invalid specification digest")
|
|
125
|
+
if not self.root_lakefile or "\x00" in self.root_lakefile:
|
|
126
|
+
raise EnvironmentError("lock contains an invalid root Lake configuration")
|
|
127
|
+
if "\x00" in self.root_module:
|
|
128
|
+
raise EnvironmentError("lock contains invalid root Lean source")
|
|
129
|
+
names = [package.name for package in self.packages]
|
|
130
|
+
if len(names) != len(set(names)):
|
|
131
|
+
raise EnvironmentError("lock contains duplicate package names")
|
|
132
|
+
|
|
133
|
+
def identity_payload(self) -> dict[str, Any]:
|
|
134
|
+
return {
|
|
135
|
+
"schema": LOCK_SCHEMA,
|
|
136
|
+
"toolchain": self.toolchain,
|
|
137
|
+
"spec_digest": self.spec_digest,
|
|
138
|
+
"root_lakefile": self.root_lakefile,
|
|
139
|
+
"root_module": self.root_module,
|
|
140
|
+
"manifest": self.manifest,
|
|
141
|
+
"packages": [
|
|
142
|
+
package.to_dict() for package in sorted(self.packages, key=lambda item: item.name)
|
|
143
|
+
],
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def lock_id(self) -> str:
|
|
148
|
+
return sha256_id("lock", self.identity_payload())
|
|
149
|
+
|
|
150
|
+
def to_dict(self) -> dict[str, Any]:
|
|
151
|
+
return {"lock_id": self.lock_id, **self.identity_payload()}
|
|
152
|
+
|
|
153
|
+
def write(self, path: str | Path) -> None:
|
|
154
|
+
write_json_atomic(Path(path), self.to_dict())
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def from_dict(cls, value: dict[str, Any]) -> EnvironmentLock:
|
|
158
|
+
if value.get("schema") != LOCK_SCHEMA:
|
|
159
|
+
raise EnvironmentError(f"unsupported lock schema: {value.get('schema')!r}")
|
|
160
|
+
required_strings = ("toolchain", "spec_digest", "root_lakefile", "root_module")
|
|
161
|
+
if any(not isinstance(value.get(key), str) for key in required_strings):
|
|
162
|
+
raise EnvironmentError("environment lock is missing a required string field")
|
|
163
|
+
manifest = value.get("manifest")
|
|
164
|
+
packages = value.get("packages")
|
|
165
|
+
if not isinstance(manifest, dict):
|
|
166
|
+
raise EnvironmentError("environment lock manifest must be an object")
|
|
167
|
+
if not isinstance(packages, list) or not all(isinstance(item, dict) for item in packages):
|
|
168
|
+
raise EnvironmentError("environment lock packages must be an array of objects")
|
|
169
|
+
lock = cls(
|
|
170
|
+
toolchain=value["toolchain"],
|
|
171
|
+
spec_digest=value["spec_digest"],
|
|
172
|
+
root_lakefile=value["root_lakefile"],
|
|
173
|
+
root_module=value["root_module"],
|
|
174
|
+
manifest=manifest,
|
|
175
|
+
packages=tuple(LockedPackage.from_dict(item) for item in packages),
|
|
176
|
+
)
|
|
177
|
+
recorded = value.get("lock_id")
|
|
178
|
+
if recorded is not None and recorded != lock.lock_id:
|
|
179
|
+
raise EnvironmentError(
|
|
180
|
+
f"lock identity mismatch: recorded {recorded!r}, computed {lock.lock_id!r}"
|
|
181
|
+
)
|
|
182
|
+
return lock
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def load(cls, path: str | Path) -> EnvironmentLock:
|
|
186
|
+
try:
|
|
187
|
+
value = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
188
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
189
|
+
raise EnvironmentError(f"could not read environment lock: {path}") from exc
|
|
190
|
+
if not isinstance(value, dict):
|
|
191
|
+
raise EnvironmentError("environment lock must contain an object")
|
|
192
|
+
return cls.from_dict(value)
|
lean_runtime/locking.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Small cross-process file locks used for atomic store publication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import BinaryIO
|
|
10
|
+
|
|
11
|
+
from .errors import EnvironmentError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FileLock:
|
|
15
|
+
def __init__(self, path: Path, timeout: float = 300) -> None:
|
|
16
|
+
self.path = path
|
|
17
|
+
self.timeout = timeout
|
|
18
|
+
self._handle: BinaryIO | None = None
|
|
19
|
+
|
|
20
|
+
def __enter__(self) -> FileLock:
|
|
21
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
handle = self.path.open("a+b")
|
|
23
|
+
started = time.monotonic()
|
|
24
|
+
while True:
|
|
25
|
+
try:
|
|
26
|
+
if os.name == "nt":
|
|
27
|
+
import msvcrt
|
|
28
|
+
|
|
29
|
+
handle.seek(0)
|
|
30
|
+
if handle.tell() == 0:
|
|
31
|
+
handle.write(b"0")
|
|
32
|
+
handle.flush()
|
|
33
|
+
handle.seek(0)
|
|
34
|
+
getattr(msvcrt, "locking")( # noqa: B009
|
|
35
|
+
handle.fileno(),
|
|
36
|
+
getattr(msvcrt, "LK_NBLCK"), # noqa: B009
|
|
37
|
+
1, # noqa: B009
|
|
38
|
+
)
|
|
39
|
+
else:
|
|
40
|
+
import fcntl
|
|
41
|
+
|
|
42
|
+
getattr(fcntl, "flock")( # noqa: B009
|
|
43
|
+
handle.fileno(),
|
|
44
|
+
getattr(fcntl, "LOCK_EX") | getattr(fcntl, "LOCK_NB"), # noqa: B009
|
|
45
|
+
)
|
|
46
|
+
self._handle = handle
|
|
47
|
+
return self
|
|
48
|
+
except OSError as error:
|
|
49
|
+
if time.monotonic() - started >= self.timeout:
|
|
50
|
+
handle.close()
|
|
51
|
+
raise EnvironmentError(
|
|
52
|
+
f"timed out waiting for store lock: {self.path}"
|
|
53
|
+
) from error
|
|
54
|
+
time.sleep(0.05)
|
|
55
|
+
|
|
56
|
+
def __exit__(
|
|
57
|
+
self,
|
|
58
|
+
exc_type: type[BaseException] | None,
|
|
59
|
+
exc: BaseException | None,
|
|
60
|
+
traceback: TracebackType | None,
|
|
61
|
+
) -> None:
|
|
62
|
+
handle = self._handle
|
|
63
|
+
if handle is None:
|
|
64
|
+
return
|
|
65
|
+
if os.name == "nt":
|
|
66
|
+
import msvcrt
|
|
67
|
+
|
|
68
|
+
handle.seek(0)
|
|
69
|
+
getattr(msvcrt, "locking")( # noqa: B009
|
|
70
|
+
handle.fileno(),
|
|
71
|
+
getattr(msvcrt, "LK_UNLCK"), # noqa: B009
|
|
72
|
+
1, # noqa: B009
|
|
73
|
+
)
|
|
74
|
+
else:
|
|
75
|
+
import fcntl
|
|
76
|
+
|
|
77
|
+
getattr(fcntl, "flock")( # noqa: B009
|
|
78
|
+
handle.fileno(),
|
|
79
|
+
getattr(fcntl, "LOCK_UN"), # noqa: B009
|
|
80
|
+
)
|
|
81
|
+
handle.close()
|
|
82
|
+
self._handle = None
|
lean_runtime/models.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Stable result models returned by the runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
Severity = Literal["error", "warning", "information", "unknown"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class Diagnostic:
|
|
13
|
+
"""One compiler diagnostic parsed from Lean's textual output."""
|
|
14
|
+
|
|
15
|
+
severity: Severity
|
|
16
|
+
message: str
|
|
17
|
+
file: str | None = None
|
|
18
|
+
line: int | None = None
|
|
19
|
+
column: int | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class PackageProvenance:
|
|
24
|
+
name: str
|
|
25
|
+
url: str
|
|
26
|
+
revision: str
|
|
27
|
+
tree_hash: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class ExecutionProvenance:
|
|
32
|
+
environment_id: str | None
|
|
33
|
+
execution_id: str
|
|
34
|
+
request_digest: str
|
|
35
|
+
lock_id: str | None
|
|
36
|
+
toolchain: str
|
|
37
|
+
packages: tuple[PackageProvenance, ...]
|
|
38
|
+
platform: dict[str, str]
|
|
39
|
+
backend: str
|
|
40
|
+
requested_policy: dict[str, Any]
|
|
41
|
+
enforced_policy_fields: tuple[str, ...]
|
|
42
|
+
source_digest: str
|
|
43
|
+
started_at: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class ExecutionResult:
|
|
48
|
+
"""The reproducible outcome of one Lean or Lake invocation."""
|
|
49
|
+
|
|
50
|
+
ok: bool
|
|
51
|
+
exit_code: int
|
|
52
|
+
toolchain: str
|
|
53
|
+
command: tuple[str, ...]
|
|
54
|
+
cwd: str
|
|
55
|
+
stdout: str
|
|
56
|
+
stderr: str
|
|
57
|
+
elapsed_seconds: float
|
|
58
|
+
timed_out: bool = False
|
|
59
|
+
cancelled: bool = False
|
|
60
|
+
output_truncated: bool = False
|
|
61
|
+
diagnostics: tuple[Diagnostic, ...] = field(default_factory=tuple)
|
|
62
|
+
provenance: ExecutionProvenance | None = None
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def environment_id(self) -> str | None:
|
|
66
|
+
return self.provenance.environment_id if self.provenance else None
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def execution_id(self) -> str | None:
|
|
70
|
+
return self.provenance.execution_id if self.provenance else None
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def lock_id(self) -> str | None:
|
|
74
|
+
return self.provenance.lock_id if self.provenance else None
|
|
75
|
+
|
|
76
|
+
def to_dict(self) -> dict[str, Any]:
|
|
77
|
+
"""Return a JSON-serializable representation."""
|
|
78
|
+
return asdict(self)
|
lean_runtime/policies.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Backend-independent execution policy requests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
NetworkPolicy = Literal["inherit", "disabled"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class ExecutionPolicy:
|
|
13
|
+
timeout_seconds: float = 120
|
|
14
|
+
max_output_bytes: int = 1_000_000
|
|
15
|
+
memory_mb: int | None = None
|
|
16
|
+
cpu_seconds: int | None = None
|
|
17
|
+
network: NetworkPolicy = "inherit"
|
|
18
|
+
|
|
19
|
+
def __post_init__(self) -> None:
|
|
20
|
+
if self.timeout_seconds <= 0:
|
|
21
|
+
raise ValueError("timeout_seconds must be positive")
|
|
22
|
+
if self.max_output_bytes < 0:
|
|
23
|
+
raise ValueError("max_output_bytes must be nonnegative")
|
|
24
|
+
if self.memory_mb is not None and self.memory_mb <= 0:
|
|
25
|
+
raise ValueError("memory_mb must be positive")
|
|
26
|
+
if self.cpu_seconds is not None and self.cpu_seconds <= 0:
|
|
27
|
+
raise ValueError("cpu_seconds must be positive")
|
|
28
|
+
if self.network not in {"inherit", "disabled"}:
|
|
29
|
+
raise ValueError(f"unsupported network policy: {self.network!r}")
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def trusted_local(cls, **kwargs: Any) -> ExecutionPolicy:
|
|
33
|
+
return cls(**kwargs)
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict[str, Any]:
|
|
36
|
+
return asdict(self)
|
lean_runtime/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|