lean-runtime 2.9.2__tar.gz → 2.10.0__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 (187) hide show
  1. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/CHANGELOG.md +16 -0
  2. {lean_runtime-2.9.2/lean_runtime.egg-info → lean_runtime-2.10.0}/PKG-INFO +1 -1
  3. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/docs/local-projects.md +13 -4
  4. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/cli.py +165 -20
  5. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/console.py +7 -0
  6. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/header_cache.py +109 -26
  7. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/locking.py +50 -1
  8. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/models.py +4 -0
  9. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/project_execution.py +99 -30
  10. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/runtime.py +1 -1
  11. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/shared_projects.py +33 -2
  12. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/wire.py +18 -0
  13. {lean_runtime-2.9.2 → lean_runtime-2.10.0/lean_runtime.egg-info}/PKG-INFO +1 -1
  14. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime.egg-info/SOURCES.txt +2 -0
  15. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/pyproject.toml +1 -1
  16. lean_runtime-2.10.0/schemas/check-batch-v1.schema.json +36 -0
  17. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/schemas/execution-v1.schema.json +1 -1
  18. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_cli.py +173 -0
  19. lean_runtime-2.10.0/tests/test_header_cache.py +329 -0
  20. lean_runtime-2.10.0/tests/test_locking.py +59 -0
  21. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_schemas.py +6 -0
  22. lean_runtime-2.9.2/tests/test_header_cache.py +0 -44
  23. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/LICENSE +0 -0
  24. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/MANIFEST.in +0 -0
  25. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/README.md +0 -0
  26. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/catalog/environments.toml +0 -0
  27. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/catalog/locks/core-v4.32.2.lock.json +0 -0
  28. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/catalog/locks/leancert-v4.30.0.5.lock.json +0 -0
  29. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/catalog/locks/leancert-v4.31.0.lock.json +0 -0
  30. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/catalog/locks/leancert-v4.32.2.4.lock.json +0 -0
  31. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/catalog/locks/leancert-v4.33.0.lock.json +0 -0
  32. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/catalog/locks/mathlib-v4.30.0.lock.json +0 -0
  33. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/catalog/locks/mathlib-v4.31.0.lock.json +0 -0
  34. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/catalog/locks/mathlib-v4.32.2.lock.json +0 -0
  35. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/catalog/locks/mathlib-v4.33.0.lock.json +0 -0
  36. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/compatibility/README.md +0 -0
  37. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/compatibility/lake-artifact-cache-4.33.0.json +0 -0
  38. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/compatibility/mathlib-4.32.2.json +0 -0
  39. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/compatibility/mathlib-4.32.2.toml +0 -0
  40. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/compatibility/mathlib-4.33.0.json +0 -0
  41. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/compatibility/mathlib-4.33.0.toml +0 -0
  42. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/docs/architecture.md +0 -0
  43. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/docs/captures.md +0 -0
  44. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/docs/case-study-v1.md +0 -0
  45. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/docs/cli.md +0 -0
  46. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/docs/development.md +0 -0
  47. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/docs/environments.md +0 -0
  48. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/docs/getting-started.md +0 -0
  49. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/docs/index.md +0 -0
  50. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/docs/lake-artifact-cache-design.md +0 -0
  51. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/docs/portable-copies.md +0 -0
  52. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/docs/project-publishing.md +0 -0
  53. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/docs/python-api.md +0 -0
  54. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/docs/ready-programs.md +0 -0
  55. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/docs/standalone-files.md +0 -0
  56. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/docs/trust-and-limitations.md +0 -0
  57. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/docs/v1-precision.md +0 -0
  58. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/examples/mathlib.toml +0 -0
  59. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/__init__.py +0 -0
  60. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/__main__.py +0 -0
  61. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/_git.py +0 -0
  62. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/_paths.py +0 -0
  63. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/backends.py +0 -0
  64. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/bundles.py +0 -0
  65. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/capsules.py +0 -0
  66. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/comparison.py +0 -0
  67. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/decisions.py +0 -0
  68. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/diagnostics.py +0 -0
  69. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/discovery/__init__.py +0 -0
  70. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/discovery/analyzer.py +0 -0
  71. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/discovery/api.py +0 -0
  72. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/discovery/candidate.py +0 -0
  73. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/discovery/catalog.py +0 -0
  74. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/discovery/catalog_build.py +0 -0
  75. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/discovery/catalog_cli.py +0 -0
  76. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/discovery/catalog_manifest.py +0 -0
  77. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/discovery/data/catalog.json +0 -0
  78. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/discovery/defaults.py +0 -0
  79. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/discovery/engine.py +0 -0
  80. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/discovery/errors.py +0 -0
  81. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/discovery/module_inventory.py +0 -0
  82. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/discovery/planner.py +0 -0
  83. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/discovery/policy.py +0 -0
  84. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/discovery/probe.py +0 -0
  85. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/discovery/py.typed +0 -0
  86. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/discovery/result.py +0 -0
  87. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/discovery/schema_resources.py +0 -0
  88. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/discovery/schemas/catalog-v1.schema.json +0 -0
  89. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/discovery/schemas/plan-v1.schema.json +0 -0
  90. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/discovery/schemas/result-v1.schema.json +0 -0
  91. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/environments.py +0 -0
  92. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/errors.py +0 -0
  93. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/events.py +0 -0
  94. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/facade.py +0 -0
  95. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/frontmatter.py +0 -0
  96. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/health.py +0 -0
  97. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/identifier_resolver.py +0 -0
  98. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/lake.py +0 -0
  99. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/lake_cache.py +0 -0
  100. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/lockfiles.py +0 -0
  101. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/matrix.py +0 -0
  102. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/oci.py +0 -0
  103. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/oci_protocol.py +0 -0
  104. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/packs.py +0 -0
  105. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/policies.py +0 -0
  106. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/profiling.py +0 -0
  107. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/programs.py +0 -0
  108. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/project_sharing.py +0 -0
  109. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/projects.py +0 -0
  110. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/publisher_verification.py +0 -0
  111. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/py.typed +0 -0
  112. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/references.py +0 -0
  113. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/resolver.py +0 -0
  114. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/run_cli.py +0 -0
  115. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/schema_resources.py +0 -0
  116. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/serialization.py +0 -0
  117. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/specs.py +0 -0
  118. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/store.py +0 -0
  119. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/timings.py +0 -0
  120. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/toolchain_oci.py +0 -0
  121. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/toolchain_slim.py +0 -0
  122. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/toolchains.py +0 -0
  123. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime/verification.py +0 -0
  124. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime.egg-info/dependency_links.txt +0 -0
  125. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime.egg-info/entry_points.txt +0 -0
  126. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime.egg-info/requires.txt +0 -0
  127. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/lean_runtime.egg-info/top_level.txt +0 -0
  128. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/mkdocs.yml +0 -0
  129. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/schemas/cleanup-v1.schema.json +0 -0
  130. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/schemas/comparison-v1.schema.json +0 -0
  131. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/schemas/inspect-v1.schema.json +0 -0
  132. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/schemas/matrix-v1.schema.json +0 -0
  133. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/schemas/profile-v1.schema.json +0 -0
  134. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/schemas/publication-v1.schema.json +0 -0
  135. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/schemas/verify-v1.schema.json +0 -0
  136. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/scripts/announcement_gate.py +0 -0
  137. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/scripts/lake_cache_gate.py +0 -0
  138. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/scripts/registry_preflight.py +0 -0
  139. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/scripts/run_compatibility.py +0 -0
  140. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/scripts/run_v1_case_study.py +0 -0
  141. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/scripts/smoke_wheel.py +0 -0
  142. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/setup.cfg +0 -0
  143. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/conftest.py +0 -0
  144. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/discovery/e2e/test_real_discovery.py +0 -0
  145. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/discovery/test_analyzer.py +0 -0
  146. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/discovery/test_catalog.py +0 -0
  147. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/discovery/test_catalog_build.py +0 -0
  148. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/discovery/test_discovery_schema_resources.py +0 -0
  149. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/discovery/test_engine.py +0 -0
  150. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/discovery/test_planner.py +0 -0
  151. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/discovery/test_policy.py +0 -0
  152. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_backend.py +0 -0
  153. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_bundles.py +0 -0
  154. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_capsules.py +0 -0
  155. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_console.py +0 -0
  156. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_diagnostics.py +0 -0
  157. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_environment_integration.py +0 -0
  158. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_events_health.py +0 -0
  159. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_facade.py +0 -0
  160. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_frontmatter.py +0 -0
  161. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_git.py +0 -0
  162. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_identifier_resolver.py +0 -0
  163. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_interactive.py +0 -0
  164. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_lake_cache.py +0 -0
  165. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_lockfiles.py +0 -0
  166. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_models.py +0 -0
  167. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_multifile.py +0 -0
  168. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_oci_protocol.py +0 -0
  169. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_packs.py +0 -0
  170. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_paths.py +0 -0
  171. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_prebuilt_policy.py +0 -0
  172. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_programs.py +0 -0
  173. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_project_publish.py +0 -0
  174. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_projects.py +0 -0
  175. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_references.py +0 -0
  176. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_run_cli.py +0 -0
  177. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_runtime.py +0 -0
  178. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_schema_resources.py +0 -0
  179. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_signatures.py +0 -0
  180. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_specs.py +0 -0
  181. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_store.py +0 -0
  182. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_toolchain_oci.py +0 -0
  183. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_toolchain_slim.py +0 -0
  184. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_toolchains.py +0 -0
  185. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_v1_precision.py +0 -0
  186. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_verification_inventory.py +0 -0
  187. {lean_runtime-2.9.2 → lean_runtime-2.10.0}/tests/test_verification_probe.py +0 -0
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 2.10.0 - 2026-08-16
6
+
7
+ - Make header snapshots opt-in (`LEAN_RUNTIME_HEADER_SNAPSHOTS=1`; `--watch` and
8
+ `--repeat` enable them automatically), key them by module identity, load
9
+ existing snapshots without holding the creation lock, cancel lock waits
10
+ promptly, and retry once without a snapshot—quarantining it—when a snapshot
11
+ check times out or reports snapshot errors.
12
+ - Support `check FILE...` and `check DIRECTORY...` with independent per-file
13
+ results, `--concurrency`, a `lean-runtime.check-batch/v1` JSON envelope, and
14
+ an explicit `--environment NAME`; the legacy positional `ENVIRONMENT FILE`
15
+ form now applies only when the first argument is not an existing path.
16
+ - Attribute shared workspace lock waits to their holder (PID and operation),
17
+ announce header snapshot waits, and record `workspace_lock` and
18
+ `header_snapshot` phase timings so coordination cost is visible next to the
19
+ actual Lean check time.
20
+
5
21
  ## 2.9.2 - 2026-08-16
6
22
 
7
23
  - Keep one fail-closed publication authentication session from push preflight through
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lean-runtime
3
- Version: 2.9.2
3
+ Version: 2.10.0
4
4
  Summary: Run Lean 4 proofs from Python or standalone files
5
5
  Author: Alejandro Radisic
6
6
  License-Expression: Apache-2.0
@@ -68,10 +68,19 @@ stays silent. Only root-project outputs enter this cache: locked dependencies
68
68
  continue to use the verified shared workspace, avoiding a second multi-gigabyte
69
69
  copy. `build` retains Lake's complete target semantics.
70
70
 
71
- Repeated file checks reuse Lean's native header snapshots, keyed by the exact
72
- toolchain, project identity, and import block. Body-only edits therefore avoid
73
- reloading the unchanged import environment. `--watch` is a thin save loop over
74
- the same ordinary check/result contract. Failed checks may add exact-graph
71
+ Repeated checks of one file can reuse Lean's native header snapshots, keyed by
72
+ the exact toolchain, project identity, module, and import block. Body-only
73
+ edits therefore avoid reloading the unchanged import environment. Snapshots are
74
+ used automatically by `--watch` and `--repeat`; set
75
+ `LEAN_RUNTIME_HEADER_SNAPSHOTS=1` to enable them for one-shot checks (or `=0`
76
+ to disable them everywhere). A check that fails or times out under a snapshot
77
+ is retried once without it, and the snapshot is discarded. `--watch` is a thin
78
+ save loop over the same ordinary check/result contract. `check` also accepts
79
+ several files or directories and checks each independently, in parallel with
80
+ `--concurrency`. When one invocation must wait on another, the console names
81
+ the holder (for example `Waiting for shared workspace lock held by PID 512
82
+ (shared build of mathlib)`), and `--timings` reports `workspace_lock` and
83
+ `header_snapshot` phases alongside the actual Lean execution time. Failed checks may add exact-graph
75
84
  identifier hints derived from pinned `.ilean` indexes; raw Lean streams remain
76
85
  unchanged.
77
86
 
@@ -8,6 +8,7 @@ import os
8
8
  import re
9
9
  import sys
10
10
  import time
11
+ from concurrent.futures import ThreadPoolExecutor, as_completed
11
12
  from dataclasses import replace
12
13
  from datetime import datetime
13
14
  from importlib.metadata import version as distribution_version
@@ -24,6 +25,7 @@ from .errors import (
24
25
  ResolutionError,
25
26
  )
26
27
  from .events import RuntimeEvent
28
+ from .header_cache import ENABLE_VARIABLE as _HEADER_SNAPSHOTS_VARIABLE
27
29
  from .health import DoctorReport
28
30
  from .lake import ROOT_MODULE
29
31
  from .lockfiles import EnvironmentLock
@@ -40,6 +42,7 @@ from .timings import render_timings
40
42
  from .wire import (
41
43
  envelope,
42
44
  error,
45
+ serialize_check_batch_v1,
43
46
  serialize_comparison_v1,
44
47
  serialize_execution_v1,
45
48
  serialize_matrix_v1,
@@ -343,6 +346,82 @@ def _cli_source_name(path: Path) -> str:
343
346
  return path.as_posix()
344
347
 
345
348
 
349
+ def _expand_check_inputs(inputs: list[str]) -> list[Path]:
350
+ """Expand FILE and DIRECTORY inputs into an ordered, deduplicated file list."""
351
+ files: list[Path] = []
352
+ for raw in inputs:
353
+ path = Path(raw)
354
+ if path.is_dir():
355
+ found: list[Path] = []
356
+ for current, directories, filenames in os.walk(path):
357
+ directories[:] = sorted(name for name in directories if not name.startswith("."))
358
+ found.extend(
359
+ Path(current) / name for name in sorted(filenames) if name.endswith(".lean")
360
+ )
361
+ if not found:
362
+ raise ValueError(f"no Lean files found under directory: {path}")
363
+ files.extend(found)
364
+ elif path.is_file():
365
+ files.append(path)
366
+ else:
367
+ raise ValueError(f"check input does not exist: {path}")
368
+ return list(dict.fromkeys(files))
369
+
370
+
371
+ def _run_check_batch(runtime: Runtime, files: list[Path], args: argparse.Namespace) -> int:
372
+ """Check each file independently, optionally in parallel, and report per file."""
373
+ if args.concurrency < 1 or args.concurrency > 32:
374
+ raise ValueError("check concurrency must be between 1 and 32")
375
+ environment = runtime.environment(args.environment) if args.environment else None
376
+
377
+ def execute(path: Path) -> ExecutionResult:
378
+ if environment is not None:
379
+ name = _cli_source_name(path)
380
+ return environment.check_files(
381
+ {name: path.read_text(encoding="utf-8")}, entrypoint=name, policy=_policy(args)
382
+ )
383
+ return runtime.check_file(
384
+ path, toolchain=args.toolchain, project=args.project, policy=_policy(args)
385
+ )
386
+
387
+ started = time.monotonic()
388
+ results: dict[Path, ExecutionResult] = {}
389
+ if args.concurrency == 1:
390
+ for path in files:
391
+ results[path] = execute(path)
392
+ else:
393
+ with ThreadPoolExecutor(
394
+ max_workers=args.concurrency, thread_name_prefix="lean-check"
395
+ ) as pool:
396
+ futures = {pool.submit(execute, path): path for path in files}
397
+ try:
398
+ for future in as_completed(futures):
399
+ results[futures[future]] = future.result()
400
+ except BaseException:
401
+ for future in futures:
402
+ future.cancel()
403
+ raise
404
+ entries = [(str(path), results[path]) for path in files]
405
+ runtime.events.emit(
406
+ "check.completed",
407
+ "Lean check completed",
408
+ ok=all(result.ok for _, result in entries),
409
+ )
410
+ if args.json:
411
+ _json(serialize_check_batch_v1(entries, time.monotonic() - started))
412
+ else:
413
+ for display, result in entries:
414
+ status = "accepted" if result.ok else "rejected"
415
+ print(f"{display}\t{status}\t{result.elapsed_seconds:.2f}s")
416
+ if not result.ok:
417
+ output = "\n".join(part for part in (result.stdout, result.stderr) if part).strip()
418
+ if output:
419
+ print(output, file=sys.stderr)
420
+ accepted = sum(1 for _, result in entries if result.ok)
421
+ print(f"{accepted}/{len(entries)} accepted")
422
+ return 0 if all(result.ok for _, result in entries) else 1
423
+
424
+
346
425
  def _display_result_text(text: str, result: ExecutionResult, display_path: str | None) -> str:
347
426
  """Rewrite only the staged entrypoint when the CLI has a logical input name."""
348
427
  if display_path is None or not result.command:
@@ -657,11 +736,36 @@ Run `lean-runtime COMMAND --help` for examples and options.""",
657
736
  program_index.add_argument("--tag", action="append", default=[])
658
737
  program_index.add_argument("--sign", action="store_true")
659
738
 
660
- check = commands.add_parser("check", help="check one Lean file or all local libraries")
739
+ check = commands.add_parser(
740
+ "check",
741
+ help="check Lean files or all local libraries",
742
+ formatter_class=argparse.RawDescriptionHelpFormatter,
743
+ epilog="""examples:
744
+ lean-runtime check check every local library of this project
745
+ lean-runtime check Foo.lean check one file in its discovered project
746
+ lean-runtime check Foo.lean Bar.lean check several files, one result per file
747
+ lean-runtime check src --concurrency 4 check every *.lean under src in parallel
748
+ lean-runtime check Foo.lean --project ~/p check a file against an explicit Lake project
749
+ lean-runtime check --environment NAME Foo.lean
750
+ check inside a published environment
751
+ lean-runtime check Foo.lean --with github:owner/repo@v1.0
752
+ check against exact package references
753
+ lean-runtime check Foo.lean --timeout 60 bound one Lean invocation to 60 seconds
754
+ lean-runtime --timings check Foo.lean append stable phase timings to the result
755
+
756
+ Multi-file checks exit nonzero if any file is rejected. Header snapshots for
757
+ warm re-checks are used by --watch and --repeat automatically; set
758
+ LEAN_RUNTIME_HEADER_SNAPSHOTS=1 to enable them for one-shot checks.""",
759
+ )
661
760
  check.add_argument(
662
761
  "inputs",
663
762
  nargs="*",
664
- help="FILE, legacy ENVIRONMENT FILE, or omit inside a Lake project; FILE may be -",
763
+ help="FILE..., DIRECTORY..., legacy ENVIRONMENT FILE, or omit inside a Lake "
764
+ "project; FILE may be -",
765
+ )
766
+ check.add_argument(
767
+ "--environment",
768
+ help="check the given FILEs inside this published environment",
665
769
  )
666
770
  check.add_argument(
667
771
  "--with",
@@ -686,7 +790,9 @@ Run `lean-runtime COMMAND --help` for examples and options.""",
686
790
  check.add_argument("--repeat", type=int, help="measure N repeated checks of one project file")
687
791
  check.add_argument("--warmup", type=int, default=1, help="warmups before --repeat samples")
688
792
  check.add_argument("--across", type=Path, help="check FILE across contexts in CONFIGURATION")
689
- check.add_argument("--concurrency", type=int, default=1, help="workers for --across")
793
+ check.add_argument(
794
+ "--concurrency", type=int, default=1, help="workers for --across and multi-file checks"
795
+ )
690
796
  _add_policy(check)
691
797
 
692
798
  inspect = commands.add_parser("inspect", help="inspect a published environment")
@@ -1554,7 +1660,13 @@ def main(argv: list[str] | None = None) -> int:
1554
1660
  return 0
1555
1661
  if args.command == "check":
1556
1662
  if args.across is not None:
1557
- if len(args.inputs) != 1 or args.package_refs or args.include or args.project:
1663
+ if (
1664
+ len(args.inputs) != 1
1665
+ or args.package_refs
1666
+ or args.include
1667
+ or args.project
1668
+ or args.environment
1669
+ ):
1558
1670
  raise ValueError("check --across requires exactly one FILE")
1559
1671
  across_file = Path(args.inputs[0])
1560
1672
  contexts = load_matrix(args.across)
@@ -1589,6 +1701,8 @@ def main(argv: list[str] | None = None) -> int:
1589
1701
  "check --repeat requires positive samples and nonnegative warmups"
1590
1702
  )
1591
1703
  repeated_file = Path(args.inputs[0])
1704
+ if os.environ.get(_HEADER_SNAPSHOTS_VARIABLE) is None:
1705
+ runtime.header_cache.enabled = True
1592
1706
  for _ in range(args.warmup):
1593
1707
  warm = runtime.check_file(
1594
1708
  repeated_file, project=args.project, policy=_policy(args)
@@ -1626,6 +1740,8 @@ def main(argv: list[str] | None = None) -> int:
1626
1740
  if not watched.is_file():
1627
1741
  raise ValueError(f"watched Lean file does not exist: {watched}")
1628
1742
  print(f"Watching {watched} · Ctrl-C to stop")
1743
+ if os.environ.get(_HEADER_SNAPSHOTS_VARIABLE) is None:
1744
+ runtime.header_cache.enabled = True
1629
1745
  previous: tuple[int, int] | None = None
1630
1746
  while True:
1631
1747
  stat = watched.stat()
@@ -1650,9 +1766,24 @@ def main(argv: list[str] | None = None) -> int:
1650
1766
  runtime.events.emit("check.started", "Checking Lean input", subject=check_subject)
1651
1767
  if args.project is not None and args.package_refs:
1652
1768
  raise ValueError("check cannot combine --project with --with")
1769
+ if args.environment is not None and (
1770
+ args.package_refs or args.project or args.toolchain
1771
+ ):
1772
+ raise ValueError(
1773
+ "check --environment cannot be combined with --with, --project, or --toolchain"
1774
+ )
1775
+ legacy_environment = (
1776
+ args.environment is None
1777
+ and not args.package_refs
1778
+ and len(args.inputs) == 2
1779
+ and args.inputs[0] != "-"
1780
+ and not Path(args.inputs[0]).exists()
1781
+ )
1653
1782
  if not args.inputs:
1654
1783
  if args.package_refs or args.include:
1655
1784
  raise ValueError("project-wide check does not accept --with or --include")
1785
+ if args.environment is not None:
1786
+ raise ValueError("check --environment requires at least one FILE")
1656
1787
  result = runtime.check_project(
1657
1788
  args.project or Path("."), toolchain=args.toolchain, policy=_policy(args)
1658
1789
  )
@@ -1662,11 +1793,22 @@ def main(argv: list[str] | None = None) -> int:
1662
1793
  raise ValueError("check with --with expects exactly one FILE")
1663
1794
  environment = runtime.open_references(args.package_refs, toolchain=args.toolchain)
1664
1795
  source_file = Path(args.inputs[0])
1665
- elif len(args.inputs) == 1:
1666
- if args.include:
1667
- raise ValueError("local project checks do not accept --include")
1668
- source_file = Path(args.inputs[0])
1669
- if str(source_file) == "-":
1796
+ elif legacy_environment:
1797
+ if args.toolchain:
1798
+ raise ValueError("check --toolchain is only valid with --with")
1799
+ if args.project:
1800
+ raise ValueError("check --project is only valid with a single FILE")
1801
+ environment = runtime.environment(args.inputs[0])
1802
+ source_file = Path(args.inputs[1])
1803
+ elif "-" in args.inputs:
1804
+ if len(args.inputs) != 1:
1805
+ raise ValueError("stdin (-) must be the only check input")
1806
+ if args.environment is not None:
1807
+ environment = runtime.environment(args.environment)
1808
+ source_file = Path("-")
1809
+ else:
1810
+ if args.include:
1811
+ raise ValueError("local project checks do not accept --include")
1670
1812
  display_path = "<stdin>"
1671
1813
  result = runtime.check(
1672
1814
  sys.stdin.read(),
@@ -1674,23 +1816,26 @@ def main(argv: list[str] | None = None) -> int:
1674
1816
  project=args.project,
1675
1817
  policy=_policy(args),
1676
1818
  )
1819
+ source_file = None
1820
+ else:
1821
+ selected_files = _expand_check_inputs(args.inputs)
1822
+ if len(selected_files) > 1:
1823
+ if args.include:
1824
+ raise ValueError("multi-file checks do not accept --include")
1825
+ return _run_check_batch(runtime, selected_files, args)
1826
+ if args.environment is not None:
1827
+ environment = runtime.environment(args.environment)
1828
+ source_file = selected_files[0]
1677
1829
  else:
1830
+ if args.include:
1831
+ raise ValueError("local project checks do not accept --include")
1678
1832
  result = runtime.check_file(
1679
- source_file,
1833
+ selected_files[0],
1680
1834
  toolchain=args.toolchain,
1681
1835
  project=args.project,
1682
1836
  policy=_policy(args),
1683
1837
  )
1684
- source_file = None
1685
- else:
1686
- if len(args.inputs) != 2:
1687
- raise ValueError("check expects FILE, ENVIRONMENT FILE, or FILE with --with")
1688
- if args.toolchain:
1689
- raise ValueError("check --toolchain is only valid with --with")
1690
- if args.project:
1691
- raise ValueError("check --project is only valid with a single FILE")
1692
- environment = runtime.environment(args.inputs[0])
1693
- source_file = Path(args.inputs[1])
1838
+ source_file = None
1694
1839
  if source_file is None:
1695
1840
  pass
1696
1841
  elif str(source_file) == "-":
@@ -227,6 +227,13 @@ class ConsoleRenderer:
227
227
  def _render_check_completed(self, _event: RuntimeEvent) -> None:
228
228
  self._end_progress_line()
229
229
 
230
+ def _render_check_header_wait(self, event: RuntimeEvent) -> None:
231
+ module = str(event.data.get("module") or "Lean input")
232
+ self._print(f"Waiting for header snapshot initialization: {module}")
233
+
234
+ def _render_project_workspace_lock_wait(self, event: RuntimeEvent) -> None:
235
+ self._print(event.message)
236
+
230
237
  # -- low-level output -----------------------------------------------
231
238
 
232
239
  def _verbose_line(self, event: RuntimeEvent) -> None:
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import contextlib
5
6
  import hashlib
6
7
  import json
7
8
  import os
@@ -10,15 +11,26 @@ import tempfile
10
11
  from collections.abc import Iterator, Sequence
11
12
  from contextlib import contextmanager
12
13
  from pathlib import Path
13
- from typing import TYPE_CHECKING
14
+ from typing import TYPE_CHECKING, NamedTuple
14
15
 
16
+ from .errors import EnvironmentError
15
17
  from .locking import FileLock
16
18
  from .serialization import sha256_id
17
19
  from .store import platform_compatibility
18
20
 
19
21
  if TYPE_CHECKING:
22
+ import threading
23
+
24
+ from .events import EventEmitter
20
25
  from .toolchains import ToolchainManager
21
26
 
27
+ ENABLE_VARIABLE = "LEAN_RUNTIME_HEADER_SNAPSHOTS"
28
+ _TRUTHY = {"1", "true", "on", "yes"}
29
+
30
+
31
+ def _configured_enabled() -> bool:
32
+ return os.environ.get(ENABLE_VARIABLE, "").strip().lower() in _TRUTHY
33
+
22
34
 
23
35
  def _header_identity(source: str) -> str:
24
36
  """Return a conservative identity for the module header/import block."""
@@ -43,12 +55,34 @@ def _header_identity(source: str) -> str:
43
55
  return "".join(lines)
44
56
 
45
57
 
58
+ class _SnapshotPaths(NamedTuple):
59
+ snapshot: Path
60
+ deps: Path
61
+ lock: Path
62
+
63
+ def valid(self) -> bool:
64
+ return self.snapshot.is_file() and self.deps.is_file()
65
+
66
+
46
67
  class LeanHeaderCache:
47
- """Reuse Lean's native experimental import snapshots when the binary supports them."""
68
+ """Reuse Lean's native experimental import snapshots when the binary supports them.
69
+
70
+ Snapshots are keyed by exact toolchain, workspace identity, logical module
71
+ name, and import-block content, so distinct modules never share a snapshot.
72
+ Existing snapshots are loaded without holding any lock; only first-time
73
+ snapshot creation serializes behind a per-key file lock.
74
+ """
48
75
 
49
- def __init__(self, home: Path, toolchains: ToolchainManager) -> None:
76
+ def __init__(
77
+ self,
78
+ home: Path,
79
+ toolchains: ToolchainManager,
80
+ events: EventEmitter | None = None,
81
+ ) -> None:
50
82
  self.home = home / "header-snapshots"
51
83
  self.toolchains = toolchains
84
+ self.events = events
85
+ self.enabled = _configured_enabled()
52
86
  self._support: dict[str, bool] = {}
53
87
  self._toolchain_keys: dict[str, str] = {}
54
88
 
@@ -95,37 +129,86 @@ class LeanHeaderCache:
95
129
  self._support[key] = supported
96
130
  return supported
97
131
 
98
- @contextmanager
99
- def command(
100
- self,
101
- toolchain: str,
102
- workspace_identity: str,
103
- source: str,
104
- command: Sequence[str],
105
- ) -> Iterator[list[str]]:
106
- if not self.supported(toolchain):
107
- yield list(command)
108
- return
132
+ def _paths(
133
+ self, toolchain: str, workspace_identity: str, module: str, source: str
134
+ ) -> _SnapshotPaths:
109
135
  key = sha256_id(
110
136
  "header",
111
137
  {
112
138
  "toolchain": self._toolchain_key(toolchain),
113
139
  "workspace": workspace_identity,
140
+ "module": module,
114
141
  "header": hashlib.sha256(_header_identity(source).encode()).hexdigest(),
115
142
  },
116
143
  ).removeprefix("header-")
117
144
  root = self.home / self._toolchain_key(toolchain)
118
145
  snapshot = root / f"{key}.snap"
119
- deps = Path(str(snapshot) + ".deps")
120
- root.mkdir(parents=True, exist_ok=True)
121
- with FileLock(root / f"{key}.lock"):
122
- if snapshot.is_file() and deps.is_file():
123
- yield [*command[:-1], f"--incr-load={snapshot}", command[-1]]
146
+ return _SnapshotPaths(snapshot, Path(str(snapshot) + ".deps"), root / f"{key}.lock")
147
+
148
+ def discard(self, toolchain: str, workspace_identity: str, module: str, source: str) -> None:
149
+ """Quarantine a snapshot that produced timeout or staleness symptoms."""
150
+ paths = self._paths(toolchain, workspace_identity, module, source)
151
+ for path in (paths.snapshot, paths.deps):
152
+ with contextlib.suppress(OSError):
153
+ path.unlink(missing_ok=True)
154
+
155
+ @contextmanager
156
+ def _creation_lock(
157
+ self, paths: _SnapshotPaths, module: str, cancel: threading.Event | None
158
+ ) -> Iterator[None]:
159
+ """Acquire the per-key creation lock, announcing contention once."""
160
+ lock = FileLock(paths.lock, timeout=0)
161
+ try:
162
+ lock.__enter__()
163
+ except EnvironmentError:
164
+ if self.events is not None:
165
+ self.events.emit(
166
+ "check.header_wait",
167
+ f"Waiting for header snapshot initialization: {module}",
168
+ phase="check",
169
+ module=module,
170
+ )
171
+ lock = FileLock(paths.lock, cancel=cancel)
172
+ lock.__enter__()
173
+ try:
174
+ yield
175
+ finally:
176
+ lock.__exit__(None, None, None)
177
+
178
+ @contextmanager
179
+ def command(
180
+ self,
181
+ toolchain: str,
182
+ workspace_identity: str,
183
+ module: str,
184
+ source: str,
185
+ command: Sequence[str],
186
+ *,
187
+ cancel: threading.Event | None = None,
188
+ ) -> Iterator[list[str]]:
189
+ if not self.enabled or not self.supported(toolchain):
190
+ yield list(command)
191
+ return
192
+ paths = self._paths(toolchain, workspace_identity, module, source)
193
+ if paths.valid():
194
+ yield [*command[:-1], f"--incr-load={paths.snapshot}", command[-1]]
195
+ return
196
+ paths.snapshot.parent.mkdir(parents=True, exist_ok=True)
197
+ hit_after_wait = False
198
+ with self._creation_lock(paths, module, cancel):
199
+ if paths.valid():
200
+ hit_after_wait = True
201
+ else:
202
+ root = str(paths.snapshot.parent)
203
+ with tempfile.TemporaryDirectory(
204
+ prefix=f".{paths.snapshot.stem}.", dir=root
205
+ ) as temporary_root:
206
+ temporary = Path(temporary_root) / "header.snap"
207
+ temporary_deps = Path(str(temporary) + ".deps")
208
+ yield [*command[:-1], f"--incr-header-save={temporary}", command[-1]]
209
+ if temporary.is_file() and temporary_deps.is_file():
210
+ os.replace(temporary, paths.snapshot)
211
+ os.replace(temporary_deps, paths.deps)
124
212
  return
125
- with tempfile.TemporaryDirectory(prefix=f".{key}.", dir=root) as temporary_root:
126
- temporary = Path(temporary_root) / "header.snap"
127
- temporary_deps = Path(str(temporary) + ".deps")
128
- yield [*command[:-1], f"--incr-header-save={temporary}", command[-1]]
129
- if temporary.is_file() and temporary_deps.is_file():
130
- os.replace(temporary, snapshot)
131
- os.replace(temporary_deps, deps)
213
+ if hit_after_wait:
214
+ yield [*command[:-1], f"--incr-load={paths.snapshot}", command[-1]]
@@ -2,32 +2,70 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import contextlib
6
+ import json
5
7
  import os
6
8
  import threading
7
9
  import time
10
+ from collections.abc import Callable
8
11
  from pathlib import Path
9
12
  from types import TracebackType
10
- from typing import BinaryIO
13
+ from typing import Any, BinaryIO
11
14
 
12
15
  from .errors import EnvironmentError
13
16
 
14
17
 
15
18
  class FileLock:
19
+ """One advisory cross-process lock.
20
+
21
+ ``owner`` is a small JSON-serializable description of the acquiring
22
+ operation; on POSIX it is written into the lock file while the lock is
23
+ held so that waiters can attribute their wait. ``on_wait`` is invoked at
24
+ most once, on first contention, with the current holder's description (or
25
+ ``None`` when it cannot be read).
26
+ """
27
+
16
28
  def __init__(
17
29
  self,
18
30
  path: Path,
19
31
  timeout: float = 300,
20
32
  cancel: threading.Event | None = None,
33
+ owner: dict[str, Any] | None = None,
34
+ on_wait: Callable[[dict[str, Any] | None], None] | None = None,
21
35
  ) -> None:
22
36
  self.path = path
23
37
  self.timeout = timeout
24
38
  self.cancel = cancel
39
+ self.owner = owner
40
+ self.on_wait = on_wait
25
41
  self._handle: BinaryIO | None = None
42
+ self._owner_written = False
43
+
44
+ def holder(self) -> dict[str, Any] | None:
45
+ """Best-effort description of the current lock holder."""
46
+ try:
47
+ value = json.loads(self.path.read_text(encoding="utf-8"))
48
+ except (OSError, UnicodeError, json.JSONDecodeError):
49
+ return None
50
+ return value if isinstance(value, dict) else None
51
+
52
+ def _write_owner(self, handle: BinaryIO) -> None:
53
+ if self.owner is None or os.name == "nt":
54
+ return
55
+ try:
56
+ handle.seek(0)
57
+ handle.truncate()
58
+ handle.write(json.dumps({"pid": os.getpid(), **self.owner}).encode("utf-8"))
59
+ handle.flush()
60
+ self._owner_written = True
61
+ except (OSError, TypeError, ValueError):
62
+ self._owner_written = False
26
63
 
27
64
  def __enter__(self) -> FileLock:
28
65
  self.path.parent.mkdir(parents=True, exist_ok=True)
29
66
  handle = self.path.open("a+b")
30
67
  started = time.monotonic()
68
+ announced = False
31
69
  while True:
32
70
  try:
33
71
  if os.name == "nt":
@@ -51,8 +89,13 @@ class FileLock:
51
89
  getattr(fcntl, "LOCK_EX") | getattr(fcntl, "LOCK_NB"), # noqa: B009
52
90
  )
53
91
  self._handle = handle
92
+ self._write_owner(handle)
54
93
  return self
55
94
  except OSError as error:
95
+ if not announced:
96
+ announced = True
97
+ if self.on_wait is not None:
98
+ self.on_wait(self.holder())
56
99
  if self.cancel is not None and self.cancel.is_set():
57
100
  handle.close()
58
101
  raise EnvironmentError(
@@ -74,6 +117,12 @@ class FileLock:
74
117
  handle = self._handle
75
118
  if handle is None:
76
119
  return
120
+ if self._owner_written:
121
+ with contextlib.suppress(OSError):
122
+ handle.seek(0)
123
+ handle.truncate()
124
+ handle.flush()
125
+ self._owner_written = False
77
126
  if os.name == "nt":
78
127
  import msvcrt
79
128
 
@@ -20,6 +20,8 @@ TimingPhase = Literal[
20
20
  "instance_creation",
21
21
  "input_staging",
22
22
  "command_preparation",
23
+ "workspace_lock",
24
+ "header_snapshot",
23
25
  "execution",
24
26
  "result_collection",
25
27
  "result_publication",
@@ -40,6 +42,8 @@ TIMING_PHASES = frozenset(
40
42
  "instance_creation",
41
43
  "input_staging",
42
44
  "command_preparation",
45
+ "workspace_lock",
46
+ "header_snapshot",
43
47
  "execution",
44
48
  "result_collection",
45
49
  "result_publication",