leanback 0.1.4__tar.gz → 0.1.6__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.
- {leanback-0.1.4 → leanback-0.1.6}/PKG-INFO +4 -4
- {leanback-0.1.4 → leanback-0.1.6}/README.md +1 -1
- {leanback-0.1.4 → leanback-0.1.6}/pyproject.toml +2 -2
- {leanback-0.1.4 → leanback-0.1.6}/src/leanback/check.py +8 -0
- leanback-0.1.6/src/leanback/common/env.py +309 -0
- leanback-0.1.6/src/leanback/common/stale.py +83 -0
- {leanback-0.1.4 → leanback-0.1.6}/src/leanback/common/warm.py +9 -0
- {leanback-0.1.4 → leanback-0.1.6}/src/leanback/mcp_server.py +63 -4
- leanback-0.1.4/src/leanback/common/env.py +0 -157
- {leanback-0.1.4 → leanback-0.1.6}/.gitignore +0 -0
- {leanback-0.1.4 → leanback-0.1.6}/LICENSE +0 -0
- {leanback-0.1.4 → leanback-0.1.6}/src/leanback/README.md +0 -0
- {leanback-0.1.4 → leanback-0.1.6}/src/leanback/__init__.py +0 -0
- {leanback-0.1.4 → leanback-0.1.6}/src/leanback/common/__init__.py +0 -0
- {leanback-0.1.4 → leanback-0.1.6}/src/leanback/common/errors.py +0 -0
- {leanback-0.1.4 → leanback-0.1.6}/src/leanback/common/lsp_client.py +0 -0
- {leanback-0.1.4 → leanback-0.1.6}/src/leanback/common/path_manager.py +0 -0
- {leanback-0.1.4 → leanback-0.1.6}/src/leanback/common/project_context.py +0 -0
- {leanback-0.1.4 → leanback-0.1.6}/src/leanback/common/util.py +0 -0
- {leanback-0.1.4 → leanback-0.1.6}/src/leanback/common/watchdog.py +0 -0
- {leanback-0.1.4 → leanback-0.1.6}/src/leanback/goals.py +0 -0
- {leanback-0.1.4 → leanback-0.1.6}/src/leanback/mathlib_search.py +0 -0
- {leanback-0.1.4 → leanback-0.1.6}/src/leanback/mathlib_search_simple.py +0 -0
- {leanback-0.1.4 → leanback-0.1.6}/src/leanback/mcp_test.py +0 -0
- {leanback-0.1.4 → leanback-0.1.6}/src/leanback/prove.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: leanback
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: MCP server for Lean 4 theorem proving — check, prove, goals, eval, search
|
|
5
5
|
Author: Stefan Szeider
|
|
6
6
|
License: Apache-2.0
|
|
@@ -13,7 +13,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.13
|
|
14
14
|
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
15
15
|
Requires-Python: >=3.11
|
|
16
|
-
Requires-Dist: mcp
|
|
16
|
+
Requires-Dist: mcp<2,>=1.26.0
|
|
17
17
|
Requires-Dist: rich>=14.0.0
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
19
|
|
|
@@ -140,7 +140,7 @@ search(project="/home/user/lean/myproject", batch="add_assoc,mul_assoc,add_comm"
|
|
|
140
140
|
|
|
141
141
|
## All Tools Are Read-Only
|
|
142
142
|
|
|
143
|
-
LeanBack never modifies your source files. It only reads project structure and runs Lean to check/evaluate code. File creation and editing is left to your agent's own tools.
|
|
143
|
+
LeanBack never modifies your source files. It only reads project structure and runs Lean to check/evaluate code. File creation and editing is left to your agent's own tools. Two exceptions write build artifacts to the project's `.lake` directory: `eval` with `build=True` runs `lake build`, and `check` with `file=` compiles out-of-date imports of the checked file, as the Lean language server does.
|
|
144
144
|
|
|
145
145
|
## Response Format
|
|
146
146
|
|
|
@@ -121,7 +121,7 @@ search(project="/home/user/lean/myproject", batch="add_assoc,mul_assoc,add_comm"
|
|
|
121
121
|
|
|
122
122
|
## All Tools Are Read-Only
|
|
123
123
|
|
|
124
|
-
LeanBack never modifies your source files. It only reads project structure and runs Lean to check/evaluate code. File creation and editing is left to your agent's own tools.
|
|
124
|
+
LeanBack never modifies your source files. It only reads project structure and runs Lean to check/evaluate code. File creation and editing is left to your agent's own tools. Two exceptions write build artifacts to the project's `.lake` directory: `eval` with `build=True` runs `lake build`, and `check` with `file=` compiles out-of-date imports of the checked file, as the Lean language server does.
|
|
125
125
|
|
|
126
126
|
## Response Format
|
|
127
127
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "leanback"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.6"
|
|
8
8
|
description = "MCP server for Lean 4 theorem proving — check, prove, goals, eval, search"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "Apache-2.0" }
|
|
@@ -21,7 +21,7 @@ classifiers = [
|
|
|
21
21
|
]
|
|
22
22
|
dependencies = [
|
|
23
23
|
"rich>=14.0.0",
|
|
24
|
-
"mcp>=1.26.0",
|
|
24
|
+
"mcp>=1.26.0,<2",
|
|
25
25
|
]
|
|
26
26
|
|
|
27
27
|
[dependency-groups]
|
|
@@ -23,6 +23,7 @@ def check_file(
|
|
|
23
23
|
capture_json: bool = True,
|
|
24
24
|
console: Console | None = None,
|
|
25
25
|
preserve_relative: bool = True,
|
|
26
|
+
stats: dict | None = None,
|
|
26
27
|
) -> tuple[bool, list[LeanError]]:
|
|
27
28
|
"""
|
|
28
29
|
Check a single Lean file for errors.
|
|
@@ -33,6 +34,8 @@ def check_file(
|
|
|
33
34
|
capture_json: Whether to capture output as JSON for better error parsing
|
|
34
35
|
console: Rich console for output (creates a new one if None)
|
|
35
36
|
preserve_relative: Whether to preserve relative paths (True by default)
|
|
37
|
+
stats: Optional dict that receives run statistics (see
|
|
38
|
+
``LeanRunner.run_lean``)
|
|
36
39
|
|
|
37
40
|
Returns:
|
|
38
41
|
Tuple of (success, errors)
|
|
@@ -100,6 +103,7 @@ def check_file(
|
|
|
100
103
|
project_path=project_path,
|
|
101
104
|
capture_json=capture_json,
|
|
102
105
|
check=False,
|
|
106
|
+
stats=stats,
|
|
103
107
|
)
|
|
104
108
|
|
|
105
109
|
# Parse the output for errors
|
|
@@ -320,6 +324,7 @@ def check_lean_expr(
|
|
|
320
324
|
project_path: str | Path | None = None,
|
|
321
325
|
capture_json: bool = True,
|
|
322
326
|
console: Console | None = None,
|
|
327
|
+
stats: dict | None = None,
|
|
323
328
|
) -> tuple[bool, list[LeanError]]:
|
|
324
329
|
"""
|
|
325
330
|
Check a Lean expression for errors.
|
|
@@ -330,6 +335,8 @@ def check_lean_expr(
|
|
|
330
335
|
project_path: Path to the Lean project (if None, tries to find it)
|
|
331
336
|
capture_json: Whether to capture output as JSON for better error parsing
|
|
332
337
|
console: Rich console for output (creates a new one if None)
|
|
338
|
+
stats: Optional dict that receives run statistics (see
|
|
339
|
+
``LeanRunner.run_lean``)
|
|
333
340
|
|
|
334
341
|
Returns:
|
|
335
342
|
Tuple of (success, errors)
|
|
@@ -356,6 +363,7 @@ def check_lean_expr(
|
|
|
356
363
|
project_path=project_path,
|
|
357
364
|
capture_json=capture_json,
|
|
358
365
|
console=console,
|
|
366
|
+
stats=stats,
|
|
359
367
|
)
|
|
360
368
|
finally:
|
|
361
369
|
# Clean up the temporary file
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lean environment handling utilities.
|
|
3
|
+
|
|
4
|
+
This module provides functionality for executing Lean commands in the correct environment.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Timeout (seconds) for `lake setup-file --no-build`. It only reads traces and
|
|
18
|
+
# prints JSON; on a built project it takes 1-3 s.
|
|
19
|
+
SETUP_FILE_TIMEOUT = 60.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _lib_ext() -> str:
|
|
23
|
+
if sys.platform == "darwin":
|
|
24
|
+
return "dylib"
|
|
25
|
+
if sys.platform == "win32":
|
|
26
|
+
return "dll"
|
|
27
|
+
return "so"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _find_native_dynlibs(project_path: Path) -> list[Path]:
|
|
31
|
+
"""Discover native shared libraries in Lake packages.
|
|
32
|
+
|
|
33
|
+
Scans .lake/packages/*/.lake/build/lib/ for .so (Linux) or .dylib (macOS)
|
|
34
|
+
files that need --load-dynlib for the Lean interpreter. Fallback used when
|
|
35
|
+
``lake setup-file`` cannot be consulted.
|
|
36
|
+
"""
|
|
37
|
+
packages_dir = project_path / ".lake" / "packages"
|
|
38
|
+
if not packages_dir.is_dir():
|
|
39
|
+
return []
|
|
40
|
+
return sorted(packages_dir.glob(f"*/.lake/build/lib/lib*.{_lib_ext()}"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _has_native_libs(project_path: Path) -> bool:
|
|
44
|
+
"""True if the project or one of its packages has a built shared library.
|
|
45
|
+
|
|
46
|
+
Only such projects (``precompileModules``, FFI packages) need plugins or
|
|
47
|
+
dynlibs, so projects without any skip the ``lake setup-file`` call.
|
|
48
|
+
"""
|
|
49
|
+
ext = _lib_ext()
|
|
50
|
+
lake = project_path / ".lake"
|
|
51
|
+
if any((lake / "build" / "lib").glob(f"lib*.{ext}")):
|
|
52
|
+
return True
|
|
53
|
+
return any((lake / "packages").glob(f"*/.lake/build/lib/lib*.{ext}"))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def lake_setup_file(
|
|
57
|
+
project_path: Path, file_path: Path, timeout: float = SETUP_FILE_TIMEOUT
|
|
58
|
+
) -> tuple[list[str], list[str]] | None:
|
|
59
|
+
"""Ask Lake which plugins and dynlibs a file needs.
|
|
60
|
+
|
|
61
|
+
Runs ``lake setup-file --no-build`` (what the language server does for an
|
|
62
|
+
opened file) and returns ``(plugins, dynlibs)`` in Lake's order. Plugins
|
|
63
|
+
are the precompiled library dylibs of the import closure (projects with
|
|
64
|
+
``precompileModules``); passing a project's own dylib without the dylibs
|
|
65
|
+
of its dependencies crashes Lean, so the order matters. Returns None when
|
|
66
|
+
Lake fails (unbuilt or out-of-date imports, no lakefile) or times out.
|
|
67
|
+
|
|
68
|
+
Handles both JSON schemas: Lake 5 (Lean >= 4.22) prints top-level
|
|
69
|
+
``plugins``/``dynlibs``; older versions print ``paths.pluginPaths`` and
|
|
70
|
+
``paths.loadDynlibPaths``.
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
result = subprocess.run(
|
|
74
|
+
["lake", "setup-file", "--no-build", str(file_path)],
|
|
75
|
+
cwd=project_path,
|
|
76
|
+
capture_output=True,
|
|
77
|
+
text=True,
|
|
78
|
+
check=False,
|
|
79
|
+
timeout=timeout,
|
|
80
|
+
)
|
|
81
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
82
|
+
return None
|
|
83
|
+
if result.returncode != 0:
|
|
84
|
+
return None
|
|
85
|
+
try:
|
|
86
|
+
data = json.loads(result.stdout)
|
|
87
|
+
except json.JSONDecodeError:
|
|
88
|
+
return None
|
|
89
|
+
if not isinstance(data, dict):
|
|
90
|
+
return None
|
|
91
|
+
paths = data.get("paths")
|
|
92
|
+
if isinstance(paths, dict):
|
|
93
|
+
plugins = paths.get("pluginPaths", [])
|
|
94
|
+
dynlibs = paths.get("loadDynlibPaths", [])
|
|
95
|
+
else:
|
|
96
|
+
plugins = data.get("plugins", [])
|
|
97
|
+
dynlibs = data.get("dynlibs", [])
|
|
98
|
+
if not isinstance(plugins, list) or not isinstance(dynlibs, list):
|
|
99
|
+
return None
|
|
100
|
+
return [str(x) for x in plugins], [str(x) for x in dynlibs]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _max_rss_mb(ru_maxrss: int) -> float:
|
|
104
|
+
"""Convert ``ru_maxrss`` to MB (bytes on macOS, KiB elsewhere)."""
|
|
105
|
+
if sys.platform == "darwin":
|
|
106
|
+
return ru_maxrss / (1024 * 1024)
|
|
107
|
+
return ru_maxrss / 1024
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _run_with_rusage(
|
|
111
|
+
command: list[str],
|
|
112
|
+
cwd: Path,
|
|
113
|
+
env: dict[str, str],
|
|
114
|
+
timeout: float | None,
|
|
115
|
+
) -> tuple[int, str, str, float | None]:
|
|
116
|
+
"""Run a command and return (exit_code, stdout, stderr, max_rss_mb).
|
|
117
|
+
|
|
118
|
+
Uses ``os.wait4`` so the peak memory of this child alone is known. Falls
|
|
119
|
+
back to ``subprocess.run`` (no RSS) where ``wait4`` is unavailable.
|
|
120
|
+
"""
|
|
121
|
+
if not hasattr(os, "wait4"):
|
|
122
|
+
result = subprocess.run(
|
|
123
|
+
command,
|
|
124
|
+
cwd=cwd,
|
|
125
|
+
check=False,
|
|
126
|
+
capture_output=True,
|
|
127
|
+
text=True,
|
|
128
|
+
env=env,
|
|
129
|
+
timeout=timeout,
|
|
130
|
+
)
|
|
131
|
+
return result.returncode, result.stdout, result.stderr, None
|
|
132
|
+
|
|
133
|
+
with tempfile.TemporaryFile() as out_f, tempfile.TemporaryFile() as err_f:
|
|
134
|
+
proc = subprocess.Popen(command, cwd=cwd, stdout=out_f, stderr=err_f, env=env)
|
|
135
|
+
reaped: dict = {}
|
|
136
|
+
|
|
137
|
+
def waiter() -> None:
|
|
138
|
+
_, status, rusage = os.wait4(proc.pid, 0)
|
|
139
|
+
reaped["status"] = status
|
|
140
|
+
reaped["rusage"] = rusage
|
|
141
|
+
|
|
142
|
+
thread = threading.Thread(target=waiter, daemon=True)
|
|
143
|
+
thread.start()
|
|
144
|
+
thread.join(timeout)
|
|
145
|
+
if thread.is_alive():
|
|
146
|
+
proc.kill()
|
|
147
|
+
thread.join()
|
|
148
|
+
proc.returncode = -9
|
|
149
|
+
raise subprocess.TimeoutExpired(command, timeout or 0)
|
|
150
|
+
# Mark the Popen as reaped so it does not try to wait again.
|
|
151
|
+
proc.returncode = os.waitstatus_to_exitcode(reaped["status"])
|
|
152
|
+
out_f.seek(0)
|
|
153
|
+
err_f.seek(0)
|
|
154
|
+
stdout = out_f.read().decode("utf-8", errors="replace")
|
|
155
|
+
stderr = err_f.read().decode("utf-8", errors="replace")
|
|
156
|
+
return (
|
|
157
|
+
proc.returncode,
|
|
158
|
+
stdout,
|
|
159
|
+
stderr,
|
|
160
|
+
_max_rss_mb(reaped["rusage"].ru_maxrss),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def find_local_modules(project_path: Path, modules: list[str]) -> list[str]:
|
|
165
|
+
"""Filter module names to those with source files in the project.
|
|
166
|
+
|
|
167
|
+
A module like ``Foo.Bar`` is local if ``<project>/Foo/Bar.lean`` exists.
|
|
168
|
+
Dependency modules (e.g. Mathlib.*) and core modules are excluded — they
|
|
169
|
+
are already built or come from the cache.
|
|
170
|
+
"""
|
|
171
|
+
local = []
|
|
172
|
+
for module in modules:
|
|
173
|
+
source = project_path / (module.replace(".", "/") + ".lean")
|
|
174
|
+
if source.is_file():
|
|
175
|
+
local.append(module)
|
|
176
|
+
return local
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def build_modules(
|
|
180
|
+
project_path: Path, modules: list[str], timeout: float = 600
|
|
181
|
+
) -> tuple[bool, str]:
|
|
182
|
+
"""Build the given modules with ``lake build`` in the project directory.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Tuple of (success, output). On failure, output contains the
|
|
186
|
+
combined stdout/stderr from lake.
|
|
187
|
+
"""
|
|
188
|
+
if not modules:
|
|
189
|
+
return True, ""
|
|
190
|
+
try:
|
|
191
|
+
result = subprocess.run(
|
|
192
|
+
["lake", "build", *modules],
|
|
193
|
+
cwd=project_path,
|
|
194
|
+
capture_output=True,
|
|
195
|
+
text=True,
|
|
196
|
+
check=False,
|
|
197
|
+
timeout=timeout,
|
|
198
|
+
)
|
|
199
|
+
except subprocess.TimeoutExpired:
|
|
200
|
+
return False, f"lake build timed out after {timeout:.0f} seconds"
|
|
201
|
+
output = ((result.stdout or "") + "\n" + (result.stderr or "")).strip()
|
|
202
|
+
return result.returncode == 0, output
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class LeanRunner:
|
|
206
|
+
"""
|
|
207
|
+
Utility for running Lean commands in the appropriate environment.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def run_lean(
|
|
212
|
+
file_path: str | Path,
|
|
213
|
+
project_path: str | Path | None = None,
|
|
214
|
+
capture_json: bool = False,
|
|
215
|
+
check: bool = True,
|
|
216
|
+
env_vars: dict[str, str] | None = None,
|
|
217
|
+
timeout: float | None = None,
|
|
218
|
+
stats: dict | None = None,
|
|
219
|
+
) -> tuple[int, str, str]:
|
|
220
|
+
"""
|
|
221
|
+
Run Lean on a file in the appropriate environment.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
file_path: Path to the Lean file to run
|
|
225
|
+
project_path: Path to a Lean project (for lake env)
|
|
226
|
+
capture_json: Whether to request JSON output
|
|
227
|
+
check: Whether to raise an exception on non-zero exit codes
|
|
228
|
+
env_vars: Additional environment variables
|
|
229
|
+
timeout: Timeout in seconds
|
|
230
|
+
stats: Optional dict that receives run statistics: ``elapsed_s``
|
|
231
|
+
(wall time of the lean process), ``max_rss_mb`` (peak memory,
|
|
232
|
+
None if unknown) and ``plugins`` (library files loaded).
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Tuple of (exit_code, stdout, stderr)
|
|
236
|
+
"""
|
|
237
|
+
# Convert to Path
|
|
238
|
+
if isinstance(file_path, str):
|
|
239
|
+
file_path = Path(file_path)
|
|
240
|
+
|
|
241
|
+
# We need absolute paths for checking existence internally
|
|
242
|
+
abs_file_path = file_path.absolute()
|
|
243
|
+
|
|
244
|
+
# Determine which path and command to use
|
|
245
|
+
if project_path:
|
|
246
|
+
# If a project is specified, use that project's environment
|
|
247
|
+
project_path = (
|
|
248
|
+
Path(project_path) if isinstance(project_path, str) else project_path
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Always use lake env lean for projects - this ensures proper dependency management
|
|
252
|
+
command = ["lake", "env", "lean"]
|
|
253
|
+
|
|
254
|
+
# Load precompiled libraries (precompileModules) and FFI dynlibs
|
|
255
|
+
# the way `lake build` does. Lake knows the exact set and order;
|
|
256
|
+
# projects without any built shared library skip the lookup.
|
|
257
|
+
if _has_native_libs(project_path):
|
|
258
|
+
setup = lake_setup_file(project_path, abs_file_path)
|
|
259
|
+
if setup is not None:
|
|
260
|
+
plugins, dynlibs = setup
|
|
261
|
+
else:
|
|
262
|
+
plugins = []
|
|
263
|
+
dynlibs = [str(lib) for lib in _find_native_dynlibs(project_path)]
|
|
264
|
+
for lib in plugins:
|
|
265
|
+
command.append(f"--plugin={lib}")
|
|
266
|
+
for lib in dynlibs:
|
|
267
|
+
command.append(f"--load-dynlib={lib}")
|
|
268
|
+
if stats is not None:
|
|
269
|
+
stats["plugins"] = plugins + dynlibs
|
|
270
|
+
|
|
271
|
+
if capture_json:
|
|
272
|
+
command.append("--json")
|
|
273
|
+
command.append(str(abs_file_path))
|
|
274
|
+
cwd = project_path
|
|
275
|
+
|
|
276
|
+
# Lake automatically handles all dependency paths - no manual LEAN_PATH setup needed
|
|
277
|
+
|
|
278
|
+
else:
|
|
279
|
+
# Default: run directly with lean for files without project context
|
|
280
|
+
command = ["lean"]
|
|
281
|
+
if capture_json:
|
|
282
|
+
command.append("--json")
|
|
283
|
+
command.append(str(abs_file_path))
|
|
284
|
+
|
|
285
|
+
cwd = file_path.parent
|
|
286
|
+
|
|
287
|
+
# Set up environment
|
|
288
|
+
proc_env = os.environ.copy()
|
|
289
|
+
if env_vars:
|
|
290
|
+
proc_env.update(env_vars)
|
|
291
|
+
|
|
292
|
+
start = time.monotonic()
|
|
293
|
+
try:
|
|
294
|
+
exit_code, stdout, stderr, max_rss_mb = _run_with_rusage(
|
|
295
|
+
command, cwd=cwd, env=proc_env, timeout=timeout
|
|
296
|
+
)
|
|
297
|
+
except subprocess.TimeoutExpired:
|
|
298
|
+
if stats is not None:
|
|
299
|
+
stats["elapsed_s"] = round(time.monotonic() - start, 3)
|
|
300
|
+
stats["max_rss_mb"] = None
|
|
301
|
+
return 124, "", "Lean execution timed out"
|
|
302
|
+
if stats is not None:
|
|
303
|
+
stats["elapsed_s"] = round(time.monotonic() - start, 3)
|
|
304
|
+
stats["max_rss_mb"] = (
|
|
305
|
+
round(max_rss_mb, 1) if max_rss_mb is not None else None
|
|
306
|
+
)
|
|
307
|
+
if check and exit_code != 0:
|
|
308
|
+
raise subprocess.CalledProcessError(exit_code, command, stdout, stderr)
|
|
309
|
+
return exit_code, stdout, stderr
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Detection of out-of-date project modules in a file's import closure.
|
|
3
|
+
|
|
4
|
+
Lean loads the compiled ``.olean`` of every import. When a project module
|
|
5
|
+
was edited after its ``.olean`` was built, a plain ``lean`` run (and a warm
|
|
6
|
+
language-server worker that opened the file earlier) silently uses the old
|
|
7
|
+
compiled version, so a green check can be a false pass. This module finds
|
|
8
|
+
such modules by comparing modification times of source and ``.olean`` for
|
|
9
|
+
project-local imports, transitively. Lake decides by content hashes, so a
|
|
10
|
+
module whose source was merely touched is reported as well; that is a
|
|
11
|
+
harmless over-approximation.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_IMPORT_RE = re.compile(r"^\s*import\s+(\S+)", re.MULTILINE)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_imports(source: str) -> list[str]:
|
|
22
|
+
"""Return the module names of all ``import`` lines in a Lean source text."""
|
|
23
|
+
return _IMPORT_RE.findall(source)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def module_source(project_path: Path, module: str) -> Path | None:
|
|
27
|
+
"""Path of the source file of a project-local module, or None."""
|
|
28
|
+
source = project_path / (module.replace(".", "/") + ".lean")
|
|
29
|
+
return source if source.is_file() else None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def module_olean(project_path: Path, module: str) -> Path | None:
|
|
33
|
+
"""Path of the compiled ``.olean`` of a project-local module, or None."""
|
|
34
|
+
rel = module.replace(".", "/") + ".olean"
|
|
35
|
+
for lib_dir in ("lean", ""):
|
|
36
|
+
candidate = project_path / ".lake" / "build" / "lib" / lib_dir / rel
|
|
37
|
+
if candidate.is_file():
|
|
38
|
+
return candidate
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def find_stale_modules(project_path: Path, modules: list[str]) -> list[str]:
|
|
43
|
+
"""Project-local modules in the import closure of ``modules`` whose
|
|
44
|
+
source is newer than their ``.olean``.
|
|
45
|
+
|
|
46
|
+
Only modules whose source lives in the project are followed; dependencies
|
|
47
|
+
and core modules are assumed up to date. Modules without any ``.olean``
|
|
48
|
+
are skipped (Lean reports those as missing anyway).
|
|
49
|
+
"""
|
|
50
|
+
stale: list[str] = []
|
|
51
|
+
seen: set[str] = set()
|
|
52
|
+
queue = list(modules)
|
|
53
|
+
while queue:
|
|
54
|
+
module = queue.pop(0)
|
|
55
|
+
if module in seen:
|
|
56
|
+
continue
|
|
57
|
+
seen.add(module)
|
|
58
|
+
src = module_source(project_path, module)
|
|
59
|
+
if src is None:
|
|
60
|
+
continue
|
|
61
|
+
olean = module_olean(project_path, module)
|
|
62
|
+
if olean is not None:
|
|
63
|
+
try:
|
|
64
|
+
if src.stat().st_mtime > olean.stat().st_mtime:
|
|
65
|
+
stale.append(module)
|
|
66
|
+
except OSError:
|
|
67
|
+
pass
|
|
68
|
+
try:
|
|
69
|
+
queue.extend(
|
|
70
|
+
parse_imports(src.read_text(encoding="utf-8", errors="replace"))
|
|
71
|
+
)
|
|
72
|
+
except OSError:
|
|
73
|
+
pass
|
|
74
|
+
return stale
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def find_stale_imports(project_path: Path, file_path: Path) -> list[str]:
|
|
78
|
+
"""Stale project-local modules in the import closure of a file."""
|
|
79
|
+
try:
|
|
80
|
+
source = file_path.read_text(encoding="utf-8", errors="replace")
|
|
81
|
+
except OSError:
|
|
82
|
+
return []
|
|
83
|
+
return find_stale_modules(project_path, parse_imports(source))
|
|
@@ -148,6 +148,15 @@ _registry = _WorkerRegistry()
|
|
|
148
148
|
atexit.register(_registry.shutdown_all)
|
|
149
149
|
|
|
150
150
|
|
|
151
|
+
def discard_project(project_path: Path) -> None:
|
|
152
|
+
"""Shut down the project's warm worker so the next check starts fresh.
|
|
153
|
+
|
|
154
|
+
Used when a project module in the import closure is out of date: a new
|
|
155
|
+
``lake serve`` worker rebuilds stale imports when it opens the file.
|
|
156
|
+
"""
|
|
157
|
+
_registry.discard(project_path.resolve())
|
|
158
|
+
|
|
159
|
+
|
|
151
160
|
def check_file_warm(
|
|
152
161
|
project_path: Path, file_path: Path, timeout: float = 600.0
|
|
153
162
|
) -> list[dict] | None:
|
|
@@ -57,8 +57,9 @@ mcp = FastMCP(
|
|
|
57
57
|
instructions="""\
|
|
58
58
|
LeanBack provides tools for Lean 4 theorem proving. All tools are read-only for source files — \
|
|
59
59
|
they never modify your .lean files. Use your own file tools to create/edit .lean files, then use \
|
|
60
|
-
LeanBack to verify them.
|
|
61
|
-
|
|
60
|
+
LeanBack to verify them. Two operations write build artifacts to .lake/: eval with build=true \
|
|
61
|
+
compiles project modules, and check(file=...) compiles out-of-date imports of the checked file \
|
|
62
|
+
(like the Lean language server does).
|
|
62
63
|
|
|
63
64
|
## Getting Started
|
|
64
65
|
1. Call project_info(project="/path/to/project") to check if a project is ready
|
|
@@ -428,6 +429,34 @@ def project_info(project: str) -> str:
|
|
|
428
429
|
)
|
|
429
430
|
|
|
430
431
|
|
|
432
|
+
def _add_run_stats(result: dict, stats: dict) -> None:
|
|
433
|
+
"""Attach peak memory and loaded plugins of a cold Lean run."""
|
|
434
|
+
if stats.get("max_rss_mb") is not None:
|
|
435
|
+
result["max_rss_mb"] = stats["max_rss_mb"]
|
|
436
|
+
if stats.get("plugins"):
|
|
437
|
+
result["plugins_loaded"] = [Path(p).name for p in stats["plugins"]]
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _stale_import_warnings(stale: list[str], file_name: str) -> list:
|
|
441
|
+
"""One warning per out-of-date project module used by a cold check."""
|
|
442
|
+
from leanback.common.errors import LeanError
|
|
443
|
+
|
|
444
|
+
return [
|
|
445
|
+
LeanError(
|
|
446
|
+
file_name=file_name,
|
|
447
|
+
line=0,
|
|
448
|
+
column=0,
|
|
449
|
+
severity="warning",
|
|
450
|
+
message=(
|
|
451
|
+
f"Import {module} is out of date: its source is newer than its "
|
|
452
|
+
f"compiled .olean, so this check used the OLD compiled version."
|
|
453
|
+
),
|
|
454
|
+
suggestion=f"Run `lake build {module}` in the project, then check again.",
|
|
455
|
+
)
|
|
456
|
+
for module in stale
|
|
457
|
+
]
|
|
458
|
+
|
|
459
|
+
|
|
431
460
|
@mcp.tool()
|
|
432
461
|
@_with_elapsed
|
|
433
462
|
def check(
|
|
@@ -450,7 +479,7 @@ def check(
|
|
|
450
479
|
|
|
451
480
|
Parameters:
|
|
452
481
|
- project: Absolute path to the Lean project directory (e.g., "/home/user/lean/myproject")
|
|
453
|
-
- file: Path to a .lean file within the project (e.g., "Basic.lean", "src/Logic.lean"). Repeated checks of the same file reuse a warm Lean server: the first check pays the import cost (~20-30s with Mathlib), subsequent checks after edits take seconds as long as the file's import lines are unchanged.
|
|
482
|
+
- file: Path to a .lean file within the project (e.g., "Basic.lean", "src/Logic.lean"). Repeated checks of the same file reuse a warm Lean server: the first check pays the import cost (~20-30s with Mathlib), subsequent checks after edits take seconds as long as the file's import lines are unchanged. Like the Lean language server, a file check compiles out-of-date imports of THIS project when it opens the file (writing to .lake/build); the result lists them under 'imports_rebuilt'.
|
|
454
483
|
- check_all: Check each file individually for detailed error reporting (slower but more thorough)
|
|
455
484
|
- expr: A Lean expression or command to check (e.g., "2 + 2 = 4", "Nat.add", "#eval 1 + 1"). Bare expressions are auto-wrapped in #check.
|
|
456
485
|
- imports: List of modules to import when checking expressions (e.g., ["Mathlib.Tactic"]). First Mathlib import takes ~20-30s.
|
|
@@ -461,6 +490,7 @@ def check(
|
|
|
461
490
|
- severity: "error", "warning", or "info" (e.g., output from #check)
|
|
462
491
|
- message: The error or output message
|
|
463
492
|
- suggestion: Helpful hint to fix the issue (when available)
|
|
493
|
+
Other fields: 'elapsed_ms' (whole call); 'engine' (file checks only: "warm" = the persistent Lean server, the normal case, which rebuilds out-of-date imports itself; "cold" = a fresh lean process, used when the warm server fails and for all expression checks); 'max_rss_mb' (peak memory of the Lean process, cold runs only); 'plugins_loaded' (cold runs on projects with precompileModules only: the precompiled libraries passed to Lean, which may include toolchain libraries such as libLake_shared); 'stale_imports' (cold runs only: project modules whose source is newer than their compiled .olean, so the check used the OLD version; run `lake build` and check again); 'imports_rebuilt' (warm file checks only: out-of-date project modules the Lean server recompiled before checking).
|
|
464
494
|
|
|
465
495
|
Examples:
|
|
466
496
|
- Verify project compiles: check(project="/home/user/lean/myproject")
|
|
@@ -524,12 +554,18 @@ def check(
|
|
|
524
554
|
):
|
|
525
555
|
expr = f"#check ({expr_stripped})"
|
|
526
556
|
|
|
557
|
+
from leanback.common.stale import find_stale_modules
|
|
558
|
+
|
|
559
|
+
stats: dict = {}
|
|
527
560
|
success, errors = check_lean_expr(
|
|
528
561
|
expr=expr,
|
|
529
562
|
imports=imports or [],
|
|
530
563
|
project_path=project_path,
|
|
531
564
|
console=console,
|
|
565
|
+
stats=stats,
|
|
532
566
|
)
|
|
567
|
+
stale = find_stale_modules(project_path, imports or [])
|
|
568
|
+
errors.extend(_stale_import_warnings(stale, "<inline>"))
|
|
533
569
|
|
|
534
570
|
error_count = sum(1 for e in errors if e.severity == "error")
|
|
535
571
|
warning_count = sum(1 for e in errors if e.severity == "warning")
|
|
@@ -547,6 +583,9 @@ def check(
|
|
|
547
583
|
"total_info": info_count,
|
|
548
584
|
},
|
|
549
585
|
}
|
|
586
|
+
_add_run_stats(result, stats)
|
|
587
|
+
if stale:
|
|
588
|
+
result["stale_imports"] = stale
|
|
550
589
|
return _json(result)
|
|
551
590
|
|
|
552
591
|
# Case 2: Check a specific file
|
|
@@ -587,9 +626,20 @@ def check(
|
|
|
587
626
|
# repeated checks of the same file fast (the import environment
|
|
588
627
|
# stays in memory). Falls back to the cold path on any failure.
|
|
589
628
|
from leanback.common.errors import lsp_diagnostics_to_errors
|
|
590
|
-
from leanback.common.
|
|
629
|
+
from leanback.common.stale import find_stale_imports
|
|
630
|
+
from leanback.common.warm import check_file_warm, discard_project
|
|
631
|
+
|
|
632
|
+
# Project modules edited after their .olean was built: a warm
|
|
633
|
+
# worker that opened the file earlier would keep using the old
|
|
634
|
+
# compiled version, so it is replaced; a fresh lake serve worker
|
|
635
|
+
# rebuilds stale imports when it opens the file. The cold path
|
|
636
|
+
# cannot rebuild and reports them instead.
|
|
637
|
+
stale = find_stale_imports(project_path, file_path)
|
|
638
|
+
if stale:
|
|
639
|
+
discard_project(project_path)
|
|
591
640
|
|
|
592
641
|
engine = "cold"
|
|
642
|
+
stats: dict = {}
|
|
593
643
|
warm_diags = check_file_warm(project_path, file_path)
|
|
594
644
|
if warm_diags is not None:
|
|
595
645
|
errors = lsp_diagnostics_to_errors(warm_diags, str(display_path))
|
|
@@ -600,7 +650,10 @@ def check(
|
|
|
600
650
|
file_path=file_path,
|
|
601
651
|
project_path=project_path,
|
|
602
652
|
console=console,
|
|
653
|
+
stats=stats,
|
|
603
654
|
)
|
|
655
|
+
if stale and engine == "cold":
|
|
656
|
+
errors.extend(_stale_import_warnings(stale, str(display_path)))
|
|
604
657
|
|
|
605
658
|
error_count = sum(1 for e in errors if e.severity == "error")
|
|
606
659
|
warning_count = sum(1 for e in errors if e.severity == "warning")
|
|
@@ -619,6 +672,12 @@ def check(
|
|
|
619
672
|
"files_checked": 1,
|
|
620
673
|
},
|
|
621
674
|
}
|
|
675
|
+
_add_run_stats(result, stats)
|
|
676
|
+
if stale:
|
|
677
|
+
if engine == "warm":
|
|
678
|
+
result["imports_rebuilt"] = stale
|
|
679
|
+
else:
|
|
680
|
+
result["stale_imports"] = stale
|
|
622
681
|
if file_path.name == "lakefile.lean" and not success:
|
|
623
682
|
result["note"] = (
|
|
624
683
|
"lakefile.lean uses Lake DSL which may produce errors "
|
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Lean environment handling utilities.
|
|
3
|
-
|
|
4
|
-
This module provides functionality for executing Lean commands in the correct environment.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import os
|
|
8
|
-
import subprocess
|
|
9
|
-
import sys
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def _find_native_dynlibs(project_path: Path) -> list[Path]:
|
|
14
|
-
"""Discover native shared libraries in Lake packages.
|
|
15
|
-
|
|
16
|
-
Scans .lake/packages/*/.lake/build/lib/ for .so (Linux) or .dylib (macOS)
|
|
17
|
-
files that need --load-dynlib for the Lean interpreter.
|
|
18
|
-
"""
|
|
19
|
-
packages_dir = project_path / ".lake" / "packages"
|
|
20
|
-
if not packages_dir.is_dir():
|
|
21
|
-
return []
|
|
22
|
-
|
|
23
|
-
if sys.platform == "darwin":
|
|
24
|
-
ext = "dylib"
|
|
25
|
-
elif sys.platform == "win32":
|
|
26
|
-
ext = "dll"
|
|
27
|
-
else:
|
|
28
|
-
ext = "so"
|
|
29
|
-
return sorted(packages_dir.glob(f"*/.lake/build/lib/lib*.{ext}"))
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def find_local_modules(project_path: Path, modules: list[str]) -> list[str]:
|
|
33
|
-
"""Filter module names to those with source files in the project.
|
|
34
|
-
|
|
35
|
-
A module like ``Foo.Bar`` is local if ``<project>/Foo/Bar.lean`` exists.
|
|
36
|
-
Dependency modules (e.g. Mathlib.*) and core modules are excluded — they
|
|
37
|
-
are already built or come from the cache.
|
|
38
|
-
"""
|
|
39
|
-
local = []
|
|
40
|
-
for module in modules:
|
|
41
|
-
source = project_path / (module.replace(".", "/") + ".lean")
|
|
42
|
-
if source.is_file():
|
|
43
|
-
local.append(module)
|
|
44
|
-
return local
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def build_modules(
|
|
48
|
-
project_path: Path, modules: list[str], timeout: float = 600
|
|
49
|
-
) -> tuple[bool, str]:
|
|
50
|
-
"""Build the given modules with ``lake build`` in the project directory.
|
|
51
|
-
|
|
52
|
-
Returns:
|
|
53
|
-
Tuple of (success, output). On failure, output contains the
|
|
54
|
-
combined stdout/stderr from lake.
|
|
55
|
-
"""
|
|
56
|
-
if not modules:
|
|
57
|
-
return True, ""
|
|
58
|
-
try:
|
|
59
|
-
result = subprocess.run(
|
|
60
|
-
["lake", "build", *modules],
|
|
61
|
-
cwd=project_path,
|
|
62
|
-
capture_output=True,
|
|
63
|
-
text=True,
|
|
64
|
-
check=False,
|
|
65
|
-
timeout=timeout,
|
|
66
|
-
)
|
|
67
|
-
except subprocess.TimeoutExpired:
|
|
68
|
-
return False, f"lake build timed out after {timeout:.0f} seconds"
|
|
69
|
-
output = ((result.stdout or "") + "\n" + (result.stderr or "")).strip()
|
|
70
|
-
return result.returncode == 0, output
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
class LeanRunner:
|
|
74
|
-
"""
|
|
75
|
-
Utility for running Lean commands in the appropriate environment.
|
|
76
|
-
"""
|
|
77
|
-
|
|
78
|
-
@staticmethod
|
|
79
|
-
def run_lean(
|
|
80
|
-
file_path: str | Path,
|
|
81
|
-
project_path: str | Path | None = None,
|
|
82
|
-
capture_json: bool = False,
|
|
83
|
-
check: bool = True,
|
|
84
|
-
env_vars: dict[str, str] | None = None,
|
|
85
|
-
timeout: float | None = None,
|
|
86
|
-
) -> tuple[int, str, str]:
|
|
87
|
-
"""
|
|
88
|
-
Run Lean on a file in the appropriate environment.
|
|
89
|
-
|
|
90
|
-
Args:
|
|
91
|
-
file_path: Path to the Lean file to run
|
|
92
|
-
project_path: Path to a Lean project (for lake env)
|
|
93
|
-
capture_json: Whether to request JSON output
|
|
94
|
-
check: Whether to raise an exception on non-zero exit codes
|
|
95
|
-
env_vars: Additional environment variables
|
|
96
|
-
timeout: Timeout in seconds
|
|
97
|
-
|
|
98
|
-
Returns:
|
|
99
|
-
Tuple of (exit_code, stdout, stderr)
|
|
100
|
-
"""
|
|
101
|
-
# Convert to Path
|
|
102
|
-
if isinstance(file_path, str):
|
|
103
|
-
file_path = Path(file_path)
|
|
104
|
-
|
|
105
|
-
# We need absolute paths for checking existence internally
|
|
106
|
-
abs_file_path = file_path.absolute()
|
|
107
|
-
|
|
108
|
-
# Determine which path and command to use
|
|
109
|
-
if project_path:
|
|
110
|
-
# If a project is specified, use that project's environment
|
|
111
|
-
project_path = (
|
|
112
|
-
Path(project_path) if isinstance(project_path, str) else project_path
|
|
113
|
-
)
|
|
114
|
-
|
|
115
|
-
# Always use lake env lean for projects - this ensures proper dependency management
|
|
116
|
-
command = ["lake", "env", "lean"]
|
|
117
|
-
|
|
118
|
-
# Load native shared libraries for FFI-dependent packages (e.g., cvc5)
|
|
119
|
-
for lib in _find_native_dynlibs(project_path):
|
|
120
|
-
command.append(f"--load-dynlib={lib}")
|
|
121
|
-
|
|
122
|
-
if capture_json:
|
|
123
|
-
command.append("--json")
|
|
124
|
-
command.append(str(abs_file_path))
|
|
125
|
-
cwd = project_path
|
|
126
|
-
|
|
127
|
-
# Lake automatically handles all dependency paths - no manual LEAN_PATH setup needed
|
|
128
|
-
|
|
129
|
-
else:
|
|
130
|
-
# Default: run directly with lean for files without project context
|
|
131
|
-
command = ["lean"]
|
|
132
|
-
if capture_json:
|
|
133
|
-
command.append("--json")
|
|
134
|
-
command.append(str(abs_file_path))
|
|
135
|
-
|
|
136
|
-
cwd = file_path.parent
|
|
137
|
-
|
|
138
|
-
# Set up environment
|
|
139
|
-
proc_env = os.environ.copy()
|
|
140
|
-
if env_vars:
|
|
141
|
-
proc_env.update(env_vars)
|
|
142
|
-
|
|
143
|
-
try:
|
|
144
|
-
result = subprocess.run(
|
|
145
|
-
command,
|
|
146
|
-
cwd=cwd,
|
|
147
|
-
check=check,
|
|
148
|
-
capture_output=True,
|
|
149
|
-
text=True,
|
|
150
|
-
env=proc_env,
|
|
151
|
-
timeout=timeout,
|
|
152
|
-
)
|
|
153
|
-
return result.returncode, result.stdout, result.stderr
|
|
154
|
-
except subprocess.CalledProcessError as e:
|
|
155
|
-
return e.returncode, e.stdout, e.stderr
|
|
156
|
-
except subprocess.TimeoutExpired:
|
|
157
|
-
return 124, "", "Lean execution timed out"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|