lean-python 0.1.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/__init__.py +6 -0
- lean_python/cli.py +50 -0
- lean_python/embed.py +131 -0
- lean_python/io.py +15 -0
- lean_python/io_externs.py +1192 -0
- lean_python/marshal.py +727 -0
- lean_python/models.py +297 -0
- lean_python/runtime.py +1400 -0
- lean_python/sync.py +265 -0
- lean_python/types.py +33 -0
- lean_python-0.1.0.dist-info/METADATA +231 -0
- lean_python-0.1.0.dist-info/RECORD +15 -0
- lean_python-0.1.0.dist-info/WHEEL +4 -0
- lean_python-0.1.0.dist-info/entry_points.txt +2 -0
- lean_python-0.1.0.dist-info/licenses/LICENSE +202 -0
lean_python/models.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Lean structures for the user's pydantic models (``lean(..., models=[...])``).
|
|
2
|
+
|
|
3
|
+
A model becomes a ``structure`` named ``LeanPythonModels.<python module>.<class>``. Field
|
|
4
|
+
constraints that Lean can state (``ge``/``gt``/``le``/``lt`` on ``int``, ``min_length``/``max_length``
|
|
5
|
+
on ``str`` and ``list``) become proof fields, so Lean proofs may assume them; an ``int`` field with
|
|
6
|
+
a lower bound of at least 0 becomes a ``Nat``. Other constraints and validators are listed in the
|
|
7
|
+
structure's docstring and are not assumed.
|
|
8
|
+
|
|
9
|
+
Other field types: an ``enum.Enum`` becomes an inductive type ``LeanPythonModels.<module>.<class>``
|
|
10
|
+
with a constructor per member; ``Literal["a", "b"]`` becomes ``LeanPythonLiterals.<module>.<model>_<field>``;
|
|
11
|
+
a union of models becomes ``LeanPythonUnions.<module>.<model>_<field>`` with a constructor per model.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import enum
|
|
15
|
+
import re
|
|
16
|
+
import types
|
|
17
|
+
import typing
|
|
18
|
+
|
|
19
|
+
import annotated_types as at
|
|
20
|
+
from pydantic import BaseModel
|
|
21
|
+
|
|
22
|
+
LEAN_KEYWORDS = {
|
|
23
|
+
"at", "by", "do", "else", "end", "fun", "have", "if", "in", "let", "match", "mut", "namespace", "open",
|
|
24
|
+
"section", "then", "theorem", "def", "structure", "inductive", "where", "with", "from", "import",
|
|
25
|
+
"instance", "class", "variable", "universe", "deriving", "show", "calc", "for", "return", "try",
|
|
26
|
+
"catch", "finally", "unless", "break", "continue", "macro", "syntax", "local", "private", "protected",
|
|
27
|
+
"partial", "unsafe", "noncomputable", "abbrev", "example", "axiom", "opaque", "extends", "notation",
|
|
28
|
+
"Type", "Sort", "Prop",
|
|
29
|
+
} # fmt: skip
|
|
30
|
+
|
|
31
|
+
_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_']*$")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ModelError(Exception):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def ident(s):
|
|
39
|
+
"""A Lean identifier component, escaped with «» when needed."""
|
|
40
|
+
if _IDENT.match(s) and s not in LEAN_KEYWORDS:
|
|
41
|
+
return s
|
|
42
|
+
return f"«{s}»"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def dotted(parts):
|
|
46
|
+
return ".".join(ident(p) for p in parts)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def models_namespace(module):
|
|
50
|
+
return ["LeanPythonModels", *module.split(".")]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _strip_annotated(t):
|
|
54
|
+
metadata = []
|
|
55
|
+
while typing.get_origin(t) is typing.Annotated:
|
|
56
|
+
args = typing.get_args(t)
|
|
57
|
+
t = args[0]
|
|
58
|
+
metadata.extend(args[1:])
|
|
59
|
+
return t, metadata
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _flatten(metadata):
|
|
63
|
+
out = []
|
|
64
|
+
for m in metadata:
|
|
65
|
+
if isinstance(m, at.Interval):
|
|
66
|
+
for v, c in ((m.gt, at.Gt), (m.ge, at.Ge), (m.lt, at.Lt), (m.le, at.Le)):
|
|
67
|
+
if v is not None:
|
|
68
|
+
out.append(c(v))
|
|
69
|
+
elif isinstance(m, at.Len):
|
|
70
|
+
if m.min_length:
|
|
71
|
+
out.append(at.MinLen(m.min_length))
|
|
72
|
+
if m.max_length is not None:
|
|
73
|
+
out.append(at.MaxLen(m.max_length))
|
|
74
|
+
else:
|
|
75
|
+
out.append(m)
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_model(t):
|
|
80
|
+
return isinstance(t, type) and issubclass(t, BaseModel)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _is_enum(t):
|
|
84
|
+
return isinstance(t, type) and issubclass(t, enum.Enum)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _union_args(t):
|
|
88
|
+
if typing.get_origin(t) in (typing.Union, types.UnionType):
|
|
89
|
+
return typing.get_args(t)
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Collector:
|
|
94
|
+
"""The models and enums reachable from the roots, dependencies first."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, roots):
|
|
97
|
+
self.order = []
|
|
98
|
+
self.seen = set()
|
|
99
|
+
self.in_progress = set()
|
|
100
|
+
for cls in roots:
|
|
101
|
+
self.add(cls)
|
|
102
|
+
|
|
103
|
+
def add(self, cls):
|
|
104
|
+
if cls in self.seen:
|
|
105
|
+
return
|
|
106
|
+
if cls in self.in_progress:
|
|
107
|
+
raise ModelError(f"recursive models are not supported: {cls.__qualname__}")
|
|
108
|
+
self.in_progress.add(cls)
|
|
109
|
+
if _is_model(cls):
|
|
110
|
+
for f in cls.model_fields.values():
|
|
111
|
+
self._visit(f.annotation)
|
|
112
|
+
self.in_progress.discard(cls)
|
|
113
|
+
self.seen.add(cls)
|
|
114
|
+
self.order.append(cls)
|
|
115
|
+
|
|
116
|
+
def _visit(self, t):
|
|
117
|
+
t, _ = _strip_annotated(t)
|
|
118
|
+
if _is_model(t) or _is_enum(t):
|
|
119
|
+
self.add(t)
|
|
120
|
+
return
|
|
121
|
+
for a in typing.get_args(t):
|
|
122
|
+
if not isinstance(a, str):
|
|
123
|
+
self._visit(a)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def class_name(cls):
|
|
127
|
+
return dotted([*models_namespace(cls.__module__), cls.__qualname__])
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Context:
|
|
131
|
+
"""Declarations a model's fields need before its structure (literal and union types)."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, model):
|
|
134
|
+
self.model = model
|
|
135
|
+
self.decls = []
|
|
136
|
+
|
|
137
|
+
def literal(self, field, values):
|
|
138
|
+
if not all(isinstance(v, str) for v in values):
|
|
139
|
+
raise ModelError(f"{self.model.__qualname__}.{field}: only Literal of strings is supported")
|
|
140
|
+
name = dotted(["LeanPythonLiterals", *self.model.__module__.split("."), f"{self.model.__qualname__}_{field}"])
|
|
141
|
+
ctors = "\n".join(f" | {ident(v)}" for v in values)
|
|
142
|
+
self.decls.append(f"inductive {name} where\n{ctors}")
|
|
143
|
+
return name
|
|
144
|
+
|
|
145
|
+
def union(self, field, classes):
|
|
146
|
+
name = dotted(["LeanPythonUnions", *self.model.__module__.split("."), f"{self.model.__qualname__}_{field}"])
|
|
147
|
+
ctors = "\n".join(f" | {ident(c.__qualname__)} (val : {class_name(c)})" for c in classes)
|
|
148
|
+
self.decls.append(f"inductive {name} where\n{ctors}")
|
|
149
|
+
return name
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def lean_type(t, ctx, field):
|
|
153
|
+
t, metadata = _strip_annotated(t)
|
|
154
|
+
if metadata:
|
|
155
|
+
base, _ = lean_field_type(t, _flatten(metadata), ctx, field)
|
|
156
|
+
return base
|
|
157
|
+
origin = typing.get_origin(t)
|
|
158
|
+
if t is int:
|
|
159
|
+
return "Int"
|
|
160
|
+
if t is bool:
|
|
161
|
+
return "Bool"
|
|
162
|
+
if t is str:
|
|
163
|
+
return "String"
|
|
164
|
+
if t is float:
|
|
165
|
+
return "Float"
|
|
166
|
+
if t is type(None):
|
|
167
|
+
return "Unit"
|
|
168
|
+
if origin is list:
|
|
169
|
+
(a,) = typing.get_args(t)
|
|
170
|
+
return f"(List {lean_type(a, ctx, field)})"
|
|
171
|
+
if origin is tuple:
|
|
172
|
+
args = typing.get_args(t)
|
|
173
|
+
if len(args) < 2 or Ellipsis in args:
|
|
174
|
+
raise ModelError(f"{ctx.model.__qualname__}.{field}: only tuples of fixed length 2 or more are supported")
|
|
175
|
+
return "(" + " × ".join(lean_type(a, ctx, field) for a in args) + ")"
|
|
176
|
+
if origin is typing.Literal:
|
|
177
|
+
return ctx.literal(field, typing.get_args(t))
|
|
178
|
+
args = _union_args(t)
|
|
179
|
+
if args is not None:
|
|
180
|
+
rest = [a for a in args if a is not type(None)]
|
|
181
|
+
inner = rest[0] if len(rest) == 1 else None
|
|
182
|
+
if inner is None:
|
|
183
|
+
if not all(_is_model(_strip_annotated(a)[0]) for a in rest):
|
|
184
|
+
raise ModelError(
|
|
185
|
+
f"{ctx.model.__qualname__}.{field}: only `T | None` and unions of models are supported"
|
|
186
|
+
)
|
|
187
|
+
inner_t = ctx.union(field, [_strip_annotated(a)[0] for a in rest])
|
|
188
|
+
else:
|
|
189
|
+
inner_t = lean_type(inner, ctx, field)
|
|
190
|
+
return f"(Option {inner_t})" if len(rest) < len(args) else inner_t
|
|
191
|
+
if _is_model(t) or _is_enum(t):
|
|
192
|
+
return class_name(t)
|
|
193
|
+
raise ModelError(f"{ctx.model.__qualname__}.{field}: unsupported field type {t!r}")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def lean_field_type(t, constraints, ctx, name):
|
|
197
|
+
"""The Lean type of a field and its proof fields as ``(suffix, proposition)``."""
|
|
198
|
+
t, inner = _strip_annotated(t)
|
|
199
|
+
constraints = _flatten(constraints) + _flatten(inner)
|
|
200
|
+
proofs = []
|
|
201
|
+
unmapped = []
|
|
202
|
+
if t is int:
|
|
203
|
+
lo = None
|
|
204
|
+
for c in constraints:
|
|
205
|
+
if isinstance(c, at.Ge):
|
|
206
|
+
lo = c.ge if lo is None else max(lo, c.ge)
|
|
207
|
+
elif isinstance(c, at.Gt):
|
|
208
|
+
lo = c.gt + 1 if lo is None else max(lo, c.gt + 1)
|
|
209
|
+
nat = lo is not None and lo >= 0
|
|
210
|
+
base = "Nat" if nat else "Int"
|
|
211
|
+
|
|
212
|
+
def lit(v):
|
|
213
|
+
return str(v) if nat else f"({v} : Int)"
|
|
214
|
+
|
|
215
|
+
for c in constraints:
|
|
216
|
+
if isinstance(c, at.Ge):
|
|
217
|
+
if not (nat and c.ge == 0):
|
|
218
|
+
proofs.append(("ge", f"{lit(c.ge)} ≤ {ident(name)}"))
|
|
219
|
+
elif isinstance(c, at.Gt):
|
|
220
|
+
if not (nat and c.gt == -1):
|
|
221
|
+
proofs.append(("gt", f"{lit(c.gt)} < {ident(name)}"))
|
|
222
|
+
elif isinstance(c, at.Le):
|
|
223
|
+
proofs.append(("le", f"{ident(name)} ≤ {lit(c.le)}"))
|
|
224
|
+
elif isinstance(c, at.Lt):
|
|
225
|
+
proofs.append(("lt", f"{ident(name)} < {lit(c.lt)}"))
|
|
226
|
+
else:
|
|
227
|
+
unmapped.append(repr(c))
|
|
228
|
+
return base, (proofs, unmapped)
|
|
229
|
+
base = lean_type(t, ctx, name)
|
|
230
|
+
for c in constraints:
|
|
231
|
+
if isinstance(c, at.MinLen) and (t is str or typing.get_origin(t) is list):
|
|
232
|
+
proofs.append(("min_length", f"{c.min_length} ≤ {ident(name)}.length"))
|
|
233
|
+
elif isinstance(c, at.MaxLen) and (t is str or typing.get_origin(t) is list):
|
|
234
|
+
proofs.append(("max_length", f"{ident(name)}.length ≤ {c.max_length}"))
|
|
235
|
+
else:
|
|
236
|
+
unmapped.append(repr(c))
|
|
237
|
+
return base, (proofs, unmapped)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def enum_source(cls):
|
|
241
|
+
ctors = "\n".join(f" | {ident(m.name)}" for m in cls)
|
|
242
|
+
return f"/-- The enum `{cls.__module__}.{cls.__qualname__}`. -/\ninductive {class_name(cls)} where\n{ctors}"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def structure(cls):
|
|
246
|
+
ctx = Context(cls)
|
|
247
|
+
fields = []
|
|
248
|
+
unmapped_all = []
|
|
249
|
+
for name, f in cls.model_fields.items():
|
|
250
|
+
if f.alias is not None and f.alias != name:
|
|
251
|
+
raise ModelError(f"{cls.__qualname__}.{name}: aliases are not supported")
|
|
252
|
+
base, (proofs, unmapped) = lean_field_type(f.annotation, list(f.metadata), ctx, name)
|
|
253
|
+
fields.append(f" {ident(name)} : {base}")
|
|
254
|
+
for suffix, prop in proofs:
|
|
255
|
+
fields.append(f" {ident(name + '_' + suffix)} : {prop}")
|
|
256
|
+
unmapped_all.extend(f"{name}: {u}" for u in unmapped)
|
|
257
|
+
decorators = cls.__pydantic_decorators__
|
|
258
|
+
validators = [*decorators.field_validators, *decorators.model_validators]
|
|
259
|
+
doc = [f"The pydantic model `{cls.__module__}.{cls.__qualname__}`."]
|
|
260
|
+
if unmapped_all or validators:
|
|
261
|
+
doc.append("Not assumed in Lean: " + ", ".join([*unmapped_all, *(f"validator {v}" for v in validators)]) + ".")
|
|
262
|
+
lines = [*ctx.decls, f"/-- {' '.join(doc)} -/", f"structure {class_name(cls)} where", *fields]
|
|
263
|
+
return "\n\n".join(lines[: len(ctx.decls)] + ["\n".join(lines[len(ctx.decls) :])])
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def modules_source(roots):
|
|
267
|
+
"""Lean source per Python module of the models (and the models and enums their fields use)."""
|
|
268
|
+
order = Collector(roots).order
|
|
269
|
+
by_module = {}
|
|
270
|
+
for cls in order:
|
|
271
|
+
by_module.setdefault(cls.__module__, []).append(cls)
|
|
272
|
+
out = {}
|
|
273
|
+
for module, classes in by_module.items():
|
|
274
|
+
deps = sorted({c.__module__ for cls in classes for c in _referenced(cls)} - {module})
|
|
275
|
+
header = "".join(f"import {dotted(models_namespace(d))}\n" for d in deps)
|
|
276
|
+
body = "\n\n".join(enum_source(c) if _is_enum(c) else structure(c) for c in classes)
|
|
277
|
+
out[module] = f"{header}\n{body}\n"
|
|
278
|
+
return out
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _referenced(cls):
|
|
282
|
+
found = []
|
|
283
|
+
if not _is_model(cls):
|
|
284
|
+
return found
|
|
285
|
+
|
|
286
|
+
def visit(t):
|
|
287
|
+
t, _ = _strip_annotated(t)
|
|
288
|
+
if _is_model(t) or _is_enum(t):
|
|
289
|
+
found.append(t)
|
|
290
|
+
return
|
|
291
|
+
for a in typing.get_args(t):
|
|
292
|
+
if not isinstance(a, str):
|
|
293
|
+
visit(a)
|
|
294
|
+
|
|
295
|
+
for f in cls.model_fields.values():
|
|
296
|
+
visit(f.annotation)
|
|
297
|
+
return found
|