lean-runtime 2.0.6__tar.gz → 2.0.8__tar.gz

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.
Files changed (146) hide show
  1. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/CHANGELOG.md +13 -0
  2. {lean_runtime-2.0.6/lean_runtime.egg-info → lean_runtime-2.0.8}/PKG-INFO +1 -1
  3. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/docs/ready-programs.md +6 -2
  4. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/cli.py +14 -0
  5. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/oci.py +66 -44
  6. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/programs.py +45 -8
  7. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/run_cli.py +1 -0
  8. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/runtime.py +2 -0
  9. {lean_runtime-2.0.6 → lean_runtime-2.0.8/lean_runtime.egg-info}/PKG-INFO +1 -1
  10. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/pyproject.toml +1 -1
  11. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_bundles.py +52 -2
  12. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_cli.py +39 -0
  13. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_programs.py +39 -0
  14. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/LICENSE +0 -0
  15. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/MANIFEST.in +0 -0
  16. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/README.md +0 -0
  17. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/catalog/environments.toml +0 -0
  18. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/catalog/locks/core-v4.32.2.lock.json +0 -0
  19. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/catalog/locks/mathlib-v4.30.0.lock.json +0 -0
  20. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/catalog/locks/mathlib-v4.31.0.lock.json +0 -0
  21. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/catalog/locks/mathlib-v4.32.2.lock.json +0 -0
  22. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/compatibility/README.md +0 -0
  23. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/compatibility/mathlib-4.32.2.json +0 -0
  24. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/compatibility/mathlib-4.32.2.toml +0 -0
  25. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/docs/architecture.md +0 -0
  26. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/docs/captures.md +0 -0
  27. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/docs/case-study-v1.md +0 -0
  28. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/docs/cli.md +0 -0
  29. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/docs/development.md +0 -0
  30. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/docs/environments.md +0 -0
  31. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/docs/getting-started.md +0 -0
  32. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/docs/index.md +0 -0
  33. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/docs/local-projects.md +0 -0
  34. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/docs/portable-copies.md +0 -0
  35. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/docs/python-api.md +0 -0
  36. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/docs/standalone-files.md +0 -0
  37. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/docs/trust-and-limitations.md +0 -0
  38. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/docs/v1-precision.md +0 -0
  39. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/examples/mathlib.toml +0 -0
  40. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/__init__.py +0 -0
  41. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/__main__.py +0 -0
  42. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/_git.py +0 -0
  43. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/_paths.py +0 -0
  44. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/backends.py +0 -0
  45. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/bundles.py +0 -0
  46. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/comparison.py +0 -0
  47. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/decisions.py +0 -0
  48. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/diagnostics.py +0 -0
  49. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/discovery/__init__.py +0 -0
  50. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/discovery/analyzer.py +0 -0
  51. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/discovery/api.py +0 -0
  52. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/discovery/candidate.py +0 -0
  53. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/discovery/catalog.py +0 -0
  54. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/discovery/catalog_build.py +0 -0
  55. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/discovery/catalog_cli.py +0 -0
  56. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/discovery/catalog_manifest.py +0 -0
  57. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/discovery/data/catalog.json +0 -0
  58. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/discovery/defaults.py +0 -0
  59. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/discovery/engine.py +0 -0
  60. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/discovery/errors.py +0 -0
  61. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/discovery/module_inventory.py +0 -0
  62. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/discovery/planner.py +0 -0
  63. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/discovery/policy.py +0 -0
  64. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/discovery/probe.py +0 -0
  65. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/discovery/py.typed +0 -0
  66. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/discovery/result.py +0 -0
  67. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/discovery/schema_resources.py +0 -0
  68. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/discovery/schemas/catalog-v1.schema.json +0 -0
  69. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/discovery/schemas/plan-v1.schema.json +0 -0
  70. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/discovery/schemas/result-v1.schema.json +0 -0
  71. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/environments.py +0 -0
  72. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/errors.py +0 -0
  73. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/events.py +0 -0
  74. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/facade.py +0 -0
  75. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/frontmatter.py +0 -0
  76. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/health.py +0 -0
  77. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/lake.py +0 -0
  78. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/lockfiles.py +0 -0
  79. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/locking.py +0 -0
  80. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/matrix.py +0 -0
  81. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/models.py +0 -0
  82. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/policies.py +0 -0
  83. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/profiling.py +0 -0
  84. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/projects.py +0 -0
  85. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/publisher_verification.py +0 -0
  86. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/py.typed +0 -0
  87. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/references.py +0 -0
  88. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/resolver.py +0 -0
  89. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/schema_resources.py +0 -0
  90. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/serialization.py +0 -0
  91. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/specs.py +0 -0
  92. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/store.py +0 -0
  93. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/timings.py +0 -0
  94. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/toolchains.py +0 -0
  95. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/verification.py +0 -0
  96. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime/wire.py +0 -0
  97. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime.egg-info/SOURCES.txt +0 -0
  98. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime.egg-info/dependency_links.txt +0 -0
  99. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime.egg-info/entry_points.txt +0 -0
  100. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime.egg-info/requires.txt +0 -0
  101. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/lean_runtime.egg-info/top_level.txt +0 -0
  102. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/mkdocs.yml +0 -0
  103. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/schemas/cleanup-v1.schema.json +0 -0
  104. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/schemas/comparison-v1.schema.json +0 -0
  105. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/schemas/execution-v1.schema.json +0 -0
  106. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/schemas/inspect-v1.schema.json +0 -0
  107. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/schemas/matrix-v1.schema.json +0 -0
  108. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/schemas/profile-v1.schema.json +0 -0
  109. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/schemas/verify-v1.schema.json +0 -0
  110. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/scripts/run_compatibility.py +0 -0
  111. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/scripts/run_v1_case_study.py +0 -0
  112. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/scripts/smoke_wheel.py +0 -0
  113. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/setup.cfg +0 -0
  114. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/conftest.py +0 -0
  115. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/discovery/e2e/test_real_discovery.py +0 -0
  116. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/discovery/test_analyzer.py +0 -0
  117. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/discovery/test_catalog.py +0 -0
  118. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/discovery/test_catalog_build.py +0 -0
  119. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/discovery/test_discovery_schema_resources.py +0 -0
  120. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/discovery/test_engine.py +0 -0
  121. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/discovery/test_planner.py +0 -0
  122. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/discovery/test_policy.py +0 -0
  123. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_backend.py +0 -0
  124. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_diagnostics.py +0 -0
  125. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_environment_integration.py +0 -0
  126. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_events_health.py +0 -0
  127. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_facade.py +0 -0
  128. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_frontmatter.py +0 -0
  129. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_git.py +0 -0
  130. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_interactive.py +0 -0
  131. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_lockfiles.py +0 -0
  132. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_multifile.py +0 -0
  133. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_paths.py +0 -0
  134. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_prebuilt_policy.py +0 -0
  135. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_projects.py +0 -0
  136. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_references.py +0 -0
  137. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_run_cli.py +0 -0
  138. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_runtime.py +0 -0
  139. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_schema_resources.py +0 -0
  140. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_schemas.py +0 -0
  141. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_signatures.py +0 -0
  142. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_specs.py +0 -0
  143. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_store.py +0 -0
  144. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_toolchains.py +0 -0
  145. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_v1_precision.py +0 -0
  146. {lean_runtime-2.0.6 → lean_runtime-2.0.8}/tests/test_verification_inventory.py +0 -0
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 2.0.8 - 2026-08-10
6
+
7
+ - Add immutable, content-addressed provenance to ready-to-run program
8
+ descriptions and accept it through `program-create --provenance-file`.
9
+ - Preserve the identity and import compatibility of existing version 1 program
10
+ descriptions while emitting version 2 descriptions for newly created programs.
11
+
12
+ ## 2.0.7
13
+
14
+ - Retry an OCI layer once from a clean partial after size or SHA-256 integrity
15
+ verification fails, bypassing intermediary caches while preserving strict
16
+ digest validation and emitting structured retry diagnostics.
17
+
5
18
  ## 2.0.6
6
19
 
7
20
  - Preserve the highest-ranked Lean compiler rejection when automatic discovery
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lean-runtime
3
- Version: 2.0.6
3
+ Version: 2.0.8
4
4
  Summary: Run Lean 4 proofs from Python or standalone files
5
5
  Author: Alejandro Radisic
6
6
  License-Expression: Apache-2.0
@@ -17,6 +17,7 @@ program = runtime.create_program(
17
17
  "build/program",
18
18
  command=["my-program"],
19
19
  source_revision="0123456789abcdef0123456789abcdef01234567",
20
+ provenance={"example.protocol.version": "1.0.0"},
20
21
  )
21
22
 
22
23
  with program.spawn_interactive() as session:
@@ -28,8 +29,11 @@ alive and returns one decoded response per request. This is the fast path for
28
29
  large batches: prepare and verify the checker once, then stream compact inputs
29
30
  instead of elaborating a new Lean source file for every item.
30
31
 
31
- `create_program` records the program's files and computer compatibility. Opening
32
- it later with `runtime.program(program.id)` verifies that neither has changed.
32
+ `create_program` records the program's files, computer compatibility, and
33
+ optional flat string provenance. All three contribute to the program ID.
34
+ Opening it later with `runtime.program(program.id)` verifies that none has
35
+ changed. The CLI accepts the same metadata as a JSON object through
36
+ `program-create --provenance-file`.
33
37
 
34
38
  ## Move it or share it
35
39
 
@@ -181,6 +181,11 @@ def parser() -> argparse.ArgumentParser:
181
181
  program_create.add_argument("--exact-environment-id")
182
182
  program_create.add_argument("--toolchain", default="unknown")
183
183
  program_create.add_argument("--capability-id")
184
+ program_create.add_argument(
185
+ "--provenance-file",
186
+ type=Path,
187
+ help="JSON object of content-addressed program provenance",
188
+ )
184
189
 
185
190
  program_export = commands.add_parser(
186
191
  "program-save-copy", help="save a portable copy of a ready-to-run program"
@@ -398,6 +403,14 @@ def main(argv: list[str] | None = None) -> int:
398
403
  _json(environment.inspect().to_dict())
399
404
  return 0
400
405
  if args.command == "program-create":
406
+ provenance = None
407
+ if args.provenance_file is not None:
408
+ value = json.loads(args.provenance_file.read_text(encoding="utf-8"))
409
+ if not isinstance(value, dict) or not all(
410
+ isinstance(key, str) and isinstance(item, str) for key, item in value.items()
411
+ ):
412
+ raise ValueError("program provenance file must contain a JSON string object")
413
+ provenance = value
401
414
  program = runtime.create_program(
402
415
  args.payload,
403
416
  command=args.program_command,
@@ -406,6 +419,7 @@ def main(argv: list[str] | None = None) -> int:
406
419
  exact_environment_id=args.exact_environment_id,
407
420
  toolchain=args.toolchain,
408
421
  capability_id=args.capability_id,
422
+ provenance=provenance,
409
423
  )
410
424
  _json(
411
425
  {
@@ -32,6 +32,7 @@ _BEARER_PARAMETER = re.compile(r'([a-zA-Z]+)="([^"]*)"')
32
32
  _DIGEST = re.compile(r"sha256:([0-9a-f]{64})")
33
33
  _ACCEPT = ", ".join((INDEX_MEDIA_TYPE, MANIFEST_MEDIA_TYPE))
34
34
  DEFAULT_ENVIRONMENT_LIBRARIES = ("oci://ghcr.io/alerad/lean-runtime-cache",)
35
+ _BLOB_INTEGRITY_ATTEMPTS = 2
35
36
 
36
37
 
37
38
  class SignatureVerifier(Protocol):
@@ -215,52 +216,73 @@ class OCIRegistryClient:
215
216
  return destination
216
217
  destination.unlink()
217
218
  temporary = destination.with_name(f".{destination.name}.partial")
218
- if temporary.exists() and temporary.stat().st_size > size:
219
- temporary.unlink()
220
- offset = temporary.stat().st_size if temporary.exists() else 0
221
- request = urllib.request.Request(self._url(f"blobs/{digest}"))
222
- if offset:
223
- request.add_header("Range", f"bytes={offset}-")
224
- events.emit(
225
- "library.layer_download_started",
226
- "Downloading OCI blob",
227
- digest=digest,
228
- size=size,
229
- )
230
- observed = hashlib.sha256()
231
- written = offset
232
- if offset:
233
- with temporary.open("rb") as existing:
234
- for chunk in iter(lambda: existing.read(1024 * 1024), b""):
235
- observed.update(chunk)
236
- try:
237
- response = self._request(request)
238
- if offset and response.status != 206:
239
- offset = 0
240
- written = 0
241
- observed = hashlib.sha256()
219
+ for attempt in range(1, _BLOB_INTEGRITY_ATTEMPTS + 1):
220
+ if temporary.exists() and temporary.stat().st_size > size:
221
+ temporary.unlink()
222
+ offset = temporary.stat().st_size if temporary.exists() else 0
223
+ request = urllib.request.Request(self._url(f"blobs/{digest}"))
224
+ if offset:
225
+ request.add_header("Range", f"bytes={offset}-")
226
+ if attempt > 1:
227
+ request.add_header("Cache-Control", "no-cache")
228
+ events.emit(
229
+ "library.layer_download_started",
230
+ "Downloading OCI blob",
231
+ digest=digest,
232
+ size=size,
233
+ attempt=attempt,
234
+ resumed_bytes=offset,
235
+ )
236
+ observed = hashlib.sha256()
237
+ written = offset
242
238
  if offset:
243
- content_range = response.headers.get("Content-Range", "")
244
- if not content_range.startswith(f"bytes {offset}-"):
245
- response.close()
246
- temporary.unlink(missing_ok=True)
247
- raise EnvironmentError("OCI registry returned an invalid byte range")
248
- mode = "ab" if offset else "wb"
249
- with response, temporary.open(mode) as output:
250
- while chunk := response.read(1024 * 1024):
251
- written += len(chunk)
252
- if written > size:
253
- raise EnvironmentError("OCI blob exceeds its declared size")
254
- observed.update(chunk)
255
- output.write(chunk)
256
- if written != size or "sha256:" + observed.hexdigest() != digest:
239
+ with temporary.open("rb") as existing:
240
+ for chunk in iter(lambda: existing.read(1024 * 1024), b""):
241
+ observed.update(chunk)
242
+ try:
243
+ response = self._request(request)
244
+ if offset and response.status != 206:
245
+ offset = 0
246
+ written = 0
247
+ observed = hashlib.sha256()
248
+ if offset:
249
+ content_range = response.headers.get("Content-Range", "")
250
+ if not content_range.startswith(f"bytes {offset}-"):
251
+ response.close()
252
+ temporary.unlink(missing_ok=True)
253
+ raise EnvironmentError("OCI registry returned an invalid byte range")
254
+ mode = "ab" if offset else "wb"
255
+ with response, temporary.open(mode) as output:
256
+ while chunk := response.read(1024 * 1024):
257
+ written += len(chunk)
258
+ if written > size:
259
+ raise EnvironmentError("OCI blob exceeds its declared size")
260
+ observed.update(chunk)
261
+ output.write(chunk)
262
+ observed_digest = "sha256:" + observed.hexdigest()
263
+ if written == size and observed_digest == digest:
264
+ temporary.replace(destination)
265
+ break
257
266
  temporary.unlink(missing_ok=True)
258
- raise EnvironmentError("downloaded OCI blob failed digest verification")
259
- temporary.replace(destination)
260
- except urllib.error.HTTPError as exc:
261
- raise DownloadUnavailable(f"OCI blob download failed: HTTP {exc.code}") from exc
262
- except OSError as exc:
263
- raise DownloadUnavailable(f"OCI blob download failed: {exc}") from exc
267
+ if attempt == _BLOB_INTEGRITY_ATTEMPTS:
268
+ raise EnvironmentError(
269
+ "downloaded OCI blob failed digest verification "
270
+ f"after {attempt} attempts (expected {digest}, got "
271
+ f"{observed_digest}, bytes {written}/{size})"
272
+ )
273
+ events.emit(
274
+ "library.layer_download_retry",
275
+ "Retrying OCI blob after integrity verification failed",
276
+ digest=digest,
277
+ attempt=attempt + 1,
278
+ observed_digest=observed_digest,
279
+ downloaded_bytes=written,
280
+ expected_bytes=size,
281
+ )
282
+ except urllib.error.HTTPError as exc:
283
+ raise DownloadUnavailable(f"OCI blob download failed: HTTP {exc.code}") from exc
284
+ except OSError as exc:
285
+ raise DownloadUnavailable(f"OCI blob download failed: {exc}") from exc
264
286
  return destination
265
287
 
266
288
  def blob_exists(self, digest: str) -> bool:
@@ -14,9 +14,10 @@ import tempfile
14
14
  import uuid
15
15
  from collections.abc import Mapping, Sequence
16
16
  from contextlib import suppress
17
- from dataclasses import asdict, dataclass
17
+ from dataclasses import asdict, dataclass, field
18
18
  from datetime import datetime, timezone
19
19
  from pathlib import Path, PurePosixPath
20
+ from types import MappingProxyType
20
21
  from typing import Any, cast
21
22
 
22
23
  from ._paths import remove_tree
@@ -47,8 +48,10 @@ from .policies import ExecutionPolicy
47
48
  from .serialization import canonical_json_bytes, sha256_id, write_json_atomic
48
49
  from .store import EnvironmentStore, clone_tree, platform_compatibility, platform_record
49
50
 
50
- PROGRAM_SCHEMA = "lean-runtime-execution-program/1"
51
- PROGRAM_CONFIG_MEDIA_TYPE = "application/vnd.lean-runtime.program.config.v1+json"
51
+ LEGACY_PROGRAM_SCHEMA = "lean-runtime-execution-program/1"
52
+ PROGRAM_SCHEMA = "lean-runtime-execution-program/2"
53
+ LEGACY_PROGRAM_CONFIG_MEDIA_TYPE = "application/vnd.lean-runtime.program.config.v1+json"
54
+ PROGRAM_CONFIG_MEDIA_TYPE = "application/vnd.lean-runtime.program.config.v2+json"
52
55
  PROGRAM_LAYER_MEDIA_TYPE = "application/vnd.lean-runtime.program.layer.v1.tar+gzip"
53
56
  _PROGRAM_ID = re.compile(r"program_[0-9a-f]{64}")
54
57
 
@@ -106,11 +109,14 @@ class ProgramDescription:
106
109
  exact_environment_id: str | None = None
107
110
  toolchain: str = "unknown"
108
111
  capability_id: str | None = None
112
+ provenance: Mapping[str, str] = field(default_factory=dict)
109
113
  schema: str = PROGRAM_SCHEMA
110
114
 
111
115
  def __post_init__(self) -> None:
112
- if self.schema != PROGRAM_SCHEMA:
116
+ if self.schema not in {LEGACY_PROGRAM_SCHEMA, PROGRAM_SCHEMA}:
113
117
  raise EnvironmentError(f"unsupported program schema: {self.schema!r}")
118
+ if self.schema == LEGACY_PROGRAM_SCHEMA and self.provenance:
119
+ raise EnvironmentError("legacy program descriptions cannot contain provenance")
114
120
  if not self.command or any(not isinstance(part, str) or not part for part in self.command):
115
121
  raise EnvironmentError("program command must contain non-empty strings")
116
122
  _relative_executable(self.command[0])
@@ -118,13 +124,26 @@ class ProgramDescription:
118
124
  raise EnvironmentError("program command executable is absent from its file inventory")
119
125
  if not re.fullmatch(r"[0-9a-f]{40,64}", self.source_revision):
120
126
  raise EnvironmentError("program source revision must be an exact Git commit")
127
+ if not all(
128
+ isinstance(key, str)
129
+ and bool(re.fullmatch(r"[a-z0-9][a-z0-9._-]*", key))
130
+ and isinstance(value, str)
131
+ and bool(value)
132
+ for key, value in self.provenance.items()
133
+ ):
134
+ raise EnvironmentError(
135
+ "program provenance must contain non-empty string values under canonical keys"
136
+ )
137
+ object.__setattr__(
138
+ self, "provenance", MappingProxyType(dict(sorted(self.provenance.items())))
139
+ )
121
140
 
122
141
  @property
123
142
  def program_id(self) -> str:
124
143
  return sha256_id("program", self.to_dict(include_id=False))
125
144
 
126
145
  def to_dict(self, *, include_id: bool = True) -> dict[str, Any]:
127
- value = {
146
+ value: dict[str, Any] = {
128
147
  "schema": self.schema,
129
148
  "command": list(self.command),
130
149
  "files": dict(sorted(self.files.items())),
@@ -135,6 +154,8 @@ class ProgramDescription:
135
154
  "toolchain": self.toolchain,
136
155
  "capability_id": self.capability_id,
137
156
  }
157
+ if self.schema == PROGRAM_SCHEMA:
158
+ value["provenance"] = dict(sorted(self.provenance.items()))
138
159
  return {"program_id": self.program_id, **value} if include_id else value
139
160
 
140
161
  @classmethod
@@ -150,6 +171,10 @@ class ProgramDescription:
150
171
  raise EnvironmentError("program manifest file inventory is invalid")
151
172
  if not isinstance(compatibility, dict):
152
173
  raise EnvironmentError("program platform compatibility is invalid")
174
+ schema = str(value.get("schema", ""))
175
+ provenance = value.get("provenance", {})
176
+ if not isinstance(provenance, dict):
177
+ raise EnvironmentError("program provenance is invalid")
153
178
  manifest = cls(
154
179
  command=tuple(command),
155
180
  files={str(name): dict(record) for name, record in files.items()},
@@ -169,7 +194,8 @@ class ProgramDescription:
169
194
  capability_id=(
170
195
  str(value["capability_id"]) if value.get("capability_id") is not None else None
171
196
  ),
172
- schema=str(value.get("schema", "")),
197
+ provenance={str(key): item for key, item in provenance.items()},
198
+ schema=schema,
173
199
  )
174
200
  recorded = value.get("program_id")
175
201
  if recorded is not None and recorded != manifest.program_id:
@@ -323,6 +349,7 @@ class ProgramManager:
323
349
  exact_environment_id: str | None = None,
324
350
  toolchain: str = "unknown",
325
351
  capability_id: str | None = None,
352
+ provenance: Mapping[str, str] | None = None,
326
353
  ) -> ReadyProgram:
327
354
  payload = payload.expanduser().resolve()
328
355
  if not payload.is_dir():
@@ -336,6 +363,7 @@ class ProgramManager:
336
363
  exact_environment_id=exact_environment_id,
337
364
  toolchain=toolchain,
338
365
  capability_id=capability_id,
366
+ provenance=dict(provenance or {}),
339
367
  )
340
368
  destination = self.store.programs / manifest.program_id
341
369
  with FileLock(self.store.lock_dir / f"{manifest.program_id}.lock", timeout=1800):
@@ -392,7 +420,12 @@ class ProgramManager:
392
420
  )
393
421
  config_path = staging / "config.json"
394
422
  config_path.write_bytes(canonical_json_bytes(program.description.to_dict()))
395
- config = _blob_descriptor_path(config_path, PROGRAM_CONFIG_MEDIA_TYPE)
423
+ config_media_type = (
424
+ LEGACY_PROGRAM_CONFIG_MEDIA_TYPE
425
+ if program.description.schema == LEGACY_PROGRAM_SCHEMA
426
+ else PROGRAM_CONFIG_MEDIA_TYPE
427
+ )
428
+ config = _blob_descriptor_path(config_path, config_media_type)
396
429
  manifest_path = staging / "manifest.json"
397
430
  manifest_path.write_bytes(
398
431
  canonical_json_bytes(
@@ -507,7 +540,11 @@ class ProgramManager:
507
540
  or len(layers) != 1
508
541
  ):
509
542
  raise EnvironmentError("program OCI manifest is incomplete")
510
- _require_media_type(config_descriptor, PROGRAM_CONFIG_MEDIA_TYPE, "program config")
543
+ if config_descriptor.get("mediaType") not in {
544
+ LEGACY_PROGRAM_CONFIG_MEDIA_TYPE,
545
+ PROGRAM_CONFIG_MEDIA_TYPE,
546
+ }:
547
+ raise EnvironmentError("program config has an unsupported media type")
511
548
  _require_media_type(layers[0], PROGRAM_LAYER_MEDIA_TYPE, "program layer")
512
549
  config_path = _descriptor_blob_path(entries, config_descriptor, "program config")
513
550
  manifest = ProgramDescription.from_dict(
@@ -34,6 +34,7 @@ def _progress(event: RuntimeEvent) -> None:
34
34
  "package_reference.started": "Resolving dependency",
35
35
  "library.lookup": "Looking for a cached environment",
36
36
  "library.layer_download_started": "Downloading cached environment",
37
+ "library.layer_download_retry": "Retrying cached-environment download",
37
38
  "environment.build_started": "Building environment",
38
39
  "environment.cache_hit": "Using local environment",
39
40
  }
@@ -219,6 +219,7 @@ class Runtime:
219
219
  exact_environment_id: str | None = None,
220
220
  toolchain: str = "unknown",
221
221
  capability_id: str | None = None,
222
+ provenance: Mapping[str, str] | None = None,
222
223
  ) -> ReadyProgram:
223
224
  """Create a verified ready-to-run program from a payload directory."""
224
225
  return self.programs.create(
@@ -229,6 +230,7 @@ class Runtime:
229
230
  exact_environment_id=exact_environment_id,
230
231
  toolchain=toolchain,
231
232
  capability_id=capability_id,
233
+ provenance=provenance,
232
234
  )
233
235
 
234
236
  def program(self, program_id: str) -> ReadyProgram:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lean-runtime
3
- Version: 2.0.6
3
+ Version: 2.0.8
4
4
  Summary: Run Lean 4 proofs from Python or standalone files
5
5
  Author: Alejandro Radisic
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lean-runtime"
7
- version = "2.0.6"
7
+ version = "2.0.8"
8
8
  description = "Run Lean 4 proofs from Python or standalone files"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -18,8 +18,14 @@ import pytest
18
18
  from lean_runtime import EnvironmentError, EnvironmentLock, LockedPackage, Runtime
19
19
  from lean_runtime.bundles import _extract_layer, _oci_archive
20
20
  from lean_runtime.environments import ENVIRONMENT_SCHEMA
21
- from lean_runtime.oci import OCIEnvironmentPublisher, OCIRepository
22
- from lean_runtime.store import environment_identity, platform_record, source_snapshot_digest
21
+ from lean_runtime.events import EventEmitter
22
+ from lean_runtime.oci import OCIEnvironmentPublisher, OCIRegistryClient, OCIRepository
23
+ from lean_runtime.store import (
24
+ EnvironmentStore,
25
+ environment_identity,
26
+ platform_record,
27
+ source_snapshot_digest,
28
+ )
23
29
 
24
30
 
25
31
  def _git_package(path: Path) -> tuple[str, str]:
@@ -403,6 +409,50 @@ def test_transparent_authenticated_oci_pull_and_blob_reuse(tmp_path: Path) -> No
403
409
  thread.join(timeout=5)
404
410
 
405
411
 
412
+ def test_oci_blob_integrity_failure_retries_once_from_scratch(tmp_path: Path) -> None:
413
+ data = b"verified layer contents"
414
+ digest = "sha256:" + hashlib.sha256(data).hexdigest()
415
+ requests = 0
416
+ cache_controls: list[str | None] = []
417
+
418
+ class Handler(http.server.BaseHTTPRequestHandler):
419
+ def do_GET(self) -> None:
420
+ nonlocal requests
421
+ requests += 1
422
+ cache_controls.append(self.headers.get("Cache-Control"))
423
+ response = b"x" * len(data) if requests == 1 else data
424
+ self.send_response(200)
425
+ self.send_header("Content-Length", str(len(response)))
426
+ self.end_headers()
427
+ self.wfile.write(response)
428
+
429
+ def log_message(self, _format: str, *_args: object) -> None:
430
+ pass
431
+
432
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
433
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
434
+ thread.start()
435
+ events = []
436
+ try:
437
+ repository = OCIRepository.parse(f"oci+http://127.0.0.1:{server.server_port}/owner/cache")
438
+ path = OCIRegistryClient(repository).download_blob(
439
+ {"digest": digest, "size": len(data)},
440
+ EnvironmentStore(tmp_path / "consumer"),
441
+ EventEmitter(events.append),
442
+ )
443
+ assert path.read_bytes() == data
444
+ assert requests == 2
445
+ assert cache_controls == [None, "no-cache"]
446
+ assert [event.kind for event in events].count("library.layer_download_retry") == 1
447
+ starts = [event for event in events if event.kind == "library.layer_download_started"]
448
+ assert [event.data["attempt"] for event in starts] == [1, 2]
449
+ assert starts[1].data["resumed_bytes"] == 0
450
+ finally:
451
+ server.shutdown()
452
+ server.server_close()
453
+ thread.join(timeout=5)
454
+
455
+
406
456
  def test_oci_publisher_uploads_blobs_before_manifests(tmp_path: Path) -> None:
407
457
  producer, environment_id, lock = _published_runtime(tmp_path / "producer")
408
458
  publisher = OCIEnvironmentPublisher(
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  from pathlib import Path
5
+ from types import SimpleNamespace
5
6
 
6
7
  from lean_runtime.bundles import PortableCopyInfo
7
8
  from lean_runtime.cli import _print_operation_failure, main, parser
@@ -172,6 +173,44 @@ def test_save_copy_cli_reports_copy_identity(monkeypatch, tmp_path: Path, capsys
172
173
  assert json.loads(capsys.readouterr().out)["copy_id"] == info.copy_id
173
174
 
174
175
 
176
+ def test_program_create_cli_loads_content_addressed_provenance(
177
+ monkeypatch, tmp_path: Path, capsys
178
+ ) -> None:
179
+ payload = tmp_path / "payload"
180
+ payload.mkdir()
181
+ profile = tmp_path / "profile.json"
182
+ profile.write_text(json.dumps({"example.protocol.version": "1.0.0"}))
183
+ observed = {}
184
+
185
+ def create_program(_runtime, selected_payload, **options):
186
+ observed.update(payload=selected_payload, **options)
187
+ return SimpleNamespace(
188
+ id="program_" + "a" * 64,
189
+ description=SimpleNamespace(to_dict=lambda: {"schema": "test"}),
190
+ root=payload,
191
+ )
192
+
193
+ monkeypatch.setattr("lean_runtime.cli.Runtime.create_program", create_program)
194
+ assert (
195
+ main(
196
+ [
197
+ "--quiet",
198
+ "program-create",
199
+ str(payload),
200
+ "--command",
201
+ "bin/checker",
202
+ "--source-revision",
203
+ "b" * 40,
204
+ "--provenance-file",
205
+ str(profile),
206
+ ]
207
+ )
208
+ == 0
209
+ )
210
+ assert observed["provenance"] == {"example.protocol.version": "1.0.0"}
211
+ assert json.loads(capsys.readouterr().out)["program_id"].startswith("program_")
212
+
213
+
175
214
  def test_finalize_publication_reads_computer_records(monkeypatch, tmp_path: Path, capsys) -> None:
176
215
  result = tmp_path / "computer.json"
177
216
  record = {"digest": "sha256:" + "a" * 64, "size": 42}
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
  import pytest
8
8
 
9
9
  from lean_runtime import EnvironmentError, ExecutionPolicy, Runtime
10
+ from lean_runtime.programs import LEGACY_PROGRAM_SCHEMA, ProgramDescription
10
11
 
11
12
 
12
13
  def _revision() -> str:
@@ -30,6 +31,10 @@ def test_program_identity_payload_validation_and_interactive_execution(tmp_path:
30
31
  source_revision=_revision(),
31
32
  toolchain="leanprover/lean4:v4.32.2",
32
33
  capability_id="sha256:" + hashlib.sha256(b"capabilities").hexdigest(),
34
+ provenance={
35
+ "lean.toolchain": "leanprover/lean4:v4.32.2",
36
+ "leancert.core.revision": "b" * 40,
37
+ },
33
38
  )
34
39
 
35
40
  assert program.id.startswith("program_")
@@ -40,6 +45,40 @@ def test_program_identity_payload_validation_and_interactive_execution(tmp_path:
40
45
 
41
46
  reopened = runtime.program(program.id)
42
47
  assert reopened.description.source_revision == _revision()
48
+ assert reopened.description.provenance["leancert.core.revision"] == "b" * 40
49
+
50
+
51
+ def test_program_provenance_is_content_addressed(tmp_path: Path) -> None:
52
+ runtime = Runtime(home=tmp_path / "runtime")
53
+ payload = _payload(tmp_path)
54
+ first = runtime.create_program(
55
+ payload,
56
+ command=("echo-bridge",),
57
+ source_revision=_revision(),
58
+ provenance={"leancert.core.revision": "b" * 40},
59
+ )
60
+ second = runtime.create_program(
61
+ payload,
62
+ command=("echo-bridge",),
63
+ source_revision=_revision(),
64
+ provenance={"leancert.core.revision": "c" * 40},
65
+ )
66
+
67
+ assert first.id != second.id
68
+
69
+
70
+ def test_legacy_program_identity_remains_stable() -> None:
71
+ description = ProgramDescription(
72
+ command=("echo-bridge",),
73
+ files={"echo-bridge": {"kind": "file", "sha256": "sha256:" + "0" * 64}},
74
+ computer_compatibility={"schema": "computer/1"},
75
+ source_revision=_revision(),
76
+ schema=LEGACY_PROGRAM_SCHEMA,
77
+ )
78
+
79
+ restored = ProgramDescription.from_dict(description.to_dict())
80
+ assert restored.program_id == description.program_id
81
+ assert "provenance" not in restored.to_dict()
43
82
 
44
83
 
45
84
  def test_program_rejects_payload_tampering(tmp_path: Path) -> None:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes