lean-interact 0.4.0__tar.gz → 0.4.1__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.
- {lean_interact-0.4.0 → lean_interact-0.4.1}/.github/workflows/ci.yml +2 -6
- {lean_interact-0.4.0 → lean_interact-0.4.1}/PKG-INFO +10 -11
- {lean_interact-0.4.0 → lean_interact-0.4.1}/README.md +8 -10
- {lean_interact-0.4.0 → lean_interact-0.4.1}/examples/proof_generation_and_autoformalization.py +1 -1
- {lean_interact-0.4.0 → lean_interact-0.4.1}/examples/type_check.py +1 -1
- {lean_interact-0.4.0 → lean_interact-0.4.1}/pyproject.toml +2 -1
- {lean_interact-0.4.0 → lean_interact-0.4.1}/src/lean_interact/config.py +88 -81
- {lean_interact-0.4.0 → lean_interact-0.4.1}/src/lean_interact/interface.py +3 -3
- {lean_interact-0.4.0 → lean_interact-0.4.1}/src/lean_interact/server.py +45 -34
- lean_interact-0.4.1/tests/test_concurrency.py +163 -0
- {lean_interact-0.4.0 → lean_interact-0.4.1}/tests/test_server.py +10 -6
- {lean_interact-0.4.0 → lean_interact-0.4.1}/uv.lock +12 -1
- {lean_interact-0.4.0 → lean_interact-0.4.1}/.devcontainer/devcontainer.json +0 -0
- {lean_interact-0.4.0 → lean_interact-0.4.1}/.github/workflows/publish-to-pypi.yml +0 -0
- {lean_interact-0.4.0 → lean_interact-0.4.1}/.gitignore +0 -0
- {lean_interact-0.4.0 → lean_interact-0.4.1}/LICENSE +0 -0
- {lean_interact-0.4.0 → lean_interact-0.4.1}/examples/beq_plus.py +0 -0
- {lean_interact-0.4.0 → lean_interact-0.4.1}/src/lean_interact/__init__.py +0 -0
- {lean_interact-0.4.0 → lean_interact-0.4.1}/src/lean_interact/utils.py +0 -0
- {lean_interact-0.4.0 → lean_interact-0.4.1}/tests/test_utils.py +0 -0
|
@@ -9,12 +9,6 @@ jobs:
|
|
|
9
9
|
- name: Checkout
|
|
10
10
|
uses: actions/checkout@v3
|
|
11
11
|
|
|
12
|
-
- name: Install elan
|
|
13
|
-
run: |
|
|
14
|
-
set -o pipefail
|
|
15
|
-
curl https://raw.githubusercontent.com/leanprover/elan/master/elan-init.sh -sSf | sh -s -- -y --default-toolchain none
|
|
16
|
-
echo "$HOME/.elan/bin" >> $GITHUB_PATH
|
|
17
|
-
|
|
18
12
|
- name: Set up Python
|
|
19
13
|
uses: actions/setup-python@v4
|
|
20
14
|
with:
|
|
@@ -24,6 +18,8 @@ jobs:
|
|
|
24
18
|
run: |
|
|
25
19
|
pip install --upgrade pip
|
|
26
20
|
pip install .
|
|
21
|
+
install-lean
|
|
22
|
+
echo "$HOME/.elan/bin" >> $GITHUB_PATH
|
|
27
23
|
|
|
28
24
|
- name: Run tests
|
|
29
25
|
run: python -m unittest discover -s ./tests
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lean-interact
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: LeanInteract is a Python package that allows you to interact with the Lean theorem prover.
|
|
5
5
|
Author-email: Auguste Poiroux <auguste.poiroux@epfl.ch>
|
|
6
6
|
License: MIT License
|
|
@@ -27,6 +27,7 @@ License: MIT License
|
|
|
27
27
|
License-File: LICENSE
|
|
28
28
|
Keywords: Lean,REPL,autoformalization,theorem proving
|
|
29
29
|
Requires-Python: >=3.10
|
|
30
|
+
Requires-Dist: filelock>=3.18.0
|
|
30
31
|
Requires-Dist: gitpython>=3.1.44
|
|
31
32
|
Requires-Dist: pexpect>=4.9.0
|
|
32
33
|
Requires-Dist: psutil>=6.1.0
|
|
@@ -97,11 +98,11 @@ Requirements:
|
|
|
97
98
|
|
|
98
99
|
## Script examples
|
|
99
100
|
|
|
100
|
-
In the `examples` directory, you will find a few scripts demonstrating how to use LeanInteract.
|
|
101
|
+
In the [`examples`](examples) directory, you will find a few scripts demonstrating how to use LeanInteract.
|
|
101
102
|
|
|
102
|
-
- `proof_generation_and_autoformalization.py
|
|
103
|
-
- `beq_plus.py
|
|
104
|
-
- `type_check.py
|
|
103
|
+
- [`proof_generation_and_autoformalization.py`](examples/proof_generation_and_autoformalization.py): use [DeepSeek-Prover-V1.5](https://arxiv.org/abs/2408.08152), [Goedel-Prover](https://goedel-lm.github.io/), and other models on [MiniF2F](https://github.com/yangky11/miniF2F-lean4) and [ProofNet#](https://huggingface.co/datasets/PAug/ProofNetSharp) benchmarks.
|
|
104
|
+
- [`beq_plus.py`](examples/beq_plus.py): run the autoformalization [BEq+](https://arxiv.org/abs/2406.07222) metric on the [ProofNetVerif](https://huggingface.co/datasets/PAug/ProofNetVerif) benchmark.
|
|
105
|
+
- [`type_check.py`](examples/type_check.py): optimize type checking using environment states.
|
|
105
106
|
|
|
106
107
|
## Usage
|
|
107
108
|
|
|
@@ -334,7 +335,7 @@ CommandResponse(
|
|
|
334
335
|
|
|
335
336
|
#### Fine-grained temporary project
|
|
336
337
|
|
|
337
|
-
For more control over the temporary project, you can use `TemporaryProject` to specify the content of the lakefile.
|
|
338
|
+
For more control over the temporary project, you can use `TemporaryProject` to specify the content of the lakefile (`.lean` format).
|
|
338
339
|
|
|
339
340
|
```python
|
|
340
341
|
from lean_interact import TemporaryProject
|
|
@@ -392,7 +393,7 @@ response = server.run(FileCommand(path="myfile.lean", root_goals=True))
|
|
|
392
393
|
|
|
393
394
|
### ProofStep
|
|
394
395
|
|
|
395
|
-
Work with
|
|
396
|
+
Work with proofs step by step using tactics:
|
|
396
397
|
|
|
397
398
|
```python
|
|
398
399
|
from lean_interact import ProofStep
|
|
@@ -401,7 +402,7 @@ from lean_interact import ProofStep
|
|
|
401
402
|
response = server.run(ProofStep(proof_state=0, tactic="intro h"))
|
|
402
403
|
|
|
403
404
|
# Apply multiple tactics at once
|
|
404
|
-
response = server.run(ProofStep(proof_state=0, tactic="
|
|
405
|
+
response = server.run(ProofStep(proof_state=0, tactic="(\nintro h\nexact h)"))
|
|
405
406
|
```
|
|
406
407
|
|
|
407
408
|
### Environment Pickling
|
|
@@ -489,15 +490,13 @@ Contributions are welcome! Here's how you can help:
|
|
|
489
490
|
4. Push to the branch: `git push origin feature-name`
|
|
490
491
|
5. Submit a pull request
|
|
491
492
|
|
|
492
|
-
Please ensure your code includes appropriate tests.
|
|
493
|
-
|
|
494
493
|
## Citation
|
|
495
494
|
|
|
496
495
|
If you use LeanInteract in your research, please cite it as follows:
|
|
497
496
|
|
|
498
497
|
```bibtex
|
|
499
498
|
@software{leaninteract,
|
|
500
|
-
author = {Poiroux, Auguste},
|
|
499
|
+
author = {Poiroux, Auguste and Kuncak, Viktor and Bosselut, Antoine},
|
|
501
500
|
title = {LeanInteract: A Python Interface for Lean 4},
|
|
502
501
|
url = {https://github.com/augustepoiroux/lean-interact},
|
|
503
502
|
year = {2025}
|
|
@@ -60,11 +60,11 @@ Requirements:
|
|
|
60
60
|
|
|
61
61
|
## Script examples
|
|
62
62
|
|
|
63
|
-
In the `examples` directory, you will find a few scripts demonstrating how to use LeanInteract.
|
|
63
|
+
In the [`examples`](examples) directory, you will find a few scripts demonstrating how to use LeanInteract.
|
|
64
64
|
|
|
65
|
-
- `proof_generation_and_autoformalization.py
|
|
66
|
-
- `beq_plus.py
|
|
67
|
-
- `type_check.py
|
|
65
|
+
- [`proof_generation_and_autoformalization.py`](examples/proof_generation_and_autoformalization.py): use [DeepSeek-Prover-V1.5](https://arxiv.org/abs/2408.08152), [Goedel-Prover](https://goedel-lm.github.io/), and other models on [MiniF2F](https://github.com/yangky11/miniF2F-lean4) and [ProofNet#](https://huggingface.co/datasets/PAug/ProofNetSharp) benchmarks.
|
|
66
|
+
- [`beq_plus.py`](examples/beq_plus.py): run the autoformalization [BEq+](https://arxiv.org/abs/2406.07222) metric on the [ProofNetVerif](https://huggingface.co/datasets/PAug/ProofNetVerif) benchmark.
|
|
67
|
+
- [`type_check.py`](examples/type_check.py): optimize type checking using environment states.
|
|
68
68
|
|
|
69
69
|
## Usage
|
|
70
70
|
|
|
@@ -297,7 +297,7 @@ CommandResponse(
|
|
|
297
297
|
|
|
298
298
|
#### Fine-grained temporary project
|
|
299
299
|
|
|
300
|
-
For more control over the temporary project, you can use `TemporaryProject` to specify the content of the lakefile.
|
|
300
|
+
For more control over the temporary project, you can use `TemporaryProject` to specify the content of the lakefile (`.lean` format).
|
|
301
301
|
|
|
302
302
|
```python
|
|
303
303
|
from lean_interact import TemporaryProject
|
|
@@ -355,7 +355,7 @@ response = server.run(FileCommand(path="myfile.lean", root_goals=True))
|
|
|
355
355
|
|
|
356
356
|
### ProofStep
|
|
357
357
|
|
|
358
|
-
Work with
|
|
358
|
+
Work with proofs step by step using tactics:
|
|
359
359
|
|
|
360
360
|
```python
|
|
361
361
|
from lean_interact import ProofStep
|
|
@@ -364,7 +364,7 @@ from lean_interact import ProofStep
|
|
|
364
364
|
response = server.run(ProofStep(proof_state=0, tactic="intro h"))
|
|
365
365
|
|
|
366
366
|
# Apply multiple tactics at once
|
|
367
|
-
response = server.run(ProofStep(proof_state=0, tactic="
|
|
367
|
+
response = server.run(ProofStep(proof_state=0, tactic="(\nintro h\nexact h)"))
|
|
368
368
|
```
|
|
369
369
|
|
|
370
370
|
### Environment Pickling
|
|
@@ -452,15 +452,13 @@ Contributions are welcome! Here's how you can help:
|
|
|
452
452
|
4. Push to the branch: `git push origin feature-name`
|
|
453
453
|
5. Submit a pull request
|
|
454
454
|
|
|
455
|
-
Please ensure your code includes appropriate tests.
|
|
456
|
-
|
|
457
455
|
## Citation
|
|
458
456
|
|
|
459
457
|
If you use LeanInteract in your research, please cite it as follows:
|
|
460
458
|
|
|
461
459
|
```bibtex
|
|
462
460
|
@software{leaninteract,
|
|
463
|
-
author = {Poiroux, Auguste},
|
|
461
|
+
author = {Poiroux, Auguste and Kuncak, Viktor and Bosselut, Antoine},
|
|
464
462
|
title = {LeanInteract: A Python Interface for Lean 4},
|
|
465
463
|
url = {https://github.com/augustepoiroux/lean-interact},
|
|
466
464
|
year = {2025}
|
{lean_interact-0.4.0 → lean_interact-0.4.1}/examples/proof_generation_and_autoformalization.py
RENAMED
|
@@ -97,7 +97,7 @@ def load_proofnetsharp_dataset(split: Literal["valid", "test"] = "valid") -> lis
|
|
|
97
97
|
|
|
98
98
|
def check_context_proofs(args: tuple[int, LeanREPLConfig, int, tuple[str, str, list[str]]]) -> tuple[int, str | None]:
|
|
99
99
|
"""
|
|
100
|
-
Filter function to check if at least one proof is valid for a given context and
|
|
100
|
+
Filter function to check if at least one proof is valid for a given context and declaration to prove.
|
|
101
101
|
"""
|
|
102
102
|
idx, repl_config, timeout_per_proof, context_proofs = args
|
|
103
103
|
context_code, formalization_code, proofs = context_proofs
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "lean-interact"
|
|
3
|
-
version = "0.4.
|
|
3
|
+
version = "0.4.1"
|
|
4
4
|
description = "LeanInteract is a Python package that allows you to interact with the Lean theorem prover."
|
|
5
5
|
keywords = ["Lean", "theorem proving", "autoformalization", "REPL"]
|
|
6
6
|
license = { file = "LICENSE" }
|
|
@@ -8,6 +8,7 @@ readme = "README.md"
|
|
|
8
8
|
authors = [{ name = "Auguste Poiroux", email = "auguste.poiroux@epfl.ch" }]
|
|
9
9
|
requires-python = ">=3.10"
|
|
10
10
|
dependencies = [
|
|
11
|
+
"filelock>=3.18.0",
|
|
11
12
|
"gitpython>=3.1.44",
|
|
12
13
|
"pexpect>=4.9.0",
|
|
13
14
|
"psutil>=6.1.0",
|
|
@@ -5,6 +5,7 @@ import subprocess
|
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
from typing import Literal
|
|
7
7
|
|
|
8
|
+
from filelock import FileLock
|
|
8
9
|
from git import GitCommandError, Repo
|
|
9
10
|
|
|
10
11
|
from lean_interact.utils import (
|
|
@@ -55,8 +56,10 @@ class LocalProject(BaseProject):
|
|
|
55
56
|
"""Instantiate the local project."""
|
|
56
57
|
stdout = None if verbose else subprocess.DEVNULL
|
|
57
58
|
stderr = None if verbose else subprocess.DEVNULL
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
|
|
60
|
+
with FileLock(f"{self.directory}.lock"):
|
|
61
|
+
# check that the project is buildable
|
|
62
|
+
subprocess.run(["lake", "build"], cwd=self.directory, check=True, stdout=stdout, stderr=stderr)
|
|
60
63
|
|
|
61
64
|
|
|
62
65
|
@dataclass(frozen=True)
|
|
@@ -77,18 +80,19 @@ class GitProject(BaseProject):
|
|
|
77
80
|
|
|
78
81
|
project_dir = self._get_directory(cache_dir)
|
|
79
82
|
|
|
80
|
-
|
|
81
|
-
|
|
83
|
+
with FileLock(f"{project_dir}.lock"):
|
|
84
|
+
# check if the git repository is already cloned
|
|
85
|
+
repo = Repo(project_dir) if os.path.exists(project_dir) else Repo.clone_from(self.url, project_dir)
|
|
82
86
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
if self.rev:
|
|
88
|
+
repo.git.checkout(self.rev)
|
|
89
|
+
else:
|
|
90
|
+
repo.git.pull()
|
|
87
91
|
|
|
88
|
-
|
|
92
|
+
repo.submodule_update(init=True, recursive=True)
|
|
89
93
|
|
|
90
|
-
|
|
91
|
-
|
|
94
|
+
# check that the project is buildable
|
|
95
|
+
subprocess.run(["lake", "build"], cwd=project_dir, check=True, stdout=stdout, stderr=stderr)
|
|
92
96
|
|
|
93
97
|
|
|
94
98
|
@dataclass(frozen=True)
|
|
@@ -111,26 +115,28 @@ class BaseTempProject(BaseProject):
|
|
|
111
115
|
|
|
112
116
|
tmp_project_dir = self._get_directory(cache_dir, lean_version)
|
|
113
117
|
|
|
114
|
-
#
|
|
115
|
-
|
|
116
|
-
#
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
cmd_init = ["lake", f"+{lean_version}", "init", "dummy", "exe"]
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
118
|
+
# Lock the temporary project directory during setup
|
|
119
|
+
with FileLock(f"{tmp_project_dir}.lock"):
|
|
120
|
+
# check if the Lean project is already built
|
|
121
|
+
if not os.path.exists(os.path.join(tmp_project_dir, "lakefile.lean")):
|
|
122
|
+
# clean the content of the folder in case of a previous aborted build
|
|
123
|
+
shutil.rmtree(tmp_project_dir, ignore_errors=True)
|
|
124
|
+
os.makedirs(tmp_project_dir, exist_ok=True)
|
|
125
|
+
|
|
126
|
+
# initialize the Lean project
|
|
127
|
+
cmd_init = ["lake", f"+{lean_version}", "init", "dummy", "exe.lean"]
|
|
128
|
+
if lean_version.startswith("v4") and int(lean_version.split(".")[1]) <= 7:
|
|
129
|
+
cmd_init = ["lake", f"+{lean_version}", "init", "dummy", "exe"]
|
|
130
|
+
subprocess.run(cmd_init, cwd=tmp_project_dir, check=True, stdout=stdout, stderr=stderr)
|
|
131
|
+
|
|
132
|
+
# Create or modify the lakefile
|
|
133
|
+
self._modify_lakefile(tmp_project_dir, lean_version)
|
|
134
|
+
|
|
135
|
+
logger.info("Preparing Lean environment with dependencies (may take a while the first time)...")
|
|
136
|
+
subprocess.run(["lake", "update"], cwd=tmp_project_dir, check=True, stdout=stdout, stderr=stderr)
|
|
137
|
+
# in case mathlib is used as a dependency, we try to get the cache
|
|
138
|
+
subprocess.run(["lake", "exe", "cache", "get"], cwd=tmp_project_dir, stdout=stdout, stderr=stderr)
|
|
139
|
+
subprocess.run(["lake", "build"], cwd=tmp_project_dir, check=True, stdout=stdout, stderr=stderr)
|
|
134
140
|
|
|
135
141
|
def _get_hash_content(self, lean_version: str) -> str:
|
|
136
142
|
"""Return a unique hash for the project content."""
|
|
@@ -263,62 +269,63 @@ class LeanREPLConfig:
|
|
|
263
269
|
def _setup_repl(self) -> None:
|
|
264
270
|
assert isinstance(self.repl_rev, str)
|
|
265
271
|
|
|
266
|
-
#
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
272
|
+
# Lock the clean REPL directory during setup to prevent race conditions
|
|
273
|
+
with FileLock(f"{self.cache_clean_repl_dir}.lock", timeout=300):
|
|
274
|
+
# check if the repl is already cloned
|
|
275
|
+
if not os.path.exists(self.cache_clean_repl_dir):
|
|
276
|
+
os.makedirs(self.cache_clean_repl_dir, exist_ok=True)
|
|
277
|
+
Repo.clone_from(self.repl_git, self.cache_clean_repl_dir)
|
|
270
278
|
|
|
271
|
-
|
|
272
|
-
try:
|
|
273
|
-
repo.git.checkout(self.repl_rev)
|
|
274
|
-
except GitCommandError:
|
|
275
|
-
repo.remote().pull()
|
|
279
|
+
repo = Repo(self.cache_clean_repl_dir)
|
|
276
280
|
try:
|
|
277
281
|
repo.git.checkout(self.repl_rev)
|
|
278
|
-
except GitCommandError
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
282
|
+
except GitCommandError:
|
|
283
|
+
repo.remote().pull()
|
|
284
|
+
try:
|
|
285
|
+
repo.git.checkout(self.repl_rev)
|
|
286
|
+
except GitCommandError as e:
|
|
287
|
+
raise ValueError(f"Lean REPL version `{self.repl_rev}` is not available.") from e
|
|
288
|
+
|
|
289
|
+
# check if the Lean version is available in the repository
|
|
290
|
+
lean_versions_sha = self._get_available_lean_versions_sha()
|
|
291
|
+
lean_versions_sha_dict = dict(lean_versions_sha)
|
|
292
|
+
if not lean_versions_sha:
|
|
293
|
+
raise ValueError("No Lean versions are available in the Lean REPL repository.")
|
|
294
|
+
if self.lean_version is None:
|
|
295
|
+
if self.project is None or isinstance(self.project, (TemporaryProject, TempRequireProject)):
|
|
296
|
+
self.lean_version = lean_versions_sha[-1][0]
|
|
297
|
+
elif isinstance(self.project, (LocalProject, GitProject)):
|
|
298
|
+
# get the Lean version from the project
|
|
299
|
+
inferred_ver = get_project_lean_version(self.project._get_directory(self.cache_dir))
|
|
300
|
+
self.lean_version = inferred_ver if inferred_ver else lean_versions_sha[-1][0]
|
|
301
|
+
if self.lean_version not in lean_versions_sha_dict:
|
|
302
|
+
raise ValueError(
|
|
303
|
+
f"Lean version `{self.lean_version}` is required but not available in the Lean REPL repository."
|
|
304
|
+
)
|
|
301
305
|
|
|
302
306
|
# check if the repl revision is already in the cache
|
|
303
307
|
self._cache_repl_dir = os.path.join(self.cache_dir, self.repo_name, f"repl_{self.repl_rev}_{self.lean_version}")
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
308
|
+
|
|
309
|
+
# Lock the version-specific REPL directory during setup
|
|
310
|
+
with FileLock(f"{self._cache_repl_dir}.lock", timeout=300): # 5 minute timeout for long-running operations
|
|
311
|
+
if not os.path.exists(self._cache_repl_dir):
|
|
312
|
+
# copy the repository to the version directory and checkout the required revision
|
|
313
|
+
os.makedirs(self._cache_repl_dir, exist_ok=True)
|
|
314
|
+
shutil.copytree(self.cache_clean_repl_dir, self._cache_repl_dir, dirs_exist_ok=True)
|
|
315
|
+
cached_repo = Repo(self._cache_repl_dir)
|
|
316
|
+
cached_repo.git.checkout(lean_versions_sha_dict[self.lean_version])
|
|
317
|
+
|
|
318
|
+
# check that the lean version is correct
|
|
319
|
+
assert self.lean_version == get_project_lean_version(self._cache_repl_dir), (
|
|
320
|
+
f"An error occured while preparing the Lean REPL. The requested Lean version `{self.lean_version}` "
|
|
321
|
+
f"does not match the fetched Lean version in the repository `{get_project_lean_version(self._cache_repl_dir)}`."
|
|
322
|
+
f"Please open an issue on GitHub if you think this is a bug."
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# build the repl
|
|
326
|
+
subprocess.run(
|
|
327
|
+
["lake", "build"], cwd=self._cache_repl_dir, check=True, stdout=self._stdout, stderr=self._stderr
|
|
328
|
+
)
|
|
322
329
|
|
|
323
330
|
def _get_available_lean_versions_sha(self) -> list[tuple[str, str]]:
|
|
324
331
|
"""
|
|
@@ -67,7 +67,7 @@ class FileCommand(BaseREPLQuery, CommandOptions):
|
|
|
67
67
|
path: Annotated[str, Field(min_length=1)]
|
|
68
68
|
|
|
69
69
|
|
|
70
|
-
class ProofStep(BaseREPLQuery
|
|
70
|
+
class ProofStep(BaseREPLQuery):
|
|
71
71
|
"""Proof step in the REPL.
|
|
72
72
|
Attributes:
|
|
73
73
|
proof_state: The proof state to start from.
|
|
@@ -161,8 +161,8 @@ class Sorry(REPLBaseModel):
|
|
|
161
161
|
proof_state: The proof state associated to the sorry.
|
|
162
162
|
"""
|
|
163
163
|
|
|
164
|
-
start_pos: Annotated[Pos, Field(alias="pos")]
|
|
165
|
-
end_pos: Annotated[Pos, Field(alias="endPos")]
|
|
164
|
+
start_pos: Annotated[Pos | None, Field(alias="pos")] = None
|
|
165
|
+
end_pos: Annotated[Pos | None, Field(alias="endPos")] = None
|
|
166
166
|
goal: str
|
|
167
167
|
proof_state: Annotated[int | None, Field(alias="proofState")] = None
|
|
168
168
|
|
|
@@ -9,6 +9,7 @@ from typing import overload
|
|
|
9
9
|
|
|
10
10
|
import pexpect
|
|
11
11
|
import psutil
|
|
12
|
+
from filelock import FileLock
|
|
12
13
|
|
|
13
14
|
from lean_interact.config import LeanREPLConfig
|
|
14
15
|
from lean_interact.interface import (
|
|
@@ -132,10 +133,10 @@ class LeanServer:
|
|
|
132
133
|
self.kill()
|
|
133
134
|
raise ConnectionAbortedError(
|
|
134
135
|
"The Lean server closed unexpectedly. Possible reasons (not exhaustive):\n"
|
|
135
|
-
"- An uncaught exception in the Lean REPL (for example, an
|
|
136
|
+
"- An uncaught exception in the Lean REPL (for example, an inexistent file has been requested)\n"
|
|
136
137
|
"- Not enough memory and/or compute available\n"
|
|
137
138
|
"- The cached Lean REPL is corrupted. In this case, clear the cache"
|
|
138
|
-
" using the `
|
|
139
|
+
" using the `clear-lean-cache` command."
|
|
139
140
|
) from e
|
|
140
141
|
|
|
141
142
|
return self._parse_repl_output(raw_output, verbose)
|
|
@@ -260,28 +261,31 @@ class AutoLeanServer(LeanServer):
|
|
|
260
261
|
Reload the session cache. This method should be called only after a restart of the Lean REPL.
|
|
261
262
|
"""
|
|
262
263
|
for state_data in self._restart_persistent_session_cache.values():
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
f"Could not reload the session cache. The Lean server returned an error: {result.message}"
|
|
276
|
-
)
|
|
277
|
-
elif isinstance(result, CommandResponse):
|
|
278
|
-
state_data.repl_id = result.env
|
|
279
|
-
elif isinstance(result, ProofStepResponse):
|
|
280
|
-
state_data.repl_id = result.proof_state
|
|
281
|
-
else:
|
|
282
|
-
raise ValueError(
|
|
283
|
-
f"Could not reload the session cache. The Lean server returned an unexpected response: {result}"
|
|
264
|
+
# Use file lock when accessing the pickle file to prevent cache invalidation
|
|
265
|
+
# from multiple concurrent processes
|
|
266
|
+
with FileLock(f"{state_data.pickle_file}.lock", timeout=60):
|
|
267
|
+
if state_data.is_proof_state:
|
|
268
|
+
cmd = UnpickleProofState(unpickle_proof_state_from=state_data.pickle_file, env=state_data.repl_id)
|
|
269
|
+
else:
|
|
270
|
+
cmd = UnpickleEnvironment(unpickle_env_from=state_data.pickle_file)
|
|
271
|
+
result = self.run(
|
|
272
|
+
cmd,
|
|
273
|
+
verbose=verbose,
|
|
274
|
+
timeout=DEFAULT_TIMEOUT,
|
|
275
|
+
add_to_session_cache=False,
|
|
284
276
|
)
|
|
277
|
+
if isinstance(result, LeanError):
|
|
278
|
+
raise ValueError(
|
|
279
|
+
f"Could not reload the session cache. The Lean server returned an error: {result.message}"
|
|
280
|
+
)
|
|
281
|
+
elif isinstance(result, CommandResponse):
|
|
282
|
+
state_data.repl_id = result.env
|
|
283
|
+
elif isinstance(result, ProofStepResponse):
|
|
284
|
+
state_data.repl_id = result.proof_state
|
|
285
|
+
else:
|
|
286
|
+
raise ValueError(
|
|
287
|
+
f"Could not reload the session cache. The Lean server returned an unexpected response: {result}"
|
|
288
|
+
)
|
|
285
289
|
|
|
286
290
|
def restart(self, verbose: bool = False) -> None:
|
|
287
291
|
super().restart()
|
|
@@ -295,7 +299,10 @@ class AutoLeanServer(LeanServer):
|
|
|
295
299
|
env_id: The environment id to remove.
|
|
296
300
|
"""
|
|
297
301
|
if (state_cache := self._restart_persistent_session_cache.pop(session_state_id, None)) is not None:
|
|
298
|
-
|
|
302
|
+
pickle_file = state_cache.pickle_file
|
|
303
|
+
with FileLock(f"{pickle_file}.lock", timeout=60):
|
|
304
|
+
if os.path.exists(pickle_file):
|
|
305
|
+
os.remove(pickle_file)
|
|
299
306
|
|
|
300
307
|
def clear_session_cache(self, force: bool = False) -> None:
|
|
301
308
|
"""
|
|
@@ -338,18 +345,22 @@ class AutoLeanServer(LeanServer):
|
|
|
338
345
|
else:
|
|
339
346
|
request = PickleEnvironment(env=repl_id, pickle_to=pickle_file)
|
|
340
347
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
348
|
+
# Use file lock when accessing the pickle file to prevent cache invalidation
|
|
349
|
+
# from multiple concurrent processes
|
|
350
|
+
with FileLock(f"{pickle_file}.lock", timeout=60):
|
|
351
|
+
result = self.run(request, verbose=verbose, timeout=DEFAULT_TIMEOUT)
|
|
352
|
+
if isinstance(result, LeanError):
|
|
353
|
+
raise ValueError(
|
|
354
|
+
f"Could not store the result in the session cache. The Lean server returned an error: {result.message}"
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
self._restart_persistent_session_cache[self._state_counter] = _SessionState(
|
|
358
|
+
session_id=self._state_counter,
|
|
359
|
+
repl_id=repl_id,
|
|
360
|
+
pickle_file=pickle_file,
|
|
361
|
+
is_proof_state=is_proof_state,
|
|
345
362
|
)
|
|
346
363
|
|
|
347
|
-
self._restart_persistent_session_cache[self._state_counter] = _SessionState(
|
|
348
|
-
session_id=self._state_counter,
|
|
349
|
-
repl_id=repl_id,
|
|
350
|
-
pickle_file=pickle_file,
|
|
351
|
-
is_proof_state=is_proof_state,
|
|
352
|
-
)
|
|
353
364
|
return self._state_counter
|
|
354
365
|
|
|
355
366
|
def _run_dict_backoff(self, request: dict, verbose: bool, timeout: float | None, restart_counter: int = 0) -> dict:
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import multiprocessing as mp
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import tempfile
|
|
5
|
+
import time
|
|
6
|
+
import unittest
|
|
7
|
+
from unittest import mock
|
|
8
|
+
|
|
9
|
+
from filelock import FileLock, Timeout
|
|
10
|
+
|
|
11
|
+
from lean_interact import AutoLeanServer, LeanREPLConfig
|
|
12
|
+
from lean_interact.config import BaseTempProject
|
|
13
|
+
from lean_interact.interface import Command, CommandResponse
|
|
14
|
+
from lean_interact.utils import DEFAULT_REPL_GIT_URL
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Helper function for testing config creation in a separate process
|
|
18
|
+
def create_config_process(cache_dir, lean_version="v4.18.0", verbose=False):
|
|
19
|
+
"""Function that attempts to create a LeanREPLConfig instance."""
|
|
20
|
+
try:
|
|
21
|
+
# This should timeout if another process is already holding the lock
|
|
22
|
+
LeanREPLConfig(lean_version=lean_version, cache_dir=str(cache_dir), verbose=verbose)
|
|
23
|
+
return True
|
|
24
|
+
except Exception:
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Function to access/modify a cache file with locking
|
|
29
|
+
def access_cache_file(file_path, process_id, timeout=1):
|
|
30
|
+
"""Function to simulate a process accessing and modifying a cache file."""
|
|
31
|
+
try:
|
|
32
|
+
lock_path = f"{file_path}.lock"
|
|
33
|
+
with FileLock(lock_path, timeout=timeout):
|
|
34
|
+
# Read the file
|
|
35
|
+
try:
|
|
36
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
37
|
+
content = f.read()
|
|
38
|
+
|
|
39
|
+
# Modify the file (simulate updating cache)
|
|
40
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
41
|
+
f.write(f"{content} - Modified by process {process_id}")
|
|
42
|
+
time.sleep(timeout)
|
|
43
|
+
return True
|
|
44
|
+
except Exception as e:
|
|
45
|
+
print(f"Error in access_cache_file: {e}")
|
|
46
|
+
return False
|
|
47
|
+
except Timeout:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestFileLocks(unittest.TestCase):
|
|
52
|
+
"""Tests for file locking functionality."""
|
|
53
|
+
|
|
54
|
+
def setUp(self):
|
|
55
|
+
self.temp_dir = tempfile.mkdtemp()
|
|
56
|
+
|
|
57
|
+
def tearDown(self):
|
|
58
|
+
# Clean up temporary directory
|
|
59
|
+
if os.path.exists(self.temp_dir):
|
|
60
|
+
shutil.rmtree(self.temp_dir)
|
|
61
|
+
|
|
62
|
+
def test_config_repl_setup_lock(self):
|
|
63
|
+
"""Test that the REPL setup lock prevents concurrent access."""
|
|
64
|
+
# Create a temporary cache directory
|
|
65
|
+
cache_dir = os.path.join(self.temp_dir, "cache")
|
|
66
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
67
|
+
|
|
68
|
+
# Prepare the lock file path that would be used by LeanREPLConfig
|
|
69
|
+
repo_name = "/".join(DEFAULT_REPL_GIT_URL.split("/")[-2:]).replace(".git", "")
|
|
70
|
+
cache_clean_repl_dir = os.path.join(cache_dir, repo_name, "repl_clean_copy")
|
|
71
|
+
os.makedirs(os.path.dirname(cache_clean_repl_dir), exist_ok=True)
|
|
72
|
+
lock_file = f"{cache_clean_repl_dir}.lock"
|
|
73
|
+
|
|
74
|
+
# Acquire the lock that would be used by LeanREPLConfig
|
|
75
|
+
with FileLock(lock_file, timeout=0.1):
|
|
76
|
+
# Start a process that tries to create a LeanREPLConfig, which should try to acquire the same lock
|
|
77
|
+
ctx = mp.get_context("spawn")
|
|
78
|
+
|
|
79
|
+
p = ctx.Process(target=create_config_process, args=(cache_dir,))
|
|
80
|
+
p.start()
|
|
81
|
+
p.join(timeout=30)
|
|
82
|
+
|
|
83
|
+
# The process should have exited with a timeout exception
|
|
84
|
+
self.assertNotEqual(p.exitcode, 0, "Process should have failed to acquire the lock")
|
|
85
|
+
|
|
86
|
+
def test_temporary_project_lock(self):
|
|
87
|
+
"""Test that the temporary project lock prevents concurrent access."""
|
|
88
|
+
|
|
89
|
+
# Create a temp project instance with a simple hash
|
|
90
|
+
class SimpleTestProject(BaseTempProject):
|
|
91
|
+
def _get_hash_content(self, lean_version: str) -> str:
|
|
92
|
+
"""Return a fixed hash."""
|
|
93
|
+
return "test_hash"
|
|
94
|
+
|
|
95
|
+
def _modify_lakefile(self, project_dir: str, lean_version: str) -> None:
|
|
96
|
+
"""Do nothing."""
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
test_project = SimpleTestProject()
|
|
100
|
+
|
|
101
|
+
# Get the project directory and lock path
|
|
102
|
+
cache_dir = os.path.join(self.temp_dir, "cache")
|
|
103
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
104
|
+
project_dir = test_project._get_directory(str(cache_dir), "v4.18.0")
|
|
105
|
+
lock_file = f"{project_dir}.lock"
|
|
106
|
+
|
|
107
|
+
# Hold the lock to simulate another process using it
|
|
108
|
+
with FileLock(lock_file, timeout=0.1):
|
|
109
|
+
# Try to instantiate the project which should try to acquire the same lock
|
|
110
|
+
with self.assertRaises(Exception):
|
|
111
|
+
# Use a short timeout to avoid test hanging
|
|
112
|
+
with mock.patch("lean_interact.config.FileLock", return_value=FileLock(lock_file, timeout=0.1)):
|
|
113
|
+
test_project._instantiate(str(cache_dir), "v4.18.0", verbose=False)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _worker(idx, result_queue, config):
|
|
117
|
+
try:
|
|
118
|
+
# Each process gets its own server instance
|
|
119
|
+
server = AutoLeanServer(config)
|
|
120
|
+
# Each process runs a simple Lean command
|
|
121
|
+
cmd = Command(cmd=f"def x{idx} := {idx}")
|
|
122
|
+
result = server.run(cmd)
|
|
123
|
+
# Return result type and env id
|
|
124
|
+
if isinstance(result, CommandResponse):
|
|
125
|
+
result_queue.put((idx, True, result.env))
|
|
126
|
+
else:
|
|
127
|
+
result_queue.put((idx, False, str(result)))
|
|
128
|
+
except Exception as e:
|
|
129
|
+
result_queue.put((idx, False, str(e)))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class TestAutoLeanServerConcurrency(unittest.TestCase):
|
|
133
|
+
"""Realistic concurrent use-cases for AutoLeanServer and LeanREPLConfig (no mocks)."""
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def setUpClass(cls):
|
|
137
|
+
# Use a dedicated cache dir for concurrency tests
|
|
138
|
+
cls.cache_dir = tempfile.mkdtemp(prefix="lean_concurrency_cache_")
|
|
139
|
+
# Pre-instantiate config to avoid race in REPL setup
|
|
140
|
+
cls.config = LeanREPLConfig(cache_dir=cls.cache_dir, verbose=True)
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def tearDownClass(cls):
|
|
144
|
+
shutil.rmtree(cls.cache_dir, ignore_errors=True)
|
|
145
|
+
|
|
146
|
+
def test_concurrent_autoleanserver(self):
|
|
147
|
+
"""Test concurrent use of AutoLeanServer."""
|
|
148
|
+
num_proc = 4
|
|
149
|
+
ctx = mp.get_context("spawn")
|
|
150
|
+
result_queue = ctx.Queue()
|
|
151
|
+
procs = [ctx.Process(target=_worker, args=(i, result_queue, self.config)) for i in range(num_proc)]
|
|
152
|
+
for p in procs:
|
|
153
|
+
p.start()
|
|
154
|
+
for p in procs:
|
|
155
|
+
p.join(timeout=60)
|
|
156
|
+
results = [result_queue.get(timeout=5) for _ in range(num_proc)]
|
|
157
|
+
self.assertTrue(all(success for (_, success, _) in results), f"Failures: {results}")
|
|
158
|
+
env_ids = [env for (_, _, env) in results]
|
|
159
|
+
self.assertEqual(len(env_ids), num_proc)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if __name__ == "__main__":
|
|
163
|
+
unittest.main()
|
|
@@ -433,6 +433,7 @@ class TestLeanServer(unittest.TestCase):
|
|
|
433
433
|
# Get initial memory usage
|
|
434
434
|
assert server._proc is not None
|
|
435
435
|
server_process = psutil.Process(server._proc.pid)
|
|
436
|
+
time.sleep(1) # Give the process time to start
|
|
436
437
|
start_mem = server_process.memory_info().rss / (1024 * 1024) # Convert to MB
|
|
437
438
|
|
|
438
439
|
# Run code in separate thread to allow memory monitoring
|
|
@@ -462,12 +463,15 @@ class TestLeanServer(unittest.TestCase):
|
|
|
462
463
|
# Monitor memory usage
|
|
463
464
|
max_mem = start_mem
|
|
464
465
|
while thread.is_alive():
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
466
|
+
try:
|
|
467
|
+
current_mem = server_process.memory_info().rss / (1024 * 1024)
|
|
468
|
+
max_mem = max(max_mem, current_mem)
|
|
469
|
+
if current_mem > mem_limit:
|
|
470
|
+
server.kill()
|
|
471
|
+
raise MemoryError(f"Memory usage exceeded limit: {current_mem:.1f}MB > {mem_limit}MB")
|
|
472
|
+
time.sleep(1)
|
|
473
|
+
except psutil.NoSuchProcess:
|
|
474
|
+
break
|
|
471
475
|
|
|
472
476
|
# Get result
|
|
473
477
|
status, result = result_queue.get()
|
|
@@ -228,6 +228,15 @@ wheels = [
|
|
|
228
228
|
{ url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702 },
|
|
229
229
|
]
|
|
230
230
|
|
|
231
|
+
[[package]]
|
|
232
|
+
name = "filelock"
|
|
233
|
+
version = "3.18.0"
|
|
234
|
+
source = { registry = "https://pypi.org/simple" }
|
|
235
|
+
sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075 }
|
|
236
|
+
wheels = [
|
|
237
|
+
{ url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215 },
|
|
238
|
+
]
|
|
239
|
+
|
|
231
240
|
[[package]]
|
|
232
241
|
name = "gitdb"
|
|
233
242
|
version = "4.0.12"
|
|
@@ -351,9 +360,10 @@ wheels = [
|
|
|
351
360
|
|
|
352
361
|
[[package]]
|
|
353
362
|
name = "lean-interact"
|
|
354
|
-
version = "0.
|
|
363
|
+
version = "0.4.0"
|
|
355
364
|
source = { editable = "." }
|
|
356
365
|
dependencies = [
|
|
366
|
+
{ name = "filelock" },
|
|
357
367
|
{ name = "gitpython" },
|
|
358
368
|
{ name = "pexpect" },
|
|
359
369
|
{ name = "psutil" },
|
|
@@ -369,6 +379,7 @@ dev = [
|
|
|
369
379
|
|
|
370
380
|
[package.metadata]
|
|
371
381
|
requires-dist = [
|
|
382
|
+
{ name = "filelock", specifier = ">=3.18.0" },
|
|
372
383
|
{ name = "gitpython", specifier = ">=3.1.44" },
|
|
373
384
|
{ name = "pexpect", specifier = ">=4.9.0" },
|
|
374
385
|
{ name = "psutil", specifier = ">=6.1.0" },
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|