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.
Files changed (20) hide show
  1. {lean_interact-0.4.0 → lean_interact-0.4.1}/.github/workflows/ci.yml +2 -6
  2. {lean_interact-0.4.0 → lean_interact-0.4.1}/PKG-INFO +10 -11
  3. {lean_interact-0.4.0 → lean_interact-0.4.1}/README.md +8 -10
  4. {lean_interact-0.4.0 → lean_interact-0.4.1}/examples/proof_generation_and_autoformalization.py +1 -1
  5. {lean_interact-0.4.0 → lean_interact-0.4.1}/examples/type_check.py +1 -1
  6. {lean_interact-0.4.0 → lean_interact-0.4.1}/pyproject.toml +2 -1
  7. {lean_interact-0.4.0 → lean_interact-0.4.1}/src/lean_interact/config.py +88 -81
  8. {lean_interact-0.4.0 → lean_interact-0.4.1}/src/lean_interact/interface.py +3 -3
  9. {lean_interact-0.4.0 → lean_interact-0.4.1}/src/lean_interact/server.py +45 -34
  10. lean_interact-0.4.1/tests/test_concurrency.py +163 -0
  11. {lean_interact-0.4.0 → lean_interact-0.4.1}/tests/test_server.py +10 -6
  12. {lean_interact-0.4.0 → lean_interact-0.4.1}/uv.lock +12 -1
  13. {lean_interact-0.4.0 → lean_interact-0.4.1}/.devcontainer/devcontainer.json +0 -0
  14. {lean_interact-0.4.0 → lean_interact-0.4.1}/.github/workflows/publish-to-pypi.yml +0 -0
  15. {lean_interact-0.4.0 → lean_interact-0.4.1}/.gitignore +0 -0
  16. {lean_interact-0.4.0 → lean_interact-0.4.1}/LICENSE +0 -0
  17. {lean_interact-0.4.0 → lean_interact-0.4.1}/examples/beq_plus.py +0 -0
  18. {lean_interact-0.4.0 → lean_interact-0.4.1}/src/lean_interact/__init__.py +0 -0
  19. {lean_interact-0.4.0 → lean_interact-0.4.1}/src/lean_interact/utils.py +0 -0
  20. {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.0
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`: 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.
103
- - `beq_plus.py`: run the autoformalization [BEq+](https://arxiv.org/abs/2406.07222) metric on the [ProofNetVerif](https://huggingface.co/datasets/PAug/ProofNetVerif) benchmark.
104
- - `type_check.py`: optimize type checking using environment states.
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 proof tactics step by step:
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="intro h\nexact h"))
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`: 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`: 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`: optimize type checking using environment states.
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 proof tactics step by step:
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="intro h\nexact h"))
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}
@@ -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 formalization.
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
@@ -9,7 +9,7 @@
9
9
  # ///
10
10
  """
11
11
  This module provides functions to type-check Lean formalizations
12
- using sequential, parallel, and optimized batch methods.
12
+ using sequential and optimized batch methods.
13
13
  """
14
14
 
15
15
  import json
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lean-interact"
3
- version = "0.4.0"
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
- # check that the project is buildable
59
- subprocess.run(["lake", "build"], cwd=self.directory, check=True, stdout=stdout, stderr=stderr)
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
- # check if the git repository is already cloned
81
- repo = Repo(project_dir) if os.path.exists(project_dir) else Repo.clone_from(self.url, project_dir)
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
- if self.rev:
84
- repo.git.checkout(self.rev)
85
- else:
86
- repo.git.pull()
87
+ if self.rev:
88
+ repo.git.checkout(self.rev)
89
+ else:
90
+ repo.git.pull()
87
91
 
88
- repo.submodule_update(init=True, recursive=True)
92
+ repo.submodule_update(init=True, recursive=True)
89
93
 
90
- # check that the project is buildable
91
- subprocess.run(["lake", "build"], cwd=project_dir, check=True, stdout=stdout, stderr=stderr)
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
- # check if the Lean project is already built
115
- if not os.path.exists(os.path.join(tmp_project_dir, "lakefile.lean")):
116
- # clean the content of the folder in case of a previous aborted build
117
- shutil.rmtree(tmp_project_dir, ignore_errors=True)
118
- os.makedirs(tmp_project_dir, exist_ok=True)
119
-
120
- # initialize the Lean project
121
- cmd_init = ["lake", f"+{lean_version}", "init", "dummy", "exe.lean"]
122
- if lean_version.startswith("v4") and int(lean_version.split(".")[1]) <= 7:
123
- cmd_init = ["lake", f"+{lean_version}", "init", "dummy", "exe"]
124
- subprocess.run(cmd_init, cwd=tmp_project_dir, check=True, stdout=stdout, stderr=stderr)
125
-
126
- # Create or modify the lakefile
127
- self._modify_lakefile(tmp_project_dir, lean_version)
128
-
129
- logger.info("Preparing Lean environment with dependencies (may take a while the first time)...")
130
- subprocess.run(["lake", "update"], cwd=tmp_project_dir, check=True, stdout=stdout, stderr=stderr)
131
- # in case mathlib is used as a dependency, we try to get the cache
132
- subprocess.run(["lake", "exe", "cache", "get"], cwd=tmp_project_dir, stdout=stdout, stderr=stderr)
133
- subprocess.run(["lake", "build"], cwd=tmp_project_dir, check=True, stdout=stdout, stderr=stderr)
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
- # check if the repl is already cloned
267
- if not os.path.exists(self.cache_clean_repl_dir):
268
- os.makedirs(self.cache_clean_repl_dir, exist_ok=True)
269
- Repo.clone_from(self.repl_git, self.cache_clean_repl_dir)
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
- repo = Repo(self.cache_clean_repl_dir)
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 as e:
279
- raise ValueError(f"Lean REPL version `{self.repl_rev}` is not available.") from e
280
-
281
- # check if the Lean version is available in the repository
282
- lean_versions_sha = self._get_available_lean_versions_sha()
283
- lean_versions_sha_dict = dict(lean_versions_sha)
284
- if not lean_versions_sha:
285
- raise ValueError("No Lean versions are available in the Lean REPL repository.")
286
- if self.lean_version is None:
287
- if (
288
- self.project is None
289
- or isinstance(self.project, TemporaryProject)
290
- or isinstance(self.project, TempRequireProject)
291
- ):
292
- self.lean_version = lean_versions_sha[-1][0]
293
- elif isinstance(self.project, LocalProject) or isinstance(self.project, GitProject):
294
- # get the Lean version from the project
295
- inferred_ver = get_project_lean_version(self.project._get_directory(self.cache_dir))
296
- self.lean_version = inferred_ver if inferred_ver else lean_versions_sha[-1][0]
297
- if self.lean_version not in lean_versions_sha_dict:
298
- raise ValueError(
299
- f"Lean version `{self.lean_version}` is required but not available in the Lean REPL repository."
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
- if not os.path.exists(self._cache_repl_dir):
305
- # copy the repository to the version directory and checkout the required revision
306
- os.makedirs(self._cache_repl_dir, exist_ok=True)
307
- shutil.copytree(self.cache_clean_repl_dir, self._cache_repl_dir, dirs_exist_ok=True)
308
- cached_repo = Repo(self._cache_repl_dir)
309
- cached_repo.git.checkout(lean_versions_sha_dict[self.lean_version])
310
-
311
- # check that the lean version is correct
312
- assert self.lean_version == get_project_lean_version(self._cache_repl_dir), (
313
- f"An error occured while preparing the Lean REPL. The requested Lean version `{self.lean_version}` "
314
- f"does not match the fetched Lean version in the repository `{get_project_lean_version(self._cache_repl_dir)}`."
315
- f"Please open an issue on GitHub if you think this is a bug."
316
- )
317
-
318
- # build the repl
319
- subprocess.run(
320
- ["lake", "build"], cwd=self._cache_repl_dir, check=True, stdout=self._stdout, stderr=self._stderr
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, REPLBaseModel):
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 unexistent file has been requested)\n"
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 `clear_cache` (`from pyleanrepl import clear_cache`) method."
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
- if state_data.is_proof_state:
264
- cmd = UnpickleProofState(unpickle_proof_state_from=state_data.pickle_file, env=state_data.repl_id)
265
- else:
266
- cmd = UnpickleEnvironment(unpickle_env_from=state_data.pickle_file)
267
- result = self.run(
268
- cmd,
269
- verbose=verbose,
270
- timeout=DEFAULT_TIMEOUT,
271
- add_to_session_cache=False,
272
- )
273
- if isinstance(result, LeanError):
274
- raise ValueError(
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
- os.remove(state_cache.pickle_file)
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
- result = self.run(request, verbose=verbose, timeout=DEFAULT_TIMEOUT)
342
- if isinstance(result, LeanError):
343
- raise ValueError(
344
- f"Could not store the result in the session cache. The Lean server returned an error: {result.message}"
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
- current_mem = server_process.memory_info().rss / (1024 * 1024)
466
- max_mem = max(max_mem, current_mem)
467
- if current_mem > mem_limit:
468
- server.kill()
469
- raise MemoryError(f"Memory usage exceeded limit: {current_mem:.1f}MB > {mem_limit}MB")
470
- time.sleep(1)
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.3.2"
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