lean-python 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lean-python
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Run Lean 4 code as Python: a runtime for code generated from Lean's IR, Lean blocks in Python files, and pydantic models for Lean types
5
5
  Project-URL: Repository, https://github.com/conao3/lean-py
6
6
  License-Expression: Apache-2.0
@@ -170,12 +170,94 @@ member), `Literal["a", "b"]` (`LeanPythonLiterals.<module>.<Model>_<field>`), an
170
170
  docstring lists them. Generated functions take and return your classes; values that did not go
171
171
  through validation (`model_construct`) are checked again on the way to Lean.
172
172
 
173
+ ## Verifying Python functions
174
+
175
+ `@model` binds a typed, pure Python function to specifications written in a Lean block. The function
176
+ stays ordinary Python; `lean-py check` translates it to Lean and checks your proofs, and
177
+ `check_model` runs the same specifications as a Hypothesis property test.
178
+
179
+ ```python
180
+ from lean_python import lean
181
+ from lean_python.verify import model
182
+
183
+ spec = lean("spec", r"""
184
+ def clampSpec (x lo hi : Int) : Int := max lo (min hi x)
185
+ def ordered (_x lo hi : Int) : Bool := lo ≤ hi
186
+ def inRange (_x lo hi : Int) (r : Int) : Bool := lo ≤ r && r ≤ hi
187
+ """)
188
+
189
+ @model(refines=spec.clampSpec, requires=spec.ordered, ensures={"in_range": spec.inRange})
190
+ def clamp(x: int, lo: int, hi: int) -> int:
191
+ if x < lo:
192
+ return lo
193
+ if x > hi:
194
+ return hi
195
+ return x
196
+ ```
197
+
198
+ `lean-py check --package calc` writes the translation and one obligation per specification:
199
+
200
+ ```lean
201
+ def LeanPythonModel.calc.calc.clamp (x : Int) (lo : Int) (hi : Int) : Int := Id.run do
202
+ if ((decide (x < lo))) then
203
+ return lo
204
+ if ((decide (x > hi))) then
205
+ return hi
206
+ return x
207
+
208
+ abbrev LeanPythonModel.calc.calc.clamp.obligation.refines : Prop :=
209
+ ∀ (x : Int) (lo : Int) (hi : Int), ordered x lo hi = true → clamp x lo hi = clampSpec x lo hi
210
+ ```
211
+
212
+ and judges the theorems `<function>.proof.<label>` of `lean/Proofs/*.lean` next to `pyproject.toml`:
213
+
214
+ ```lean
215
+ import LeanPythonModel.calc.calc
216
+ open LeanPythonEmbed.calc.spec.spec
217
+ namespace LeanPythonModel.«calc».«calc»
218
+
219
+ theorem clamp.proof.refines : clamp.obligation.refines := by
220
+ intro x lo hi h
221
+ simp only [ordered, decide_eq_true_eq] at h
222
+ simp only [clamp, clampSpec]
223
+ split <;> (try split) <;> simp_all <;> omega
224
+ ```
225
+
226
+ ```
227
+ proved calc.calc:clamp refines
228
+ proved calc.calc:clamp in_range
229
+ sorry calc.calc:sum_to refines
230
+ missing calc.calc:sum_abs nonneg
231
+ ```
232
+
233
+ A `for` loop over a list becomes `List.foldl` under `simp` (`forIn_pure_yield_eq_foldl`), so a loop
234
+ is proved by induction on the list (`tests/verify/lean/Proofs/Calc.lean` proves `sum_abs`).
235
+
236
+ The states are proved, sorry, axioms (non-standard axioms), failed (the proof file does not build),
237
+ missing, mismatch (the theorem states something else). `--require proved` fails unless all are
238
+ proved. The result goes to `lean-python.lock` with a hash of each function's source;
239
+ `lean-py status` reads it without Lean and marks changed functions `stale`.
240
+
241
+ - `refines=s`: `f x = s x` (`Except.ok (s x)` for a function that may fail); `requires=p`: `p x = true →`
242
+ before each obligation; `ensures={"label": q}`: `q x (f x) = true`. A function annotated `-> Nat`
243
+ also gets `returns_nat` (`0 ≤ f x`).
244
+ - The translation: `int` is `Int` (a `Nat` parameter is a `Nat` converted to `Int` on entry);
245
+ statements become Lean `do` notation (`let mut`, `for`, `while`, `break`, `continue`, early
246
+ `return`); indexing, `//`, `%`, `assert` and `raise` make the function return
247
+ `Py.M α = Except Py.Exc α`, with Python's negative indices and floor division (`LeanPythonLib.Py`).
248
+ Supported: `int`, `bool`, `str`, `float`, `list`, `tuple`, `T | None`, pydantic models (as the
249
+ Lean structures of `models=`), `len`, `abs`, `min`, `max`, `sum`, list comprehensions, `append` on
250
+ local lists, and calls of other `@model` functions of the module.
251
+ - `check_model(f)` / `check_module(m)` generate arguments from the annotations (pydantic constraints
252
+ included), skip those failing `requires`, and compare with `refines` and `ensures`.
253
+
173
254
  ## The CLI
174
255
 
175
256
  ```
176
257
  lean-python module generate --module-name <Name> [--roots <Name> ...] [--namespace <Name>]
177
258
  [--search-path <dir> ...] [--output-file <path>] [--internal-only] [--output json|text]
178
259
  [--type-module <Name>=<module> ...] [--model-module <Name>=<module> ...] [--batch <file>]
260
+ lean-python obligations check --modules <Name> ... --obligations <file> [--search-path <dir> ...]
179
261
  lean-python --version | --generator-version | help
180
262
  ```
181
263
 
@@ -224,7 +306,7 @@ nix develop -c make bench
224
306
  - `Task`s run synchronously.
225
307
  - Building a `String` with `String.push` copies the string each time (Python `bytes`), so it grows
226
308
  quadratically.
227
- - Python's own functions are not verified in Lean yet.
309
+ - The translation of Python functions covers the subset above; the guarantee is partial correctness.
228
310
 
229
311
  ## License
230
312
 
@@ -156,12 +156,94 @@ member), `Literal["a", "b"]` (`LeanPythonLiterals.<module>.<Model>_<field>`), an
156
156
  docstring lists them. Generated functions take and return your classes; values that did not go
157
157
  through validation (`model_construct`) are checked again on the way to Lean.
158
158
 
159
+ ## Verifying Python functions
160
+
161
+ `@model` binds a typed, pure Python function to specifications written in a Lean block. The function
162
+ stays ordinary Python; `lean-py check` translates it to Lean and checks your proofs, and
163
+ `check_model` runs the same specifications as a Hypothesis property test.
164
+
165
+ ```python
166
+ from lean_python import lean
167
+ from lean_python.verify import model
168
+
169
+ spec = lean("spec", r"""
170
+ def clampSpec (x lo hi : Int) : Int := max lo (min hi x)
171
+ def ordered (_x lo hi : Int) : Bool := lo ≤ hi
172
+ def inRange (_x lo hi : Int) (r : Int) : Bool := lo ≤ r && r ≤ hi
173
+ """)
174
+
175
+ @model(refines=spec.clampSpec, requires=spec.ordered, ensures={"in_range": spec.inRange})
176
+ def clamp(x: int, lo: int, hi: int) -> int:
177
+ if x < lo:
178
+ return lo
179
+ if x > hi:
180
+ return hi
181
+ return x
182
+ ```
183
+
184
+ `lean-py check --package calc` writes the translation and one obligation per specification:
185
+
186
+ ```lean
187
+ def LeanPythonModel.calc.calc.clamp (x : Int) (lo : Int) (hi : Int) : Int := Id.run do
188
+ if ((decide (x < lo))) then
189
+ return lo
190
+ if ((decide (x > hi))) then
191
+ return hi
192
+ return x
193
+
194
+ abbrev LeanPythonModel.calc.calc.clamp.obligation.refines : Prop :=
195
+ ∀ (x : Int) (lo : Int) (hi : Int), ordered x lo hi = true → clamp x lo hi = clampSpec x lo hi
196
+ ```
197
+
198
+ and judges the theorems `<function>.proof.<label>` of `lean/Proofs/*.lean` next to `pyproject.toml`:
199
+
200
+ ```lean
201
+ import LeanPythonModel.calc.calc
202
+ open LeanPythonEmbed.calc.spec.spec
203
+ namespace LeanPythonModel.«calc».«calc»
204
+
205
+ theorem clamp.proof.refines : clamp.obligation.refines := by
206
+ intro x lo hi h
207
+ simp only [ordered, decide_eq_true_eq] at h
208
+ simp only [clamp, clampSpec]
209
+ split <;> (try split) <;> simp_all <;> omega
210
+ ```
211
+
212
+ ```
213
+ proved calc.calc:clamp refines
214
+ proved calc.calc:clamp in_range
215
+ sorry calc.calc:sum_to refines
216
+ missing calc.calc:sum_abs nonneg
217
+ ```
218
+
219
+ A `for` loop over a list becomes `List.foldl` under `simp` (`forIn_pure_yield_eq_foldl`), so a loop
220
+ is proved by induction on the list (`tests/verify/lean/Proofs/Calc.lean` proves `sum_abs`).
221
+
222
+ The states are proved, sorry, axioms (non-standard axioms), failed (the proof file does not build),
223
+ missing, mismatch (the theorem states something else). `--require proved` fails unless all are
224
+ proved. The result goes to `lean-python.lock` with a hash of each function's source;
225
+ `lean-py status` reads it without Lean and marks changed functions `stale`.
226
+
227
+ - `refines=s`: `f x = s x` (`Except.ok (s x)` for a function that may fail); `requires=p`: `p x = true →`
228
+ before each obligation; `ensures={"label": q}`: `q x (f x) = true`. A function annotated `-> Nat`
229
+ also gets `returns_nat` (`0 ≤ f x`).
230
+ - The translation: `int` is `Int` (a `Nat` parameter is a `Nat` converted to `Int` on entry);
231
+ statements become Lean `do` notation (`let mut`, `for`, `while`, `break`, `continue`, early
232
+ `return`); indexing, `//`, `%`, `assert` and `raise` make the function return
233
+ `Py.M α = Except Py.Exc α`, with Python's negative indices and floor division (`LeanPythonLib.Py`).
234
+ Supported: `int`, `bool`, `str`, `float`, `list`, `tuple`, `T | None`, pydantic models (as the
235
+ Lean structures of `models=`), `len`, `abs`, `min`, `max`, `sum`, list comprehensions, `append` on
236
+ local lists, and calls of other `@model` functions of the module.
237
+ - `check_model(f)` / `check_module(m)` generate arguments from the annotations (pydantic constraints
238
+ included), skip those failing `requires`, and compare with `refines` and `ensures`.
239
+
159
240
  ## The CLI
160
241
 
161
242
  ```
162
243
  lean-python module generate --module-name <Name> [--roots <Name> ...] [--namespace <Name>]
163
244
  [--search-path <dir> ...] [--output-file <path>] [--internal-only] [--output json|text]
164
245
  [--type-module <Name>=<module> ...] [--model-module <Name>=<module> ...] [--batch <file>]
246
+ lean-python obligations check --modules <Name> ... --obligations <file> [--search-path <dir> ...]
165
247
  lean-python --version | --generator-version | help
166
248
  ```
167
249
 
@@ -210,7 +292,7 @@ nix develop -c make bench
210
292
  - `Task`s run synchronously.
211
293
  - Building a `String` with `String.push` copies the string each time (Python `bytes`), so it grows
212
294
  quadratically.
213
- - Python's own functions are not verified in Lean yet.
295
+ - The translation of Python functions covers the subset above; the guarantee is partial correctness.
214
296
 
215
297
  ## License
216
298
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lean-python"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Run Lean 4 code as Python: a runtime for code generated from Lean's IR, Lean blocks in Python files, and pydantic models for Lean types"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -0,0 +1,292 @@
1
+ """``lean-py check`` and ``lean-py status``: the proofs of the ``@model`` functions of a package.
2
+
3
+ ``check`` syncs the package's Lean blocks, translates every ``@model`` function into
4
+ ``LeanPythonModel.<module>`` with one obligation per specification (``<function>.obligation.<label>``),
5
+ builds them with the proofs in ``<project>/lean/Proofs/`` (theorems ``<function>.proof.<label>``),
6
+ and asks the Lean CLI for the state of each obligation: proved, sorry, axioms, missing,
7
+ mismatch. The result and a hash of each function's source go to ``<project>/lean-python.lock``.
8
+ ``status`` reads the lock without Lean and marks a function stale when its source changed.
9
+ """
10
+
11
+ import ast
12
+ import hashlib
13
+ import importlib
14
+ import inspect
15
+ import json
16
+ import pathlib
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ import textwrap
21
+
22
+ from lean_python import models, sync, translate, verify
23
+
24
+ LAKEFILE_EXTRA = """
25
+ [[lean_lib]]
26
+ name = "LeanPythonLib"
27
+
28
+ [[lean_lib]]
29
+ name = "LeanPythonModel"
30
+
31
+ [[lean_lib]]
32
+ name = "Proofs"
33
+ globs = ["Proofs.+"]
34
+ """
35
+
36
+ STATUSES = ("proved", "sorry", "axioms", "failed", "missing", "mismatch", "unextracted", "stale")
37
+
38
+
39
+ def model_module(module):
40
+ return ["LeanPythonModel", *module.split(".")]
41
+
42
+
43
+ def function_name(fn):
44
+ return models.dotted([*model_module(fn.__module__), fn.__qualname__])
45
+
46
+
47
+ def source_hash(fn):
48
+ src = textwrap.dedent(inspect.getsource(fn))
49
+ return hashlib.sha256(ast.dump(ast.parse(src)).encode()).hexdigest()
50
+
51
+
52
+ def lean_name_of(spec_fn, label, fn):
53
+ name = getattr(spec_fn, "__lean_name__", None)
54
+ if name is None:
55
+ raise sync.SyncError(
56
+ f"{fn.__qualname__}: the {label} specification is not a function of a lean(...) block", 252
57
+ )
58
+ return name, getattr(spec_fn, "__lean_module__", None)
59
+
60
+
61
+ def obligations(spec, tfn):
62
+ """The Lean statements of the obligations of one function, by label."""
63
+ fn = spec.fn
64
+ binders = []
65
+ args = []
66
+ for name, ann in tfn.params:
67
+ t = translate.py_type(ann, fn)
68
+ ty = "Nat" if translate.is_nat(ann) else translate.lean_type(t)
69
+ binders.append(f"({translate.lean_ident(name)} : {ty})")
70
+ args.append(translate.lean_ident(name))
71
+ call = f"({tfn.lean_name} {' '.join(args)})"
72
+ a = " ".join(args)
73
+ imports = set()
74
+ pre = ""
75
+ if spec.requires is not None:
76
+ r, m = lean_name_of(spec.requires, "requires", fn)
77
+ imports.add(m)
78
+ pre = f"{r} {a} = true → "
79
+ out = {}
80
+
81
+ def wrap(body):
82
+ return f"∀ {' '.join(binders)}, {pre}{body}" if binders else f"{pre}{body}"
83
+
84
+ if spec.refines is not None:
85
+ s, m = lean_name_of(spec.refines, "refines", fn)
86
+ imports.add(m)
87
+ ret = getattr(spec.refines, "__annotations__", {}).get("return")
88
+ expected = f"(({s} {a} : Nat) : Int)" if ret == "Nat" else f"({s} {a})"
89
+ out["refines"] = wrap(f"{call} = Except.ok {expected}" if tfn.fails else f"{call} = {expected}")
90
+ for label, e in spec.ensures.items():
91
+ en, m = lean_name_of(e, label, fn)
92
+ imports.add(m)
93
+ out[label] = wrap(f"∀ r, {call} = Except.ok r → {en} {a} r = true" if tfn.fails else f"{en} {a} {call} = true")
94
+ if tfn.ret_nat:
95
+ out["returns_nat"] = wrap(f"∀ r, {call} = Except.ok r → 0 ≤ r" if tfn.fails else f"0 ≤ {call}")
96
+ return out, {i for i in imports if i}
97
+
98
+
99
+ def reimport(pkg):
100
+ for name in [m for m in sys.modules if m == pkg or m.startswith(pkg + ".")]:
101
+ del sys.modules[name]
102
+ importlib.invalidate_caches()
103
+
104
+
105
+ def check_package(pkg_dir, cli=None, out=sys.stderr):
106
+ pkg_dir = pathlib.Path(pkg_dir).resolve()
107
+ cli = cli or shutil.which("lean-python")
108
+ sync.sync_package(pkg_dir, cli=cli, out=out)
109
+ root = sync.project_root(pkg_dir)
110
+ ws = root / ".lean-python" / "workspace"
111
+
112
+ reimport(pkg_dir.name)
113
+ verify.REGISTRY.clear()
114
+ for path in sorted(pkg_dir.rglob("*.py")):
115
+ rel = path.relative_to(pkg_dir)
116
+ if rel.parts[0] == "_lean" or rel.name == "__main__.py":
117
+ continue
118
+ parts = [pkg_dir.name, *rel.with_suffix("").parts]
119
+ if parts[-1] == "__init__":
120
+ parts = parts[:-1]
121
+ importlib.import_module(".".join(parts))
122
+ specs = list(verify.REGISTRY)
123
+
124
+ lib = ws / "LeanPythonLib"
125
+ shutil.rmtree(lib, ignore_errors=True)
126
+ shutil.copytree(pathlib.Path(__file__).parent / "lean" / "LeanPythonLib", lib)
127
+ (ws / "LeanPythonLib.lean").write_text("import LeanPythonLib.Py\n")
128
+ shutil.rmtree(ws / "LeanPythonModel", ignore_errors=True)
129
+ shutil.rmtree(ws / "Proofs", ignore_errors=True)
130
+ proofs_src = root / "lean" / "Proofs"
131
+ if proofs_src.is_dir():
132
+ shutil.copytree(proofs_src, ws / "Proofs")
133
+ else:
134
+ (ws / "Proofs").mkdir()
135
+ (ws / "Proofs" / "Empty.lean").write_text("")
136
+ lakefile = (ws / "lakefile.toml").read_text()
137
+ (ws / "lakefile.toml").write_text(lakefile + LAKEFILE_EXTRA)
138
+
139
+ by_module = {}
140
+ for s in specs:
141
+ by_module.setdefault(s.fn.__module__, []).append(s)
142
+ model_modules = []
143
+ obligation_refs = []
144
+ for module, ss in by_module.items():
145
+ tfns = [translate.Fn(s.fn, function_name(s.fn)) for s in ss]
146
+ tr = translate.Translator(tfns)
147
+ model_classes = set()
148
+ for t in tfns:
149
+ for _, ann in t.params:
150
+ _collect_models(ann, model_classes)
151
+ _collect_models(t.hints["return"], model_classes)
152
+ imports = {"LeanPythonLib.Py"}
153
+ imports |= (
154
+ {models.dotted(models.models_namespace(m)) for m in models.modules_source(list(model_classes))}
155
+ if model_classes
156
+ else set()
157
+ )
158
+ defs = []
159
+ for s, t in zip(ss, tfns, strict=True):
160
+ try:
161
+ defs.append(tr.definition(t))
162
+ except translate.TranslateError as e:
163
+ raise sync.SyncError(str(e), 254) from None
164
+ obls, extra = obligations(s, t)
165
+ imports |= extra
166
+ for label, prop in obls.items():
167
+ defs.append(f"abbrev {t.lean_name}.obligation.{label} : Prop :=\n {prop}")
168
+ obligation_refs.append((s, label))
169
+ missing_models = [m for m in models.modules_source(list(model_classes))] if model_classes else []
170
+ for m in missing_models:
171
+ p = ws / pathlib.Path(*models.models_namespace(m)).with_suffix(".lean")
172
+ if not p.exists():
173
+ p.parent.mkdir(parents=True, exist_ok=True)
174
+ p.write_text(models.modules_source(list(model_classes))[m])
175
+ path = ws / pathlib.Path(*model_module(module)).with_suffix(".lean")
176
+ path.parent.mkdir(parents=True, exist_ok=True)
177
+ head = "".join(f"import {i}\n" for i in sorted(imports))
178
+ path.write_text(head + "\n" + "\n\n".join(defs) + "\n")
179
+ model_modules.append(".".join(model_module(module)))
180
+ (ws / "LeanPythonModel.lean").write_text(
181
+ "".join(f"import {models.dotted(m.split('.'))}\n" for m in model_modules) or "\n"
182
+ )
183
+ (ws / "LeanPythonModels.lean").write_text(
184
+ "".join(
185
+ f"import {models.dotted(p.relative_to(ws).with_suffix('').parts)}\n"
186
+ for p in sorted((ws / "LeanPythonModels").rglob("*.lean"))
187
+ )
188
+ if (ws / "LeanPythonModels").is_dir()
189
+ else "\n"
190
+ )
191
+ proof_modules = [".".join(p.relative_to(ws).with_suffix("").parts) for p in sorted((ws / "Proofs").rglob("*.lean"))]
192
+ (ws / "Proofs.lean").write_text("".join(f"import {models.dotted(m.split('.'))}\n" for m in proof_modules))
193
+
194
+ r = subprocess.run(
195
+ ["lake", "build", "LeanPythonLib", "LeanPythonModels", "LeanPythonEmbed", "LeanPythonModel"],
196
+ cwd=ws,
197
+ capture_output=True,
198
+ text=True,
199
+ check=False,
200
+ )
201
+ if r.returncode != 0:
202
+ print(r.stdout + r.stderr, file=out)
203
+ raise sync.SyncError("lake build of the translated functions failed", 254)
204
+ failed_files = {}
205
+ built = []
206
+ for m in proof_modules:
207
+ r = subprocess.run(["lake", "build", m], cwd=ws, capture_output=True, text=True, check=False)
208
+ if r.returncode == 0:
209
+ built.append(m)
210
+ continue
211
+ src = (ws / pathlib.Path(*m.split("."))).with_suffix(".lean")
212
+ failed_files[m] = src.read_text()
213
+ for line in (r.stdout + r.stderr).splitlines():
214
+ if line.startswith(("error:", " ", "⊢", "case ")) or ": error" in line:
215
+ print(f"lean-py: {line}", file=out)
216
+ proof_modules = built
217
+
218
+ obl_file = ws / "obligations.json"
219
+ obl_file.write_text(
220
+ json.dumps([{"model": _plain(function_name(s.fn)), "label": label} for s, label in obligation_refs])
221
+ )
222
+ r = subprocess.run(
223
+ [
224
+ cli,
225
+ "obligations",
226
+ "check",
227
+ "--search-path",
228
+ str(ws / ".lake/build/lib/lean"),
229
+ "--obligations",
230
+ str(obl_file),
231
+ "--modules",
232
+ *model_modules,
233
+ *proof_modules,
234
+ ],
235
+ capture_output=True,
236
+ text=True,
237
+ check=False,
238
+ )
239
+ if r.returncode != 0:
240
+ print(r.stderr, file=out)
241
+ raise sync.SyncError("lean-python obligations check failed", r.returncode)
242
+ results = json.loads(r.stdout)
243
+ for (s, label), res in zip(obligation_refs, results, strict=True):
244
+ theorem = f"{s.fn.__qualname__}.proof.{label}"
245
+ if res["status"] == "missing" and any(theorem in text for text in failed_files.values()):
246
+ res["status"] = "failed"
247
+ lock = {}
248
+ for (s, label), res in zip(obligation_refs, results, strict=True):
249
+ entry = lock.setdefault(s.qualname, {"hash": source_hash(s.fn), "lean": function_name(s.fn), "obligations": {}})
250
+ entry["obligations"][label] = res["status"]
251
+ (root / "lean-python.lock").write_text(json.dumps(lock, indent=2, sort_keys=True) + "\n")
252
+ return lock, results
253
+
254
+
255
+ def _plain(name):
256
+ return name.replace("«", "").replace("»", "")
257
+
258
+
259
+ def _collect_models(ann, acc):
260
+ import typing
261
+
262
+ from pydantic import BaseModel
263
+
264
+ while typing.get_origin(ann) is typing.Annotated:
265
+ ann = typing.get_args(ann)[0]
266
+ if isinstance(ann, type) and issubclass(ann, BaseModel):
267
+ acc.add(ann)
268
+ return
269
+ for a in typing.get_args(ann):
270
+ _collect_models(a, acc)
271
+
272
+
273
+ def status(pkg_dir):
274
+ """The lock's states, with ``stale`` for functions whose source changed (no Lean needed)."""
275
+ pkg_dir = pathlib.Path(pkg_dir).resolve()
276
+ root = sync.project_root(pkg_dir)
277
+ lock_path = root / "lean-python.lock"
278
+ lock = json.loads(lock_path.read_text()) if lock_path.exists() else {}
279
+ sys.path.insert(0, str(pkg_dir.parent))
280
+ verify.REGISTRY.clear()
281
+ sync.record_blocks(pkg_dir)
282
+ rows = []
283
+ for s in verify.REGISTRY:
284
+ entry = lock.get(s.qualname)
285
+ labels = list(s.specs())
286
+ if entry is None:
287
+ rows.extend((s.qualname, label, "stale") for label in labels if label != "requires")
288
+ continue
289
+ stale = entry["hash"] != source_hash(s.fn)
290
+ for label, st in entry["obligations"].items():
291
+ rows.append((s.qualname, label, "stale" if stale else st))
292
+ return rows
@@ -0,0 +1,73 @@
1
+ """``lean-py``: the command line of the Python package.
2
+
3
+ usage: lean-py sync --package <dir> [--lean-python <path>]
4
+ """
5
+
6
+ import argparse
7
+ import os
8
+ import sys
9
+
10
+ USAGE = """usage: lean-py <command> [parameters]
11
+
12
+ commands:
13
+ sync Check the Lean blocks of a package and generate their Python modules
14
+ check Translate the @model functions to Lean and judge the proofs of their obligations
15
+ status Show the states recorded by `check`, marking functions changed since then (no Lean)
16
+
17
+ lean-py sync
18
+ --package <dir> The Python package (the directory with __init__.py) (required)
19
+ --lean-python <path> The Lean CLI (default: $LEAN_PYTHON_CLI, then `lean-python` on PATH)
20
+
21
+ lean-py check
22
+ --package <dir>, --lean-python <path> as for sync
23
+ --require proved Exit with 254 unless every obligation is proved
24
+
25
+ lean-py status
26
+ --package <dir> The Python package (required)
27
+
28
+ exit codes: 0 success, 252 invalid parameters, 254 input that cannot be converted, 255 other errors
29
+ """
30
+
31
+
32
+ def main(argv=None):
33
+ argv = sys.argv[1:] if argv is None else argv
34
+ if not argv or argv[0] in ("help", "--help", "-h"):
35
+ print(USAGE, end="")
36
+ return 0
37
+ command = argv[0]
38
+ if command not in ("sync", "check", "status"):
39
+ print(f"lean-py: unknown command {command!r} (see `lean-py help`)", file=sys.stderr)
40
+ return 252
41
+ p = argparse.ArgumentParser(prog=f"lean-py {command}", add_help=False, exit_on_error=False)
42
+ p.add_argument("--package", required=True)
43
+ p.add_argument("--lean-python")
44
+ p.add_argument("--require", choices=["proved"])
45
+ try:
46
+ args = p.parse_args(argv[1:])
47
+ except (argparse.ArgumentError, SystemExit) as e:
48
+ print(f"lean-py: {e}", file=sys.stderr)
49
+ return 252
50
+ from lean_python import check, sync
51
+
52
+ cli = args.lean_python or os.environ.get("LEAN_PYTHON_CLI")
53
+ try:
54
+ if command == "sync":
55
+ sync.sync_package(args.package, cli=cli)
56
+ return 0
57
+ if command == "check":
58
+ lock, _ = check.check_package(args.package, cli=cli)
59
+ rows = [(q, label, st) for q, e in sorted(lock.items()) for label, st in e["obligations"].items()]
60
+ else:
61
+ rows = check.status(args.package)
62
+ except sync.SyncError as e:
63
+ print(f"lean-py: {e}", file=sys.stderr)
64
+ return e.code
65
+ for q, label, st in rows:
66
+ print(f"{st:<10} {q} {label}")
67
+ if args.require == "proved" and any(st != "proved" for _, _, st in rows):
68
+ return 254
69
+ return 0
70
+
71
+
72
+ if __name__ == "__main__":
73
+ sys.exit(main())
@@ -14,7 +14,7 @@ import sys
14
14
  import typing
15
15
  from dataclasses import dataclass, field
16
16
 
17
- GENERATOR_VERSION = "1"
17
+ GENERATOR_VERSION = "2"
18
18
 
19
19
 
20
20
  class LeanBlockStale(RuntimeError):
@@ -0,0 +1,52 @@
1
+ /-! The meaning of the Python operations the translated functions use (`lean-py check`).
2
+
3
+ Failing operations (indexing, `//`, `%`, `assert`, `raise`) return `Py.M α = Except Py.Exc α`.
4
+ Python's `int` is `Int`; `//` and `%` round towards negative infinity (`Int.fdiv`, `Int.fmod`). -/
5
+
6
+ namespace Py
7
+
8
+ /-- The exceptions of the translated functions. -/
9
+ inductive Exc where
10
+ | indexError
11
+ | zeroDivisionError
12
+ | assertionError
13
+ | valueError (msg : String)
14
+ deriving Repr, DecidableEq
15
+
16
+ abbrev M := Except Exc
17
+
18
+ /-- `xs[i]`, with Python's negative indices. -/
19
+ def getItem (xs : List α) (i : Int) : M α :=
20
+ let j := if i < 0 then i + xs.length else i
21
+ if 0 ≤ j then
22
+ match xs[j.toNat]? with
23
+ | some v => pure v
24
+ | none => throw .indexError
25
+ else throw .indexError
26
+
27
+ /-- `a // b`. -/
28
+ def floordiv (a b : Int) : M Int := if b = 0 then throw .zeroDivisionError else pure (Int.fdiv a b)
29
+
30
+ /-- `a % b`. -/
31
+ def mod (a b : Int) : M Int := if b = 0 then throw .zeroDivisionError else pure (Int.fmod a b)
32
+
33
+ /-- `range(a, b)`. -/
34
+ def range (a b : Int) : List Int := (List.range (b - a).toNat).map fun (k : Nat) => a + (k : Int)
35
+
36
+ def abs (a : Int) : Int := if a < 0 then -a else a
37
+
38
+ def len (xs : List α) : Int := xs.length
39
+
40
+ def strLen (s : String) : Int := s.length
41
+
42
+ def sum (xs : List Int) : Int := xs.foldl (· + ·) 0
43
+
44
+ def assert (c : Bool) : M Unit := if c then pure () else throw .assertionError
45
+
46
+ @[simp] theorem range_mem (a b i : Int) : i ∈ range a b ↔ a ≤ i ∧ i < b := by
47
+ simp only [range, List.mem_map, List.mem_range]
48
+ constructor
49
+ · rintro ⟨k, hk, rfl⟩; omega
50
+ · intro h; exact ⟨(i - a).toNat, by omega, by omega⟩
51
+
52
+ end Py
@@ -701,9 +701,11 @@ def register_inductive(base, pairs):
701
701
  # Module assembly
702
702
 
703
703
 
704
- def attach(g, path, fn):
704
+ def attach(g, path, fn, lean_name=None):
705
705
  fn.__name__ = path[-1]
706
706
  fn.__qualname__ = ".".join(path)
707
+ fn.__lean_name__ = lean_name
708
+ fn.__lean_module__ = g.get("__lean_module__")
707
709
  if len(path) == 1:
708
710
  g[path[0]] = fn
709
711
  return