sqlmodel-mypy-plugin 0.12.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.
- sqlmodel_mypy/__init__.py +3 -0
- sqlmodel_mypy/plugin.py +1140 -0
- sqlmodel_mypy/py.typed +1 -0
- sqlmodel_mypy/transform.py +836 -0
- sqlmodel_mypy_plugin-0.12.0.dist-info/METADATA +208 -0
- sqlmodel_mypy_plugin-0.12.0.dist-info/RECORD +8 -0
- sqlmodel_mypy_plugin-0.12.0.dist-info/WHEEL +4 -0
- sqlmodel_mypy_plugin-0.12.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
"""SQLModel-specific class transformation for mypy.
|
|
2
|
+
|
|
3
|
+
This module is intentionally small and focused:
|
|
4
|
+
- collect SQLModel fields (treating `sqlmodel.Field(...)` correctly for requiredness)
|
|
5
|
+
- include relationship kwargs for table-model constructors
|
|
6
|
+
- record relationship member names (and types) for later typing hooks
|
|
7
|
+
- synthesize `__init__` on SQLModel subclasses
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import keyword
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from typing import Any, Protocol
|
|
15
|
+
|
|
16
|
+
from mypy.errorcodes import ErrorCode
|
|
17
|
+
from mypy.expandtype import expand_type
|
|
18
|
+
from mypy.nodes import (
|
|
19
|
+
ARG_NAMED,
|
|
20
|
+
ARG_NAMED_OPT,
|
|
21
|
+
ARG_OPT,
|
|
22
|
+
ARG_POS,
|
|
23
|
+
ARG_STAR2,
|
|
24
|
+
INVARIANT,
|
|
25
|
+
MDEF,
|
|
26
|
+
Argument,
|
|
27
|
+
AssignmentStmt,
|
|
28
|
+
Block,
|
|
29
|
+
CallExpr,
|
|
30
|
+
ClassDef,
|
|
31
|
+
Decorator,
|
|
32
|
+
DictExpr,
|
|
33
|
+
EllipsisExpr,
|
|
34
|
+
Expression,
|
|
35
|
+
FuncDef,
|
|
36
|
+
IfStmt,
|
|
37
|
+
JsonDict,
|
|
38
|
+
NameExpr,
|
|
39
|
+
PlaceholderNode,
|
|
40
|
+
RefExpr,
|
|
41
|
+
Statement,
|
|
42
|
+
StrExpr,
|
|
43
|
+
SymbolTableNode,
|
|
44
|
+
TempNode,
|
|
45
|
+
TypeAlias,
|
|
46
|
+
TypeInfo,
|
|
47
|
+
Var,
|
|
48
|
+
)
|
|
49
|
+
from mypy.plugin import SemanticAnalyzerPluginInterface
|
|
50
|
+
from mypy.plugins.common import deserialize_and_fixup_type
|
|
51
|
+
from mypy.semanal import set_callable_name
|
|
52
|
+
from mypy.server.trigger import make_wildcard_trigger
|
|
53
|
+
from mypy.state import state
|
|
54
|
+
from mypy.type_visitor import TypeTranslator
|
|
55
|
+
from mypy.typeops import map_type_from_supertype
|
|
56
|
+
from mypy.types import (
|
|
57
|
+
AnyType,
|
|
58
|
+
CallableType,
|
|
59
|
+
Instance,
|
|
60
|
+
NoneType,
|
|
61
|
+
Type,
|
|
62
|
+
TypeAliasType,
|
|
63
|
+
TypeOfAny,
|
|
64
|
+
TypeType,
|
|
65
|
+
TypeVarType,
|
|
66
|
+
UnionType,
|
|
67
|
+
get_proper_type,
|
|
68
|
+
)
|
|
69
|
+
from mypy.typevars import fill_typevars
|
|
70
|
+
from mypy.util import get_unique_redefinition_name
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SQLModelPluginConfig(Protocol):
|
|
74
|
+
init_typed: bool
|
|
75
|
+
init_forbid_extra: bool
|
|
76
|
+
warn_untyped_fields: bool
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
METADATA_KEY = "sqlmodel-mypy-metadata"
|
|
80
|
+
|
|
81
|
+
SQLMODEL_BASEMODEL_FULLNAME = "sqlmodel.main.SQLModel"
|
|
82
|
+
SQLMODEL_FIELD_FULLNAME = "sqlmodel.main.Field"
|
|
83
|
+
SQLMODEL_RELATIONSHIP_FULLNAME = "sqlmodel.main.Relationship"
|
|
84
|
+
|
|
85
|
+
SQLALCHEMY_MAPPED_FULLNAMES = {"sqlalchemy.orm.Mapped", "sqlalchemy.orm.base.Mapped"}
|
|
86
|
+
|
|
87
|
+
ERROR_FIELD = ErrorCode("sqlmodel-field", "SQLModel field error", "SQLModel")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ForceInvariantTypeVars(TypeTranslator):
|
|
91
|
+
def visit_type_var(self, t: TypeVarType) -> Type: # noqa: D102
|
|
92
|
+
if t.variance == INVARIANT:
|
|
93
|
+
return t
|
|
94
|
+
# `TypeVarType.copy_modified(variance=...)` does not reliably update the variance across mypy
|
|
95
|
+
# versions, so mutate explicitly.
|
|
96
|
+
modified = t.copy_modified()
|
|
97
|
+
modified.variance = INVARIANT
|
|
98
|
+
return modified
|
|
99
|
+
|
|
100
|
+
def visit_type_alias_type(self, t: TypeAliasType) -> Type: # noqa: D102
|
|
101
|
+
# Expand type aliases and then continue translation.
|
|
102
|
+
return get_proper_type(t).accept(self)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class SQLModelField:
|
|
106
|
+
"""A collected SQLModel field used for signature generation."""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
*,
|
|
111
|
+
name: str,
|
|
112
|
+
has_default: bool,
|
|
113
|
+
line: int,
|
|
114
|
+
column: int,
|
|
115
|
+
aliases: list[str] | None = None,
|
|
116
|
+
type: Type | None,
|
|
117
|
+
info: TypeInfo,
|
|
118
|
+
) -> None:
|
|
119
|
+
self.name = name
|
|
120
|
+
self.has_default = has_default
|
|
121
|
+
self.line = line
|
|
122
|
+
self.column = column
|
|
123
|
+
self.aliases = list(aliases or [])
|
|
124
|
+
self.type = type
|
|
125
|
+
self.info = info
|
|
126
|
+
|
|
127
|
+
def expand_type(
|
|
128
|
+
self,
|
|
129
|
+
current_info: TypeInfo,
|
|
130
|
+
api: SemanticAnalyzerPluginInterface,
|
|
131
|
+
*,
|
|
132
|
+
force_typevars_invariant: bool = False,
|
|
133
|
+
) -> Type | None:
|
|
134
|
+
if self.type is None:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
typ = self.type
|
|
138
|
+
if force_typevars_invariant and isinstance(typ, TypeVarType):
|
|
139
|
+
modified = typ.copy_modified()
|
|
140
|
+
modified.variance = INVARIANT
|
|
141
|
+
typ = modified
|
|
142
|
+
|
|
143
|
+
# This plugin runs very late in semantic analysis for the class, so types should be ready.
|
|
144
|
+
if self.info.self_type is not None:
|
|
145
|
+
with state.strict_optional_set(api.options.strict_optional):
|
|
146
|
+
filled_with_typevars = fill_typevars(current_info)
|
|
147
|
+
assert isinstance(filled_with_typevars, Instance)
|
|
148
|
+
return expand_type(typ, {self.info.self_type.id: filled_with_typevars})
|
|
149
|
+
return typ
|
|
150
|
+
|
|
151
|
+
def serialize(self) -> JsonDict:
|
|
152
|
+
assert self.type is not None
|
|
153
|
+
return {
|
|
154
|
+
"name": self.name,
|
|
155
|
+
"has_default": self.has_default,
|
|
156
|
+
"line": self.line,
|
|
157
|
+
"column": self.column,
|
|
158
|
+
"aliases": self.aliases,
|
|
159
|
+
"type": self.type.serialize(),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
@classmethod
|
|
163
|
+
def deserialize(
|
|
164
|
+
cls, info: TypeInfo, data: JsonDict, api: SemanticAnalyzerPluginInterface
|
|
165
|
+
) -> SQLModelField:
|
|
166
|
+
data = data.copy()
|
|
167
|
+
aliases = data.pop("aliases", [])
|
|
168
|
+
if isinstance(aliases, list):
|
|
169
|
+
deduped: list[str] = []
|
|
170
|
+
seen = set()
|
|
171
|
+
for alias in aliases:
|
|
172
|
+
if not isinstance(alias, str):
|
|
173
|
+
continue
|
|
174
|
+
if alias in seen:
|
|
175
|
+
continue
|
|
176
|
+
seen.add(alias)
|
|
177
|
+
deduped.append(alias)
|
|
178
|
+
aliases = deduped
|
|
179
|
+
else:
|
|
180
|
+
aliases = []
|
|
181
|
+
typ = deserialize_and_fixup_type(data.pop("type"), api)
|
|
182
|
+
name = data.pop("name")
|
|
183
|
+
return cls(name=name, type=typ, aliases=aliases, info=info, **data)
|
|
184
|
+
|
|
185
|
+
def expand_typevar_from_subtype(
|
|
186
|
+
self, sub_type: TypeInfo, api: SemanticAnalyzerPluginInterface
|
|
187
|
+
) -> None:
|
|
188
|
+
if self.type is None:
|
|
189
|
+
return
|
|
190
|
+
with state.strict_optional_set(api.options.strict_optional):
|
|
191
|
+
self.type = map_type_from_supertype(self.type, sub_type, self.info)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class SQLModelRelationship:
|
|
195
|
+
"""A collected SQLModel relationship.
|
|
196
|
+
|
|
197
|
+
We store relationship member names in metadata so later hooks can adjust class attribute typing
|
|
198
|
+
(e.g. `Hero.team` in SQLAlchemy expressions).
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
def __init__(self, *, name: str, line: int, column: int) -> None:
|
|
202
|
+
self.name = name
|
|
203
|
+
self.line = line
|
|
204
|
+
self.column = column
|
|
205
|
+
|
|
206
|
+
# Optional: available once semantic analysis resolves the annotation.
|
|
207
|
+
self.type: Type | None = None
|
|
208
|
+
self.info: TypeInfo | None = None
|
|
209
|
+
|
|
210
|
+
def expand_type(
|
|
211
|
+
self,
|
|
212
|
+
current_info: TypeInfo,
|
|
213
|
+
api: SemanticAnalyzerPluginInterface,
|
|
214
|
+
*,
|
|
215
|
+
force_typevars_invariant: bool = False,
|
|
216
|
+
) -> Type | None:
|
|
217
|
+
if self.type is None or self.info is None:
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
typ = self.type
|
|
221
|
+
if force_typevars_invariant and isinstance(typ, TypeVarType):
|
|
222
|
+
modified = typ.copy_modified()
|
|
223
|
+
modified.variance = INVARIANT
|
|
224
|
+
typ = modified
|
|
225
|
+
|
|
226
|
+
# Mirror SQLModelField.expand_type() behavior.
|
|
227
|
+
if self.info.self_type is not None:
|
|
228
|
+
with state.strict_optional_set(api.options.strict_optional):
|
|
229
|
+
filled_with_typevars = fill_typevars(current_info)
|
|
230
|
+
assert isinstance(filled_with_typevars, Instance)
|
|
231
|
+
return expand_type(typ, {self.info.self_type.id: filled_with_typevars})
|
|
232
|
+
return typ
|
|
233
|
+
|
|
234
|
+
def serialize(self) -> JsonDict:
|
|
235
|
+
return {
|
|
236
|
+
"name": self.name,
|
|
237
|
+
"line": self.line,
|
|
238
|
+
"column": self.column,
|
|
239
|
+
"type": None if self.type is None else self.type.serialize(),
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
@classmethod
|
|
243
|
+
def deserialize(
|
|
244
|
+
cls, info: TypeInfo, data: JsonDict, api: SemanticAnalyzerPluginInterface
|
|
245
|
+
) -> SQLModelRelationship:
|
|
246
|
+
data = data.copy()
|
|
247
|
+
typ_data = data.pop("type", None)
|
|
248
|
+
name = data.pop("name")
|
|
249
|
+
rel = cls(name=name, **data)
|
|
250
|
+
rel.info = info
|
|
251
|
+
if typ_data is not None:
|
|
252
|
+
rel.type = deserialize_and_fixup_type(typ_data, api)
|
|
253
|
+
return rel
|
|
254
|
+
|
|
255
|
+
def expand_typevar_from_subtype(
|
|
256
|
+
self, sub_type: TypeInfo, api: SemanticAnalyzerPluginInterface
|
|
257
|
+
) -> None:
|
|
258
|
+
if self.type is None or self.info is None:
|
|
259
|
+
return
|
|
260
|
+
with state.strict_optional_set(api.options.strict_optional):
|
|
261
|
+
self.type = map_type_from_supertype(self.type, sub_type, self.info)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class SQLModelTransformer:
|
|
265
|
+
def __init__(
|
|
266
|
+
self,
|
|
267
|
+
cls: ClassDef,
|
|
268
|
+
reason: Expression | Statement,
|
|
269
|
+
api: SemanticAnalyzerPluginInterface,
|
|
270
|
+
plugin_config: SQLModelPluginConfig,
|
|
271
|
+
) -> None:
|
|
272
|
+
self._cls = cls
|
|
273
|
+
self._reason = reason
|
|
274
|
+
self._api = api
|
|
275
|
+
self.plugin_config = plugin_config
|
|
276
|
+
|
|
277
|
+
def transform(self) -> bool:
|
|
278
|
+
info = self._cls.info
|
|
279
|
+
|
|
280
|
+
members = self.collect_members()
|
|
281
|
+
if members is None:
|
|
282
|
+
self._api.defer()
|
|
283
|
+
return False
|
|
284
|
+
fields, relationships = members
|
|
285
|
+
|
|
286
|
+
for f in fields:
|
|
287
|
+
if f.type is None:
|
|
288
|
+
self._api.defer()
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
# Relationship types are nice-to-have but not required to proceed; if unknown,
|
|
292
|
+
# relationship args fall back to Any in signatures.
|
|
293
|
+
relationship_list = [relationships[k] for k in sorted(relationships)]
|
|
294
|
+
|
|
295
|
+
self.add_initializer(fields, relationship_list)
|
|
296
|
+
self.add_model_construct(fields, relationship_list)
|
|
297
|
+
|
|
298
|
+
info.metadata[METADATA_KEY] = {
|
|
299
|
+
"fields": {field.name: field.serialize() for field in fields},
|
|
300
|
+
"relationships": {k: relationships[k].serialize() for k in sorted(relationships)},
|
|
301
|
+
}
|
|
302
|
+
return True
|
|
303
|
+
|
|
304
|
+
def collect_members(self) -> tuple[list[SQLModelField], dict[str, SQLModelRelationship]] | None:
|
|
305
|
+
cls = self._cls
|
|
306
|
+
info = cls.info
|
|
307
|
+
|
|
308
|
+
found_fields: dict[str, SQLModelField] = {}
|
|
309
|
+
found_relationships: dict[str, SQLModelRelationship] = {}
|
|
310
|
+
|
|
311
|
+
# 1) Inherited fields (base first).
|
|
312
|
+
for base_info in reversed(info.mro[1:-1]): # exclude current class and object
|
|
313
|
+
if METADATA_KEY not in base_info.metadata:
|
|
314
|
+
# If this is an SQLModel subclass we haven't processed yet, defer so we can include it.
|
|
315
|
+
if base_info.fullname == SQLMODEL_BASEMODEL_FULLNAME:
|
|
316
|
+
continue
|
|
317
|
+
if base_info.has_base(SQLMODEL_BASEMODEL_FULLNAME):
|
|
318
|
+
return None
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
self._api.add_plugin_dependency(make_wildcard_trigger(base_info.fullname))
|
|
322
|
+
base_metadata = base_info.metadata[METADATA_KEY]
|
|
323
|
+
for name, data in base_metadata.get("fields", {}).items():
|
|
324
|
+
sym_node = info.names.get(name)
|
|
325
|
+
if sym_node and sym_node.node and not isinstance(sym_node.node, Var):
|
|
326
|
+
self._api.fail(
|
|
327
|
+
"SQLModel field may only be overridden by another field",
|
|
328
|
+
sym_node.node,
|
|
329
|
+
code=ERROR_FIELD,
|
|
330
|
+
)
|
|
331
|
+
base_field = SQLModelField.deserialize(base_info, data, self._api)
|
|
332
|
+
base_field.expand_typevar_from_subtype(info, self._api)
|
|
333
|
+
found_fields[name] = base_field
|
|
334
|
+
|
|
335
|
+
rels = base_metadata.get("relationships", {})
|
|
336
|
+
if isinstance(rels, dict):
|
|
337
|
+
for name, data in rels.items():
|
|
338
|
+
if not isinstance(name, str) or not isinstance(data, dict):
|
|
339
|
+
continue
|
|
340
|
+
base_rel = SQLModelRelationship.deserialize(base_info, data, self._api)
|
|
341
|
+
base_rel.expand_typevar_from_subtype(info, self._api)
|
|
342
|
+
found_relationships[name] = base_rel
|
|
343
|
+
elif isinstance(rels, list):
|
|
344
|
+
# Backwards compatibility with older metadata formats.
|
|
345
|
+
for item in rels:
|
|
346
|
+
if isinstance(item, str):
|
|
347
|
+
found_relationships[item] = SQLModelRelationship(
|
|
348
|
+
name=item, line=base_info.line, column=base_info.column
|
|
349
|
+
)
|
|
350
|
+
elif isinstance(item, dict) and isinstance(item.get("name"), str):
|
|
351
|
+
base_rel = SQLModelRelationship.deserialize(base_info, item, self._api)
|
|
352
|
+
base_rel.expand_typevar_from_subtype(info, self._api)
|
|
353
|
+
found_relationships[base_rel.name] = base_rel
|
|
354
|
+
|
|
355
|
+
# 2) Current class fields.
|
|
356
|
+
for stmt in self._get_assignment_statements_from_block(cls.defs):
|
|
357
|
+
member = self.collect_member_from_stmt(stmt)
|
|
358
|
+
if member is None:
|
|
359
|
+
continue
|
|
360
|
+
if isinstance(member, SQLModelField):
|
|
361
|
+
found_relationships.pop(member.name, None)
|
|
362
|
+
found_fields[member.name] = member
|
|
363
|
+
else:
|
|
364
|
+
found_fields.pop(member.name, None)
|
|
365
|
+
found_relationships[member.name] = member
|
|
366
|
+
|
|
367
|
+
return list(found_fields.values()), found_relationships
|
|
368
|
+
|
|
369
|
+
def _get_assignment_statements_from_if_statement(
|
|
370
|
+
self, stmt: IfStmt
|
|
371
|
+
) -> Iterator[AssignmentStmt]:
|
|
372
|
+
for body in stmt.body:
|
|
373
|
+
if not body.is_unreachable:
|
|
374
|
+
yield from self._get_assignment_statements_from_block(body)
|
|
375
|
+
if stmt.else_body is not None and not stmt.else_body.is_unreachable:
|
|
376
|
+
yield from self._get_assignment_statements_from_block(stmt.else_body)
|
|
377
|
+
|
|
378
|
+
def _get_assignment_statements_from_block(self, block: Block) -> Iterator[AssignmentStmt]:
|
|
379
|
+
for stmt in block.body:
|
|
380
|
+
if isinstance(stmt, AssignmentStmt):
|
|
381
|
+
yield stmt
|
|
382
|
+
elif isinstance(stmt, IfStmt):
|
|
383
|
+
yield from self._get_assignment_statements_from_if_statement(stmt)
|
|
384
|
+
|
|
385
|
+
def collect_member_from_stmt(
|
|
386
|
+
self, stmt: AssignmentStmt
|
|
387
|
+
) -> SQLModelField | SQLModelRelationship | None:
|
|
388
|
+
# Untyped assignment (e.g. `x = Field(...)`).
|
|
389
|
+
if not stmt.new_syntax:
|
|
390
|
+
lhs = stmt.lvalues[0]
|
|
391
|
+
if (
|
|
392
|
+
self.plugin_config.warn_untyped_fields
|
|
393
|
+
and isinstance(lhs, NameExpr)
|
|
394
|
+
and isinstance(stmt.rvalue, CallExpr)
|
|
395
|
+
and _callee_fullname(stmt.rvalue)
|
|
396
|
+
in {SQLMODEL_FIELD_FULLNAME, SQLMODEL_RELATIONSHIP_FULLNAME}
|
|
397
|
+
):
|
|
398
|
+
self._api.fail("Untyped fields disallowed", stmt, code=ERROR_FIELD)
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
lhs = stmt.lvalues[0]
|
|
402
|
+
if not isinstance(lhs, NameExpr):
|
|
403
|
+
return None
|
|
404
|
+
|
|
405
|
+
name = lhs.name
|
|
406
|
+
if name == "model_config" or name.startswith("_"):
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
# Relationship: `foo: list[Bar] = Relationship(...)`
|
|
410
|
+
if (
|
|
411
|
+
isinstance(stmt.rvalue, CallExpr)
|
|
412
|
+
and _callee_fullname(stmt.rvalue) == SQLMODEL_RELATIONSHIP_FULLNAME
|
|
413
|
+
):
|
|
414
|
+
rel = SQLModelRelationship(name=name, line=stmt.line, column=stmt.column)
|
|
415
|
+
rel.info = self._cls.info
|
|
416
|
+
# Best-effort: keep the declared annotation type for typing relationship kwargs.
|
|
417
|
+
sym = self._cls.info.names.get(name)
|
|
418
|
+
if sym is not None and isinstance(sym.node, Var) and not sym.node.is_classvar:
|
|
419
|
+
rel.type = sym.node.type
|
|
420
|
+
return rel
|
|
421
|
+
|
|
422
|
+
sym = self._cls.info.names.get(name)
|
|
423
|
+
if sym is None or sym.node is None:
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
node = sym.node
|
|
427
|
+
if isinstance(node, PlaceholderNode):
|
|
428
|
+
return None
|
|
429
|
+
if isinstance(node, TypeAlias):
|
|
430
|
+
return None
|
|
431
|
+
if not isinstance(node, Var):
|
|
432
|
+
return None
|
|
433
|
+
if node.is_classvar:
|
|
434
|
+
return None
|
|
435
|
+
|
|
436
|
+
has_default = self.get_has_default(stmt)
|
|
437
|
+
aliases = self.get_field_aliases(stmt)
|
|
438
|
+
init_type = node.type
|
|
439
|
+
|
|
440
|
+
return SQLModelField(
|
|
441
|
+
name=name,
|
|
442
|
+
has_default=has_default,
|
|
443
|
+
line=stmt.line,
|
|
444
|
+
column=stmt.column,
|
|
445
|
+
aliases=aliases,
|
|
446
|
+
type=init_type,
|
|
447
|
+
info=self._cls.info,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
@staticmethod
|
|
451
|
+
def get_has_default(stmt: AssignmentStmt) -> bool:
|
|
452
|
+
expr = stmt.rvalue
|
|
453
|
+
if isinstance(expr, TempNode):
|
|
454
|
+
return False
|
|
455
|
+
|
|
456
|
+
# `x: int = Field(...)`
|
|
457
|
+
if isinstance(expr, CallExpr) and _callee_fullname(expr) == SQLMODEL_FIELD_FULLNAME:
|
|
458
|
+
for arg, arg_name in zip(expr.args, expr.arg_names, strict=True):
|
|
459
|
+
if arg_name is None or arg_name == "default":
|
|
460
|
+
return arg.__class__ is not EllipsisExpr
|
|
461
|
+
if arg_name == "default_factory":
|
|
462
|
+
return not (isinstance(arg, NameExpr) and arg.fullname == "builtins.None")
|
|
463
|
+
return False
|
|
464
|
+
|
|
465
|
+
# `x: int = ...` is required
|
|
466
|
+
if isinstance(expr, EllipsisExpr):
|
|
467
|
+
return False
|
|
468
|
+
|
|
469
|
+
return True
|
|
470
|
+
|
|
471
|
+
@staticmethod
|
|
472
|
+
def get_field_aliases(stmt: AssignmentStmt) -> list[str]:
|
|
473
|
+
"""Best-effort collection of Field() alias names usable as constructor kwargs.
|
|
474
|
+
|
|
475
|
+
Mirrors SQLModel's precedence for validation aliases:
|
|
476
|
+
`validation_alias` > `schema_extra["validation_alias"]` > `alias`.
|
|
477
|
+
|
|
478
|
+
We currently only expose aliases that are statically known string literals and are valid
|
|
479
|
+
Python keyword names.
|
|
480
|
+
"""
|
|
481
|
+
expr = stmt.rvalue
|
|
482
|
+
if not (isinstance(expr, CallExpr) and _callee_fullname(expr) == SQLMODEL_FIELD_FULLNAME):
|
|
483
|
+
return []
|
|
484
|
+
|
|
485
|
+
alias_arg: str | None = None
|
|
486
|
+
validation_alias_arg: str | None = None
|
|
487
|
+
serialization_alias_arg: str | None = None
|
|
488
|
+
schema_validation_alias: str | None = None
|
|
489
|
+
schema_serialization_alias: str | None = None
|
|
490
|
+
|
|
491
|
+
def _literal_str_or_none(value: Expression) -> str | None:
|
|
492
|
+
if isinstance(value, StrExpr):
|
|
493
|
+
return value.value
|
|
494
|
+
if isinstance(value, NameExpr) and (
|
|
495
|
+
value.fullname == "builtins.None" or value.name == "None"
|
|
496
|
+
):
|
|
497
|
+
return None
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
def _is_usable_kwarg_name(name: str) -> bool:
|
|
501
|
+
return name.isidentifier() and not keyword.iskeyword(name)
|
|
502
|
+
|
|
503
|
+
for arg, arg_name in zip(expr.args, expr.arg_names, strict=True):
|
|
504
|
+
if arg_name == "alias":
|
|
505
|
+
alias_arg = _literal_str_or_none(arg)
|
|
506
|
+
elif arg_name == "validation_alias":
|
|
507
|
+
validation_alias_arg = _literal_str_or_none(arg)
|
|
508
|
+
elif arg_name == "serialization_alias":
|
|
509
|
+
serialization_alias_arg = _literal_str_or_none(arg)
|
|
510
|
+
elif arg_name == "schema_extra" and isinstance(arg, DictExpr):
|
|
511
|
+
for key_expr, value_expr in arg.items:
|
|
512
|
+
if not isinstance(key_expr, StrExpr):
|
|
513
|
+
continue
|
|
514
|
+
if key_expr.value == "validation_alias":
|
|
515
|
+
schema_validation_alias = _literal_str_or_none(value_expr)
|
|
516
|
+
elif key_expr.value == "serialization_alias":
|
|
517
|
+
schema_serialization_alias = _literal_str_or_none(value_expr)
|
|
518
|
+
|
|
519
|
+
validation_alias_final = validation_alias_arg or schema_validation_alias or alias_arg
|
|
520
|
+
_serialization_alias_final = (
|
|
521
|
+
serialization_alias_arg or schema_serialization_alias or alias_arg
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
aliases: list[str] = []
|
|
525
|
+
for candidate in (alias_arg, validation_alias_final):
|
|
526
|
+
if candidate is None:
|
|
527
|
+
continue
|
|
528
|
+
if not _is_usable_kwarg_name(candidate):
|
|
529
|
+
continue
|
|
530
|
+
if candidate in aliases:
|
|
531
|
+
continue
|
|
532
|
+
aliases.append(candidate)
|
|
533
|
+
return aliases
|
|
534
|
+
|
|
535
|
+
def add_initializer(
|
|
536
|
+
self, fields: list[SQLModelField], relationships: list[SQLModelRelationship]
|
|
537
|
+
) -> None:
|
|
538
|
+
info = self._cls.info
|
|
539
|
+
|
|
540
|
+
if "__init__" in info.names and not info.names["__init__"].plugin_generated:
|
|
541
|
+
return
|
|
542
|
+
|
|
543
|
+
typed = self.plugin_config.init_typed
|
|
544
|
+
table_model = _is_table_model(info)
|
|
545
|
+
|
|
546
|
+
canonical_names = {field.name for field in fields}
|
|
547
|
+
if table_model:
|
|
548
|
+
canonical_names.update(rel.name for rel in relationships)
|
|
549
|
+
if not self.plugin_config.init_forbid_extra:
|
|
550
|
+
canonical_names.add("kwargs")
|
|
551
|
+
|
|
552
|
+
args: list[Argument] = []
|
|
553
|
+
field_params: list[tuple[SQLModelField, Type, list[str]]] = []
|
|
554
|
+
for field in fields:
|
|
555
|
+
expanded = field.expand_type(info, self._api, force_typevars_invariant=True)
|
|
556
|
+
if expanded is not None:
|
|
557
|
+
expanded = expanded.accept(ForceInvariantTypeVars())
|
|
558
|
+
expanded = _unwrap_mapped_type(expanded)
|
|
559
|
+
if typed and expanded is not None:
|
|
560
|
+
type_annotation = expanded
|
|
561
|
+
else:
|
|
562
|
+
type_annotation = AnyType(TypeOfAny.explicit)
|
|
563
|
+
|
|
564
|
+
field_aliases = [
|
|
565
|
+
alias
|
|
566
|
+
for alias in field.aliases
|
|
567
|
+
if alias != field.name and alias not in canonical_names
|
|
568
|
+
]
|
|
569
|
+
variable = Var(field.name, type_annotation)
|
|
570
|
+
args.append(
|
|
571
|
+
Argument(
|
|
572
|
+
variable=variable,
|
|
573
|
+
type_annotation=type_annotation,
|
|
574
|
+
initializer=None,
|
|
575
|
+
kind=ARG_NAMED_OPT if field.has_default or field_aliases else ARG_NAMED,
|
|
576
|
+
)
|
|
577
|
+
)
|
|
578
|
+
field_params.append((field, type_annotation, field_aliases))
|
|
579
|
+
|
|
580
|
+
if table_model:
|
|
581
|
+
for rel in relationships:
|
|
582
|
+
expanded = rel.expand_type(info, self._api, force_typevars_invariant=True)
|
|
583
|
+
if expanded is not None:
|
|
584
|
+
expanded = expanded.accept(ForceInvariantTypeVars())
|
|
585
|
+
expanded = _unwrap_mapped_type(expanded)
|
|
586
|
+
if typed and expanded is not None:
|
|
587
|
+
type_annotation = expanded
|
|
588
|
+
else:
|
|
589
|
+
type_annotation = AnyType(TypeOfAny.explicit)
|
|
590
|
+
variable = Var(rel.name, type_annotation)
|
|
591
|
+
args.append(
|
|
592
|
+
Argument(
|
|
593
|
+
variable=variable,
|
|
594
|
+
type_annotation=type_annotation,
|
|
595
|
+
initializer=None,
|
|
596
|
+
kind=ARG_NAMED_OPT,
|
|
597
|
+
)
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
used_names = set(canonical_names)
|
|
601
|
+
for _field, type_annotation, field_aliases in field_params:
|
|
602
|
+
for alias in field_aliases:
|
|
603
|
+
if alias in used_names:
|
|
604
|
+
continue
|
|
605
|
+
used_names.add(alias)
|
|
606
|
+
variable = Var(alias, type_annotation)
|
|
607
|
+
args.append(
|
|
608
|
+
Argument(
|
|
609
|
+
variable=variable,
|
|
610
|
+
type_annotation=type_annotation,
|
|
611
|
+
initializer=None,
|
|
612
|
+
kind=ARG_NAMED_OPT,
|
|
613
|
+
)
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
if not self.plugin_config.init_forbid_extra:
|
|
617
|
+
kw = AnyType(TypeOfAny.explicit)
|
|
618
|
+
kwargs_var = Var("kwargs", kw)
|
|
619
|
+
args.append(Argument(kwargs_var, kw, None, ARG_STAR2))
|
|
620
|
+
|
|
621
|
+
add_method(self._api, self._cls, "__init__", args=args, return_type=NoneType())
|
|
622
|
+
|
|
623
|
+
def add_model_construct(
|
|
624
|
+
self, fields: list[SQLModelField], relationships: list[SQLModelRelationship]
|
|
625
|
+
) -> None:
|
|
626
|
+
info = self._cls.info
|
|
627
|
+
|
|
628
|
+
if "model_construct" in info.names and not info.names["model_construct"].plugin_generated:
|
|
629
|
+
return
|
|
630
|
+
|
|
631
|
+
set_str = self._api.named_type("builtins.set", [self._api.named_type("builtins.str")])
|
|
632
|
+
optional_set_str = UnionType([set_str, NoneType()])
|
|
633
|
+
fields_set_argument = Argument(
|
|
634
|
+
Var("_fields_set", optional_set_str), optional_set_str, None, ARG_OPT
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
table_model = _is_table_model(info)
|
|
638
|
+
|
|
639
|
+
canonical_names = {field.name for field in fields}
|
|
640
|
+
if table_model:
|
|
641
|
+
canonical_names.update(rel.name for rel in relationships)
|
|
642
|
+
canonical_names.add("_fields_set")
|
|
643
|
+
if not self.plugin_config.init_forbid_extra:
|
|
644
|
+
canonical_names.add("kwargs")
|
|
645
|
+
|
|
646
|
+
args: list[Argument] = []
|
|
647
|
+
field_params: list[tuple[SQLModelField, Type, list[str]]] = []
|
|
648
|
+
for field in fields:
|
|
649
|
+
expanded = field.expand_type(info, self._api, force_typevars_invariant=True)
|
|
650
|
+
# `model_construct` bypasses validation, so this is always typed.
|
|
651
|
+
if expanded is not None:
|
|
652
|
+
expanded = expanded.accept(ForceInvariantTypeVars())
|
|
653
|
+
expanded = _unwrap_mapped_type(expanded)
|
|
654
|
+
type_annotation = expanded or AnyType(TypeOfAny.explicit)
|
|
655
|
+
|
|
656
|
+
field_aliases = [
|
|
657
|
+
alias
|
|
658
|
+
for alias in field.aliases
|
|
659
|
+
if alias != field.name and alias not in canonical_names
|
|
660
|
+
]
|
|
661
|
+
variable = Var(field.name, type_annotation)
|
|
662
|
+
args.append(
|
|
663
|
+
Argument(
|
|
664
|
+
variable=variable,
|
|
665
|
+
type_annotation=type_annotation,
|
|
666
|
+
initializer=None,
|
|
667
|
+
kind=ARG_NAMED_OPT if field.has_default or field_aliases else ARG_NAMED,
|
|
668
|
+
)
|
|
669
|
+
)
|
|
670
|
+
field_params.append((field, type_annotation, field_aliases))
|
|
671
|
+
|
|
672
|
+
if table_model:
|
|
673
|
+
for rel in relationships:
|
|
674
|
+
expanded = rel.expand_type(info, self._api, force_typevars_invariant=True)
|
|
675
|
+
if expanded is not None:
|
|
676
|
+
expanded = expanded.accept(ForceInvariantTypeVars())
|
|
677
|
+
expanded = _unwrap_mapped_type(expanded)
|
|
678
|
+
type_annotation = expanded or AnyType(TypeOfAny.explicit)
|
|
679
|
+
variable = Var(rel.name, type_annotation)
|
|
680
|
+
args.append(
|
|
681
|
+
Argument(
|
|
682
|
+
variable=variable,
|
|
683
|
+
type_annotation=type_annotation,
|
|
684
|
+
initializer=None,
|
|
685
|
+
kind=ARG_NAMED_OPT,
|
|
686
|
+
)
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
used_names = set(canonical_names)
|
|
690
|
+
for _field, type_annotation, field_aliases in field_params:
|
|
691
|
+
for alias in field_aliases:
|
|
692
|
+
if alias in used_names:
|
|
693
|
+
continue
|
|
694
|
+
used_names.add(alias)
|
|
695
|
+
variable = Var(alias, type_annotation)
|
|
696
|
+
args.append(
|
|
697
|
+
Argument(
|
|
698
|
+
variable=variable,
|
|
699
|
+
type_annotation=type_annotation,
|
|
700
|
+
initializer=None,
|
|
701
|
+
kind=ARG_NAMED_OPT,
|
|
702
|
+
)
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
if not self.plugin_config.init_forbid_extra:
|
|
706
|
+
kw = AnyType(TypeOfAny.explicit)
|
|
707
|
+
kwargs_var = Var("kwargs", kw)
|
|
708
|
+
args.append(Argument(kwargs_var, kw, None, ARG_STAR2))
|
|
709
|
+
|
|
710
|
+
add_method(
|
|
711
|
+
self._api,
|
|
712
|
+
self._cls,
|
|
713
|
+
"model_construct",
|
|
714
|
+
args=[fields_set_argument, *args],
|
|
715
|
+
return_type=fill_typevars(self._cls.info),
|
|
716
|
+
is_classmethod=True,
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _callee_fullname(call: CallExpr) -> str | None:
|
|
721
|
+
callee: Any = call.callee
|
|
722
|
+
if isinstance(callee, RefExpr):
|
|
723
|
+
return callee.fullname
|
|
724
|
+
return None
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _unwrap_mapped_type(typ: Type) -> Type:
|
|
728
|
+
"""Convert SQLAlchemy `Mapped[T]` annotations to `T` for constructor signatures.
|
|
729
|
+
|
|
730
|
+
SQLModel model attributes can be annotated as `Mapped[T]` for ORM mapping, but users pass plain `T`
|
|
731
|
+
values into `__init__`/`model_construct` (not `Mapped[T]` descriptors).
|
|
732
|
+
"""
|
|
733
|
+
proper = get_proper_type(typ)
|
|
734
|
+
if isinstance(proper, Instance) and proper.type.fullname in SQLALCHEMY_MAPPED_FULLNAMES:
|
|
735
|
+
if proper.args:
|
|
736
|
+
return proper.args[0]
|
|
737
|
+
return AnyType(TypeOfAny.from_omitted_generics)
|
|
738
|
+
return typ
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _is_bool_nameexpr(expr: Expression, value: bool) -> bool:
|
|
742
|
+
if not isinstance(expr, NameExpr):
|
|
743
|
+
return False
|
|
744
|
+
if value:
|
|
745
|
+
return expr.fullname == "builtins.True" or expr.name == "True"
|
|
746
|
+
return expr.fullname == "builtins.False" or expr.name == "False"
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _is_table_model(info: TypeInfo) -> bool:
|
|
750
|
+
"""Best-effort detection for `class Model(SQLModel, table=True)`."""
|
|
751
|
+
kw = info.defn.keywords.get("table")
|
|
752
|
+
if kw is not None:
|
|
753
|
+
return _is_bool_nameexpr(kw, True)
|
|
754
|
+
|
|
755
|
+
# Inherit `table=True` from bases if present.
|
|
756
|
+
for base in info.mro[1:]:
|
|
757
|
+
if base.fullname == SQLMODEL_BASEMODEL_FULLNAME:
|
|
758
|
+
continue
|
|
759
|
+
kw = base.defn.keywords.get("table")
|
|
760
|
+
if kw is not None and _is_bool_nameexpr(kw, True):
|
|
761
|
+
return True
|
|
762
|
+
return False
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def add_method(
|
|
766
|
+
api: SemanticAnalyzerPluginInterface,
|
|
767
|
+
cls: ClassDef,
|
|
768
|
+
name: str,
|
|
769
|
+
args: list[Argument],
|
|
770
|
+
return_type: Type,
|
|
771
|
+
*,
|
|
772
|
+
is_classmethod: bool = False,
|
|
773
|
+
) -> None:
|
|
774
|
+
"""Close to `mypy.plugins.common.add_method_to_class`, but avoids `self` name conflicts.
|
|
775
|
+
|
|
776
|
+
SQLModel models can legally have a field named `self`; generating a signature using a parameter named
|
|
777
|
+
`self` causes a `no-redef` error in mypy. Use a name that cannot conflict.
|
|
778
|
+
"""
|
|
779
|
+
info = cls.info
|
|
780
|
+
|
|
781
|
+
# Remove previously generated methods.
|
|
782
|
+
if name in info.names:
|
|
783
|
+
sym = info.names[name]
|
|
784
|
+
if sym.plugin_generated and isinstance(sym.node, FuncDef):
|
|
785
|
+
cls.defs.body.remove(sym.node)
|
|
786
|
+
|
|
787
|
+
# Keep existing definition for semantic analysis by renaming it.
|
|
788
|
+
if name in info.names:
|
|
789
|
+
r_name = get_unique_redefinition_name(name, info.names)
|
|
790
|
+
info.names[r_name] = info.names[name]
|
|
791
|
+
|
|
792
|
+
function_type = api.named_type("builtins.function")
|
|
793
|
+
|
|
794
|
+
if is_classmethod:
|
|
795
|
+
cls_type = TypeType(fill_typevars(info))
|
|
796
|
+
first = [Argument(Var("_cls"), cls_type, None, ARG_POS, True)]
|
|
797
|
+
else:
|
|
798
|
+
inst_type = fill_typevars(info)
|
|
799
|
+
first = [Argument(Var("__sqlmodel_self__"), inst_type, None, ARG_POS)]
|
|
800
|
+
|
|
801
|
+
full_args = first + args
|
|
802
|
+
arg_types: list[Type] = []
|
|
803
|
+
arg_names: list[str] = []
|
|
804
|
+
arg_kinds = []
|
|
805
|
+
for arg in full_args:
|
|
806
|
+
assert arg.type_annotation is not None
|
|
807
|
+
arg_types.append(arg.type_annotation)
|
|
808
|
+
arg_names.append(arg.variable.name)
|
|
809
|
+
arg_kinds.append(arg.kind)
|
|
810
|
+
|
|
811
|
+
signature = CallableType(arg_types, arg_kinds, arg_names, return_type, function_type)
|
|
812
|
+
|
|
813
|
+
from mypy.nodes import PassStmt
|
|
814
|
+
|
|
815
|
+
func = FuncDef(name, full_args, Block([PassStmt()]))
|
|
816
|
+
func.info = info
|
|
817
|
+
func.type = set_callable_name(signature, func)
|
|
818
|
+
func.is_class = is_classmethod
|
|
819
|
+
func._fullname = info.fullname + "." + name
|
|
820
|
+
func.line = info.line
|
|
821
|
+
|
|
822
|
+
if is_classmethod:
|
|
823
|
+
func.is_decorated = True
|
|
824
|
+
v = Var(name, func.type)
|
|
825
|
+
v.info = info
|
|
826
|
+
v._fullname = func._fullname
|
|
827
|
+
v.is_classmethod = True
|
|
828
|
+
dec = Decorator(func, [NameExpr("classmethod")], v)
|
|
829
|
+
dec.line = info.line
|
|
830
|
+
sym = SymbolTableNode(MDEF, dec)
|
|
831
|
+
else:
|
|
832
|
+
sym = SymbolTableNode(MDEF, func)
|
|
833
|
+
|
|
834
|
+
sym.plugin_generated = True
|
|
835
|
+
info.names[name] = sym
|
|
836
|
+
info.defn.defs.body.append(func)
|