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/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