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