lean-runtime 2.0.1__tar.gz → 2.0.3__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 (111) hide show
  1. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/CHANGELOG.md +16 -0
  2. {lean_runtime-2.0.1/lean_runtime.egg-info → lean_runtime-2.0.3}/PKG-INFO +1 -1
  3. lean_runtime-2.0.3/lean_runtime/_paths.py +38 -0
  4. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/bundles.py +56 -9
  5. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/cli.py +14 -1
  6. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/environments.py +7 -4
  7. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/programs.py +4 -4
  8. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/store.py +3 -2
  9. {lean_runtime-2.0.1 → lean_runtime-2.0.3/lean_runtime.egg-info}/PKG-INFO +1 -1
  10. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime.egg-info/SOURCES.txt +2 -0
  11. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/pyproject.toml +1 -1
  12. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_bundles.py +53 -1
  13. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_cli.py +22 -1
  14. lean_runtime-2.0.3/tests/test_paths.py +25 -0
  15. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_store.py +4 -2
  16. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/LICENSE +0 -0
  17. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/MANIFEST.in +0 -0
  18. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/README.md +0 -0
  19. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/compatibility/README.md +0 -0
  20. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/compatibility/mathlib-4.32.2.json +0 -0
  21. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/compatibility/mathlib-4.32.2.toml +0 -0
  22. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/docs/architecture.md +0 -0
  23. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/docs/captures.md +0 -0
  24. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/docs/case-study-v1.md +0 -0
  25. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/docs/cli.md +0 -0
  26. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/docs/development.md +0 -0
  27. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/docs/environments.md +0 -0
  28. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/docs/getting-started.md +0 -0
  29. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/docs/index.md +0 -0
  30. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/docs/local-projects.md +0 -0
  31. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/docs/portable-copies.md +0 -0
  32. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/docs/python-api.md +0 -0
  33. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/docs/ready-programs.md +0 -0
  34. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/docs/standalone-files.md +0 -0
  35. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/docs/trust-and-limitations.md +0 -0
  36. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/docs/v1-precision.md +0 -0
  37. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/examples/mathlib.toml +0 -0
  38. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/__init__.py +0 -0
  39. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/__main__.py +0 -0
  40. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/_git.py +0 -0
  41. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/backends.py +0 -0
  42. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/comparison.py +0 -0
  43. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/decisions.py +0 -0
  44. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/diagnostics.py +0 -0
  45. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/errors.py +0 -0
  46. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/events.py +0 -0
  47. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/facade.py +0 -0
  48. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/frontmatter.py +0 -0
  49. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/health.py +0 -0
  50. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/lake.py +0 -0
  51. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/lockfiles.py +0 -0
  52. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/locking.py +0 -0
  53. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/matrix.py +0 -0
  54. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/models.py +0 -0
  55. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/oci.py +0 -0
  56. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/policies.py +0 -0
  57. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/profiling.py +0 -0
  58. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/projects.py +0 -0
  59. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/publisher_verification.py +0 -0
  60. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/py.typed +0 -0
  61. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/references.py +0 -0
  62. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/resolver.py +0 -0
  63. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/run_cli.py +0 -0
  64. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/runtime.py +0 -0
  65. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/schema_resources.py +0 -0
  66. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/serialization.py +0 -0
  67. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/specs.py +0 -0
  68. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/timings.py +0 -0
  69. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/toolchains.py +0 -0
  70. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/verification.py +0 -0
  71. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime/wire.py +0 -0
  72. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime.egg-info/dependency_links.txt +0 -0
  73. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime.egg-info/entry_points.txt +0 -0
  74. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime.egg-info/requires.txt +0 -0
  75. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/lean_runtime.egg-info/top_level.txt +0 -0
  76. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/mkdocs.yml +0 -0
  77. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/schemas/cleanup-v1.schema.json +0 -0
  78. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/schemas/comparison-v1.schema.json +0 -0
  79. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/schemas/execution-v1.schema.json +0 -0
  80. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/schemas/inspect-v1.schema.json +0 -0
  81. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/schemas/matrix-v1.schema.json +0 -0
  82. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/schemas/profile-v1.schema.json +0 -0
  83. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/schemas/verify-v1.schema.json +0 -0
  84. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/scripts/run_compatibility.py +0 -0
  85. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/scripts/run_v1_case_study.py +0 -0
  86. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/scripts/smoke_wheel.py +0 -0
  87. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/setup.cfg +0 -0
  88. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/conftest.py +0 -0
  89. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_backend.py +0 -0
  90. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_diagnostics.py +0 -0
  91. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_environment_integration.py +0 -0
  92. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_events_health.py +0 -0
  93. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_facade.py +0 -0
  94. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_frontmatter.py +0 -0
  95. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_git.py +0 -0
  96. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_interactive.py +0 -0
  97. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_lockfiles.py +0 -0
  98. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_multifile.py +0 -0
  99. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_prebuilt_policy.py +0 -0
  100. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_programs.py +0 -0
  101. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_projects.py +0 -0
  102. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_references.py +0 -0
  103. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_run_cli.py +0 -0
  104. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_runtime.py +0 -0
  105. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_schema_resources.py +0 -0
  106. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_schemas.py +0 -0
  107. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_signatures.py +0 -0
  108. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_specs.py +0 -0
  109. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_toolchains.py +0 -0
  110. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_v1_precision.py +0 -0
  111. {lean_runtime-2.0.1 → lean_runtime-2.0.3}/tests/test_verification_inventory.py +0 -0
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.3
4
+
5
+ - Accept portable-copy symlinks whose normalized targets remain inside the
6
+ extracted copy, including common links such as `docs/README.md -> ../README.md`.
7
+ - Continue rejecting absolute or escaping symlink targets, duplicate archive
8
+ members, and later archive writes that traverse an extracted symlink.
9
+
10
+ ## 2.0.2
11
+
12
+ - Remove staged environments safely when Git-for-Windows leaves source pack
13
+ files read-only or briefly locked.
14
+ - Preserve the primary environment-build diagnostic when staging cleanup also
15
+ fails.
16
+ - Show the failed phase, command, exit code, and captured tool output when a
17
+ verbose CLI materialization fails.
18
+
3
19
  ## 2.0.1
4
20
 
5
21
  - Enable Git's long-path support for every Runtime-managed source operation so
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lean-runtime
3
- Version: 2.0.1
3
+ Version: 2.0.3
4
4
  Summary: Run Lean 4 proofs from Python or standalone files
5
5
  Author: Alejandro Radisic
6
6
  License-Expression: Apache-2.0
@@ -0,0 +1,38 @@
1
+ """Internal cross-platform filesystem operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import stat
8
+ import time
9
+ from collections.abc import Callable
10
+ from pathlib import Path
11
+ from types import TracebackType
12
+ from typing import Any
13
+
14
+
15
+ def _retry_writable(
16
+ function: Callable[..., Any],
17
+ path: str,
18
+ _error: tuple[type[BaseException], BaseException, TracebackType | None],
19
+ ) -> None:
20
+ """Clear Git-for-Windows read-only attributes and retry one removal."""
21
+
22
+ os.chmod(path, stat.S_IWRITE)
23
+ function(path)
24
+
25
+
26
+ def remove_tree(path: Path) -> None:
27
+ """Remove a tree containing Git packs, tolerating transient Windows locks."""
28
+
29
+ for attempt in range(4):
30
+ try:
31
+ shutil.rmtree(path, onerror=_retry_writable)
32
+ return
33
+ except PermissionError:
34
+ if not path.exists():
35
+ return
36
+ if attempt == 3:
37
+ raise
38
+ time.sleep(0.05 * (2**attempt))
@@ -20,6 +20,7 @@ from pathlib import Path, PurePosixPath
20
20
  from typing import Any
21
21
 
22
22
  from ._git import git_command
23
+ from ._paths import remove_tree
23
24
  from .backends import Backend
24
25
  from .errors import EnvironmentError
25
26
  from .events import EventEmitter
@@ -202,11 +203,54 @@ def _require_media_type(descriptor: dict[str, Any], expected: str, label: str) -
202
203
 
203
204
  def _safe_name(name: str) -> PurePosixPath:
204
205
  path = PurePosixPath(name)
205
- if not name or path.is_absolute() or ".." in path.parts or "\x00" in name:
206
+ if (
207
+ not name
208
+ or path == PurePosixPath(".")
209
+ or path.is_absolute()
210
+ or ".." in path.parts
211
+ or "\\" in name
212
+ or "\x00" in name
213
+ ):
206
214
  raise EnvironmentError(f"unsafe bundle member path: {name!r}")
207
215
  return path
208
216
 
209
217
 
218
+ def _internal_link_target(member: PurePosixPath, linkname: str) -> PurePosixPath:
219
+ link = PurePosixPath(linkname)
220
+ if not linkname or link.is_absolute() or "\\" in linkname or "\x00" in linkname:
221
+ raise EnvironmentError(f"unsafe bundle symlink: {member.as_posix()!r}")
222
+ parts: list[str] = []
223
+ for part in member.parent.joinpath(link).parts:
224
+ if part in {"", "."}:
225
+ continue
226
+ if part == "..":
227
+ if not parts:
228
+ raise EnvironmentError(f"unsafe bundle symlink: {member.as_posix()!r}")
229
+ parts.pop()
230
+ else:
231
+ parts.append(part)
232
+ return PurePosixPath(*parts) if parts else PurePosixPath(".")
233
+
234
+
235
+ def _ensure_directories(destination: Path, relative: PurePosixPath) -> None:
236
+ current = destination
237
+ for part in relative.parts:
238
+ if part == ".":
239
+ continue
240
+ current = current / part
241
+ if current.is_symlink():
242
+ raise EnvironmentError(
243
+ f"bundle member traverses an extracted symlink: {relative.as_posix()!r}"
244
+ )
245
+ if current.exists():
246
+ if not current.is_dir():
247
+ raise EnvironmentError(
248
+ f"bundle member parent is not a directory: {relative.as_posix()!r}"
249
+ )
250
+ else:
251
+ current.mkdir()
252
+
253
+
210
254
  def _packages_directory(lock: EnvironmentLock) -> PurePosixPath:
211
255
  value = lock.manifest.get("packagesDir", ".lake/packages")
212
256
  if not isinstance(value, str):
@@ -220,6 +264,8 @@ def _packages_directory(lock: EnvironmentLock) -> PurePosixPath:
220
264
  def _extract_layer(data: bytes | Path, destination: Path) -> None:
221
265
  total = 0
222
266
  count = 0
267
+ if destination.is_symlink():
268
+ raise EnvironmentError("bundle extraction destination must not be a symlink")
223
269
  destination.mkdir(parents=True, exist_ok=True)
224
270
  try:
225
271
  if isinstance(data, Path):
@@ -239,10 +285,12 @@ def _extract_layer(data: bytes | Path, destination: Path) -> None:
239
285
  relative = _safe_name(member.name)
240
286
  target = destination.joinpath(*relative.parts)
241
287
  if member.isdir():
242
- target.mkdir(parents=True, exist_ok=True)
288
+ _ensure_directories(destination, relative)
243
289
  target.chmod(member.mode & 0o777)
244
290
  elif member.isfile():
245
- target.parent.mkdir(parents=True, exist_ok=True)
291
+ _ensure_directories(destination, relative.parent)
292
+ if target.exists() or target.is_symlink():
293
+ raise EnvironmentError(f"duplicate bundle member: {member.name!r}")
246
294
  source = archive.extractfile(member)
247
295
  if source is None:
248
296
  raise EnvironmentError(f"bundle member has no content: {member.name}")
@@ -250,11 +298,10 @@ def _extract_layer(data: bytes | Path, destination: Path) -> None:
250
298
  shutil.copyfileobj(source, handle)
251
299
  target.chmod(member.mode & 0o777)
252
300
  elif member.issym():
253
- link = PurePosixPath(member.linkname)
254
- resolved = relative.parent.joinpath(link)
255
- if link.is_absolute() or ".." in resolved.parts:
256
- raise EnvironmentError(f"unsafe bundle symlink: {member.name!r}")
257
- target.parent.mkdir(parents=True, exist_ok=True)
301
+ _internal_link_target(relative, member.linkname)
302
+ _ensure_directories(destination, relative.parent)
303
+ if target.exists() or target.is_symlink():
304
+ raise EnvironmentError(f"duplicate bundle member: {member.name!r}")
258
305
  target.symlink_to(member.linkname)
259
306
  else:
260
307
  raise EnvironmentError(f"unsupported bundle member: {member.name!r}")
@@ -667,7 +714,7 @@ class EnvironmentBundles:
667
714
  stage.replace(destination)
668
715
  finally:
669
716
  if stage.exists():
670
- shutil.rmtree(stage)
717
+ remove_tree(stage)
671
718
  if name:
672
719
  self.store.set_alias(name, environment_id)
673
720
  self.events.emit(
@@ -50,6 +50,19 @@ def _progress(event: RuntimeEvent) -> None:
50
50
  print(f"lean-runtime: {event.kind}{package}: {event.message}", file=sys.stderr)
51
51
 
52
52
 
53
+ def _print_operation_failure(exc: ResolutionError | MaterializationError, *, verbose: bool) -> None:
54
+ print(f"lean-runtime: {exc}", file=sys.stderr)
55
+ if not verbose:
56
+ return
57
+ print(f"phase: {exc.phase}", file=sys.stderr)
58
+ if exc.command:
59
+ print("command: " + " ".join(exc.command), file=sys.stderr)
60
+ if exc.exit_code is not None:
61
+ print(f"exit code: {exc.exit_code}", file=sys.stderr)
62
+ if exc.output:
63
+ print(exc.output.rstrip(), file=sys.stderr)
64
+
65
+
53
66
  def _cli_source_name(path: Path) -> str:
54
67
  if path.is_absolute():
55
68
  return path.name
@@ -643,7 +656,7 @@ def main(argv: list[str] | None = None) -> int:
643
656
  )
644
657
  )
645
658
  else:
646
- print(f"lean-runtime: {exc}", file=sys.stderr)
659
+ _print_operation_failure(exc, verbose=args.verbose)
647
660
  return 2
648
661
  except (LeanRuntimeError, OSError, UnicodeError, ValueError, json.JSONDecodeError) as exc:
649
662
  if getattr(args, "json", False):
@@ -6,7 +6,6 @@ import asyncio
6
6
  import json
7
7
  import os
8
8
  import re
9
- import shutil
10
9
  import subprocess
11
10
  import tempfile
12
11
  import threading
@@ -21,6 +20,7 @@ from pathlib import Path, PurePosixPath
21
20
  from typing import Any, Generic, TextIO, TypeVar, cast
22
21
 
23
22
  from ._git import git_command
23
+ from ._paths import remove_tree
24
24
  from .backends import Backend, BackendResult, InteractiveProcess, InteractiveTextReader
25
25
  from .diagnostics import error_diagnostic, parse_diagnostics
26
26
  from .errors import EnvironmentError, MaterializationError, PolicyError
@@ -559,7 +559,7 @@ class Environment:
559
559
 
560
560
  def cleanup() -> None:
561
561
  if instance.exists():
562
- shutil.rmtree(instance)
562
+ remove_tree(instance)
563
563
  with suppress(OSError):
564
564
  job_parent.rmdir()
565
565
 
@@ -847,7 +847,7 @@ class Environment:
847
847
  return result
848
848
  finally:
849
849
  if instance.exists():
850
- shutil.rmtree(instance)
850
+ remove_tree(instance)
851
851
  with suppress(OSError):
852
852
  job_parent.rmdir()
853
853
 
@@ -1107,7 +1107,10 @@ class EnvironmentManager:
1107
1107
  )
1108
1108
  finally:
1109
1109
  if stage.exists():
1110
- shutil.rmtree(stage)
1110
+ # Preserve a build/materialization diagnostic if cleanup of
1111
+ # Git's read-only pack files also fails on Windows.
1112
+ with suppress(OSError):
1113
+ remove_tree(stage)
1111
1114
 
1112
1115
  def _ensure_sources(self, lock: EnvironmentLock) -> None:
1113
1116
  """Acquire exact locked sources without invoking Lake resolution."""
@@ -10,7 +10,6 @@ from __future__ import annotations
10
10
  import hashlib
11
11
  import os
12
12
  import re
13
- import shutil
14
13
  import tempfile
15
14
  import uuid
16
15
  from collections.abc import Mapping, Sequence
@@ -20,6 +19,7 @@ from datetime import datetime, timezone
20
19
  from pathlib import Path, PurePosixPath
21
20
  from typing import Any, cast
22
21
 
22
+ from ._paths import remove_tree
23
23
  from .backends import Backend, BackendResult, InteractiveProcess
24
24
  from .bundles import (
25
25
  INDEX_MEDIA_TYPE,
@@ -238,7 +238,7 @@ class ReadyProgram:
238
238
 
239
239
  def cleanup() -> None:
240
240
  if instance.exists():
241
- shutil.rmtree(instance)
241
+ remove_tree(instance)
242
242
  with suppress(OSError):
243
243
  job_parent.rmdir()
244
244
 
@@ -347,7 +347,7 @@ class ProgramManager:
347
347
  stage.replace(destination)
348
348
  finally:
349
349
  if stage.exists():
350
- shutil.rmtree(stage)
350
+ remove_tree(stage)
351
351
  self.events.emit(
352
352
  "program.created", "Created executable program", program_id=manifest.program_id
353
353
  )
@@ -534,7 +534,7 @@ class ProgramManager:
534
534
  stage.replace(destination)
535
535
  finally:
536
536
  if stage.exists():
537
- shutil.rmtree(stage)
537
+ remove_tree(stage)
538
538
  return self.open(manifest.program_id)
539
539
 
540
540
 
@@ -18,6 +18,7 @@ from pathlib import Path
18
18
  from typing import Any
19
19
 
20
20
  from ._git import git_command
21
+ from ._paths import remove_tree
21
22
  from .errors import EnvironmentError
22
23
  from .lockfiles import EnvironmentLock
23
24
  from .locking import FileLock
@@ -348,7 +349,7 @@ class EnvironmentStore:
348
349
  # Failed Git checkouts can leave locked pack files on
349
350
  # Windows. Cleanup must not mask the actionable Git error.
350
351
  with suppress(OSError):
351
- shutil.rmtree(stage)
352
+ remove_tree(stage)
352
353
  return destination
353
354
 
354
355
  def environment_path(self, environment_id: str) -> Path:
@@ -556,7 +557,7 @@ class EnvironmentStore:
556
557
  ):
557
558
  retained.append(path.name)
558
559
  continue
559
- shutil.rmtree(path)
560
+ remove_tree(path)
560
561
  usage.unlink(missing_ok=True)
561
562
  removed.append(path.name)
562
563
  return CleanupReport(tuple(candidates), tuple(removed), tuple(retained), dry_run)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lean-runtime
3
- Version: 2.0.1
3
+ Version: 2.0.3
4
4
  Summary: Run Lean 4 proofs from Python or standalone files
5
5
  Author: Alejandro Radisic
6
6
  License-Expression: Apache-2.0
@@ -26,6 +26,7 @@ examples/mathlib.toml
26
26
  lean_runtime/__init__.py
27
27
  lean_runtime/__main__.py
28
28
  lean_runtime/_git.py
29
+ lean_runtime/_paths.py
29
30
  lean_runtime/backends.py
30
31
  lean_runtime/bundles.py
31
32
  lean_runtime/cli.py
@@ -91,6 +92,7 @@ tests/test_git.py
91
92
  tests/test_interactive.py
92
93
  tests/test_lockfiles.py
93
94
  tests/test_multifile.py
95
+ tests/test_paths.py
94
96
  tests/test_prebuilt_policy.py
95
97
  tests/test_programs.py
96
98
  tests/test_projects.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lean-runtime"
7
- version = "2.0.1"
7
+ version = "2.0.3"
8
8
  description = "Run Lean 4 proofs from Python or standalone files"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import hashlib
4
4
  import http.server
5
+ import io
5
6
  import json
6
7
  import os
7
8
  import shutil
@@ -15,7 +16,7 @@ from pathlib import Path
15
16
  import pytest
16
17
 
17
18
  from lean_runtime import EnvironmentError, EnvironmentLock, LockedPackage, Runtime
18
- from lean_runtime.bundles import _oci_archive
19
+ from lean_runtime.bundles import _extract_layer, _oci_archive
19
20
  from lean_runtime.environments import ENVIRONMENT_SCHEMA
20
21
  from lean_runtime.oci import OCIEnvironmentPublisher, OCIRepository
21
22
  from lean_runtime.store import environment_identity, platform_record, source_snapshot_digest
@@ -119,6 +120,57 @@ def _archive_entries(path: Path) -> dict[str, bytes]:
119
120
  return result
120
121
 
121
122
 
123
+ def _layer(*members: tuple[str, str, bytes | str]) -> bytes:
124
+ output = io.BytesIO()
125
+ with tarfile.open(fileobj=output, mode="w:gz", format=tarfile.PAX_FORMAT) as archive:
126
+ for kind, name, value in members:
127
+ info = tarfile.TarInfo(name)
128
+ info.mode = 0o644
129
+ if kind == "file":
130
+ assert isinstance(value, bytes)
131
+ info.size = len(value)
132
+ archive.addfile(info, io.BytesIO(value))
133
+ else:
134
+ assert kind == "symlink" and isinstance(value, str)
135
+ info.type = tarfile.SYMTYPE
136
+ info.linkname = value
137
+ archive.addfile(info)
138
+ return output.getvalue()
139
+
140
+
141
+ @pytest.mark.skipif(os.name == "nt", reason="Windows runners may not permit symlink creation")
142
+ def test_layer_extract_accepts_internal_parent_symlink(tmp_path: Path) -> None:
143
+ layer = _layer(
144
+ ("file", "README.md", b"Batteries\n"),
145
+ ("symlink", "docs/README.md", "../README.md"),
146
+ )
147
+
148
+ _extract_layer(layer, tmp_path / "output")
149
+
150
+ link = tmp_path / "output" / "docs" / "README.md"
151
+ assert link.is_symlink()
152
+ assert link.read_text() == "Batteries\n"
153
+
154
+
155
+ @pytest.mark.skipif(os.name == "nt", reason="Windows runners may not permit symlink creation")
156
+ def test_layer_extract_rejects_symlink_outside_destination(tmp_path: Path) -> None:
157
+ layer = _layer(("symlink", "docs/README.md", "../../outside"))
158
+
159
+ with pytest.raises(EnvironmentError, match="unsafe bundle symlink"):
160
+ _extract_layer(layer, tmp_path / "output")
161
+
162
+
163
+ @pytest.mark.skipif(os.name == "nt", reason="Windows runners may not permit symlink creation")
164
+ def test_layer_extract_rejects_later_write_through_symlink(tmp_path: Path) -> None:
165
+ layer = _layer(
166
+ ("symlink", "redirect", "inside"),
167
+ ("file", "redirect/payload", b"must not be written"),
168
+ )
169
+
170
+ with pytest.raises(EnvironmentError, match="traverses an extracted symlink"):
171
+ _extract_layer(layer, tmp_path / "output")
172
+
173
+
122
174
  def test_bundle_export_is_deterministic_and_imports_into_fresh_store(tmp_path: Path) -> None:
123
175
  producer, environment_id, lock = _published_runtime(tmp_path / "producer")
124
176
  first = tmp_path / "first.oci.tar.gz"
@@ -4,7 +4,8 @@ import json
4
4
  from pathlib import Path
5
5
 
6
6
  from lean_runtime.bundles import PortableCopyInfo
7
- from lean_runtime.cli import main, parser
7
+ from lean_runtime.cli import _print_operation_failure, main, parser
8
+ from lean_runtime.errors import MaterializationError
8
9
  from lean_runtime.models import ExecutionResult
9
10
  from lean_runtime.verification import VerificationCheck, VerificationReport
10
11
 
@@ -34,6 +35,26 @@ def test_removed_v1_commands_are_absent() -> None:
34
35
  assert commands.isdisjoint(removed)
35
36
 
36
37
 
38
+ def test_verbose_materialization_failure_includes_tool_output(capsys) -> None:
39
+ failure = MaterializationError(
40
+ "environment build failed",
41
+ phase="build",
42
+ command=("lake", "build"),
43
+ exit_code=1,
44
+ output="specific compiler failure\n",
45
+ )
46
+
47
+ _print_operation_failure(failure, verbose=True)
48
+
49
+ assert capsys.readouterr().err == (
50
+ "lean-runtime: environment build failed\n"
51
+ "phase: build\n"
52
+ "command: lake build\n"
53
+ "exit code: 1\n"
54
+ "specific compiler failure\n"
55
+ )
56
+
57
+
37
58
  def test_check_file_cli_json_result(monkeypatch, tmp_path: Path, capsys) -> None:
38
59
  source = tmp_path / "Main.lean"
39
60
  source.write_text("example : True := by trivial")
@@ -0,0 +1,25 @@
1
+ from pathlib import Path
2
+
3
+ from lean_runtime._paths import remove_tree
4
+
5
+
6
+ def test_remove_tree_retries_transient_permission_errors(tmp_path: Path, monkeypatch) -> None:
7
+ target = tmp_path / "tree"
8
+ target.mkdir()
9
+ calls = 0
10
+
11
+ def flaky_rmtree(path: Path, *, onerror) -> None:
12
+ nonlocal calls
13
+ calls += 1
14
+ if calls == 1:
15
+ raise PermissionError("Git pack is briefly locked")
16
+ assert onerror is not None
17
+ path.rmdir()
18
+
19
+ monkeypatch.setattr("lean_runtime._paths.shutil.rmtree", flaky_rmtree)
20
+ monkeypatch.setattr("lean_runtime._paths.time.sleep", lambda _seconds: None)
21
+
22
+ remove_tree(target)
23
+
24
+ assert calls == 2
25
+ assert not target.exists()
@@ -217,11 +217,13 @@ def test_source_snapshot_preserves_git_error_when_cleanup_also_fails(
217
217
  Path(command[-1]).mkdir()
218
218
  return subprocess.CompletedProcess(command, 1, "", "Filename too long")
219
219
 
220
- def failed_cleanup(_path: Path) -> None:
220
+ def failed_cleanup(_path: Path, *, onerror) -> None:
221
+ assert onerror is not None
221
222
  raise PermissionError("pack file is locked")
222
223
 
223
224
  monkeypatch.setattr("lean_runtime.store.subprocess.run", failed_clone)
224
- monkeypatch.setattr("lean_runtime.store.shutil.rmtree", failed_cleanup)
225
+ monkeypatch.setattr("lean_runtime._paths.shutil.rmtree", failed_cleanup)
226
+ monkeypatch.setattr("lean_runtime._paths.time.sleep", lambda _seconds: None)
225
227
 
226
228
  with pytest.raises(EnvironmentError, match="Filename too long"):
227
229
  store.publish_source(checkout, source_id, metadata)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes