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.
@@ -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)