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.
@@ -0,0 +1,246 @@
1
+ """Ergonomic package references compiled into exact Git package specifications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ from collections.abc import Sequence
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any, Literal
13
+
14
+ if sys.version_info >= (3, 11):
15
+ import tomllib
16
+ else: # pragma: no cover - exercised by the Python 3.10 CI job
17
+ import tomli as tomllib
18
+
19
+ from .errors import ResolutionError, SpecificationError
20
+ from .specs import GitPackage
21
+ from .toolchains import ToolchainManager, normalize_toolchain
22
+
23
+ _GITHUB = re.compile(
24
+ r"github:(?P<owner>[A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))/"
25
+ r"(?P<repo>[A-Za-z0-9_.-]+)@(?P<revision>[A-Za-z0-9][A-Za-z0-9._/+\-]{0,199})"
26
+ )
27
+ _COMMIT = re.compile(r"[0-9a-fA-F]{40}")
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class PackageReference:
32
+ """A human-friendly reference to one Git-hosted Lean package."""
33
+
34
+ url: str
35
+ revision: str
36
+ display: str
37
+ revision_kind: Literal["commit", "tag"]
38
+
39
+ @classmethod
40
+ def parse(cls, value: str) -> PackageReference:
41
+ """Parse ``github:owner/repository@tag-or-commit``."""
42
+ match = _GITHUB.fullmatch(value)
43
+ if match is None:
44
+ raise SpecificationError(
45
+ "invalid package reference; expected github:owner/repository@tag-or-commit"
46
+ )
47
+ owner = match.group("owner")
48
+ repository = match.group("repo")
49
+ if repository.endswith(".git"):
50
+ repository = repository[:-4]
51
+ if not repository:
52
+ raise SpecificationError("GitHub package reference has an empty repository name")
53
+ revision = match.group("revision")
54
+ kind: Literal["commit", "tag"] = (
55
+ "commit" if _COMMIT.fullmatch(revision) is not None else "tag"
56
+ )
57
+ return cls(
58
+ url=f"https://github.com/{owner}/{repository}.git",
59
+ revision=revision,
60
+ display=f"github:{owner}/{repository}@{revision}",
61
+ revision_kind=kind,
62
+ )
63
+
64
+ @classmethod
65
+ def git(cls, url: str, revision: str) -> PackageReference:
66
+ """Construct a reference for a generic Git URL, primarily for Python callers."""
67
+ if not url or "\n" in url or "\r" in url:
68
+ raise SpecificationError("invalid Git package-reference URL")
69
+ kind: Literal["commit", "tag"] = (
70
+ "commit" if _COMMIT.fullmatch(revision) is not None else "tag"
71
+ )
72
+ # Reuse GitPackage's strict revision validation with a temporary valid name.
73
+ if kind == "commit":
74
+ GitPackage.git("package_reference", url, revision)
75
+ else:
76
+ GitPackage.tag("package_reference", url, revision)
77
+ return cls(url=url, revision=revision, display=f"git:{url}@{revision}", revision_kind=kind)
78
+
79
+
80
+ @dataclass(frozen=True, slots=True)
81
+ class DiscoveredPackage:
82
+ """Exact package metadata discovered from a referenced Lake project."""
83
+
84
+ reference: PackageReference
85
+ toolchain: str
86
+ package: GitPackage
87
+
88
+
89
+ def _run_git(arguments: list[str], *, cwd: Path | None = None, timeout: float = 120) -> str:
90
+ try:
91
+ process = subprocess.run(
92
+ ["git", *arguments],
93
+ cwd=cwd,
94
+ text=True,
95
+ capture_output=True,
96
+ timeout=timeout,
97
+ check=False,
98
+ )
99
+ except subprocess.TimeoutExpired as exc:
100
+ raise ResolutionError(
101
+ "package-reference Git operation timed out",
102
+ phase="package-discovery",
103
+ command=("git", *arguments),
104
+ exit_code=124,
105
+ output=str(exc.stdout or "") + str(exc.stderr or ""),
106
+ ) from exc
107
+ if process.returncode:
108
+ raise ResolutionError(
109
+ "could not acquire package-reference metadata",
110
+ phase="package-discovery",
111
+ command=("git", *arguments),
112
+ exit_code=process.returncode,
113
+ output=process.stdout + process.stderr,
114
+ )
115
+ return process.stdout.strip()
116
+
117
+
118
+ def _read_lake_metadata(lakefile: Path) -> tuple[str, str]:
119
+ """Read the declarative Lake metadata needed by environment resolution."""
120
+ try:
121
+ value = tomllib.loads(lakefile.read_text(encoding="utf-8"))
122
+ except (OSError, tomllib.TOMLDecodeError) as exc:
123
+ raise SpecificationError(f"could not read package metadata {lakefile.name}: {exc}") from exc
124
+ if not isinstance(value, dict):
125
+ raise SpecificationError(f"package metadata {lakefile.name} is not a TOML object")
126
+ name = value.get("name")
127
+ if not isinstance(name, str) or not name:
128
+ raise SpecificationError(f"package metadata {lakefile.name} has no package name")
129
+ libraries = value.get("lean_lib")
130
+ if not isinstance(libraries, list) or not libraries:
131
+ raise SpecificationError(f"package metadata {lakefile.name} declares no [[lean_lib]]")
132
+ first = libraries[0]
133
+ if not isinstance(first, dict):
134
+ raise SpecificationError("package's first [[lean_lib]] is malformed")
135
+ roots = first.get("roots")
136
+ root_module: Any = roots[0] if isinstance(roots, list) and roots else first.get("name")
137
+ if not isinstance(root_module, str) or not root_module:
138
+ raise SpecificationError("package's first [[lean_lib]] has no importable root")
139
+ return name, root_module
140
+
141
+
142
+ def _lake_metadata(
143
+ checkout: Path,
144
+ *,
145
+ toolchain: str,
146
+ toolchains: ToolchainManager,
147
+ ) -> tuple[str, str]:
148
+ lakefile = checkout / "lakefile.toml"
149
+ if lakefile.is_file():
150
+ return _read_lake_metadata(lakefile)
151
+
152
+ lakefile_lean = checkout / "lakefile.lean"
153
+ if not lakefile_lean.is_file():
154
+ raise SpecificationError(
155
+ "referenced package has neither a root lakefile.toml nor lakefile.lean"
156
+ )
157
+ translated = checkout / ".lean-runtime-lakefile.toml"
158
+ try:
159
+ command = toolchains.command(
160
+ toolchain,
161
+ "lake",
162
+ "translate-config",
163
+ "toml",
164
+ str(translated),
165
+ )
166
+ process = subprocess.run(
167
+ command,
168
+ cwd=checkout,
169
+ env=toolchains.environment,
170
+ text=True,
171
+ stdout=subprocess.PIPE,
172
+ stderr=subprocess.STDOUT,
173
+ timeout=120,
174
+ check=False,
175
+ )
176
+ except subprocess.TimeoutExpired as exc:
177
+ raise ResolutionError(
178
+ "Lake package-metadata translation timed out",
179
+ phase="package-discovery",
180
+ command=tuple(command),
181
+ exit_code=124,
182
+ output=str(exc.stdout or "") + str(exc.stderr or ""),
183
+ ) from exc
184
+ if process.returncode:
185
+ raise ResolutionError(
186
+ "Lake could not translate package metadata",
187
+ phase="package-discovery",
188
+ command=tuple(command),
189
+ exit_code=process.returncode,
190
+ output=process.stdout,
191
+ )
192
+ if not translated.is_file():
193
+ raise ResolutionError(
194
+ "Lake completed without producing translated package metadata",
195
+ phase="package-discovery",
196
+ command=tuple(command),
197
+ output=process.stdout,
198
+ )
199
+ return _read_lake_metadata(translated)
200
+
201
+
202
+ def discover_package(
203
+ reference: PackageReference,
204
+ *,
205
+ directory: Path,
206
+ toolchains: ToolchainManager | None = None,
207
+ ) -> DiscoveredPackage:
208
+ """Acquire a shallow checkout and compile its metadata into an exact package."""
209
+ directory.mkdir(parents=True, exist_ok=True)
210
+ with tempfile.TemporaryDirectory(prefix="package-", dir=directory) as raw:
211
+ checkout = Path(raw)
212
+ _run_git(["init", "--quiet"], cwd=checkout)
213
+ _run_git(["remote", "add", "origin", reference.url], cwd=checkout)
214
+ target = (
215
+ reference.revision
216
+ if reference.revision_kind == "commit"
217
+ else f"refs/tags/{reference.revision}"
218
+ )
219
+ _run_git(["fetch", "--quiet", "--depth", "1", "origin", target], cwd=checkout)
220
+ _run_git(["checkout", "--quiet", "--detach", "FETCH_HEAD"], cwd=checkout)
221
+ revision = _run_git(["rev-parse", "HEAD"], cwd=checkout).lower()
222
+ if _COMMIT.fullmatch(revision) is None:
223
+ raise ResolutionError(
224
+ "package reference did not resolve to a full Git commit",
225
+ phase="package-discovery",
226
+ )
227
+ toolchain_path = checkout / "lean-toolchain"
228
+ if not toolchain_path.is_file():
229
+ raise SpecificationError("referenced package has no lean-toolchain file")
230
+ toolchain = normalize_toolchain(toolchain_path.read_text(encoding="utf-8"))
231
+ manager = toolchains or ToolchainManager(directory.parent)
232
+ name, root_module = _lake_metadata(
233
+ checkout,
234
+ toolchain=toolchain,
235
+ toolchains=manager,
236
+ )
237
+ package = GitPackage.git(name, reference.url, revision, root_module=root_module)
238
+ return DiscoveredPackage(reference=reference, toolchain=toolchain, package=package)
239
+
240
+
241
+ def normalize_references(
242
+ values: Sequence[str | PackageReference],
243
+ ) -> tuple[PackageReference, ...]:
244
+ return tuple(
245
+ PackageReference.parse(value) if isinstance(value, str) else value for value in values
246
+ )
@@ -0,0 +1,282 @@
1
+ """Compile exact-Git specifications into portable Lake-backed locks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import subprocess
8
+ import tempfile
9
+ from dataclasses import replace
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .errors import ResolutionError
14
+ from .events import EventEmitter
15
+ from .lake import ROOT_MODULE, generate_lakefile, generate_root_module
16
+ from .lockfiles import EnvironmentLock, LockedPackage
17
+ from .serialization import sha256_id
18
+ from .specs import EnvironmentSpec, GitPackage
19
+ from .store import EnvironmentStore
20
+ from .toolchains import ToolchainManager, normalize_toolchain
21
+
22
+ _COMMIT = re.compile(r"[0-9a-fA-F]{40}")
23
+
24
+
25
+ def _git(path: Path, *arguments: str) -> str:
26
+ process = subprocess.run(
27
+ ["git", "-C", str(path), *arguments],
28
+ text=True,
29
+ capture_output=True,
30
+ check=False,
31
+ )
32
+ if process.returncode:
33
+ raise ResolutionError(
34
+ f"could not inspect resolved Git package at {path}",
35
+ phase="lock-validation",
36
+ command=("git", "-C", str(path), *arguments),
37
+ exit_code=process.returncode,
38
+ output=process.stdout + process.stderr,
39
+ )
40
+ return process.stdout.strip()
41
+
42
+
43
+ class EnvironmentResolver:
44
+ def __init__(
45
+ self,
46
+ toolchains: ToolchainManager,
47
+ store: EnvironmentStore,
48
+ events: EventEmitter | None = None,
49
+ ) -> None:
50
+ self.toolchains = toolchains
51
+ self.store = store
52
+ self.events = events or EventEmitter()
53
+
54
+ def resolve(self, spec: EnvironmentSpec, *, timeout: float = 900) -> EnvironmentLock:
55
+ self.events.emit(
56
+ "resolution.started",
57
+ "Resolving environment",
58
+ toolchain=spec.toolchain,
59
+ packages=len(spec.packages),
60
+ )
61
+ toolchain = self.toolchains.ensure(spec.toolchain)
62
+ self.events.emit("toolchain.ready", "Lean toolchain is ready", toolchain=toolchain)
63
+ pinned_spec = self._pin_tags(spec)
64
+ root_lakefile = generate_lakefile(pinned_spec)
65
+ root_module = generate_root_module(spec)
66
+ resolution_root = self.store.home / "resolution"
67
+ resolution_root.mkdir(parents=True, exist_ok=True)
68
+ with tempfile.TemporaryDirectory(prefix="resolve-", dir=resolution_root) as raw:
69
+ workspace = Path(raw)
70
+ (workspace / "lean-toolchain").write_text(toolchain + "\n", encoding="utf-8")
71
+ (workspace / "lakefile.toml").write_text(root_lakefile, encoding="utf-8")
72
+ (workspace / f"{ROOT_MODULE}.lean").write_text(root_module, encoding="utf-8")
73
+ command = self.toolchains.command(toolchain, "lake", "update")
74
+ self.events.emit("resolution.lake_started", "Lake dependency resolution started")
75
+ try:
76
+ process = subprocess.run(
77
+ command,
78
+ cwd=workspace,
79
+ env=self.toolchains.environment,
80
+ text=True,
81
+ capture_output=True,
82
+ timeout=timeout,
83
+ check=False,
84
+ )
85
+ except subprocess.TimeoutExpired as exc:
86
+ raise ResolutionError(
87
+ f"Lake resolution exceeded {timeout:g} seconds",
88
+ command=tuple(command),
89
+ exit_code=124,
90
+ output=str(exc.stdout or "") + str(exc.stderr or ""),
91
+ ) from exc
92
+ if process.returncode:
93
+ raise ResolutionError(
94
+ "Lake could not resolve the environment specification",
95
+ command=tuple(command),
96
+ exit_code=process.returncode,
97
+ output=process.stdout + process.stderr,
98
+ )
99
+ manifest_path = workspace / "lake-manifest.json"
100
+ if not manifest_path.is_file():
101
+ raise ResolutionError("Lake resolution did not produce lake-manifest.json")
102
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
103
+ if not isinstance(manifest, dict):
104
+ raise ResolutionError("Lake manifest is not a JSON object")
105
+ packages = self._lock_packages(workspace, manifest, spec, toolchain)
106
+ lock = EnvironmentLock(
107
+ toolchain=toolchain,
108
+ spec_digest=spec.spec_digest,
109
+ root_lakefile=root_lakefile,
110
+ root_module=root_module,
111
+ manifest=manifest,
112
+ packages=packages,
113
+ )
114
+ self.store.publish_lock(lock)
115
+ self.events.emit(
116
+ "resolution.completed",
117
+ "Environment lock published",
118
+ lock_id=lock.lock_id,
119
+ packages=len(packages),
120
+ )
121
+ return lock
122
+
123
+ def _pin_tags(self, spec: EnvironmentSpec) -> EnvironmentSpec:
124
+ pinned: list[GitPackage] = []
125
+ for package in spec.packages:
126
+ if package.revision_kind == "commit":
127
+ pinned.append(package)
128
+ continue
129
+ reference = f"refs/tags/{package.rev}"
130
+ command = ["git", "ls-remote", package.url, reference, f"{reference}^{{}}"]
131
+ try:
132
+ process = subprocess.run(
133
+ command,
134
+ text=True,
135
+ capture_output=True,
136
+ timeout=60,
137
+ check=False,
138
+ )
139
+ except subprocess.TimeoutExpired as exc:
140
+ raise ResolutionError(
141
+ f"tag lookup timed out for package {package.name!r}",
142
+ phase="tag-resolution",
143
+ command=tuple(command),
144
+ exit_code=124,
145
+ output=str(exc.stdout or "") + str(exc.stderr or ""),
146
+ ) from exc
147
+ if process.returncode:
148
+ raise ResolutionError(
149
+ f"could not resolve tag {package.rev!r} for package {package.name!r}",
150
+ phase="tag-resolution",
151
+ command=tuple(command),
152
+ exit_code=process.returncode,
153
+ output=process.stdout + process.stderr,
154
+ )
155
+ candidates: dict[str, str] = {}
156
+ for line in process.stdout.splitlines():
157
+ fields = line.split(maxsplit=1)
158
+ if len(fields) == 2:
159
+ candidates[fields[1]] = fields[0].lower()
160
+ revision = candidates.get(f"{reference}^{{}}", candidates.get(reference, ""))
161
+ if _COMMIT.fullmatch(revision) is None:
162
+ raise ResolutionError(
163
+ f"Git tag {package.rev!r} was not found for package {package.name!r}",
164
+ phase="tag-resolution",
165
+ command=tuple(command),
166
+ exit_code=1,
167
+ output=process.stdout + process.stderr,
168
+ )
169
+ pinned.append(replace(package, rev=revision, revision_kind="commit"))
170
+ self.events.emit(
171
+ "tag.resolved",
172
+ f"Resolved {package.rev} for {package.name}",
173
+ package=package.name,
174
+ tag=package.rev,
175
+ revision=revision,
176
+ )
177
+ return EnvironmentSpec(spec.toolchain, tuple(pinned))
178
+
179
+ def _lock_packages(
180
+ self,
181
+ workspace: Path,
182
+ manifest: dict[str, Any],
183
+ spec: EnvironmentSpec,
184
+ toolchain: str,
185
+ ) -> tuple[LockedPackage, ...]:
186
+ direct: dict[str, GitPackage] = {package.name: package for package in spec.packages}
187
+ packages_dir = workspace / str(manifest.get("packagesDir", ".lake/packages"))
188
+ raw_packages = manifest.get("packages", [])
189
+ if not isinstance(raw_packages, list):
190
+ raise ResolutionError("Lake manifest packages field is not an array")
191
+ locked: list[LockedPackage] = []
192
+ for value in raw_packages:
193
+ if not isinstance(value, dict) or value.get("type") != "git":
194
+ raise ResolutionError(
195
+ "the initial lock schema supports only Git transitive dependencies",
196
+ phase="lock-validation",
197
+ )
198
+ name = str(value.get("name", ""))
199
+ revision = str(value.get("rev", "")).lower()
200
+ if _COMMIT.fullmatch(revision) is None:
201
+ raise ResolutionError(
202
+ f"Lake did not resolve package {name!r} to a full Git commit",
203
+ phase="lock-validation",
204
+ )
205
+ checkout = packages_dir / name
206
+ if not checkout.is_dir():
207
+ raise ResolutionError(
208
+ f"resolved package checkout is missing: {name}", phase="acquisition"
209
+ )
210
+ head = _git(checkout, "rev-parse", "HEAD").lower()
211
+ if head != revision:
212
+ raise ResolutionError(
213
+ f"package {name!r} checkout does not match its manifest revision",
214
+ phase="lock-validation",
215
+ )
216
+ tree = _git(checkout, "rev-parse", "HEAD^{tree}").lower()
217
+ package_root = checkout
218
+ subdir = value.get("subDir")
219
+ if isinstance(subdir, str) and subdir:
220
+ package_root /= subdir
221
+ requested = direct.get(name)
222
+ declared_toolchain = package_root / "lean-toolchain"
223
+ if declared_toolchain.is_file():
224
+ declared = declared_toolchain.read_text(encoding="utf-8").strip()
225
+ if declared and normalize_toolchain(declared) != toolchain:
226
+ self.events.emit(
227
+ "compatibility.toolchain_difference",
228
+ f"{name} declares {declared}; compatibility will be tested by the build",
229
+ package=name,
230
+ declared_toolchain=declared,
231
+ environment_toolchain=toolchain,
232
+ direct=requested is not None,
233
+ )
234
+ url = str(value.get("url", ""))
235
+ source_id = sha256_id(
236
+ "source", {"source": "git", "url": url, "revision": revision, "tree": tree}
237
+ )
238
+ metadata = {
239
+ "schema": "lean-runtime-git-source/1",
240
+ "source_id": source_id,
241
+ "name": name,
242
+ "url": url,
243
+ "revision": revision,
244
+ "tree_hash": tree,
245
+ }
246
+ self.store.publish_source(checkout, source_id, metadata)
247
+ self.events.emit(
248
+ "source.locked",
249
+ f"Locked {name}",
250
+ package=name,
251
+ revision=revision,
252
+ source_id=source_id,
253
+ )
254
+ locked.append(
255
+ LockedPackage(
256
+ name=name,
257
+ url=url,
258
+ requested_revision=(
259
+ (
260
+ requested.rev.lower()
261
+ if requested.revision_kind == "commit"
262
+ else requested.rev
263
+ )
264
+ if requested
265
+ else value.get("inputRev")
266
+ ),
267
+ revision=revision,
268
+ tree_hash=tree,
269
+ source_id=source_id,
270
+ inherited=bool(value.get("inherited", False)),
271
+ subdir=subdir if isinstance(subdir, str) else None,
272
+ root_module=requested.module if requested else None,
273
+ artifact_command=requested.artifact_command if requested else (),
274
+ )
275
+ )
276
+ missing = sorted(set(direct) - {package.name for package in locked})
277
+ if missing:
278
+ raise ResolutionError(
279
+ "direct packages missing from Lake manifest: " + ", ".join(missing),
280
+ phase="lock-validation",
281
+ )
282
+ return tuple(sorted(locked, key=lambda package: package.name))