lean-runtime 2.0.5__tar.gz → 2.0.7__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.5 → lean_runtime-2.0.7}/CHANGELOG.md +14 -0
  2. {lean_runtime-2.0.5/lean_runtime.egg-info → lean_runtime-2.0.7}/PKG-INFO +1 -1
  3. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/docs/python-api.md +7 -3
  4. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/docs/ready-programs.md +6 -3
  5. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/discovery/engine.py +7 -1
  6. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/discovery/result.py +14 -1
  7. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/environments.py +59 -1
  8. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/oci.py +66 -44
  9. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/run_cli.py +10 -5
  10. {lean_runtime-2.0.5 → lean_runtime-2.0.7/lean_runtime.egg-info}/PKG-INFO +1 -1
  11. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/pyproject.toml +1 -1
  12. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/schemas/execution-v1.schema.json +1 -1
  13. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/discovery/test_engine.py +26 -1
  14. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_bundles.py +52 -2
  15. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_interactive.py +19 -4
  16. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_programs.py +1 -3
  17. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_run_cli.py +71 -5
  18. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_schemas.py +1 -1
  19. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/LICENSE +0 -0
  20. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/MANIFEST.in +0 -0
  21. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/README.md +0 -0
  22. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/catalog/environments.toml +0 -0
  23. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/catalog/locks/core-v4.32.2.lock.json +0 -0
  24. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/catalog/locks/mathlib-v4.30.0.lock.json +0 -0
  25. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/catalog/locks/mathlib-v4.31.0.lock.json +0 -0
  26. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/catalog/locks/mathlib-v4.32.2.lock.json +0 -0
  27. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/compatibility/README.md +0 -0
  28. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/compatibility/mathlib-4.32.2.json +0 -0
  29. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/compatibility/mathlib-4.32.2.toml +0 -0
  30. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/docs/architecture.md +0 -0
  31. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/docs/captures.md +0 -0
  32. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/docs/case-study-v1.md +0 -0
  33. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/docs/cli.md +0 -0
  34. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/docs/development.md +0 -0
  35. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/docs/environments.md +0 -0
  36. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/docs/getting-started.md +0 -0
  37. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/docs/index.md +0 -0
  38. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/docs/local-projects.md +0 -0
  39. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/docs/portable-copies.md +0 -0
  40. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/docs/standalone-files.md +0 -0
  41. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/docs/trust-and-limitations.md +0 -0
  42. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/docs/v1-precision.md +0 -0
  43. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/examples/mathlib.toml +0 -0
  44. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/__init__.py +0 -0
  45. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/__main__.py +0 -0
  46. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/_git.py +0 -0
  47. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/_paths.py +0 -0
  48. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/backends.py +0 -0
  49. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/bundles.py +0 -0
  50. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/cli.py +0 -0
  51. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/comparison.py +0 -0
  52. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/decisions.py +0 -0
  53. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/diagnostics.py +0 -0
  54. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/discovery/__init__.py +0 -0
  55. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/discovery/analyzer.py +0 -0
  56. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/discovery/api.py +0 -0
  57. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/discovery/candidate.py +0 -0
  58. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/discovery/catalog.py +0 -0
  59. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/discovery/catalog_build.py +0 -0
  60. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/discovery/catalog_cli.py +0 -0
  61. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/discovery/catalog_manifest.py +0 -0
  62. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/discovery/data/catalog.json +0 -0
  63. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/discovery/defaults.py +0 -0
  64. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/discovery/errors.py +0 -0
  65. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/discovery/module_inventory.py +0 -0
  66. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/discovery/planner.py +0 -0
  67. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/discovery/policy.py +0 -0
  68. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/discovery/probe.py +0 -0
  69. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/discovery/py.typed +0 -0
  70. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/discovery/schema_resources.py +0 -0
  71. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/discovery/schemas/catalog-v1.schema.json +0 -0
  72. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/discovery/schemas/plan-v1.schema.json +0 -0
  73. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/discovery/schemas/result-v1.schema.json +0 -0
  74. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/errors.py +0 -0
  75. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/events.py +0 -0
  76. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/facade.py +0 -0
  77. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/frontmatter.py +0 -0
  78. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/health.py +0 -0
  79. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/lake.py +0 -0
  80. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/lockfiles.py +0 -0
  81. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/locking.py +0 -0
  82. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/matrix.py +0 -0
  83. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/models.py +0 -0
  84. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/policies.py +0 -0
  85. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/profiling.py +0 -0
  86. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/programs.py +0 -0
  87. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/projects.py +0 -0
  88. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/publisher_verification.py +0 -0
  89. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/py.typed +0 -0
  90. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/references.py +0 -0
  91. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/resolver.py +0 -0
  92. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/runtime.py +0 -0
  93. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/schema_resources.py +0 -0
  94. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/serialization.py +0 -0
  95. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/specs.py +0 -0
  96. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/store.py +0 -0
  97. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/timings.py +0 -0
  98. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/toolchains.py +0 -0
  99. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/verification.py +0 -0
  100. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime/wire.py +0 -0
  101. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime.egg-info/SOURCES.txt +0 -0
  102. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime.egg-info/dependency_links.txt +0 -0
  103. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime.egg-info/entry_points.txt +0 -0
  104. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime.egg-info/requires.txt +0 -0
  105. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/lean_runtime.egg-info/top_level.txt +0 -0
  106. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/mkdocs.yml +0 -0
  107. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/schemas/cleanup-v1.schema.json +0 -0
  108. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/schemas/comparison-v1.schema.json +0 -0
  109. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/schemas/inspect-v1.schema.json +0 -0
  110. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/schemas/matrix-v1.schema.json +0 -0
  111. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/schemas/profile-v1.schema.json +0 -0
  112. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/schemas/verify-v1.schema.json +0 -0
  113. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/scripts/run_compatibility.py +0 -0
  114. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/scripts/run_v1_case_study.py +0 -0
  115. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/scripts/smoke_wheel.py +0 -0
  116. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/setup.cfg +0 -0
  117. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/conftest.py +0 -0
  118. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/discovery/e2e/test_real_discovery.py +0 -0
  119. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/discovery/test_analyzer.py +0 -0
  120. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/discovery/test_catalog.py +0 -0
  121. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/discovery/test_catalog_build.py +0 -0
  122. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/discovery/test_discovery_schema_resources.py +0 -0
  123. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/discovery/test_planner.py +0 -0
  124. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/discovery/test_policy.py +0 -0
  125. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_backend.py +0 -0
  126. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_cli.py +0 -0
  127. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_diagnostics.py +0 -0
  128. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_environment_integration.py +0 -0
  129. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_events_health.py +0 -0
  130. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_facade.py +0 -0
  131. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_frontmatter.py +0 -0
  132. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_git.py +0 -0
  133. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_lockfiles.py +0 -0
  134. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_multifile.py +0 -0
  135. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_paths.py +0 -0
  136. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_prebuilt_policy.py +0 -0
  137. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_projects.py +0 -0
  138. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_references.py +0 -0
  139. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_runtime.py +0 -0
  140. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_schema_resources.py +0 -0
  141. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_signatures.py +0 -0
  142. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_specs.py +0 -0
  143. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_store.py +0 -0
  144. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_toolchains.py +0 -0
  145. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_v1_precision.py +0 -0
  146. {lean_runtime-2.0.5 → lean_runtime-2.0.7}/tests/test_verification_inventory.py +0 -0
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 2.0.7
6
+
7
+ - Retry an OCI layer once from a clean partial after size or SHA-256 integrity
8
+ verification fails, bypassing intermediary caches while preserving strict
9
+ digest validation and emitting structured retry diagnostics.
10
+
11
+ ## 2.0.6
12
+
13
+ - Preserve the highest-ranked Lean compiler rejection when automatic discovery
14
+ exhausts its candidates, including raw output and parsed diagnostics in both
15
+ human and JSON `lean-run` output.
16
+ - Add line-oriented and NDJSON request/response helpers to `InteractiveSession`
17
+ for incremental REPLs and ready-to-run checker programs.
18
+
5
19
  ## 2.0.5
6
20
 
7
21
  - Integrate bounded exact-environment discovery into the `lean-runtime` package.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lean-runtime
3
- Version: 2.0.5
3
+ Version: 2.0.7
4
4
  Summary: Run Lean 4 proofs from Python or standalone files
5
5
  Author: Alejandro Radisic
6
6
  License-Expression: Apache-2.0
@@ -179,9 +179,7 @@ with environment.spawn_interactive(
179
179
  policy=ExecutionPolicy(timeout_seconds=3600, memory_mb=4096),
180
180
  ) as session:
181
181
  request = {"id": 1, "method": "get_info", "params": {}}
182
- session.stdin.write(json.dumps(request) + "\n")
183
- session.stdin.flush()
184
- response = json.loads(session.stdout.readline())
182
+ response = session.request_json(request)
185
183
  assert session.running
186
184
 
187
185
  result = session.close()
@@ -199,6 +197,12 @@ the original text is still returned to the caller. Callers of tools that emit
199
197
  substantial data on both streams should drain both streams to avoid ordinary
200
198
  subprocess pipe backpressure.
201
199
 
200
+ For line-oriented tools, `send_line()` and `read_line()` provide incremental
201
+ REPL-style access. `request_line()` serializes one request/response exchange,
202
+ and `request_json()` does the same for NDJSON protocols. The request helpers
203
+ are locked so concurrent callers cannot interleave protocol frames. The raw
204
+ `stdin`, `stdout`, and `stderr` pipes remain available for other protocols.
205
+
202
206
  ## Progress events
203
207
 
204
208
  ```python
@@ -20,11 +20,14 @@ program = runtime.create_program(
20
20
  )
21
21
 
22
22
  with program.spawn_interactive() as session:
23
- session.stdin.write("hello\n")
24
- session.stdin.flush()
25
- print(session.stdout.readline())
23
+ print(session.request_line("hello"))
26
24
  ```
27
25
 
26
+ For an NDJSON checker, `session.request_json({...})` keeps the compiled process
27
+ alive and returns one decoded response per request. This is the fast path for
28
+ large batches: prepare and verify the checker once, then stream compact inputs
29
+ instead of elaborating a new Lean source file for every item.
30
+
28
31
  `create_program` records the program's files and computer compatibility. Opening
29
32
  it later with `runtime.program(program.id)` verifies that neither has changed.
30
33
 
@@ -336,11 +336,17 @@ def discover(
336
336
  DISCOVERY_EXHAUSTED,
337
337
  f"Lean did not accept the source in any of {len(attempts)} tested candidates",
338
338
  )
339
+ rejection_diagnostics = tuple(
340
+ diagnostic
341
+ for attempt in attempts
342
+ if attempt.status == "lean_rejected"
343
+ for diagnostic in attempt.diagnostics
344
+ )
339
345
  return DiscoveryResult(
340
346
  status="not_found",
341
347
  confidence="exhausted",
342
348
  plan=plan,
343
349
  attempts=tuple(attempts),
344
350
  duration_seconds=time.monotonic() - started,
345
- diagnostics=(reason,),
351
+ diagnostics=(reason, *rejection_diagnostics),
346
352
  )
@@ -124,9 +124,22 @@ class DiscoveryResult:
124
124
  compiled = next((item for item in self.attempts if item.status == "compiled"), None)
125
125
  return compiled.environment_id if compiled is not None else None
126
126
 
127
+ @property
128
+ def rejection_attempt(self) -> CandidateAttempt | None:
129
+ """Return the highest-ranked attempt for which Lean rejected the source."""
130
+ return next(
131
+ (
132
+ attempt
133
+ for attempt in self.attempts
134
+ if attempt.status == "lean_rejected" and attempt.execution_result is not None
135
+ ),
136
+ None,
137
+ )
138
+
127
139
  def raise_for_error(self) -> DiscoveryResult:
128
140
  if self.status != "found":
129
- detail = self.diagnostics[0].detail if self.diagnostics else self.status
141
+ details = [item.detail for item in self.diagnostics]
142
+ detail = ": ".join(dict.fromkeys(details)) if details else self.status
130
143
  raise DiscoveryError(f"Lean environment discovery did not succeed: {detail}")
131
144
  return self
132
145
 
@@ -17,7 +17,7 @@ from contextlib import suppress
17
17
  from dataclasses import asdict, dataclass
18
18
  from datetime import datetime, timezone
19
19
  from pathlib import Path, PurePosixPath
20
- from typing import Any, Generic, TextIO, TypeVar, cast
20
+ from typing import Any, Generic, Literal, TextIO, TypeVar, cast
21
21
 
22
22
  from ._git import git_command
23
23
  from ._paths import remove_tree
@@ -291,6 +291,7 @@ class InteractiveSession:
291
291
  self._cleanup = cleanup
292
292
  self._result: ExecutionResult | None = None
293
293
  self._closing = threading.Lock()
294
+ self._io = threading.RLock()
294
295
 
295
296
  @property
296
297
  def stdin(self) -> TextIO:
@@ -316,6 +317,63 @@ class InteractiveSession:
316
317
  """Return the process exit code, or ``None`` while it is running."""
317
318
  return self._process.poll()
318
319
 
320
+ def _reader(self, stream: Literal["stdout", "stderr"]) -> InteractiveTextReader:
321
+ if stream == "stdout":
322
+ return self.stdout
323
+ if stream == "stderr":
324
+ return self.stderr
325
+ raise ValueError("stream must be 'stdout' or 'stderr'")
326
+
327
+ def send_line(self, line: str) -> None:
328
+ """Send and flush one line-oriented protocol request."""
329
+ if not isinstance(line, str):
330
+ raise TypeError("interactive request must be a string")
331
+ if "\n" in line or "\r" in line:
332
+ raise ValueError("interactive request must contain exactly one line")
333
+ with self._io:
334
+ if not self.running:
335
+ raise EnvironmentError("interactive process is not running")
336
+ try:
337
+ self.stdin.write(line + "\n")
338
+ self.stdin.flush()
339
+ except (BrokenPipeError, OSError, ValueError) as exc:
340
+ raise EnvironmentError("interactive process did not accept the request") from exc
341
+
342
+ def read_line(self, *, stream: Literal["stdout", "stderr"] = "stdout") -> str:
343
+ """Read one response line from stdout or stderr."""
344
+ with self._io:
345
+ response = self._reader(stream).readline()
346
+ if response == "":
347
+ raise EnvironmentError(
348
+ f"interactive process ended before producing a {stream} response"
349
+ )
350
+ return response.removesuffix("\n").removesuffix("\r")
351
+
352
+ def request_line(
353
+ self,
354
+ line: str,
355
+ *,
356
+ response_stream: Literal["stdout", "stderr"] = "stdout",
357
+ ) -> str:
358
+ """Atomically send one line and read its corresponding response."""
359
+ with self._io:
360
+ self.send_line(line)
361
+ return self.read_line(stream=response_stream)
362
+
363
+ def request_json(
364
+ self,
365
+ value: object,
366
+ *,
367
+ response_stream: Literal["stdout", "stderr"] = "stdout",
368
+ ) -> Any:
369
+ """Round-trip one newline-delimited JSON value through the live process."""
370
+ request = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
371
+ response = self.request_line(request, response_stream=response_stream)
372
+ try:
373
+ return json.loads(response)
374
+ except json.JSONDecodeError as exc:
375
+ raise EnvironmentError("interactive process returned invalid JSON") from exc
376
+
319
377
  def close(self) -> ExecutionResult:
320
378
  """Send EOF, stop if necessary, persist provenance, and remove the instance."""
321
379
  with self._closing:
@@ -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:
@@ -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
  }
@@ -314,14 +315,18 @@ def main(argv: list[str] | None = None) -> int:
314
315
  runtime_events=runtime_events,
315
316
  ).discover_and_check(source)
316
317
  if discovered.status != "found" or discovered.execution_result is None:
317
- raise SpecificationError(_discovery_failure(discovered)) from None
318
- if arguments.lock_out is not None:
319
- assert discovered.lock is not None
320
- discovered.lock.write(arguments.lock_out)
318
+ rejection = discovered.rejection_attempt
319
+ if rejection is None or rejection.execution_result is None:
320
+ raise SpecificationError(_discovery_failure(discovered)) from None
321
+ result = rejection.execution_result
322
+ else:
323
+ if arguments.lock_out is not None:
324
+ assert discovered.lock is not None
325
+ discovered.lock.write(arguments.lock_out)
326
+ result = discovered.execution_result
321
327
  preparation = PhaseTiming(
322
328
  "discovery", round((time.monotonic() - discovery_started) * 1000)
323
329
  )
324
- result = discovered.execution_result
325
330
  if context.lock is not None or context.toolchain is not None or not context.requires:
326
331
  result = replace(result, timings=(preparation, *result.timings))
327
332
  _emit(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lean-runtime
3
- Version: 2.0.5
3
+ Version: 2.0.7
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.5"
7
+ version = "2.0.7"
8
8
  description = "Run Lean 4 proofs from Python or standalone files"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -29,7 +29,7 @@
29
29
  "required": ["phase", "duration_ms", "performed"],
30
30
  "additionalProperties": false,
31
31
  "properties": {
32
- "phase": {"enum": ["toolchain", "resolution", "source_acquisition", "cache_lookup", "cache_download", "artifact_hydration", "build", "publication", "environment_open", "instance_creation", "input_staging", "execution", "result_collection", "result_publication", "cleanup"]},
32
+ "phase": {"enum": ["discovery", "toolchain", "resolution", "source_acquisition", "cache_lookup", "cache_download", "artifact_hydration", "build", "publication", "environment_open", "instance_creation", "input_staging", "execution", "result_collection", "result_publication", "cleanup"]},
33
33
  "duration_ms": {"type": "integer", "minimum": 0},
34
34
  "performed": {"type": "boolean"}
35
35
  }
@@ -7,7 +7,13 @@ from dataclasses import dataclass, field
7
7
  import pytest
8
8
 
9
9
  from lean_runtime import ExecutionResult, Runtime
10
- from lean_runtime.discovery import Discovery, DiscoveryPolicy, PolicyError, ProbeOutcome
10
+ from lean_runtime.discovery import (
11
+ Discovery,
12
+ DiscoveryError,
13
+ DiscoveryPolicy,
14
+ PolicyError,
15
+ ProbeOutcome,
16
+ )
11
17
  from lean_runtime.discovery.candidate import Candidate
12
18
  from lean_runtime.discovery.probe import ProbeIntegrityFailure, ProbeUnavailable
13
19
 
@@ -87,6 +93,25 @@ def test_candidate_budget_never_opens_bounded_candidate(sample_catalog) -> None:
87
93
  assert probe.opened == ["mathlib-new"]
88
94
 
89
95
 
96
+ def test_exhaustion_surfaces_the_highest_ranked_lean_rejection(sample_catalog) -> None: # type: ignore[no-untyped-def]
97
+ probe = FakeProbe({"mathlib-new": execution(False, stderr="type mismatch")})
98
+ result = Discovery(
99
+ catalog=sample_catalog,
100
+ policy=DiscoveryPolicy(max_candidates=1),
101
+ probe=probe,
102
+ ).discover_and_check("import Mathlib\n")
103
+
104
+ assert result.status == "not_found"
105
+ assert result.rejection_attempt is result.attempts[0]
106
+ assert [item.code for item in result.diagnostics] == [
107
+ "DISCOVERY_CANDIDATE_LIMIT",
108
+ "CANDIDATE_LEAN_REJECTED",
109
+ ]
110
+ assert result.diagnostics[1].detail == "type mismatch"
111
+ with pytest.raises(DiscoveryError, match="type mismatch"):
112
+ result.raise_for_error()
113
+
114
+
90
115
  def test_unavailable_candidate_advances(sample_catalog) -> None: # type: ignore[no-untyped-def]
91
116
  probe = FakeProbe(
92
117
  {
@@ -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(
@@ -8,7 +8,7 @@ from pathlib import Path
8
8
 
9
9
  import pytest
10
10
 
11
- from lean_runtime import EnvironmentLock, ExecutionPolicy
11
+ from lean_runtime import EnvironmentError, EnvironmentLock, ExecutionPolicy
12
12
  from lean_runtime.backends import LocalBackend
13
13
  from lean_runtime.environments import Environment, EnvironmentManager
14
14
  from lean_runtime.store import EnvironmentStore
@@ -79,9 +79,7 @@ def test_interactive_session_round_trips_and_records_transcript(tmp_path: Path)
79
79
  execution_id = session.execution_id
80
80
  assert session.running
81
81
  assert session.poll() is None
82
- session.stdin.write("hello lean\n")
83
- session.stdin.flush()
84
- assert session.stdout.readline() == "HELLO LEAN\n"
82
+ assert session.request_line("hello lean") == "HELLO LEAN"
85
83
  assert any(environment.manager.store.jobs.iterdir())
86
84
 
87
85
  result = session.close()
@@ -97,6 +95,23 @@ def test_interactive_session_round_trips_and_records_transcript(tmp_path: Path)
97
95
  assert record["operation"] == "interactive"
98
96
 
99
97
 
98
+ def test_interactive_session_round_trips_ndjson(tmp_path: Path) -> None:
99
+ environment = _environment(tmp_path)
100
+ script = (
101
+ "import json, sys\n"
102
+ "for line in sys.stdin:\n"
103
+ " value = json.loads(line)\n"
104
+ " print(json.dumps({'id': value['id'], 'answer': value['value'] + 1}), flush=True)"
105
+ )
106
+ with environment.spawn_interactive([sys.executable, "-u", "-c", script]) as session:
107
+ assert session.request_json({"id": 7, "value": 41}) == {"id": 7, "answer": 42}
108
+ with pytest.raises(ValueError, match="exactly one line"):
109
+ session.send_line("first\nsecond")
110
+
111
+ with pytest.raises(EnvironmentError, match="not running"):
112
+ session.send_line("late request")
113
+
114
+
100
115
  def test_interactive_timeout_is_enforced_and_instance_is_removed(tmp_path: Path) -> None:
101
116
  environment = _environment(tmp_path)
102
117
  session = environment.spawn_interactive(
@@ -34,9 +34,7 @@ def test_program_identity_payload_validation_and_interactive_execution(tmp_path:
34
34
 
35
35
  assert program.id.startswith("program_")
36
36
  with program.spawn_interactive(policy=ExecutionPolicy(timeout_seconds=10)) as session:
37
- session.stdin.write('{"ping":true}\n')
38
- session.stdin.flush()
39
- assert session.stdout.readline() == '{"ping":true}\n'
37
+ assert session.request_json({"ping": True}) == {"ping": True}
40
38
  assert session.close().provenance is not None
41
39
  assert session.close().provenance.program_id == program.id
42
40
 
@@ -1,20 +1,27 @@
1
+ import json
1
2
  from pathlib import Path
2
3
 
3
4
  from lean_runtime import ProjectError
4
- from lean_runtime.models import ExecutionResult
5
+ from lean_runtime.models import Diagnostic, ExecutionResult
5
6
  from lean_runtime.run_cli import main
6
7
 
7
8
 
8
- def _result() -> ExecutionResult:
9
+ def _result(
10
+ *,
11
+ ok: bool = True,
12
+ stderr: str = "",
13
+ diagnostics: tuple[Diagnostic, ...] = (),
14
+ ) -> ExecutionResult:
9
15
  return ExecutionResult(
10
- ok=True,
11
- exit_code=0,
16
+ ok=ok,
17
+ exit_code=0 if ok else 1,
12
18
  toolchain="leanprover/lean4:v4.32.0",
13
19
  command=("lean", "Main.lean"),
14
20
  cwd="/tmp",
15
21
  stdout="",
16
- stderr="",
22
+ stderr=stderr,
17
23
  elapsed_seconds=0.01,
24
+ diagnostics=diagnostics,
18
25
  )
19
26
 
20
27
 
@@ -158,6 +165,65 @@ def test_lean_run_discovers_standalone_file_and_writes_lock(
158
165
  assert "accepted" in capsys.readouterr().out
159
166
 
160
167
 
168
+ def test_lean_run_preserves_discovered_compiler_rejection(
169
+ monkeypatch, tmp_path: Path, capsys
170
+ ) -> None:
171
+ source = tmp_path / "Main.lean"
172
+ source.write_text("import Mathlib\nexample : False := by trivial\n")
173
+ rejection = _result(
174
+ ok=False,
175
+ stderr="Main.lean:2:22: error: tactic 'trivial' failed",
176
+ diagnostics=(
177
+ Diagnostic(
178
+ "error",
179
+ "tactic 'trivial' failed",
180
+ file="Main.lean",
181
+ line=2,
182
+ column=22,
183
+ ),
184
+ ),
185
+ )
186
+
187
+ class NoProjectRuntime(FakeRuntime):
188
+ def __init__(self, **kwargs) -> None:
189
+ super().__init__(**kwargs)
190
+ self.availability = "auto"
191
+ self.libraries = ()
192
+
193
+ def check_file(self, *_args, **_kwargs) -> ExecutionResult:
194
+ raise ProjectError("no project")
195
+
196
+ class Attempt:
197
+ execution_result = rejection
198
+
199
+ class Rejected:
200
+ status = "not_found"
201
+ execution_result = None
202
+ rejection_attempt = Attempt()
203
+
204
+ class FakeDiscovery:
205
+ def __init__(self, **_kwargs) -> None:
206
+ pass
207
+
208
+ def discover_and_check(self, _source: str) -> Rejected:
209
+ return Rejected()
210
+
211
+ monkeypatch.setattr("lean_runtime.run_cli.Runtime", NoProjectRuntime)
212
+ monkeypatch.setattr("lean_runtime.run_cli.Discovery", FakeDiscovery)
213
+
214
+ assert main([str(source), "--quiet"]) == 1
215
+ captured = capsys.readouterr()
216
+ assert "tactic 'trivial' failed" in captured.err
217
+ assert "rejected" in captured.out
218
+
219
+ assert main([str(source), "--json", "--quiet"]) == 1
220
+ payload = json.loads(capsys.readouterr().out)
221
+ assert payload["ok"] is False
222
+ assert payload["data"]["stderr"].endswith("tactic 'trivial' failed")
223
+ assert payload["data"]["diagnostics"][0]["message"] == "tactic 'trivial' failed"
224
+ assert payload["data"]["timings"][0]["phase"] == "discovery"
225
+
226
+
161
227
  def test_lean_run_can_disable_automatic_discovery(monkeypatch, tmp_path: Path, capsys) -> None:
162
228
  source = tmp_path / "Main.lean"
163
229
  source.write_text("import Mathlib\n")
@@ -58,7 +58,7 @@ def test_execution_success_fixture_matches_v1_schema() -> None:
58
58
  stdout="",
59
59
  stderr="",
60
60
  elapsed_seconds=0.01,
61
- timings=(PhaseTiming("execution", 10),),
61
+ timings=(PhaseTiming("discovery", 5), PhaseTiming("execution", 10)),
62
62
  )
63
63
  _validator("execution-v1.schema.json").validate(serialize_execution_v1(result))
64
64
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes