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,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)