lean-python 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lean_python/check.py ADDED
@@ -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
lean_python/cli.py CHANGED
@@ -10,12 +10,21 @@ import sys
10
10
  USAGE = """usage: lean-py <command> [parameters]
11
11
 
12
12
  commands:
13
- sync Check the Lean blocks of a package and generate their Python modules
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)
14
16
 
15
17
  lean-py sync
16
18
  --package <dir> The Python package (the directory with __init__.py) (required)
17
19
  --lean-python <path> The Lean CLI (default: $LEAN_PYTHON_CLI, then `lean-python` on PATH)
18
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
+
19
28
  exit codes: 0 success, 252 invalid parameters, 254 input that cannot be converted, 255 other errors
20
29
  """
21
30
 
@@ -25,24 +34,38 @@ def main(argv=None):
25
34
  if not argv or argv[0] in ("help", "--help", "-h"):
26
35
  print(USAGE, end="")
27
36
  return 0
28
- if argv[0] != "sync":
29
- print(f"lean-py: unknown command {argv[0]!r} (see `lean-py help`)", file=sys.stderr)
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)
30
40
  return 252
31
- p = argparse.ArgumentParser(prog="lean-py sync", add_help=False, exit_on_error=False)
41
+ p = argparse.ArgumentParser(prog=f"lean-py {command}", add_help=False, exit_on_error=False)
32
42
  p.add_argument("--package", required=True)
33
43
  p.add_argument("--lean-python")
44
+ p.add_argument("--require", choices=["proved"])
34
45
  try:
35
46
  args = p.parse_args(argv[1:])
36
47
  except (argparse.ArgumentError, SystemExit) as e:
37
48
  print(f"lean-py: {e}", file=sys.stderr)
38
49
  return 252
39
- from lean_python import sync
50
+ from lean_python import check, sync
40
51
 
52
+ cli = args.lean_python or os.environ.get("LEAN_PYTHON_CLI")
41
53
  try:
42
- sync.sync_package(args.package, cli=args.lean_python or os.environ.get("LEAN_PYTHON_CLI"))
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)
43
62
  except sync.SyncError as e:
44
63
  print(f"lean-py: {e}", file=sys.stderr)
45
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
46
69
  return 0
47
70
 
48
71
 
lean_python/embed.py CHANGED
@@ -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
lean_python/marshal.py CHANGED
@@ -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
@@ -0,0 +1,469 @@
1
+ """Translation of a typed, pure Python function into a Lean definition (``lean-py check``).
2
+
3
+ The body becomes Lean ``do`` notation, which has Python's statements: ``let mut`` for local
4
+ variables, ``for``/``while`` with ``break``/``continue``, ``if``, early ``return``. A function that
5
+ may fail (indexing, ``//``, ``%``, ``assert``, ``raise``, or a call of such a function) returns
6
+ ``Py.M α`` (``Except Py.Exc α``); the others return ``α`` through ``Id.run do``. Python's ``int``
7
+ is ``Int``; a ``Nat`` parameter is converted to ``Int`` on entry.
8
+ """
9
+
10
+ import ast
11
+ import inspect
12
+ import textwrap
13
+ import typing
14
+
15
+ from pydantic import BaseModel
16
+
17
+ from lean_python import models
18
+ from lean_python.types import Nat
19
+
20
+
21
+ class TranslateError(Exception):
22
+ def __init__(self, msg, fn=None, node=None):
23
+ where = ""
24
+ if fn is not None:
25
+ line = fn.__code__.co_firstlineno + (node.lineno - 1 if node is not None and hasattr(node, "lineno") else 0)
26
+ where = f"{fn.__code__.co_filename}:{line}: "
27
+ super().__init__(f"{where}{msg}")
28
+
29
+
30
+ INT, BOOL, STR, FLOAT, NONE = "int", "bool", "str", "float", "none"
31
+
32
+
33
+ def py_type(t, fn):
34
+ """A small type descriptor of a Python annotation."""
35
+ if t is Nat or (typing.get_origin(t) is typing.Annotated and typing.get_args(t)[0] is int):
36
+ return INT
37
+ if t is int:
38
+ return INT
39
+ if t is bool:
40
+ return BOOL
41
+ if t is str:
42
+ return STR
43
+ if t is float:
44
+ return FLOAT
45
+ if t is type(None) or t is None:
46
+ return NONE
47
+ origin = typing.get_origin(t)
48
+ if origin is list:
49
+ return ("list", py_type(typing.get_args(t)[0], fn))
50
+ if origin is tuple:
51
+ return ("tuple", tuple(py_type(a, fn) for a in typing.get_args(t)))
52
+ args = typing.get_args(t)
53
+ if args and type(None) in args and len(args) == 2:
54
+ return ("opt", py_type(next(a for a in args if a is not type(None)), fn))
55
+ if isinstance(t, type) and issubclass(t, BaseModel):
56
+ return ("model", t)
57
+ raise TranslateError(f"unsupported type {t!r}", fn)
58
+
59
+
60
+ def lean_type(t):
61
+ if t == INT:
62
+ return "Int"
63
+ if t == BOOL:
64
+ return "Bool"
65
+ if t == STR:
66
+ return "String"
67
+ if t == FLOAT:
68
+ return "Float"
69
+ if t == NONE:
70
+ return "Unit"
71
+ kind = t[0]
72
+ if kind == "list":
73
+ return f"(List {lean_type(t[1])})"
74
+ if kind == "tuple":
75
+ return "(" + " × ".join(lean_type(a) for a in t[1]) + ")"
76
+ if kind == "opt":
77
+ return f"(Option {lean_type(t[1])})"
78
+ return models.class_name(t[1])
79
+
80
+
81
+ def is_nat(annotation):
82
+ if annotation is Nat:
83
+ return True
84
+ if typing.get_origin(annotation) is typing.Annotated:
85
+ import annotated_types as at
86
+
87
+ base, *meta = typing.get_args(annotation)
88
+ return base is int and any(isinstance(m, at.Ge) and m.ge >= 0 for m in meta)
89
+ return False
90
+
91
+
92
+ def field_info(cls, name):
93
+ """The Python type of a model field and whether its Lean field is a `Nat`."""
94
+ f = cls.model_fields.get(name)
95
+ if f is None:
96
+ raise TranslateError(f"{cls.__name__} has no field {name}")
97
+ t = py_type(f.annotation, None)
98
+ nat = False
99
+ if t == INT:
100
+ import annotated_types as at
101
+
102
+ lo = [m.ge for m in f.metadata if isinstance(m, at.Ge)] + [m.gt + 1 for m in f.metadata if isinstance(m, at.Gt)]
103
+ nat = is_nat(f.annotation) or (bool(lo) and max(lo) >= 0)
104
+ return t, nat
105
+
106
+
107
+ class Fn:
108
+ """A function to translate: its signature and the names Lean knows it by."""
109
+
110
+ def __init__(self, fn, lean_name):
111
+ self.fn = fn
112
+ self.lean_name = lean_name
113
+ self.hints = typing.get_type_hints(fn, include_extras=True)
114
+ sig = inspect.signature(fn)
115
+ self.params = []
116
+ for p in sig.parameters.values():
117
+ if p.name not in self.hints:
118
+ raise TranslateError(f"parameter {p.name} of {fn.__qualname__} has no type annotation", fn)
119
+ self.params.append((p.name, self.hints[p.name]))
120
+ if "return" not in self.hints:
121
+ raise TranslateError(f"{fn.__qualname__} has no return annotation", fn)
122
+ self.ret = py_type(self.hints["return"], fn)
123
+ self.ret_nat = is_nat(self.hints["return"])
124
+ src = textwrap.dedent(inspect.getsource(fn))
125
+ tree = ast.parse(src)
126
+ self.node = next(n for n in tree.body if isinstance(n, ast.FunctionDef))
127
+ self.fails = None
128
+
129
+
130
+ class Translator:
131
+ def __init__(self, fns):
132
+ self.fns = {f.fn.__name__: f for f in fns}
133
+ for f in fns:
134
+ f.fails = self._may_fail(f.node)
135
+ changed = True
136
+ while changed:
137
+ changed = False
138
+ for f in fns:
139
+ if not f.fails and any(
140
+ isinstance(n, ast.Call)
141
+ and isinstance(n.func, ast.Name)
142
+ and n.func.id in self.fns
143
+ and self.fns[n.func.id].fails
144
+ for stmt in f.node.body
145
+ for n in ast.walk(stmt)
146
+ ):
147
+ f.fails = True
148
+ changed = True
149
+
150
+ def _may_fail(self, node):
151
+ for stmt in node.body:
152
+ for n in ast.walk(stmt):
153
+ if isinstance(n, (ast.Subscript, ast.Assert, ast.Raise)):
154
+ return True
155
+ if isinstance(n, ast.BinOp) and isinstance(n.op, (ast.FloorDiv, ast.Mod)):
156
+ return True
157
+ return False
158
+
159
+ # -- expressions -------------------------------------------------------
160
+
161
+ def expr(self, e, env, fn):
162
+ """Lean source and type of a Python expression."""
163
+ f = fn.fn
164
+ if isinstance(e, ast.Constant):
165
+ v = e.value
166
+ if isinstance(v, bool):
167
+ return ("true" if v else "false"), BOOL
168
+ if isinstance(v, int):
169
+ return (f"({v} : Int)"), INT
170
+ if isinstance(v, str):
171
+ return json_str(v), STR
172
+ if isinstance(v, float):
173
+ return repr(v), FLOAT
174
+ if v is None:
175
+ return "none", ("opt", None)
176
+ raise TranslateError(f"unsupported constant {v!r}", f, e)
177
+ if isinstance(e, ast.Name):
178
+ if e.id not in env:
179
+ raise TranslateError(f"unknown name {e.id}", f, e)
180
+ return lean_ident(e.id), env[e.id]
181
+ if isinstance(e, ast.BinOp):
182
+ a, ta = self.expr(e.left, env, fn)
183
+ b, _ = self.expr(e.right, env, fn)
184
+ op = e.op
185
+ if isinstance(op, ast.Add):
186
+ if ta in (STR,) or (isinstance(ta, tuple) and ta[0] == "list"):
187
+ return f"({a} ++ {b})", ta
188
+ return f"({a} + {b})", ta
189
+ if isinstance(op, ast.Sub):
190
+ return f"({a} - {b})", ta
191
+ if isinstance(op, ast.Mult):
192
+ return f"({a} * {b})", ta
193
+ if isinstance(op, ast.FloorDiv):
194
+ return f"(← Py.floordiv {a} {b})", INT
195
+ if isinstance(op, ast.Mod):
196
+ return f"(← Py.mod {a} {b})", INT
197
+ raise TranslateError(f"unsupported operator {type(op).__name__}", f, e)
198
+ if isinstance(e, ast.UnaryOp):
199
+ a, ta = self.expr(e.operand, env, fn)
200
+ if isinstance(e.op, ast.USub):
201
+ return f"(-{a})", ta
202
+ if isinstance(e.op, ast.Not):
203
+ return f"(!{a})", BOOL
204
+ raise TranslateError("unsupported unary operator", f, e)
205
+ if isinstance(e, ast.BoolOp):
206
+ parts = [self.expr(v, env, fn)[0] for v in e.values]
207
+ op = " && " if isinstance(e.op, ast.And) else " || "
208
+ return "(" + op.join(parts) + ")", BOOL
209
+ if isinstance(e, ast.Compare):
210
+ parts = []
211
+ left, _ = self.expr(e.left, env, fn)
212
+ for op, right in zip(e.ops, e.comparators, strict=True):
213
+ r, _ = self.expr(right, env, fn)
214
+ if isinstance(op, ast.In):
215
+ parts.append(f"({r}.contains {left})")
216
+ elif isinstance(op, ast.NotIn):
217
+ parts.append(f"(!{r}.contains {left})")
218
+ else:
219
+ sym = {ast.Eq: "==", ast.NotEq: "!=", ast.Lt: "<", ast.LtE: "≤", ast.Gt: ">", ast.GtE: "≥"}.get(
220
+ type(op)
221
+ )
222
+ if sym is None:
223
+ raise TranslateError("unsupported comparison", f, e)
224
+ parts.append(f"({left} {sym} {r})" if sym in ("==", "!=") else f"(decide ({left} {sym} {r}))")
225
+ left = r
226
+ return "(" + " && ".join(parts) + ")", BOOL
227
+ if isinstance(e, ast.IfExp):
228
+ c, _ = self.expr(e.test, env, fn)
229
+ a, ta = self.expr(e.body, env, fn)
230
+ b, _ = self.expr(e.orelse, env, fn)
231
+ return f"(if {c} then {a} else {b})", ta
232
+ if isinstance(e, ast.Attribute):
233
+ obj, to = self.expr(e.value, env, fn)
234
+ if isinstance(to, tuple) and to[0] == "model":
235
+ t, nat = field_info(to[1], e.attr)
236
+ s = f"{obj}.{lean_ident(e.attr)}"
237
+ return (f"({s} : Int)" if nat else s), t
238
+ raise TranslateError(f"attribute {e.attr} of a non-model value", f, e)
239
+ if isinstance(e, ast.Subscript):
240
+ obj, to = self.expr(e.value, env, fn)
241
+ if isinstance(to, tuple) and to[0] == "tuple" and isinstance(e.slice, ast.Constant):
242
+ i = e.slice.value
243
+ n = len(to[1])
244
+ proj = obj
245
+ for _ in range(i):
246
+ proj = f"{proj}.2"
247
+ if i < n - 1:
248
+ proj = f"{proj}.1"
249
+ return proj, to[1][i]
250
+ if isinstance(to, tuple) and to[0] == "list":
251
+ i, _ = self.expr(e.slice, env, fn)
252
+ return f"(← Py.getItem {obj} {i})", to[1]
253
+ raise TranslateError("unsupported subscript", f, e)
254
+ if isinstance(e, ast.Tuple):
255
+ parts = [self.expr(v, env, fn) for v in e.elts]
256
+ return "(" + ", ".join(p for p, _ in parts) + ")", ("tuple", tuple(t for _, t in parts))
257
+ if isinstance(e, ast.List):
258
+ parts = [self.expr(v, env, fn) for v in e.elts]
259
+ t = parts[0][1] if parts else INT
260
+ return "[" + ", ".join(p for p, _ in parts) + "]", ("list", t)
261
+ if isinstance(e, ast.ListComp):
262
+ return self.listcomp(e, env, fn)
263
+ if isinstance(e, ast.Call):
264
+ return self.call(e, env, fn)
265
+ raise TranslateError(f"unsupported expression {type(e).__name__}", f, e)
266
+
267
+ def listcomp(self, e, env, fn):
268
+ if len(e.generators) != 1 or e.generators[0].is_async or not isinstance(e.generators[0].target, ast.Name):
269
+ raise TranslateError("only list comprehensions with one `for x in ...` are supported", fn.fn, e)
270
+ g = e.generators[0]
271
+ src, ts = self.iterable(g.iter, env, fn)
272
+ x = g.target.id
273
+ inner = {**env, x: ts}
274
+ for cond in g.ifs:
275
+ c, _ = self.expr(cond, inner, fn)
276
+ src = f"({src}.filter (fun {lean_ident(x)} => {c}))"
277
+ body, t_body = self.expr(e.elt, inner, fn)
278
+ if "←" in body:
279
+ raise TranslateError("a list comprehension must not fail", fn.fn, e)
280
+ return f"({src}.map (fun {lean_ident(x)} => {body}))", ("list", t_body)
281
+
282
+ def iterable(self, e, env, fn):
283
+ if isinstance(e, ast.Call) and isinstance(e.func, ast.Name) and e.func.id == "range":
284
+ args = [self.expr(a, env, fn)[0] for a in e.args]
285
+ if len(args) == 1:
286
+ return f"(Py.range 0 {args[0]})", INT
287
+ if len(args) == 2:
288
+ return f"(Py.range {args[0]} {args[1]})", INT
289
+ raise TranslateError("range with a step is not supported", fn.fn, e)
290
+ src, t = self.expr(e, env, fn)
291
+ if isinstance(t, tuple) and t[0] == "list":
292
+ return src, t[1]
293
+ raise TranslateError("iteration over a non-list", fn.fn, e)
294
+
295
+ def call(self, e, env, fn):
296
+ f = fn.fn
297
+ if e.keywords and not (isinstance(e.func, ast.Name) and e.func.id not in ("len", "abs", "min", "max", "sum")):
298
+ raise TranslateError("keyword arguments are not supported", f, e)
299
+ if isinstance(e.func, ast.Name):
300
+ name = e.func.id
301
+ args = [self.expr(a, env, fn) for a in e.args]
302
+ if name == "len":
303
+ ((a, ta),) = args
304
+ return (f"(Py.strLen {a})" if ta == STR else f"(Py.len {a})"), INT
305
+ if name == "abs":
306
+ return f"(Py.abs {args[0][0]})", INT
307
+ if name in ("min", "max") and len(args) == 2:
308
+ return f"({name} {args[0][0]} {args[1][0]})", args[0][1]
309
+ if name == "sum":
310
+ return f"(Py.sum {args[0][0]})", INT
311
+ if name in self.fns:
312
+ target = self.fns[name]
313
+ call = f"({target.lean_name} {' '.join(self.arg_for(target, i, a) for i, (a, _) in enumerate(args))})"
314
+ return (f"(← {call})" if target.fails else call), target.ret
315
+ raise TranslateError("unsupported call (only len, abs, min, max, sum and @model functions of the module)", f, e)
316
+
317
+ def arg_for(self, target, i, a):
318
+ if is_nat(target.params[i][1]):
319
+ return f"({a}).toNat"
320
+ return a
321
+
322
+ # -- statements --------------------------------------------------------
323
+
324
+ def body(self, stmts, env, fn, indent, declared):
325
+ out = []
326
+ pad = " " * indent
327
+ for s in stmts:
328
+ if isinstance(s, ast.Return):
329
+ if s.value is None:
330
+ raise TranslateError("a function must return a value", fn.fn, s)
331
+ v, _ = self.expr(s.value, env, fn)
332
+ out.append(f"{pad}return {v}")
333
+ elif isinstance(s, (ast.Assign, ast.AnnAssign, ast.AugAssign)):
334
+ out.extend(self.assign(s, env, fn, pad, declared))
335
+ elif isinstance(s, ast.If):
336
+ c, _ = self.expr(s.test, env, fn)
337
+ out.append(f"{pad}if {c} then")
338
+ out.extend(self.body(s.body, env, fn, indent + 1, declared) or [f"{pad} pure ()"])
339
+ if s.orelse:
340
+ out.append(f"{pad}else")
341
+ out.extend(self.body(s.orelse, env, fn, indent + 1, declared))
342
+ elif isinstance(s, ast.For):
343
+ if not isinstance(s.target, ast.Name):
344
+ raise TranslateError("only `for x in ...` is supported", fn.fn, s)
345
+ src, t = self.iterable(s.iter, env, fn)
346
+ inner = dict(env)
347
+ inner[s.target.id] = t
348
+ out.append(f"{pad}for {lean_ident(s.target.id)} in {src} do")
349
+ out.extend(self.body(s.body, inner, fn, indent + 1, declared) or [f"{pad} pure ()"])
350
+ elif isinstance(s, ast.While):
351
+ c, _ = self.expr(s.test, env, fn)
352
+ out.append(f"{pad}while {c} do")
353
+ out.extend(self.body(s.body, env, fn, indent + 1, declared) or [f"{pad} pure ()"])
354
+ elif isinstance(s, ast.Break):
355
+ out.append(f"{pad}break")
356
+ elif isinstance(s, ast.Continue):
357
+ out.append(f"{pad}continue")
358
+ elif isinstance(s, ast.Pass):
359
+ out.append(f"{pad}pure ()")
360
+ elif isinstance(s, ast.Assert):
361
+ c, _ = self.expr(s.test, env, fn)
362
+ out.append(f"{pad}Py.assert {c}")
363
+ elif isinstance(s, ast.Raise):
364
+ msg = '""'
365
+ if isinstance(s.exc, ast.Call) and s.exc.args and isinstance(s.exc.args[0], ast.Constant):
366
+ msg = json_str(str(s.exc.args[0].value))
367
+ out.append(f"{pad}throw (.valueError {msg})")
368
+ elif (
369
+ isinstance(s, ast.Expr)
370
+ and isinstance(s.value, ast.Call)
371
+ and isinstance(s.value.func, ast.Attribute)
372
+ and s.value.func.attr == "append"
373
+ ):
374
+ target = s.value.func.value
375
+ if not (isinstance(target, ast.Name) and target.id in declared):
376
+ raise TranslateError("append only to a list created in the function", fn.fn, s)
377
+ v, _ = self.expr(s.value.args[0], env, fn)
378
+ out.append(f"{pad}{lean_ident(target.id)} := {lean_ident(target.id)} ++ [{v}]")
379
+ elif isinstance(s, ast.Expr) and isinstance(s.value, ast.Constant) and isinstance(s.value.value, str):
380
+ continue
381
+ else:
382
+ raise TranslateError(f"unsupported statement {type(s).__name__}", fn.fn, s)
383
+ return out
384
+
385
+ def assign(self, s, env, fn, pad, declared):
386
+ if isinstance(s, ast.AugAssign):
387
+ target = s.target
388
+ op = {ast.Add: "+", ast.Sub: "-", ast.Mult: "*"}.get(type(s.op))
389
+ if not isinstance(target, ast.Name) or target.id not in declared:
390
+ raise TranslateError("augmented assignment to an undefined local", fn.fn, s)
391
+ v, tv = self.expr(s.value, env, fn)
392
+ t = env[target.id]
393
+ if op == "+" and (t == STR or (isinstance(t, tuple) and t[0] == "list")):
394
+ op = "++"
395
+ if op is None:
396
+ if isinstance(s.op, ast.FloorDiv):
397
+ return [f"{pad}{lean_ident(target.id)} := (← Py.floordiv {lean_ident(target.id)} {v})"]
398
+ if isinstance(s.op, ast.Mod):
399
+ return [f"{pad}{lean_ident(target.id)} := (← Py.mod {lean_ident(target.id)} {v})"]
400
+ raise TranslateError("unsupported augmented assignment", fn.fn, s)
401
+ return [f"{pad}{lean_ident(target.id)} := {lean_ident(target.id)} {op} {v}"]
402
+ targets = [s.target] if isinstance(s, ast.AnnAssign) else s.targets
403
+ if len(targets) != 1:
404
+ raise TranslateError("chained assignment is not supported", fn.fn, s)
405
+ target = targets[0]
406
+ v, tv = self.expr(s.value, env, fn)
407
+ if isinstance(s, ast.AnnAssign):
408
+ tv = py_type(eval(compile(ast.Expression(s.annotation), "<annotation>", "eval"), fn.fn.__globals__), fn.fn)
409
+ names = [target] if isinstance(target, ast.Name) else (target.elts if isinstance(target, ast.Tuple) else None)
410
+ if names is None or not all(isinstance(n, ast.Name) for n in names):
411
+ raise TranslateError("only assignment to names is supported", fn.fn, s)
412
+ if isinstance(target, ast.Tuple):
413
+ types = tv[1] if isinstance(tv, tuple) and tv[0] == "tuple" else [None] * len(names)
414
+ out = []
415
+ tmp = "__t"
416
+ out.append(f"{pad}let {tmp} := {v}")
417
+ for i, (n, t) in enumerate(zip(names, types, strict=True)):
418
+ proj = tmp + ".2" * i + (".1" if i < len(names) - 1 else "")
419
+ out.extend(self._bind(n.id, proj, t, env, pad, declared))
420
+ return out
421
+ return self._bind(target.id, v, tv, env, pad, declared)
422
+
423
+ def _bind(self, name, v, t, env, pad, declared):
424
+ if name in declared:
425
+ return [f"{pad}{lean_ident(name)} := {v}"]
426
+ declared.add(name)
427
+ env[name] = t
428
+ ty = f" : {lean_type(t)}" if t is not None and not (isinstance(t, tuple) and t[1] is None) else ""
429
+ return [f"{pad}let mut {lean_ident(name)}{ty} := {v}"]
430
+
431
+ # -- definitions -------------------------------------------------------
432
+
433
+ def definition(self, fn):
434
+ env = {}
435
+ binders = []
436
+ prelude = []
437
+ for name, ann in fn.params:
438
+ t = py_type(ann, fn.fn)
439
+ env[name] = t
440
+ if is_nat(ann):
441
+ binders.append(f"({lean_ident(name)} : Nat)")
442
+ prelude.append(f" let {lean_ident(name)} : Int := {lean_ident(name)}")
443
+ else:
444
+ binders.append(f"({lean_ident(name)} : {lean_type(t)})")
445
+ declared = set()
446
+ ret = lean_type(fn.ret)
447
+ rty = f"Py.M {ret}" if fn.fails else ret
448
+ head = "do" if fn.fails else "Id.run do"
449
+ lines = self.body(fn.node.body, env, fn, 1, declared)
450
+ doc = f"/-- `{fn.fn.__module__}.{fn.fn.__qualname__}`, translated from Python. -/"
451
+ return "\n".join([doc, f"def {fn.lean_name} {' '.join(binders)} : {rty} := {head}", *prelude, *lines])
452
+
453
+
454
+ def lean_ident(name):
455
+ return models.ident(name)
456
+
457
+
458
+ def json_str(s):
459
+ out = '"'
460
+ for c in s:
461
+ if c == '"':
462
+ out += '\\"'
463
+ elif c == "\\":
464
+ out += "\\\\"
465
+ elif c == "\n":
466
+ out += "\\n"
467
+ else:
468
+ out += c
469
+ return out + '"'
lean_python/verify.py ADDED
@@ -0,0 +1,147 @@
1
+ """``@model``: bind a Python function to Lean specifications.
2
+
3
+ ``@model(refines=spec.f)`` says the function computes ``spec.f`` (a function of a ``lean(...)``
4
+ block); ``requires=`` and ``ensures=`` name ``Bool`` functions of blocks (the precondition takes
5
+ the arguments, the postcondition the arguments and the result). ``lean-py check`` translates the
6
+ function into Lean and reports whether the obligations are proved; ``check_model`` runs the same
7
+ specifications as a property test with Hypothesis. The decorator returns the function unchanged.
8
+ """
9
+
10
+ import inspect
11
+ import typing
12
+
13
+ REGISTRY = []
14
+
15
+
16
+ class ModelSpec:
17
+ def __init__(self, fn, refines, requires, ensures):
18
+ self.fn = fn
19
+ self.refines = refines
20
+ self.requires = requires
21
+ if ensures is None:
22
+ self.ensures = {}
23
+ elif isinstance(ensures, dict):
24
+ self.ensures = dict(ensures)
25
+ else:
26
+ self.ensures = {"ensures": ensures}
27
+
28
+ @property
29
+ def qualname(self):
30
+ return f"{self.fn.__module__}:{self.fn.__qualname__}"
31
+
32
+ def specs(self):
33
+ out = {}
34
+ if self.refines is not None:
35
+ out["refines"] = self.refines
36
+ if self.requires is not None:
37
+ out["requires"] = self.requires
38
+ out.update(self.ensures)
39
+ return out
40
+
41
+
42
+ def model(*, refines=None, requires=None, ensures=None):
43
+ """Binds the decorated function to Lean specifications (see the module docstring)."""
44
+
45
+ def deco(fn):
46
+ spec = ModelSpec(fn, refines, requires, ensures)
47
+ fn.__lean_model__ = spec
48
+ REGISTRY.append(spec)
49
+ return fn
50
+
51
+ return deco
52
+
53
+
54
+ def models_of(module):
55
+ return [v.__lean_model__ for v in vars(module).values() if callable(v) and hasattr(v, "__lean_model__")]
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Property tests
60
+
61
+
62
+ def strategy(annotation, metadata=()):
63
+ """A Hypothesis strategy of the values of a parameter or field type."""
64
+ import annotated_types as at
65
+ from hypothesis import strategies as st
66
+ from pydantic import BaseModel
67
+
68
+ metadata = list(metadata)
69
+ while typing.get_origin(annotation) is typing.Annotated:
70
+ annotation, *more = typing.get_args(annotation)
71
+ metadata.extend(more)
72
+ if annotation is int:
73
+ lo = hi = None
74
+ for m in metadata:
75
+ if isinstance(m, at.Ge):
76
+ lo = m.ge if lo is None else max(lo, m.ge)
77
+ elif isinstance(m, at.Gt):
78
+ lo = m.gt + 1 if lo is None else max(lo, m.gt + 1)
79
+ elif isinstance(m, at.Le):
80
+ hi = m.le if hi is None else min(hi, m.le)
81
+ elif isinstance(m, at.Lt):
82
+ hi = m.lt - 1 if hi is None else min(hi, m.lt - 1)
83
+ return st.integers(min_value=lo if lo is not None else -1000, max_value=hi if hi is not None else 1000)
84
+ if annotation is bool:
85
+ return st.booleans()
86
+ if annotation is str:
87
+ lo = max([m.min_length for m in metadata if isinstance(m, at.MinLen)], default=0)
88
+ hi = min([m.max_length for m in metadata if isinstance(m, at.MaxLen)], default=lo + 8)
89
+ return st.text(min_size=lo, max_size=hi)
90
+ if annotation is float:
91
+ return st.floats(allow_nan=False, allow_infinity=False, width=64)
92
+ origin = typing.get_origin(annotation)
93
+ if origin is list:
94
+ (a,) = typing.get_args(annotation)
95
+ return st.lists(strategy(a), max_size=8)
96
+ if origin is tuple:
97
+ return st.tuples(*(strategy(a) for a in typing.get_args(annotation)))
98
+ args = typing.get_args(annotation)
99
+ if args and type(None) in args:
100
+ rest = [a for a in args if a is not type(None)]
101
+ return st.none() | st.one_of(*(strategy(a) for a in rest))
102
+ if isinstance(annotation, type) and issubclass(annotation, BaseModel):
103
+ fields = {n: strategy(f.annotation, f.metadata) for n, f in annotation.model_fields.items()}
104
+ return st.builds(annotation, **fields)
105
+ raise TypeError(f"no strategy for {annotation!r}")
106
+
107
+
108
+ class PropertyFailure(AssertionError):
109
+ pass
110
+
111
+
112
+ def check_model(fn, max_examples=200):
113
+ """Runs the function against its specifications on generated arguments; raises on a counterexample."""
114
+ from hypothesis import given, settings
115
+ from hypothesis import strategies as st
116
+
117
+ spec = fn.__lean_model__
118
+ hints = typing.get_type_hints(fn, include_extras=True)
119
+ params = list(inspect.signature(fn).parameters)
120
+ strategies = st.tuples(*(strategy(hints[p]) for p in params))
121
+
122
+ @settings(max_examples=max_examples, deadline=None, database=None)
123
+ @given(strategies)
124
+ def run(args):
125
+ from hypothesis import assume
126
+
127
+ if spec.requires is not None:
128
+ assume(spec.requires(*args))
129
+ try:
130
+ result = fn(*args)
131
+ except Exception as e: # noqa: BLE001
132
+ raise PropertyFailure(f"{fn.__qualname__}{args} raised {e!r}") from None
133
+ if spec.refines is not None:
134
+ expected = spec.refines(*args)
135
+ if result != expected:
136
+ raise PropertyFailure(f"{fn.__qualname__}{args} = {result!r}, the spec gives {expected!r}")
137
+ for label, e in spec.ensures.items():
138
+ if not e(*args, result):
139
+ raise PropertyFailure(f"{fn.__qualname__}{args} = {result!r} breaks {label}")
140
+
141
+ run()
142
+
143
+
144
+ def check_module(module, max_examples=200):
145
+ """``check_model`` for every ``@model`` function of the module."""
146
+ for spec in models_of(module):
147
+ check_model(spec.fn, max_examples)
@@ -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
 
@@ -0,0 +1,19 @@
1
+ lean_python/__init__.py,sha256=w09gaS4QGgFS0pM8Y3aSVMfQm-iHgEG_Sm_RqwKh--I,182
2
+ lean_python/check.py,sha256=uQkstmWq7mRSPxPW6Ac4yCCFqGacAZFBiCx19Y_XT6k,11129
3
+ lean_python/cli.py,sha256=r68dhTW7X1jUNx1GrJEsuXpx-kU9VGhCsbV0Xy5Ih9w,2575
4
+ lean_python/embed.py,sha256=4gKW2XM6EGJkJOQ8ghpnOAb_k_8GlJWscrAZVaVX2a8,3996
5
+ lean_python/io.py,sha256=l7LErXI_DhACZf9Jaef3r5Stqh4PdUk-bCNTo91BLE0,356
6
+ lean_python/io_externs.py,sha256=lI7oK7WTMjYV1JxaNzEnscVMunME_5SYoGDz3Qq1GSM,29807
7
+ lean_python/marshal.py,sha256=LBUL3bC9SoQFow6Kbj3kMyoCHfDvmQslrJnJW3SkqvQ,20877
8
+ lean_python/models.py,sha256=wunIZ9hOG-8H3shdRClLN7pR6Kmofo9zlPcL3Hr0rq0,10940
9
+ lean_python/runtime.py,sha256=Fr3R6x-kZUW2H1_NwbwI_LI97OUmMQ8Q0smnPKyyAAc,28439
10
+ lean_python/sync.py,sha256=JKJj3IWQlrqjTvK_-puPuF1Nt0LMumywUilUJANqhfc,9304
11
+ lean_python/translate.py,sha256=9K08bKrWt8yCYwdH8LutuuwMX9kLRqIGhUaI5BbKzYI,20596
12
+ lean_python/types.py,sha256=qOhcwe1lYGD17OHW_A4LzfGi2bcPovTANTggaJwG-FM,843
13
+ lean_python/verify.py,sha256=KmIdkIzNRF3AHvtdyw-XIGcnws2RhwObBF_AGycxDXY,5525
14
+ lean_python/lean/LeanPythonLib/Py.lean,sha256=NCx9mxRNnI7_wsK_FEh2eqkp6_EO9buXZrnZNiDrJmk,1658
15
+ lean_python-0.2.0.dist-info/METADATA,sha256=ghnqqVdM8sT4bk6cy0s8iKolBm39pDjdYjmfBJBCRQs,13387
16
+ lean_python-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
17
+ lean_python-0.2.0.dist-info/entry_points.txt,sha256=BD86jsJKikYcvsZse4ysbLvvN7TuGogwmiUZZwu7FBo,49
18
+ lean_python-0.2.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
19
+ lean_python-0.2.0.dist-info/RECORD,,
@@ -1,15 +0,0 @@
1
- lean_python/__init__.py,sha256=w09gaS4QGgFS0pM8Y3aSVMfQm-iHgEG_Sm_RqwKh--I,182
2
- lean_python/cli.py,sha256=ERwAkhhFJV32BqS3-lDHPYi0UiCbydyEFI0ZlHobmsk,1559
3
- lean_python/embed.py,sha256=WBEWYN7r3xFbSNLgvRRfn-6qhcR5Q1h1ge8KwQ5yyfE,3996
4
- lean_python/io.py,sha256=l7LErXI_DhACZf9Jaef3r5Stqh4PdUk-bCNTo91BLE0,356
5
- lean_python/io_externs.py,sha256=lI7oK7WTMjYV1JxaNzEnscVMunME_5SYoGDz3Qq1GSM,29807
6
- lean_python/marshal.py,sha256=WIY3yJtnIMvym8_jraY8F1tW8x1iYqRkcS4W_FGWwLM,20778
7
- lean_python/models.py,sha256=wunIZ9hOG-8H3shdRClLN7pR6Kmofo9zlPcL3Hr0rq0,10940
8
- lean_python/runtime.py,sha256=Fr3R6x-kZUW2H1_NwbwI_LI97OUmMQ8Q0smnPKyyAAc,28439
9
- lean_python/sync.py,sha256=JKJj3IWQlrqjTvK_-puPuF1Nt0LMumywUilUJANqhfc,9304
10
- lean_python/types.py,sha256=qOhcwe1lYGD17OHW_A4LzfGi2bcPovTANTggaJwG-FM,843
11
- lean_python-0.1.0.dist-info/METADATA,sha256=7IosM2WL2AjIjMM0CYy015gHPmkViqxr_vT075QI1gw,9775
12
- lean_python-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
13
- lean_python-0.1.0.dist-info/entry_points.txt,sha256=BD86jsJKikYcvsZse4ysbLvvN7TuGogwmiUZZwu7FBo,49
14
- lean_python-0.1.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
15
- lean_python-0.1.0.dist-info/RECORD,,