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
sqlmodel_mypy/plugin.py
ADDED
|
@@ -0,0 +1,1140 @@
|
|
|
1
|
+
"""Mypy plugin entry point for SQLModel."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Callable, Iterator
|
|
7
|
+
from configparser import ConfigParser
|
|
8
|
+
from typing import Any, NamedTuple
|
|
9
|
+
|
|
10
|
+
from mypy.nodes import (
|
|
11
|
+
ARG_NAMED,
|
|
12
|
+
ARG_NAMED_OPT,
|
|
13
|
+
ARG_OPT,
|
|
14
|
+
ARG_POS,
|
|
15
|
+
ARG_STAR,
|
|
16
|
+
ARG_STAR2,
|
|
17
|
+
MDEF,
|
|
18
|
+
ArgKind,
|
|
19
|
+
AssignmentStmt,
|
|
20
|
+
Block,
|
|
21
|
+
CallExpr,
|
|
22
|
+
Expression,
|
|
23
|
+
IfStmt,
|
|
24
|
+
MemberExpr,
|
|
25
|
+
NameExpr,
|
|
26
|
+
RefExpr,
|
|
27
|
+
SymbolTableNode,
|
|
28
|
+
TypeInfo,
|
|
29
|
+
Var,
|
|
30
|
+
)
|
|
31
|
+
from mypy.options import Options
|
|
32
|
+
from mypy.plugin import (
|
|
33
|
+
AttributeContext,
|
|
34
|
+
ClassDefContext,
|
|
35
|
+
FunctionContext,
|
|
36
|
+
FunctionSigContext,
|
|
37
|
+
MethodContext,
|
|
38
|
+
MethodSigContext,
|
|
39
|
+
Plugin,
|
|
40
|
+
ReportConfigContext,
|
|
41
|
+
)
|
|
42
|
+
from mypy.state import state
|
|
43
|
+
from mypy.typeops import map_type_from_supertype
|
|
44
|
+
from mypy.types import (
|
|
45
|
+
AnyType,
|
|
46
|
+
CallableType,
|
|
47
|
+
FunctionLike,
|
|
48
|
+
Instance,
|
|
49
|
+
NoneType,
|
|
50
|
+
TupleType,
|
|
51
|
+
Type,
|
|
52
|
+
TypeOfAny,
|
|
53
|
+
TypeType,
|
|
54
|
+
UnionType,
|
|
55
|
+
get_proper_type,
|
|
56
|
+
)
|
|
57
|
+
from mypy.typevars import fill_typevars
|
|
58
|
+
|
|
59
|
+
from .transform import (
|
|
60
|
+
ERROR_FIELD,
|
|
61
|
+
SQLALCHEMY_MAPPED_FULLNAMES,
|
|
62
|
+
SQLMODEL_FIELD_FULLNAME,
|
|
63
|
+
SQLMODEL_RELATIONSHIP_FULLNAME,
|
|
64
|
+
ForceInvariantTypeVars,
|
|
65
|
+
SQLModelTransformer,
|
|
66
|
+
_callee_fullname,
|
|
67
|
+
_unwrap_mapped_type,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
CONFIGFILE_KEY = "sqlmodel-mypy"
|
|
71
|
+
SQLMODEL_BASEMODEL_FULLNAME = "sqlmodel.main.SQLModel"
|
|
72
|
+
SQLMODEL_METACLASS_FULLNAME = "sqlmodel.main.SQLModelMetaclass"
|
|
73
|
+
|
|
74
|
+
SQLMODEL_COL_FULLNAME = "sqlmodel.sql.expression.col"
|
|
75
|
+
# SQLModel's `select()` is implemented in a generated module and re-exported.
|
|
76
|
+
SQLMODEL_SELECT_GEN_FULLNAME = "sqlmodel.sql._expression_select_gen.select"
|
|
77
|
+
SQLMODEL_SELECT_FULLNAMES = {
|
|
78
|
+
SQLMODEL_SELECT_GEN_FULLNAME,
|
|
79
|
+
"sqlmodel.sql.expression.select",
|
|
80
|
+
"sqlmodel.select",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
SQLALCHEMY_INSTRUMENTED_ATTRIBUTE_FULLNAMES = {
|
|
84
|
+
"sqlalchemy.orm.InstrumentedAttribute",
|
|
85
|
+
"sqlalchemy.orm.attributes.InstrumentedAttribute",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
SQLALCHEMY_SELECT_FULLNAME = "sqlalchemy.sql.selectable.Select"
|
|
89
|
+
SQLALCHEMY_SELECT_JOIN_FULLNAME = f"{SQLALCHEMY_SELECT_FULLNAME}.join"
|
|
90
|
+
SQLALCHEMY_SELECT_JOIN_FROM_FULLNAME = f"{SQLALCHEMY_SELECT_FULLNAME}.join_from"
|
|
91
|
+
SQLALCHEMY_SELECT_OUTERJOIN_FULLNAME = f"{SQLALCHEMY_SELECT_FULLNAME}.outerjoin"
|
|
92
|
+
SQLALCHEMY_SELECT_OUTERJOIN_FROM_FULLNAME = f"{SQLALCHEMY_SELECT_FULLNAME}.outerjoin_from"
|
|
93
|
+
|
|
94
|
+
# SQLModel wraps SQLAlchemy's Select in its own generic Select class.
|
|
95
|
+
SQLMODEL_SELECT_CLS_FULLNAME = "sqlmodel.sql._expression_select_cls.Select"
|
|
96
|
+
SQLMODEL_SELECT_JOIN_FULLNAME = f"{SQLMODEL_SELECT_CLS_FULLNAME}.join"
|
|
97
|
+
SQLMODEL_SELECT_JOIN_FROM_FULLNAME = f"{SQLMODEL_SELECT_CLS_FULLNAME}.join_from"
|
|
98
|
+
SQLMODEL_SELECT_OUTERJOIN_FULLNAME = f"{SQLMODEL_SELECT_CLS_FULLNAME}.outerjoin"
|
|
99
|
+
SQLMODEL_SELECT_OUTERJOIN_FROM_FULLNAME = f"{SQLMODEL_SELECT_CLS_FULLNAME}.outerjoin_from"
|
|
100
|
+
|
|
101
|
+
# Relationship / comparator methods used in query expressions.
|
|
102
|
+
#
|
|
103
|
+
# These are intentionally a small set of best-effort targets; mypy may resolve
|
|
104
|
+
# calls against different bases depending on SQLAlchemy version and stubs.
|
|
105
|
+
SQLALCHEMY_RELATIONSHIP_COMPARATOR_METHOD_FULLNAMES = {
|
|
106
|
+
# ORM-level helpers (typed on SQLAlchemy's side under TYPE_CHECKING).
|
|
107
|
+
"sqlalchemy.orm.base.SQLORMOperations.any",
|
|
108
|
+
"sqlalchemy.orm.base.SQLORMOperations.has",
|
|
109
|
+
# Comparator base (also typed under TYPE_CHECKING).
|
|
110
|
+
"sqlalchemy.orm.interfaces.PropComparator.any",
|
|
111
|
+
"sqlalchemy.orm.interfaces.PropComparator.has",
|
|
112
|
+
# Instrumented attribute access (mypy may resolve against the concrete type).
|
|
113
|
+
"sqlalchemy.orm.InstrumentedAttribute.any",
|
|
114
|
+
"sqlalchemy.orm.InstrumentedAttribute.has",
|
|
115
|
+
"sqlalchemy.orm.InstrumentedAttribute.contains",
|
|
116
|
+
"sqlalchemy.orm.attributes.InstrumentedAttribute.any",
|
|
117
|
+
"sqlalchemy.orm.attributes.InstrumentedAttribute.has",
|
|
118
|
+
"sqlalchemy.orm.attributes.InstrumentedAttribute.contains",
|
|
119
|
+
"sqlalchemy.orm.attributes.QueryableAttribute.any",
|
|
120
|
+
"sqlalchemy.orm.attributes.QueryableAttribute.has",
|
|
121
|
+
"sqlalchemy.orm.attributes.QueryableAttribute.contains",
|
|
122
|
+
# Relationship comparator implementation.
|
|
123
|
+
"sqlalchemy.orm.relationships.RelationshipProperty.Comparator.any",
|
|
124
|
+
"sqlalchemy.orm.relationships.RelationshipProperty.Comparator.has",
|
|
125
|
+
"sqlalchemy.orm.relationships.RelationshipProperty.Comparator.contains",
|
|
126
|
+
# SQLCoreOperations / ColumnOperators surface `.contains(...)` for strings and
|
|
127
|
+
# relationships; for relationships, SQLAlchemy dispatches to the relationship
|
|
128
|
+
# comparator at runtime.
|
|
129
|
+
"sqlalchemy.sql.elements.SQLCoreOperations.contains",
|
|
130
|
+
"sqlalchemy.sql.operators.ColumnOperators.contains",
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
SQLMODEL_SESSION_EXEC_FULLNAME = "sqlmodel.orm.session.Session.exec"
|
|
134
|
+
SQLMODEL_ASYNC_SESSION_EXEC_FULLNAME = "sqlmodel.ext.asyncio.session.AsyncSession.exec"
|
|
135
|
+
|
|
136
|
+
# Increment when plugin changes should invalidate mypy cache.
|
|
137
|
+
__version__ = 14
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class _CollectedField(NamedTuple):
|
|
141
|
+
name: str
|
|
142
|
+
aliases: tuple[str, ...]
|
|
143
|
+
has_default: bool
|
|
144
|
+
line: int
|
|
145
|
+
column: int
|
|
146
|
+
type: Type | None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class _CollectedRelationship(NamedTuple):
|
|
150
|
+
name: str
|
|
151
|
+
line: int
|
|
152
|
+
column: int
|
|
153
|
+
type: Type | None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _is_bool_nameexpr(expr: Expression, value: bool) -> bool:
|
|
157
|
+
if not isinstance(expr, NameExpr):
|
|
158
|
+
return False
|
|
159
|
+
if value:
|
|
160
|
+
return expr.fullname == "builtins.True" or expr.name == "True"
|
|
161
|
+
return expr.fullname == "builtins.False" or expr.name == "False"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _is_table_model(info: TypeInfo) -> bool:
|
|
165
|
+
"""Best-effort detection for `class Model(SQLModel, table=True)`."""
|
|
166
|
+
kw = info.defn.keywords.get("table")
|
|
167
|
+
if kw is not None:
|
|
168
|
+
return _is_bool_nameexpr(kw, True)
|
|
169
|
+
|
|
170
|
+
# Inherit `table=True` from bases if present.
|
|
171
|
+
for base in info.mro[1:]:
|
|
172
|
+
if base.fullname == SQLMODEL_BASEMODEL_FULLNAME:
|
|
173
|
+
continue
|
|
174
|
+
kw = base.defn.keywords.get("table")
|
|
175
|
+
if kw is not None and _is_bool_nameexpr(kw, True):
|
|
176
|
+
return True
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _call_get_kwarg(call: CallExpr, name: str) -> Expression | None:
|
|
181
|
+
for arg_name, arg_expr in zip(call.arg_names, call.args, strict=True):
|
|
182
|
+
if arg_name == name:
|
|
183
|
+
return arg_expr
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _call_get_positional(call: CallExpr, index: int) -> Expression | None:
|
|
188
|
+
pos = 0
|
|
189
|
+
for arg_name, arg_expr in zip(call.arg_names, call.args, strict=True):
|
|
190
|
+
if arg_name is None:
|
|
191
|
+
if pos == index:
|
|
192
|
+
return arg_expr
|
|
193
|
+
pos += 1
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _call_get_arg(call: CallExpr, name: str, positional_index: int) -> Expression | None:
|
|
198
|
+
return _call_get_kwarg(call, name) or _call_get_positional(call, positional_index)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _typeinfo_from_ref_expr(expr: Expression | None) -> TypeInfo | None:
|
|
202
|
+
if expr is None:
|
|
203
|
+
return None
|
|
204
|
+
if isinstance(expr, RefExpr) and isinstance(expr.node, TypeInfo):
|
|
205
|
+
return expr.node
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def plugin(version: str) -> type[Plugin]:
|
|
210
|
+
# `version` is the mypy version string; keep the signature required by mypy.
|
|
211
|
+
return SQLModelMypyPlugin
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _iter_assignment_statements_from_if_statement(stmt: IfStmt) -> Iterator[AssignmentStmt]:
|
|
215
|
+
for body in stmt.body:
|
|
216
|
+
if not body.is_unreachable:
|
|
217
|
+
yield from _iter_assignment_statements_from_block(body)
|
|
218
|
+
if stmt.else_body is not None and not stmt.else_body.is_unreachable:
|
|
219
|
+
yield from _iter_assignment_statements_from_block(stmt.else_body)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _iter_assignment_statements_from_block(block: Block) -> Iterator[AssignmentStmt]:
|
|
223
|
+
for stmt in block.body:
|
|
224
|
+
if isinstance(stmt, AssignmentStmt):
|
|
225
|
+
yield stmt
|
|
226
|
+
elif isinstance(stmt, IfStmt):
|
|
227
|
+
yield from _iter_assignment_statements_from_if_statement(stmt)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _lookup_typeinfo(plugin: Plugin, fullname: str) -> TypeInfo | None:
|
|
231
|
+
sym = plugin.lookup_fully_qualified(fullname)
|
|
232
|
+
if sym and isinstance(sym.node, TypeInfo):
|
|
233
|
+
return sym.node
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class SQLModelMypyPlugin(Plugin):
|
|
238
|
+
def __init__(self, options: Options) -> None:
|
|
239
|
+
self.plugin_config = SQLModelPluginConfig(options)
|
|
240
|
+
self._plugin_data = {"__version__": __version__, **self.plugin_config.to_data()}
|
|
241
|
+
super().__init__(options)
|
|
242
|
+
|
|
243
|
+
def get_function_signature_hook(
|
|
244
|
+
self, fullname: str
|
|
245
|
+
) -> Callable[[FunctionSigContext], FunctionLike] | None:
|
|
246
|
+
"""Adjust function call signatures (SQLModel constructors and helpers)."""
|
|
247
|
+
if fullname == SQLMODEL_FIELD_FULLNAME:
|
|
248
|
+
return self._sqlmodel_field_signature_callback
|
|
249
|
+
if fullname in SQLMODEL_SELECT_FULLNAMES:
|
|
250
|
+
return self._sqlmodel_select_signature_callback
|
|
251
|
+
|
|
252
|
+
info = _lookup_typeinfo(self, fullname)
|
|
253
|
+
if info is None:
|
|
254
|
+
return None
|
|
255
|
+
if info.fullname == SQLMODEL_BASEMODEL_FULLNAME:
|
|
256
|
+
return None
|
|
257
|
+
if not info.has_base(SQLMODEL_BASEMODEL_FULLNAME):
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
def _hook(ctx: FunctionSigContext) -> FunctionLike:
|
|
261
|
+
return self._sqlmodel_constructor_signature_callback(ctx, info)
|
|
262
|
+
|
|
263
|
+
return _hook
|
|
264
|
+
|
|
265
|
+
def get_base_class_hook(self, fullname: str) -> Callable[[ClassDefContext], None] | None:
|
|
266
|
+
sym = self.lookup_fully_qualified(fullname)
|
|
267
|
+
if sym and isinstance(sym.node, TypeInfo):
|
|
268
|
+
if sym.node.fullname == SQLMODEL_BASEMODEL_FULLNAME or sym.node.has_base(
|
|
269
|
+
SQLMODEL_BASEMODEL_FULLNAME
|
|
270
|
+
):
|
|
271
|
+
return self._sqlmodel_model_class_callback
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
def get_metaclass_hook(self, fullname: str) -> Callable[[ClassDefContext], None] | None:
|
|
275
|
+
if fullname == SQLMODEL_METACLASS_FULLNAME:
|
|
276
|
+
return self._sqlmodel_metaclass_callback
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
def get_method_signature_hook(
|
|
280
|
+
self, fullname: str
|
|
281
|
+
) -> Callable[[MethodSigContext], FunctionLike] | None:
|
|
282
|
+
if fullname == SQLMODEL_SESSION_EXEC_FULLNAME:
|
|
283
|
+
return lambda ctx: self._sqlmodel_session_exec_signature_callback(ctx, is_async=False)
|
|
284
|
+
if fullname == SQLMODEL_ASYNC_SESSION_EXEC_FULLNAME:
|
|
285
|
+
return lambda ctx: self._sqlmodel_session_exec_signature_callback(ctx, is_async=True)
|
|
286
|
+
if fullname.endswith(".model_construct"):
|
|
287
|
+
return self._sqlmodel_model_construct_signature_callback
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
def get_method_hook(self, fullname: str) -> Callable[[MethodContext], Type] | None:
|
|
291
|
+
if fullname in {SQLALCHEMY_SELECT_JOIN_FULLNAME, SQLMODEL_SELECT_JOIN_FULLNAME}:
|
|
292
|
+
return self._sqlalchemy_select_join_return_type_callback
|
|
293
|
+
if fullname in {SQLALCHEMY_SELECT_JOIN_FROM_FULLNAME, SQLMODEL_SELECT_JOIN_FROM_FULLNAME}:
|
|
294
|
+
return self._sqlalchemy_select_join_from_return_type_callback
|
|
295
|
+
if fullname in {SQLALCHEMY_SELECT_OUTERJOIN_FULLNAME, SQLMODEL_SELECT_OUTERJOIN_FULLNAME}:
|
|
296
|
+
return self._sqlalchemy_select_outerjoin_return_type_callback
|
|
297
|
+
if fullname in {
|
|
298
|
+
SQLALCHEMY_SELECT_OUTERJOIN_FROM_FULLNAME,
|
|
299
|
+
SQLMODEL_SELECT_OUTERJOIN_FROM_FULLNAME,
|
|
300
|
+
}:
|
|
301
|
+
return self._sqlalchemy_select_outerjoin_from_return_type_callback
|
|
302
|
+
if (
|
|
303
|
+
fullname in SQLALCHEMY_RELATIONSHIP_COMPARATOR_METHOD_FULLNAMES
|
|
304
|
+
# Robust fallback: different SQLAlchemy/mypy combinations may resolve
|
|
305
|
+
# these methods against different bases.
|
|
306
|
+
or fullname.endswith(".any")
|
|
307
|
+
or fullname.endswith(".has")
|
|
308
|
+
or fullname.endswith(".contains")
|
|
309
|
+
):
|
|
310
|
+
return self._sqlalchemy_relationship_comparator_return_type_callback
|
|
311
|
+
return None
|
|
312
|
+
|
|
313
|
+
def get_class_attribute_hook(self, fullname: str) -> Callable[[AttributeContext], Type] | None:
|
|
314
|
+
"""Type SQLModel class attributes as SQLAlchemy expressions (e.g. `User.id`)."""
|
|
315
|
+
if "." not in fullname:
|
|
316
|
+
return None
|
|
317
|
+
owner_fullname, attr_name = fullname.rsplit(".", 1)
|
|
318
|
+
if attr_name == "model_config":
|
|
319
|
+
return None
|
|
320
|
+
if attr_name.startswith("_") and attr_name != "__table__":
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
owner_info = _lookup_typeinfo(self, owner_fullname)
|
|
324
|
+
if owner_info is None:
|
|
325
|
+
return None
|
|
326
|
+
if owner_info.fullname == SQLMODEL_BASEMODEL_FULLNAME:
|
|
327
|
+
return None
|
|
328
|
+
if not owner_info.has_base(SQLMODEL_BASEMODEL_FULLNAME):
|
|
329
|
+
return None
|
|
330
|
+
if not _is_table_model(owner_info):
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
if attr_name == "__table__":
|
|
334
|
+
return lambda _ctx: self._sqlalchemy_table_type()
|
|
335
|
+
|
|
336
|
+
if not self._declares_sqlmodel_member(owner_info, attr_name):
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
def _hook(ctx: AttributeContext) -> Type:
|
|
340
|
+
return self._sqlmodel_class_attr_type_callback(ctx)
|
|
341
|
+
|
|
342
|
+
return _hook
|
|
343
|
+
|
|
344
|
+
def get_function_hook(self, fullname: str) -> Callable[[FunctionContext], Type] | None:
|
|
345
|
+
if fullname == SQLMODEL_COL_FULLNAME:
|
|
346
|
+
return self._sqlmodel_col_return_type_callback
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
def report_config_data(self, ctx: ReportConfigContext) -> dict[str, Any]:
|
|
350
|
+
# Used by mypy to decide whether to invalidate incremental caches.
|
|
351
|
+
return self._plugin_data
|
|
352
|
+
|
|
353
|
+
def _declares_sqlmodel_member(self, info: TypeInfo, name: str) -> bool:
|
|
354
|
+
"""Return True if `name` is declared as a field/relationship on `info`."""
|
|
355
|
+
# Fast path: if it isn't even a Var on the class, it's not a model member.
|
|
356
|
+
sym = info.names.get(name)
|
|
357
|
+
if sym is None or sym.node is None or not isinstance(sym.node, Var) or sym.node.is_classvar:
|
|
358
|
+
return False
|
|
359
|
+
|
|
360
|
+
# Confirm it's declared in the class body (including in `if` blocks).
|
|
361
|
+
cls_def = info.defn
|
|
362
|
+
for stmt in _iter_assignment_statements_from_block(cls_def.defs):
|
|
363
|
+
lhs = stmt.lvalues[0]
|
|
364
|
+
if isinstance(lhs, NameExpr) and lhs.name == name:
|
|
365
|
+
if not stmt.new_syntax:
|
|
366
|
+
return False
|
|
367
|
+
return True
|
|
368
|
+
return False
|
|
369
|
+
|
|
370
|
+
def _collect_fields_for_signature(
|
|
371
|
+
self, model_info: TypeInfo, api: Any
|
|
372
|
+
) -> list[_CollectedField]:
|
|
373
|
+
fields, _relationships = self._collect_members_for_signature(model_info, api)
|
|
374
|
+
return fields
|
|
375
|
+
|
|
376
|
+
def _collect_members_for_signature(
|
|
377
|
+
self, model_info: TypeInfo, api: Any
|
|
378
|
+
) -> tuple[list[_CollectedField], list[_CollectedRelationship]]:
|
|
379
|
+
"""Collect SQLModel members for signature generation (including inherited ones)."""
|
|
380
|
+
found_fields: dict[str, _CollectedField] = {}
|
|
381
|
+
found_relationships: dict[str, _CollectedRelationship] = {}
|
|
382
|
+
|
|
383
|
+
def _add_field(field: _CollectedField) -> None:
|
|
384
|
+
found_relationships.pop(field.name, None)
|
|
385
|
+
found_fields[field.name] = field
|
|
386
|
+
|
|
387
|
+
def _add_relationship(rel: _CollectedRelationship) -> None:
|
|
388
|
+
found_fields.pop(rel.name, None)
|
|
389
|
+
found_relationships[rel.name] = rel
|
|
390
|
+
|
|
391
|
+
# 1) Inherited members (base first, mirroring semantic-phase logic).
|
|
392
|
+
for base_info in reversed(model_info.mro[1:-1]): # exclude current class and object
|
|
393
|
+
if base_info.fullname == SQLMODEL_BASEMODEL_FULLNAME:
|
|
394
|
+
continue
|
|
395
|
+
if not base_info.has_base(SQLMODEL_BASEMODEL_FULLNAME):
|
|
396
|
+
continue
|
|
397
|
+
|
|
398
|
+
for stmt in _iter_assignment_statements_from_block(base_info.defn.defs):
|
|
399
|
+
member = self._collect_member_from_stmt(
|
|
400
|
+
stmt,
|
|
401
|
+
defining_info=base_info,
|
|
402
|
+
current_info=model_info,
|
|
403
|
+
api=api,
|
|
404
|
+
)
|
|
405
|
+
if member is None:
|
|
406
|
+
continue
|
|
407
|
+
if isinstance(member, _CollectedField):
|
|
408
|
+
sym_node = model_info.names.get(member.name)
|
|
409
|
+
if sym_node and sym_node.node and not isinstance(sym_node.node, Var):
|
|
410
|
+
api.fail(
|
|
411
|
+
"SQLModel field may only be overridden by another field",
|
|
412
|
+
sym_node.node,
|
|
413
|
+
code=ERROR_FIELD,
|
|
414
|
+
)
|
|
415
|
+
continue
|
|
416
|
+
_add_field(member)
|
|
417
|
+
else:
|
|
418
|
+
_add_relationship(member)
|
|
419
|
+
|
|
420
|
+
# 2) Current class members.
|
|
421
|
+
for stmt in _iter_assignment_statements_from_block(model_info.defn.defs):
|
|
422
|
+
member = self._collect_member_from_stmt(
|
|
423
|
+
stmt,
|
|
424
|
+
defining_info=model_info,
|
|
425
|
+
current_info=model_info,
|
|
426
|
+
api=api,
|
|
427
|
+
)
|
|
428
|
+
if member is None:
|
|
429
|
+
continue
|
|
430
|
+
if isinstance(member, _CollectedField):
|
|
431
|
+
_add_field(member)
|
|
432
|
+
else:
|
|
433
|
+
_add_relationship(member)
|
|
434
|
+
|
|
435
|
+
return list(found_fields.values()), list(found_relationships.values())
|
|
436
|
+
|
|
437
|
+
def _collect_member_from_stmt(
|
|
438
|
+
self,
|
|
439
|
+
stmt: AssignmentStmt,
|
|
440
|
+
*,
|
|
441
|
+
defining_info: TypeInfo,
|
|
442
|
+
current_info: TypeInfo,
|
|
443
|
+
api: Any,
|
|
444
|
+
) -> _CollectedField | _CollectedRelationship | None:
|
|
445
|
+
# Untyped assignment (e.g. `x = Field(...)`).
|
|
446
|
+
if not stmt.new_syntax:
|
|
447
|
+
lhs = stmt.lvalues[0]
|
|
448
|
+
if (
|
|
449
|
+
self.plugin_config.warn_untyped_fields
|
|
450
|
+
and isinstance(lhs, NameExpr)
|
|
451
|
+
and isinstance(stmt.rvalue, CallExpr)
|
|
452
|
+
and _callee_fullname(stmt.rvalue)
|
|
453
|
+
in {SQLMODEL_FIELD_FULLNAME, SQLMODEL_RELATIONSHIP_FULLNAME}
|
|
454
|
+
):
|
|
455
|
+
api.fail("Untyped fields disallowed", stmt, code=ERROR_FIELD)
|
|
456
|
+
return None
|
|
457
|
+
|
|
458
|
+
lhs = stmt.lvalues[0]
|
|
459
|
+
if not isinstance(lhs, NameExpr):
|
|
460
|
+
return None
|
|
461
|
+
|
|
462
|
+
name = lhs.name
|
|
463
|
+
if name == "model_config" or name.startswith("_"):
|
|
464
|
+
return None
|
|
465
|
+
|
|
466
|
+
# Relationship: `foo: list[Bar] = Relationship(...)`
|
|
467
|
+
if (
|
|
468
|
+
isinstance(stmt.rvalue, CallExpr)
|
|
469
|
+
and _callee_fullname(stmt.rvalue) == SQLMODEL_RELATIONSHIP_FULLNAME
|
|
470
|
+
):
|
|
471
|
+
rel_type: Type | None = None
|
|
472
|
+
sym = defining_info.names.get(name)
|
|
473
|
+
if sym is not None and isinstance(sym.node, Var) and not sym.node.is_classvar:
|
|
474
|
+
rel_type = sym.node.type
|
|
475
|
+
if rel_type is not None and defining_info is not current_info:
|
|
476
|
+
with state.strict_optional_set(api.options.strict_optional):
|
|
477
|
+
rel_type = map_type_from_supertype(rel_type, current_info, defining_info)
|
|
478
|
+
return _CollectedRelationship(
|
|
479
|
+
name=name,
|
|
480
|
+
line=stmt.line,
|
|
481
|
+
column=stmt.column,
|
|
482
|
+
type=rel_type,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
sym = defining_info.names.get(name)
|
|
486
|
+
if sym is None or sym.node is None:
|
|
487
|
+
return None
|
|
488
|
+
if not isinstance(sym.node, Var):
|
|
489
|
+
return None
|
|
490
|
+
if sym.node.is_classvar:
|
|
491
|
+
return None
|
|
492
|
+
|
|
493
|
+
has_default = SQLModelTransformer.get_has_default(stmt)
|
|
494
|
+
aliases = tuple(SQLModelTransformer.get_field_aliases(stmt))
|
|
495
|
+
init_type = sym.node.type
|
|
496
|
+
if init_type is not None and defining_info is not current_info:
|
|
497
|
+
with state.strict_optional_set(api.options.strict_optional):
|
|
498
|
+
init_type = map_type_from_supertype(init_type, current_info, defining_info)
|
|
499
|
+
|
|
500
|
+
return _CollectedField(
|
|
501
|
+
name=name,
|
|
502
|
+
aliases=aliases,
|
|
503
|
+
has_default=has_default,
|
|
504
|
+
line=stmt.line,
|
|
505
|
+
column=stmt.column,
|
|
506
|
+
type=init_type,
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
def _sqlmodel_constructor_signature_callback(
|
|
510
|
+
self, ctx: FunctionSigContext, info: TypeInfo
|
|
511
|
+
) -> FunctionLike:
|
|
512
|
+
fields, relationships = self._collect_members_for_signature(info, ctx.api)
|
|
513
|
+
|
|
514
|
+
arg_types: list[Type] = []
|
|
515
|
+
arg_names: list[str | None] = []
|
|
516
|
+
arg_kinds: list[ArgKind] = []
|
|
517
|
+
|
|
518
|
+
typed = self.plugin_config.init_typed
|
|
519
|
+
table_model = _is_table_model(info)
|
|
520
|
+
|
|
521
|
+
canonical_names = {f.name for f in fields}
|
|
522
|
+
if table_model:
|
|
523
|
+
canonical_names.update(rel.name for rel in relationships)
|
|
524
|
+
if not self.plugin_config.init_forbid_extra:
|
|
525
|
+
canonical_names.add("kwargs")
|
|
526
|
+
|
|
527
|
+
field_params: list[tuple[Type, list[str]]] = []
|
|
528
|
+
for f in fields:
|
|
529
|
+
if typed and f.type is not None:
|
|
530
|
+
t = f.type.accept(ForceInvariantTypeVars())
|
|
531
|
+
t = _unwrap_mapped_type(t)
|
|
532
|
+
else:
|
|
533
|
+
t = AnyType(TypeOfAny.explicit)
|
|
534
|
+
field_aliases = [
|
|
535
|
+
alias for alias in f.aliases if alias != f.name and alias not in canonical_names
|
|
536
|
+
]
|
|
537
|
+
arg_types.append(t)
|
|
538
|
+
arg_names.append(f.name)
|
|
539
|
+
arg_kinds.append(ARG_NAMED_OPT if f.has_default or field_aliases else ARG_NAMED)
|
|
540
|
+
field_params.append((t, field_aliases))
|
|
541
|
+
|
|
542
|
+
if table_model:
|
|
543
|
+
for rel in relationships:
|
|
544
|
+
if typed and rel.type is not None:
|
|
545
|
+
t = rel.type.accept(ForceInvariantTypeVars())
|
|
546
|
+
t = _unwrap_mapped_type(t)
|
|
547
|
+
else:
|
|
548
|
+
t = AnyType(TypeOfAny.explicit)
|
|
549
|
+
arg_types.append(t)
|
|
550
|
+
arg_names.append(rel.name)
|
|
551
|
+
arg_kinds.append(ARG_NAMED_OPT)
|
|
552
|
+
|
|
553
|
+
used_names = set(canonical_names)
|
|
554
|
+
for t, field_aliases in field_params:
|
|
555
|
+
for alias in field_aliases:
|
|
556
|
+
if alias in used_names:
|
|
557
|
+
continue
|
|
558
|
+
used_names.add(alias)
|
|
559
|
+
arg_types.append(t)
|
|
560
|
+
arg_names.append(alias)
|
|
561
|
+
arg_kinds.append(ARG_NAMED_OPT)
|
|
562
|
+
|
|
563
|
+
if not self.plugin_config.init_forbid_extra:
|
|
564
|
+
kw = AnyType(TypeOfAny.explicit)
|
|
565
|
+
arg_types.append(kw)
|
|
566
|
+
arg_names.append("kwargs")
|
|
567
|
+
arg_kinds.append(ARG_STAR2)
|
|
568
|
+
|
|
569
|
+
# Preserve mypy's inferred return type for the class call.
|
|
570
|
+
ret_type = ctx.default_signature.ret_type
|
|
571
|
+
fallback = ctx.default_signature.fallback
|
|
572
|
+
return CallableType(arg_types, arg_kinds, arg_names, ret_type, fallback)
|
|
573
|
+
|
|
574
|
+
def _sqlmodel_field_signature_callback(self, ctx: FunctionSigContext) -> FunctionLike:
|
|
575
|
+
"""Widen `Field(sa_type=...)` to accept TypeEngine instances.
|
|
576
|
+
|
|
577
|
+
SQLAlchemy `Column` accepts both a TypeEngine class and a TypeEngine instance (e.g.
|
|
578
|
+
`DateTime(timezone=True)`, `String(50)`), but SQLModel's stub history has not always
|
|
579
|
+
reflected that. This hook keeps behavior idempotent when upstream typing is already
|
|
580
|
+
widened.
|
|
581
|
+
"""
|
|
582
|
+
default = ctx.default_signature
|
|
583
|
+
if not isinstance(default, CallableType):
|
|
584
|
+
return default
|
|
585
|
+
|
|
586
|
+
try:
|
|
587
|
+
sa_type_index = default.arg_names.index("sa_type")
|
|
588
|
+
except ValueError:
|
|
589
|
+
return default
|
|
590
|
+
|
|
591
|
+
old_sa_type = default.arg_types[sa_type_index]
|
|
592
|
+
proper_old_sa_type = get_proper_type(old_sa_type)
|
|
593
|
+
if isinstance(proper_old_sa_type, AnyType):
|
|
594
|
+
return default
|
|
595
|
+
|
|
596
|
+
type_engine_info = _lookup_typeinfo(self, "sqlalchemy.sql.type_api.TypeEngine")
|
|
597
|
+
if type_engine_info is None:
|
|
598
|
+
return default
|
|
599
|
+
|
|
600
|
+
def _contains_instance_fullname(tp: Type, fullname: str) -> bool:
|
|
601
|
+
proper = get_proper_type(tp)
|
|
602
|
+
if isinstance(proper, Instance):
|
|
603
|
+
return proper.type.fullname == fullname
|
|
604
|
+
if isinstance(proper, UnionType):
|
|
605
|
+
return any(_contains_instance_fullname(item, fullname) for item in proper.items)
|
|
606
|
+
return False
|
|
607
|
+
|
|
608
|
+
# Idempotency: if upstream stubs already accept TypeEngine instances, keep unchanged.
|
|
609
|
+
if _contains_instance_fullname(old_sa_type, type_engine_info.fullname):
|
|
610
|
+
return default
|
|
611
|
+
|
|
612
|
+
any_t = AnyType(TypeOfAny.explicit)
|
|
613
|
+
type_engine_args = [any_t] * len(type_engine_info.defn.type_vars)
|
|
614
|
+
type_engine_instance = Instance(type_engine_info, type_engine_args)
|
|
615
|
+
|
|
616
|
+
new_arg_types = list(default.arg_types)
|
|
617
|
+
new_arg_types[sa_type_index] = UnionType.make_union([old_sa_type, type_engine_instance])
|
|
618
|
+
return default.copy_modified(arg_types=new_arg_types)
|
|
619
|
+
|
|
620
|
+
def _sqlmodel_select_signature_callback(self, ctx: FunctionSigContext) -> FunctionLike:
|
|
621
|
+
"""Avoid `select()` overload ceiling by adding a 5+ args fallback.
|
|
622
|
+
|
|
623
|
+
SQLModel's generated stubs traditionally cap `select()` overloads at 4 entities.
|
|
624
|
+
For 5+ entities, fall back to a conservative signature that accepts the call and
|
|
625
|
+
returns `Select[tuple[Any, ...]]` (represented as a fixed-length tuple of `Any`s).
|
|
626
|
+
"""
|
|
627
|
+
call = ctx.context
|
|
628
|
+
if not isinstance(call, CallExpr):
|
|
629
|
+
return ctx.default_signature
|
|
630
|
+
|
|
631
|
+
# Only handle straightforward positional calls; don't change behavior for
|
|
632
|
+
# `*args` / `**kwargs` forwarding or named arguments.
|
|
633
|
+
if any(k in {ARG_STAR, ARG_STAR2} for k in call.arg_kinds):
|
|
634
|
+
return ctx.default_signature
|
|
635
|
+
if any(name is not None for name in call.arg_names):
|
|
636
|
+
return ctx.default_signature
|
|
637
|
+
if any(k != ARG_POS for k in call.arg_kinds):
|
|
638
|
+
return ctx.default_signature
|
|
639
|
+
|
|
640
|
+
positional_count = len(call.arg_kinds)
|
|
641
|
+
if positional_count <= 4:
|
|
642
|
+
return ctx.default_signature
|
|
643
|
+
|
|
644
|
+
# Idempotency: if upstream stubs already accept 5+ entities (e.g. via a varargs
|
|
645
|
+
# overload), keep the default signature unchanged.
|
|
646
|
+
def _callable_supports_n_positional_args(sig: CallableType, n: int) -> bool:
|
|
647
|
+
kinds = sig.arg_kinds
|
|
648
|
+
if ARG_STAR in kinds:
|
|
649
|
+
star_index = kinds.index(ARG_STAR)
|
|
650
|
+
required = sum(1 for k in kinds[:star_index] if k == ARG_POS)
|
|
651
|
+
return n >= required
|
|
652
|
+
if len(kinds) < n:
|
|
653
|
+
return False
|
|
654
|
+
return all(k in {ARG_POS, ARG_OPT} for k in kinds[:n])
|
|
655
|
+
|
|
656
|
+
try:
|
|
657
|
+
default_items = ctx.default_signature.items
|
|
658
|
+
except Exception:
|
|
659
|
+
return ctx.default_signature
|
|
660
|
+
if any(
|
|
661
|
+
isinstance(item, CallableType)
|
|
662
|
+
and _callable_supports_n_positional_args(item, positional_count)
|
|
663
|
+
for item in default_items
|
|
664
|
+
):
|
|
665
|
+
return ctx.default_signature
|
|
666
|
+
|
|
667
|
+
select_info = _lookup_typeinfo(self, SQLALCHEMY_SELECT_FULLNAME)
|
|
668
|
+
if select_info is None:
|
|
669
|
+
return ctx.default_signature
|
|
670
|
+
|
|
671
|
+
any_t = AnyType(TypeOfAny.explicit)
|
|
672
|
+
tuple_fallback = ctx.api.named_generic_type("builtins.tuple", [any_t])
|
|
673
|
+
row_type = TupleType([any_t] * positional_count, tuple_fallback)
|
|
674
|
+
ret_type = Instance(select_info, [row_type])
|
|
675
|
+
|
|
676
|
+
fallback = default_items[0].fallback if default_items else tuple_fallback
|
|
677
|
+
arg_types = [any_t] * positional_count
|
|
678
|
+
arg_kinds: list[ArgKind] = [ARG_POS] * positional_count
|
|
679
|
+
arg_names = [f"__ent{i}" for i in range(positional_count)]
|
|
680
|
+
return CallableType(arg_types, arg_kinds, arg_names, ret_type, fallback)
|
|
681
|
+
|
|
682
|
+
def _sqlmodel_model_construct_signature_callback(self, ctx: MethodSigContext) -> FunctionLike:
|
|
683
|
+
receiver_info: TypeInfo | None = None
|
|
684
|
+
receiver_instance: Instance | None = None
|
|
685
|
+
|
|
686
|
+
ctx_type = ctx.type
|
|
687
|
+
if isinstance(ctx_type, TypeType):
|
|
688
|
+
item = get_proper_type(ctx_type.item)
|
|
689
|
+
if isinstance(item, Instance):
|
|
690
|
+
receiver_instance = item
|
|
691
|
+
receiver_info = item.type
|
|
692
|
+
elif isinstance(ctx_type, Instance):
|
|
693
|
+
receiver_instance = ctx_type
|
|
694
|
+
receiver_info = ctx_type.type
|
|
695
|
+
|
|
696
|
+
if receiver_info is None:
|
|
697
|
+
return ctx.default_signature
|
|
698
|
+
if receiver_info.fullname == SQLMODEL_BASEMODEL_FULLNAME:
|
|
699
|
+
return ctx.default_signature
|
|
700
|
+
if not receiver_info.has_base(SQLMODEL_BASEMODEL_FULLNAME):
|
|
701
|
+
return ctx.default_signature
|
|
702
|
+
|
|
703
|
+
fields, relationships = self._collect_members_for_signature(receiver_info, ctx.api)
|
|
704
|
+
|
|
705
|
+
set_str = ctx.api.named_generic_type(
|
|
706
|
+
"builtins.set", [ctx.api.named_generic_type("builtins.str", [])]
|
|
707
|
+
)
|
|
708
|
+
optional_set_str: Type = UnionType([set_str, NoneType()])
|
|
709
|
+
|
|
710
|
+
arg_types: list[Type] = [optional_set_str]
|
|
711
|
+
arg_names: list[str | None] = ["_fields_set"]
|
|
712
|
+
arg_kinds: list[ArgKind] = [ARG_OPT]
|
|
713
|
+
|
|
714
|
+
table_model = _is_table_model(receiver_info)
|
|
715
|
+
canonical_names = {f.name for f in fields}
|
|
716
|
+
if table_model:
|
|
717
|
+
canonical_names.update(rel.name for rel in relationships)
|
|
718
|
+
canonical_names.add("_fields_set")
|
|
719
|
+
if not self.plugin_config.init_forbid_extra:
|
|
720
|
+
canonical_names.add("kwargs")
|
|
721
|
+
|
|
722
|
+
field_params: list[tuple[Type, list[str]]] = []
|
|
723
|
+
for f in fields:
|
|
724
|
+
t = (
|
|
725
|
+
f.type.accept(ForceInvariantTypeVars())
|
|
726
|
+
if f.type is not None
|
|
727
|
+
else AnyType(TypeOfAny.explicit)
|
|
728
|
+
)
|
|
729
|
+
t = _unwrap_mapped_type(t)
|
|
730
|
+
field_aliases = [
|
|
731
|
+
alias for alias in f.aliases if alias != f.name and alias not in canonical_names
|
|
732
|
+
]
|
|
733
|
+
arg_types.append(t)
|
|
734
|
+
arg_names.append(f.name)
|
|
735
|
+
arg_kinds.append(ARG_NAMED_OPT if f.has_default or field_aliases else ARG_NAMED)
|
|
736
|
+
field_params.append((t, field_aliases))
|
|
737
|
+
|
|
738
|
+
if table_model:
|
|
739
|
+
for rel in relationships:
|
|
740
|
+
t = (
|
|
741
|
+
rel.type.accept(ForceInvariantTypeVars())
|
|
742
|
+
if rel.type is not None
|
|
743
|
+
else AnyType(TypeOfAny.explicit)
|
|
744
|
+
)
|
|
745
|
+
t = _unwrap_mapped_type(t)
|
|
746
|
+
arg_types.append(t)
|
|
747
|
+
arg_names.append(rel.name)
|
|
748
|
+
arg_kinds.append(ARG_NAMED_OPT)
|
|
749
|
+
|
|
750
|
+
used_names = set(canonical_names)
|
|
751
|
+
for t, field_aliases in field_params:
|
|
752
|
+
for alias in field_aliases:
|
|
753
|
+
if alias in used_names:
|
|
754
|
+
continue
|
|
755
|
+
used_names.add(alias)
|
|
756
|
+
arg_types.append(t)
|
|
757
|
+
arg_names.append(alias)
|
|
758
|
+
arg_kinds.append(ARG_NAMED_OPT)
|
|
759
|
+
|
|
760
|
+
if not self.plugin_config.init_forbid_extra:
|
|
761
|
+
kw = AnyType(TypeOfAny.explicit)
|
|
762
|
+
arg_types.append(kw)
|
|
763
|
+
arg_names.append("kwargs")
|
|
764
|
+
arg_kinds.append(ARG_STAR2)
|
|
765
|
+
|
|
766
|
+
ret_type: Type = receiver_instance or fill_typevars(receiver_info)
|
|
767
|
+
fallback = ctx.default_signature.fallback
|
|
768
|
+
return CallableType(arg_types, arg_kinds, arg_names, ret_type, fallback)
|
|
769
|
+
|
|
770
|
+
def _sqlmodel_session_exec_signature_callback(
|
|
771
|
+
self, ctx: MethodSigContext, *, is_async: bool
|
|
772
|
+
) -> FunctionLike:
|
|
773
|
+
"""Broaden `Session.exec()` accepted statement types.
|
|
774
|
+
|
|
775
|
+
SQLModel's stubs accept `sqlmodel.sql.base.Executable[...]`, but many common SQLAlchemy statements
|
|
776
|
+
(e.g. `text(...)`) are only `sqlalchemy.sql.base.Executable`, causing mypy failures.
|
|
777
|
+
"""
|
|
778
|
+
|
|
779
|
+
# NOTE: mypy applies method signature hooks *per overload item*; when the original
|
|
780
|
+
# method is `Overloaded`, returning `Overloaded` here will trigger mypy internal errors
|
|
781
|
+
# (it expects a `CallableType` per item). Keep this hook returning `CallableType`.
|
|
782
|
+
default = ctx.default_signature
|
|
783
|
+
if not isinstance(default, CallableType):
|
|
784
|
+
return default
|
|
785
|
+
if not default.arg_types:
|
|
786
|
+
return default
|
|
787
|
+
|
|
788
|
+
executable_info = _lookup_typeinfo(self, "sqlalchemy.sql.base.Executable")
|
|
789
|
+
if executable_info is None:
|
|
790
|
+
return default
|
|
791
|
+
sqlalchemy_executable = Instance(executable_info, [])
|
|
792
|
+
|
|
793
|
+
# Depending on SQLModel/SQLAlchemy versions, the "broad" overload may be expressed via
|
|
794
|
+
# `sqlmodel.sql.base.Executable[T]` or `sqlalchemy.sql.dml.UpdateBase` (DML statements).
|
|
795
|
+
sqlmodel_executable_info = _lookup_typeinfo(self, "sqlmodel.sql.base.Executable")
|
|
796
|
+
update_base_info = _lookup_typeinfo(self, "sqlalchemy.sql.dml.UpdateBase")
|
|
797
|
+
if sqlmodel_executable_info is None and update_base_info is None:
|
|
798
|
+
return default
|
|
799
|
+
|
|
800
|
+
def _contains_instance_fullname(tp: Type, fullname: str) -> bool:
|
|
801
|
+
proper = get_proper_type(tp)
|
|
802
|
+
if isinstance(proper, Instance):
|
|
803
|
+
return proper.type.fullname == fullname
|
|
804
|
+
if isinstance(proper, UnionType):
|
|
805
|
+
return any(_contains_instance_fullname(item, fullname) for item in proper.items)
|
|
806
|
+
return False
|
|
807
|
+
|
|
808
|
+
# Only adjust the broad overload; keep Select/SelectOfScalar overloads precise.
|
|
809
|
+
broad = False
|
|
810
|
+
if sqlmodel_executable_info is not None and _contains_instance_fullname(
|
|
811
|
+
default.arg_types[0], sqlmodel_executable_info.fullname
|
|
812
|
+
):
|
|
813
|
+
broad = True
|
|
814
|
+
if update_base_info is not None and _contains_instance_fullname(
|
|
815
|
+
default.arg_types[0], update_base_info.fullname
|
|
816
|
+
):
|
|
817
|
+
broad = True
|
|
818
|
+
if not broad:
|
|
819
|
+
return default
|
|
820
|
+
|
|
821
|
+
# Idempotency: if upstream stubs already accept SQLAlchemy Executable, keep unchanged.
|
|
822
|
+
if _contains_instance_fullname(default.arg_types[0], executable_info.fullname):
|
|
823
|
+
return default
|
|
824
|
+
|
|
825
|
+
new_arg_types = list(default.arg_types)
|
|
826
|
+
new_arg_types[0] = UnionType.make_union([default.arg_types[0], sqlalchemy_executable])
|
|
827
|
+
return default.copy_modified(arg_types=new_arg_types)
|
|
828
|
+
|
|
829
|
+
def _sqlalchemy_select_join_return_type_callback(self, ctx: MethodContext) -> Type:
|
|
830
|
+
# join(target, onclause=None, *, isouter: bool = False, full: bool = False)
|
|
831
|
+
return self._sqlalchemy_select_join_like_return_type(
|
|
832
|
+
ctx, target_positional_index=0, isouter_always=False
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
def _sqlalchemy_select_join_from_return_type_callback(self, ctx: MethodContext) -> Type:
|
|
836
|
+
# join_from(from_, target, onclause=None, *, isouter: bool = False, full: bool = False)
|
|
837
|
+
return self._sqlalchemy_select_join_like_return_type(
|
|
838
|
+
ctx, target_positional_index=1, isouter_always=False
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
def _sqlalchemy_select_outerjoin_return_type_callback(self, ctx: MethodContext) -> Type:
|
|
842
|
+
# outerjoin(target, onclause=None, *, full: bool = False)
|
|
843
|
+
return self._sqlalchemy_select_join_like_return_type(
|
|
844
|
+
ctx, target_positional_index=0, isouter_always=True
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
def _sqlalchemy_select_outerjoin_from_return_type_callback(self, ctx: MethodContext) -> Type:
|
|
848
|
+
# outerjoin_from(from_, target, onclause=None, *, full: bool = False)
|
|
849
|
+
return self._sqlalchemy_select_join_like_return_type(
|
|
850
|
+
ctx, target_positional_index=1, isouter_always=True
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
def _sqlalchemy_relationship_comparator_return_type_callback(self, ctx: MethodContext) -> Type:
|
|
854
|
+
"""Ensure relationship comparator calls return a SQL boolean expression.
|
|
855
|
+
|
|
856
|
+
SQLAlchemy's typing for relationship comparator helpers varies across versions and stubs.
|
|
857
|
+
For SQLModel relationship attributes (declared via `Relationship(...)`), prefer returning
|
|
858
|
+
`ColumnElement[bool]` when mypy would otherwise fall back to `Any` / `bool`.
|
|
859
|
+
"""
|
|
860
|
+
call = ctx.context
|
|
861
|
+
if not isinstance(call, CallExpr):
|
|
862
|
+
return ctx.default_return_type
|
|
863
|
+
|
|
864
|
+
# Identify `Model.relationship.<method>(...)` calls so we only affect SQLModel relationships.
|
|
865
|
+
callee = call.callee
|
|
866
|
+
if not isinstance(callee, MemberExpr):
|
|
867
|
+
return ctx.default_return_type
|
|
868
|
+
|
|
869
|
+
receiver = callee.expr
|
|
870
|
+
if not isinstance(receiver, MemberExpr):
|
|
871
|
+
return ctx.default_return_type
|
|
872
|
+
|
|
873
|
+
rel_name = receiver.name
|
|
874
|
+
owner_info = _typeinfo_from_ref_expr(receiver.expr)
|
|
875
|
+
if owner_info is None:
|
|
876
|
+
return ctx.default_return_type
|
|
877
|
+
if not owner_info.has_base(SQLMODEL_BASEMODEL_FULLNAME):
|
|
878
|
+
return ctx.default_return_type
|
|
879
|
+
if not _is_table_model(owner_info):
|
|
880
|
+
return ctx.default_return_type
|
|
881
|
+
|
|
882
|
+
try:
|
|
883
|
+
_fields, relationships = self._collect_members_for_signature(owner_info, ctx.api)
|
|
884
|
+
except Exception:
|
|
885
|
+
return ctx.default_return_type
|
|
886
|
+
|
|
887
|
+
if rel_name not in {r.name for r in relationships}:
|
|
888
|
+
return ctx.default_return_type
|
|
889
|
+
|
|
890
|
+
default = ctx.default_return_type
|
|
891
|
+
proper_default = get_proper_type(default)
|
|
892
|
+
if isinstance(proper_default, AnyType):
|
|
893
|
+
return self._sqlalchemy_column_element_bool_type(ctx)
|
|
894
|
+
if isinstance(proper_default, Instance) and proper_default.type.fullname == "builtins.bool":
|
|
895
|
+
return self._sqlalchemy_column_element_bool_type(ctx)
|
|
896
|
+
return default
|
|
897
|
+
|
|
898
|
+
@staticmethod
|
|
899
|
+
def _sqlalchemy_column_element_bool_type(ctx: MethodContext) -> Type:
|
|
900
|
+
"""Return `sqlalchemy.sql.elements.ColumnElement[bool]` or `Any` as a fallback."""
|
|
901
|
+
bool_t = ctx.api.named_generic_type("builtins.bool", [])
|
|
902
|
+
try:
|
|
903
|
+
return ctx.api.named_generic_type("sqlalchemy.sql.elements.ColumnElement", [bool_t])
|
|
904
|
+
except Exception:
|
|
905
|
+
return AnyType(TypeOfAny.explicit)
|
|
906
|
+
|
|
907
|
+
def _sqlalchemy_select_join_like_return_type(
|
|
908
|
+
self, ctx: MethodContext, *, target_positional_index: int, isouter_always: bool
|
|
909
|
+
) -> Type:
|
|
910
|
+
# Only handle "outer join" for now, and only when it's explicit/literal.
|
|
911
|
+
call = ctx.context
|
|
912
|
+
if not isinstance(call, CallExpr):
|
|
913
|
+
return ctx.default_return_type
|
|
914
|
+
|
|
915
|
+
if not isouter_always:
|
|
916
|
+
isouter_expr = _call_get_kwarg(call, "isouter")
|
|
917
|
+
if isouter_expr is None:
|
|
918
|
+
return ctx.default_return_type
|
|
919
|
+
if not _is_bool_nameexpr(isouter_expr, True):
|
|
920
|
+
return ctx.default_return_type
|
|
921
|
+
|
|
922
|
+
target_expr = _call_get_arg(call, "target", target_positional_index)
|
|
923
|
+
target_info = _typeinfo_from_ref_expr(target_expr)
|
|
924
|
+
if target_info is None:
|
|
925
|
+
return ctx.default_return_type
|
|
926
|
+
|
|
927
|
+
receiver = get_proper_type(ctx.type)
|
|
928
|
+
if not isinstance(receiver, Instance):
|
|
929
|
+
return ctx.default_return_type
|
|
930
|
+
if receiver.type.fullname not in {
|
|
931
|
+
SQLALCHEMY_SELECT_FULLNAME,
|
|
932
|
+
SQLMODEL_SELECT_CLS_FULLNAME,
|
|
933
|
+
} and not receiver.type.has_base(SQLALCHEMY_SELECT_FULLNAME):
|
|
934
|
+
return ctx.default_return_type
|
|
935
|
+
if not receiver.args:
|
|
936
|
+
return ctx.default_return_type
|
|
937
|
+
|
|
938
|
+
tp = get_proper_type(receiver.args[0])
|
|
939
|
+
if not isinstance(tp, TupleType):
|
|
940
|
+
return ctx.default_return_type
|
|
941
|
+
|
|
942
|
+
changed = False
|
|
943
|
+
new_items: list[Type] = []
|
|
944
|
+
for item in tp.items:
|
|
945
|
+
proper_item = get_proper_type(item)
|
|
946
|
+
if (
|
|
947
|
+
isinstance(proper_item, Instance)
|
|
948
|
+
and proper_item.type.fullname == target_info.fullname
|
|
949
|
+
):
|
|
950
|
+
new_items.append(UnionType.make_union([item, NoneType()]))
|
|
951
|
+
changed = True
|
|
952
|
+
else:
|
|
953
|
+
new_items.append(item)
|
|
954
|
+
|
|
955
|
+
if not changed:
|
|
956
|
+
return ctx.default_return_type
|
|
957
|
+
|
|
958
|
+
new_tp = tp.copy_modified(items=new_items)
|
|
959
|
+
return Instance(receiver.type, [new_tp])
|
|
960
|
+
|
|
961
|
+
def _sqlmodel_class_attr_type_callback(self, ctx: AttributeContext) -> Type:
|
|
962
|
+
if ctx.is_lvalue:
|
|
963
|
+
return ctx.default_attr_type
|
|
964
|
+
|
|
965
|
+
default = ctx.default_attr_type
|
|
966
|
+
proper = get_proper_type(default)
|
|
967
|
+
|
|
968
|
+
if isinstance(proper, Instance):
|
|
969
|
+
if proper.type.fullname in SQLALCHEMY_MAPPED_FULLNAMES:
|
|
970
|
+
return default
|
|
971
|
+
if proper.type.fullname in SQLALCHEMY_INSTRUMENTED_ATTRIBUTE_FULLNAMES:
|
|
972
|
+
return default
|
|
973
|
+
|
|
974
|
+
inst_attr_info = _lookup_typeinfo(
|
|
975
|
+
self, "sqlalchemy.orm.attributes.InstrumentedAttribute"
|
|
976
|
+
) or _lookup_typeinfo(self, "sqlalchemy.orm.InstrumentedAttribute")
|
|
977
|
+
if inst_attr_info is None:
|
|
978
|
+
return default
|
|
979
|
+
|
|
980
|
+
return Instance(inst_attr_info, [default])
|
|
981
|
+
|
|
982
|
+
def _sqlmodel_col_return_type_callback(self, ctx: FunctionContext) -> Type:
|
|
983
|
+
# `col()` is effectively a typed identity/cast helper.
|
|
984
|
+
if not ctx.arg_types or not ctx.arg_types[0]:
|
|
985
|
+
return ctx.default_return_type
|
|
986
|
+
|
|
987
|
+
arg0 = get_proper_type(ctx.arg_types[0][0])
|
|
988
|
+
value_type: Type | None = None
|
|
989
|
+
|
|
990
|
+
if isinstance(arg0, Instance):
|
|
991
|
+
if arg0.type.fullname in SQLALCHEMY_MAPPED_FULLNAMES and arg0.args:
|
|
992
|
+
value_type = arg0.args[0]
|
|
993
|
+
elif arg0.type.fullname in SQLALCHEMY_INSTRUMENTED_ATTRIBUTE_FULLNAMES and arg0.args:
|
|
994
|
+
value_type = arg0.args[0]
|
|
995
|
+
|
|
996
|
+
if value_type is None:
|
|
997
|
+
return ctx.default_return_type
|
|
998
|
+
|
|
999
|
+
mapped_info = _lookup_typeinfo(self, "sqlalchemy.orm.base.Mapped") or _lookup_typeinfo(
|
|
1000
|
+
self, "sqlalchemy.orm.Mapped"
|
|
1001
|
+
)
|
|
1002
|
+
if mapped_info is None:
|
|
1003
|
+
return ctx.default_return_type
|
|
1004
|
+
|
|
1005
|
+
return Instance(mapped_info, [value_type])
|
|
1006
|
+
|
|
1007
|
+
def _sqlmodel_model_class_callback(self, ctx: ClassDefContext) -> None:
|
|
1008
|
+
# SQLModel is built on Pydantic v2; users commonly override `model_config`
|
|
1009
|
+
# with `ConfigDict(...)`. SQLModel's own config typing may be narrower
|
|
1010
|
+
# than Pydantic's `ConfigDict` and can trigger strict-mode assignment
|
|
1011
|
+
# errors. Widen to a compatible supertype so `model_config = ConfigDict(...)`
|
|
1012
|
+
# is accepted without `# type: ignore`.
|
|
1013
|
+
self._widen_model_config_type(ctx)
|
|
1014
|
+
transformer = SQLModelTransformer(ctx.cls, ctx.reason, ctx.api, self.plugin_config)
|
|
1015
|
+
transformer.transform()
|
|
1016
|
+
self._add_table_dunders(ctx)
|
|
1017
|
+
|
|
1018
|
+
def _add_table_dunders(self, ctx: ClassDefContext) -> None:
|
|
1019
|
+
info = ctx.cls.info
|
|
1020
|
+
if not _is_table_model(info):
|
|
1021
|
+
return
|
|
1022
|
+
|
|
1023
|
+
if "__table__" in info.names:
|
|
1024
|
+
return
|
|
1025
|
+
|
|
1026
|
+
table_t = self._sqlalchemy_table_type()
|
|
1027
|
+
v = Var("__table__", table_t)
|
|
1028
|
+
v.info = info
|
|
1029
|
+
v._fullname = f"{info.fullname}.__table__"
|
|
1030
|
+
v.is_classvar = True
|
|
1031
|
+
sym = SymbolTableNode(MDEF, v)
|
|
1032
|
+
sym.plugin_generated = True
|
|
1033
|
+
info.names["__table__"] = sym
|
|
1034
|
+
|
|
1035
|
+
def _sqlalchemy_table_type(self) -> Type:
|
|
1036
|
+
"""Return the best available `sqlalchemy` Table-ish type.
|
|
1037
|
+
|
|
1038
|
+
Prefer `sqlalchemy.sql.schema.Table`, but fall back to broader interfaces if the
|
|
1039
|
+
installed SQLAlchemy stubs don't expose it.
|
|
1040
|
+
"""
|
|
1041
|
+
for fullname in (
|
|
1042
|
+
"sqlalchemy.sql.schema.Table",
|
|
1043
|
+
"sqlalchemy.sql.schema.TableClause",
|
|
1044
|
+
"sqlalchemy.sql.selectable.FromClause",
|
|
1045
|
+
):
|
|
1046
|
+
info = _lookup_typeinfo(self, fullname)
|
|
1047
|
+
if info is not None:
|
|
1048
|
+
return Instance(info, [])
|
|
1049
|
+
return AnyType(TypeOfAny.explicit)
|
|
1050
|
+
|
|
1051
|
+
def _widen_model_config_type(self, ctx: ClassDefContext) -> None:
|
|
1052
|
+
mapping_str_any = ctx.api.named_type(
|
|
1053
|
+
"typing.Mapping",
|
|
1054
|
+
[ctx.api.named_type("builtins.str"), AnyType(TypeOfAny.explicit)],
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
# Widen the base SQLModel attribute (used as the expected type for overrides).
|
|
1058
|
+
base_info = _lookup_typeinfo(self, SQLMODEL_BASEMODEL_FULLNAME)
|
|
1059
|
+
if base_info is not None:
|
|
1060
|
+
sym = base_info.names.get("model_config")
|
|
1061
|
+
if sym is not None and isinstance(sym.node, Var):
|
|
1062
|
+
sym.node.type = mapping_str_any
|
|
1063
|
+
|
|
1064
|
+
# Also widen the current class attribute, if declared in this class body.
|
|
1065
|
+
sym = ctx.cls.info.names.get("model_config")
|
|
1066
|
+
if sym is not None and isinstance(sym.node, Var):
|
|
1067
|
+
sym.node.type = mapping_str_any
|
|
1068
|
+
|
|
1069
|
+
def _sqlmodel_metaclass_callback(self, ctx: ClassDefContext) -> None:
|
|
1070
|
+
"""Disable dataclass-transform handling for SQLModel metaclass.
|
|
1071
|
+
|
|
1072
|
+
SQLModel decorates `SQLModelMetaclass` with `__dataclass_transform__`, but we want this plugin
|
|
1073
|
+
(not mypy's generic dataclass-transform logic) to generate the model signatures.
|
|
1074
|
+
"""
|
|
1075
|
+
if self.plugin_config.debug_dataclass_transform:
|
|
1076
|
+
return
|
|
1077
|
+
info_metaclass = ctx.cls.info.declared_metaclass
|
|
1078
|
+
if info_metaclass is None:
|
|
1079
|
+
return
|
|
1080
|
+
if getattr(info_metaclass.type, "dataclass_transform_spec", None) is not None:
|
|
1081
|
+
info_metaclass.type.dataclass_transform_spec = None
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
class SQLModelPluginConfig:
|
|
1085
|
+
__slots__ = (
|
|
1086
|
+
"init_typed",
|
|
1087
|
+
"init_forbid_extra",
|
|
1088
|
+
"warn_untyped_fields",
|
|
1089
|
+
"debug_dataclass_transform",
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
init_typed: bool
|
|
1093
|
+
init_forbid_extra: bool
|
|
1094
|
+
warn_untyped_fields: bool
|
|
1095
|
+
debug_dataclass_transform: bool # undocumented, for testing
|
|
1096
|
+
|
|
1097
|
+
def __init__(self, options: Options) -> None:
|
|
1098
|
+
# Defaults
|
|
1099
|
+
self.init_typed = False
|
|
1100
|
+
self.init_forbid_extra = False
|
|
1101
|
+
self.warn_untyped_fields = True
|
|
1102
|
+
self.debug_dataclass_transform = False
|
|
1103
|
+
|
|
1104
|
+
if options.config_file is None:
|
|
1105
|
+
return
|
|
1106
|
+
|
|
1107
|
+
toml_config = parse_toml(options.config_file)
|
|
1108
|
+
if toml_config is not None:
|
|
1109
|
+
config = toml_config.get("tool", {}).get(CONFIGFILE_KEY, {})
|
|
1110
|
+
for key in self.__slots__:
|
|
1111
|
+
value = config.get(key, getattr(self, key))
|
|
1112
|
+
if not isinstance(value, bool):
|
|
1113
|
+
raise ValueError(f"Configuration value must be a boolean for key: {key}")
|
|
1114
|
+
setattr(self, key, value)
|
|
1115
|
+
return
|
|
1116
|
+
|
|
1117
|
+
parser = ConfigParser()
|
|
1118
|
+
parser.read(options.config_file)
|
|
1119
|
+
for key in self.__slots__:
|
|
1120
|
+
setattr(self, key, parser.getboolean(CONFIGFILE_KEY, key, fallback=getattr(self, key)))
|
|
1121
|
+
|
|
1122
|
+
def to_data(self) -> dict[str, Any]:
|
|
1123
|
+
return {key: getattr(self, key) for key in self.__slots__}
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def parse_toml(config_file: str) -> dict[str, Any] | None:
|
|
1127
|
+
"""Parse `pyproject.toml` and return it as a dict (or None if not TOML)."""
|
|
1128
|
+
if not config_file.endswith(".toml"):
|
|
1129
|
+
return None
|
|
1130
|
+
|
|
1131
|
+
if sys.version_info >= (3, 11):
|
|
1132
|
+
import tomllib as toml_
|
|
1133
|
+
else:
|
|
1134
|
+
try:
|
|
1135
|
+
import tomli as toml_
|
|
1136
|
+
except ImportError: # pragma: no cover
|
|
1137
|
+
return None
|
|
1138
|
+
|
|
1139
|
+
with open(config_file, "rb") as f:
|
|
1140
|
+
return toml_.load(f)
|