Skip to content

Commit 24e1748

Browse files
committed
Don't create dynamic classes for non-dynamic managers
We don't need to create custom managers when the manager isn't dynamically created. This changes the logic to just set the type of the manager on the class. There's a small regression/limitations with this approach, as demonstrated by the custom_manager_without_typevar_returns_any_model_types test case. If the manager is _not_ typed with generics (ie. `MyManager(Manager)` vs `MyManager(Manager[T])` the manager will return querysets with `Any` as the model type instead because of implicit generics. That can be solved in one of two ways: either through "reparametrization" as implemented in typeddjango#1030 possibly or through setting the methods as attributes with type `Any` as we do for dynamic managers
1 parent 2a6f464 commit 24e1748

File tree

5 files changed

+66
-172
lines changed

5 files changed

+66
-172
lines changed

mypy_django_plugin/lib/helpers.py

Lines changed: 3 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from collections import OrderedDict
2-
from typing import TYPE_CHECKING, Any, Dict, Iterable, Iterator, List, Optional, Set, Tuple, Union
2+
from typing import TYPE_CHECKING, Any, Dict, Iterable, Iterator, List, Optional, Set, Union
33

44
from django.db.models.fields import Field
55
from django.db.models.fields.related import RelatedField
@@ -10,11 +10,9 @@
1010
from mypy.nodes import (
1111
GDEF,
1212
MDEF,
13-
Argument,
1413
Block,
1514
ClassDef,
1615
Expression,
17-
FuncDef,
1816
MemberExpr,
1917
MypyFile,
2018
NameExpr,
@@ -34,11 +32,10 @@
3432
MethodContext,
3533
SemanticAnalyzerPluginInterface,
3634
)
37-
from mypy.plugins.common import add_method_to_class
3835
from mypy.semanal import SemanticAnalyzer
39-
from mypy.types import AnyType, CallableType, Instance, NoneTyp, TupleType
36+
from mypy.types import AnyType, Instance, NoneTyp, TupleType
4037
from mypy.types import Type as MypyType
41-
from mypy.types import TypedDictType, TypeOfAny, UnboundType, UnionType
38+
from mypy.types import TypedDictType, TypeOfAny, UnionType
4239

4340
from mypy_django_plugin.lib import fullnames
4441
from mypy_django_plugin.lib.fullnames import WITH_ANNOTATIONS_FULLNAME
@@ -350,92 +347,6 @@ def add_new_sym_for_info(info: TypeInfo, *, name: str, sym_type: MypyType, no_se
350347
info.names[name] = SymbolTableNode(MDEF, var, plugin_generated=True, no_serialize=no_serialize)
351348

352349

353-
def build_unannotated_method_args(method_node: FuncDef) -> Tuple[List[Argument], MypyType]:
354-
prepared_arguments = []
355-
try:
356-
arguments = method_node.arguments[1:]
357-
except AttributeError:
358-
arguments = []
359-
for argument in arguments:
360-
argument.type_annotation = AnyType(TypeOfAny.unannotated)
361-
prepared_arguments.append(argument)
362-
return_type = AnyType(TypeOfAny.unannotated)
363-
return prepared_arguments, return_type
364-
365-
366-
def bind_or_analyze_type(t: MypyType, api: SemanticAnalyzer, module_name: Optional[str] = None) -> Optional[MypyType]:
367-
"""Analyze a type. If an unbound type, try to look it up in the given module name.
368-
369-
That should hopefully give a bound type."""
370-
if isinstance(t, UnboundType) and module_name is not None:
371-
node = api.lookup_fully_qualified_or_none(module_name + "." + t.name)
372-
if node is not None and node.type is not None:
373-
return node.type
374-
375-
return api.anal_type(t)
376-
377-
378-
def copy_method_to_another_class(
379-
ctx: ClassDefContext,
380-
self_type: Instance,
381-
new_method_name: str,
382-
method_node: FuncDef,
383-
return_type: Optional[MypyType] = None,
384-
original_module_name: Optional[str] = None,
385-
) -> None:
386-
semanal_api = get_semanal_api(ctx)
387-
if method_node.type is None:
388-
if not semanal_api.final_iteration:
389-
semanal_api.defer()
390-
return
391-
392-
arguments, return_type = build_unannotated_method_args(method_node)
393-
add_method_to_class(
394-
semanal_api, ctx.cls, new_method_name, args=arguments, return_type=return_type, self_type=self_type
395-
)
396-
return
397-
398-
method_type = method_node.type
399-
if not isinstance(method_type, CallableType):
400-
if not semanal_api.final_iteration:
401-
semanal_api.defer()
402-
return
403-
404-
if return_type is None:
405-
return_type = bind_or_analyze_type(method_type.ret_type, semanal_api, original_module_name)
406-
if return_type is None:
407-
return
408-
409-
# We build the arguments from the method signature (`CallableType`), because if we were to
410-
# use the arguments from the method node (`FuncDef.arguments`) we're not compatible with
411-
# a method loaded from cache. As mypy doesn't serialize `FuncDef.arguments` when caching
412-
arguments = []
413-
# Note that the first argument is excluded, as that's `self`
414-
for pos, (arg_type, arg_kind, arg_name) in enumerate(
415-
zip(method_type.arg_types[1:], method_type.arg_kinds[1:], method_type.arg_names[1:]),
416-
start=1,
417-
):
418-
bound_arg_type = bind_or_analyze_type(arg_type, semanal_api, original_module_name)
419-
if bound_arg_type is None:
420-
return
421-
if arg_name is None and hasattr(method_node, "arguments"):
422-
arg_name = method_node.arguments[pos].variable.name
423-
arguments.append(
424-
Argument(
425-
# Positional only arguments can have name as `None`, if we can't find a name, we just invent one..
426-
variable=Var(name=arg_name if arg_name is not None else str(pos), type=arg_type),
427-
type_annotation=bound_arg_type,
428-
initializer=None,
429-
kind=arg_kind,
430-
pos_only=arg_name is None,
431-
)
432-
)
433-
434-
add_method_to_class(
435-
semanal_api, ctx.cls, new_method_name, args=arguments, return_type=return_type, self_type=self_type
436-
)
437-
438-
439350
def add_new_manager_base(api: SemanticAnalyzerPluginInterface, fullname: str) -> None:
440351
sym = api.lookup_fully_qualified_or_none(fullnames.MANAGER_CLASS_FULLNAME)
441352
if sym is not None and isinstance(sym.node, TypeInfo):

mypy_django_plugin/transformers/managers.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,9 @@ def create_new_manager_class_from_from_queryset_method(ctx: DynamicClassDefConte
182182
"""
183183
semanal_api = helpers.get_semanal_api(ctx)
184184

185+
if semanal_api.is_class_scope():
186+
return
187+
185188
# Don't redeclare the manager class if we've already defined it.
186189
manager_node = semanal_api.lookup_current_scope(ctx.name)
187190
if manager_node and isinstance(manager_node.node, TypeInfo):

mypy_django_plugin/transformers/models.py

Lines changed: 14 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from django.db.models.fields.related import ForeignKey
66
from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel, OneToOneRel
77
from mypy.checker import TypeChecker
8-
from mypy.nodes import ARG_STAR2, Argument, AssignmentStmt, Context, FuncDef, NameExpr, TypeInfo, Var
8+
from mypy.nodes import ARG_STAR2, Argument, AssignmentStmt, Context, NameExpr, TypeInfo, Var
99
from mypy.plugin import AnalyzeTypeContext, AttributeContext, CheckerPluginInterface, ClassDefContext
1010
from mypy.plugins import common
1111
from mypy.semanal import SemanticAnalyzer
@@ -185,52 +185,6 @@ def run_with_model_cls(self, model_cls: Type[Model]) -> None:
185185

186186

187187
class AddManagers(ModelClassInitializer):
188-
def has_any_parametrized_manager_as_base(self, info: TypeInfo) -> bool:
189-
for base in helpers.iter_bases(info):
190-
if self.is_any_parametrized_manager(base):
191-
return True
192-
return False
193-
194-
def is_any_parametrized_manager(self, typ: Instance) -> bool:
195-
return typ.type.fullname in fullnames.MANAGER_CLASSES and isinstance(typ.args[0], AnyType)
196-
197-
def create_new_model_parametrized_manager(self, name: str, base_manager_info: TypeInfo) -> Instance:
198-
bases = []
199-
for original_base in base_manager_info.bases:
200-
if self.is_any_parametrized_manager(original_base):
201-
if original_base.type is None:
202-
raise helpers.IncompleteDefnException()
203-
204-
original_base = helpers.reparametrize_instance(original_base, [Instance(self.model_classdef.info, [])])
205-
bases.append(original_base)
206-
207-
new_manager_info = self.add_new_class_for_current_module(name, bases)
208-
# copy fields to a new manager
209-
new_cls_def_context = ClassDefContext(cls=new_manager_info.defn, reason=self.ctx.reason, api=self.api)
210-
custom_manager_type = Instance(new_manager_info, [Instance(self.model_classdef.info, [])])
211-
212-
for name, sym in base_manager_info.names.items():
213-
# replace self type with new class, if copying method
214-
if isinstance(sym.node, FuncDef):
215-
helpers.copy_method_to_another_class(
216-
new_cls_def_context,
217-
self_type=custom_manager_type,
218-
new_method_name=name,
219-
method_node=sym.node,
220-
original_module_name=base_manager_info.module_name,
221-
)
222-
continue
223-
224-
new_sym = sym.copy()
225-
if isinstance(new_sym.node, Var):
226-
new_var = Var(name, type=sym.type)
227-
new_var.info = new_manager_info
228-
new_var._fullname = new_manager_info.fullname + "." + name
229-
new_sym.node = new_var
230-
new_manager_info.names[name] = new_sym
231-
232-
return custom_manager_type
233-
234188
def run_with_model_cls(self, model_cls: Type[Model]) -> None:
235189
manager_info: Optional[TypeInfo]
236190

@@ -254,40 +208,30 @@ def run_with_model_cls(self, model_cls: Type[Model]) -> None:
254208
if manager_info is None:
255209
continue
256210

257-
is_dynamically_generated = manager_info.metadata.get("django", {}).get("from_queryset_manager") is not None
258-
if manager_name not in self.model_classdef.info.names or is_dynamically_generated:
259-
manager_type = Instance(manager_info, [Instance(self.model_classdef.info, [])])
260-
self.add_new_node_to_model_class(manager_name, manager_type)
261-
elif self.has_any_parametrized_manager_as_base(manager_info):
262-
# Ending up here could for instance be due to having a custom _Manager_
263-
# that is not built from a custom QuerySet. Another example is a
264-
# related manager.
265-
custom_model_manager_name = manager.model.__name__ + "_" + manager_class_name
266-
try:
267-
custom_manager_type = self.create_new_model_parametrized_manager(
268-
custom_model_manager_name, base_manager_info=manager_info
269-
)
270-
except helpers.IncompleteDefnException:
271-
continue
211+
manager_node = self.model_classdef.info.names.get(manager_name, None)
212+
if manager_node and manager_node.type is not None:
213+
continue
272214

273-
self.add_new_node_to_model_class(manager_name, custom_manager_type)
215+
manager_type = Instance(manager_info, [Instance(self.model_classdef.info, [])])
216+
self.add_new_node_to_model_class(manager_name, manager_type)
217+
218+
if incomplete_manager_defs:
219+
# Unless we're on the final round, see if another round could
220+
# figure out all manager types
221+
if not self.api.final_iteration:
222+
raise helpers.IncompleteDefnException()
274223

275-
if incomplete_manager_defs and not self.api.final_iteration:
276-
# Unless we're on the final round, see if another round could figure out all manager types
277-
raise helpers.IncompleteDefnException()
278-
elif self.api.final_iteration:
279224
for manager_name in incomplete_manager_defs:
280225
# We act graceful and set the type as the bare minimum we know of
281226
# (Django's default) before finishing. And emit an error, to allow for
282227
# ignoring a more specialised manager not being resolved while still
283228
# setting _some_ type
284229
django_manager_info = self.lookup_typeinfo(fullnames.MANAGER_CLASS_FULLNAME)
285-
assert (
286-
django_manager_info is not None
287-
), f"Type info for Django's {fullnames.MANAGER_CLASS_FULLNAME} missing"
230+
assert django_manager_info, f"Type info for {fullnames.MANAGER_CLASS_FULLNAME} missing"
288231
self.add_new_node_to_model_class(
289232
manager_name, Instance(django_manager_info, [Instance(self.model_classdef.info, [])])
290233
)
234+
291235
# Find expression for e.g. `objects = SomeManager()`
292236
manager_expr = [
293237
expr

tests/typecheck/fields/test_related.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -680,7 +680,7 @@
680680
681681
# Note, that we cannot resolve dynamic calls for custom managers:
682682
class Transaction(models.Model):
683-
objects = BaseManager.from_queryset(TransactionQuerySet)
683+
objects = BaseManager.from_queryset(TransactionQuerySet)()
684684
def test(self) -> None:
685685
reveal_type(self.transactionlog_set)
686686
# We use a fallback Any type:
@@ -689,8 +689,10 @@
689689
class TransactionLog(models.Model):
690690
transaction = models.ForeignKey(Transaction, on_delete=models.CASCADE)
691691
out: |
692+
myapp/models:9: error: Could not resolve manager type for "myapp.models.Transaction.objects"
692693
myapp/models:9: error: `.from_queryset` called from inside model class body
693694
myapp/models:11: note: Revealed type is "django.db.models.manager.RelatedManager[myapp.models.TransactionLog]"
695+
myapp/models:13: error: "Manager[Transaction]" has no attribute "custom"
694696
myapp/models:13: note: Revealed type is "Any"
695697
696698

tests/typecheck/managers/test_managers.yml

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -329,17 +329,49 @@
329329
# class MyUser(MyBaseUser):
330330
# objects = MyManager()
331331

332-
- case: custom_manager_returns_proper_model_types
332+
- case: custom_manager_without_typevar_returns_any_model_types
333333
main: |
334334
from myapp.models import User
335-
reveal_type(User.objects) # N: Revealed type is "myapp.models.User_MyManager2[myapp.models.User]"
335+
reveal_type(User.objects) # N: Revealed type is "myapp.models.MyManager[myapp.models.User]"
336+
reveal_type(User.objects.select_related()) # N: Revealed type is "django.db.models.query._QuerySet[Any, Any]"
337+
reveal_type(User.objects.get()) # N: Revealed type is "Any"
338+
reveal_type(User.objects.get_instance()) # N: Revealed type is "builtins.int"
339+
reveal_type(User.objects.get_instance_untyped('hello')) # N: Revealed type is "Any"
340+
341+
from myapp.models import ChildUser
342+
reveal_type(ChildUser.objects) # N: Revealed type is "myapp.models.MyManager[myapp.models.ChildUser]"
343+
reveal_type(ChildUser.objects.select_related()) # N: Revealed type is "django.db.models.query._QuerySet[Any, Any]"
344+
reveal_type(ChildUser.objects.get()) # N: Revealed type is "Any"
345+
reveal_type(ChildUser.objects.get_instance()) # N: Revealed type is "builtins.int"
346+
reveal_type(ChildUser.objects.get_instance_untyped('hello')) # N: Revealed type is "Any"
347+
installed_apps:
348+
- myapp
349+
files:
350+
- path: myapp/__init__.py
351+
- path: myapp/models.py
352+
content: |
353+
from django.db import models
354+
class MyManager(models.Manager):
355+
def get_instance(self) -> int:
356+
pass
357+
def get_instance_untyped(self, name):
358+
pass
359+
class User(models.Model):
360+
objects = MyManager()
361+
class ChildUser(models.Model):
362+
objects = MyManager()
363+
364+
- case: custom_manager_with_typevars_returns_proper_model_types
365+
main: |
366+
from myapp.models import User
367+
reveal_type(User.objects) # N: Revealed type is "myapp.models.MyManager[myapp.models.User]"
336368
reveal_type(User.objects.select_related()) # N: Revealed type is "django.db.models.query._QuerySet[myapp.models.User, myapp.models.User]"
337369
reveal_type(User.objects.get()) # N: Revealed type is "myapp.models.User"
338370
reveal_type(User.objects.get_instance()) # N: Revealed type is "builtins.int"
339371
reveal_type(User.objects.get_instance_untyped('hello')) # N: Revealed type is "Any"
340372
341373
from myapp.models import ChildUser
342-
reveal_type(ChildUser.objects) # N: Revealed type is "myapp.models.ChildUser_MyManager2[myapp.models.ChildUser]"
374+
reveal_type(ChildUser.objects) # N: Revealed type is "myapp.models.MyManager[myapp.models.ChildUser]"
343375
reveal_type(ChildUser.objects.select_related()) # N: Revealed type is "django.db.models.query._QuerySet[myapp.models.ChildUser, myapp.models.ChildUser]"
344376
reveal_type(ChildUser.objects.get()) # N: Revealed type is "myapp.models.ChildUser"
345377
reveal_type(ChildUser.objects.get_instance()) # N: Revealed type is "builtins.int"
@@ -350,8 +382,10 @@
350382
- path: myapp/__init__.py
351383
- path: myapp/models.py
352384
content: |
385+
from typing import TypeVar
353386
from django.db import models
354-
class MyManager(models.Manager):
387+
T = TypeVar("T", bound=models.Model)
388+
class MyManager(models.Manager[T]):
355389
def get_instance(self) -> int:
356390
pass
357391
def get_instance_untyped(self, name):
@@ -364,7 +398,7 @@
364398
- case: custom_manager_annotate_method_before_type_declaration
365399
main: |
366400
from myapp.models import ModelA, ModelB, ManagerA
367-
reveal_type(ModelA.objects) # N: Revealed type is "myapp.models.ModelA_ManagerA1[myapp.models.ModelA]"
401+
reveal_type(ModelA.objects) # N: Revealed type is "myapp.models.ManagerA[myapp.models.ModelA]"
368402
reveal_type(ModelA.objects.do_something) # N: Revealed type is "def (other_obj: myapp.models.ModelB) -> builtins.str"
369403
installed_apps:
370404
- myapp
@@ -383,7 +417,7 @@
383417
movie = models.TextField()
384418
385419
386-
- case: override_manager_create1
420+
- case: override_manager_create_missing_generic
387421
main: |
388422
from myapp.models import MyModel
389423
MyModel.objects.create()
@@ -397,14 +431,14 @@
397431
class MyModelManager(models.Manager):
398432
399433
def create(self, **kwargs) -> 'MyModel':
400-
return super().create(**kwargs)
434+
return super().create(**kwargs)
401435
402436
403437
class MyModel(models.Model):
404438
405439
objects = MyModelManager()
406440
407-
- case: override_manager_create2
441+
- case: override_manager_create_with_generic
408442
main: |
409443
from myapp.models import MyModel
410444
MyModel.objects.create()
@@ -427,7 +461,7 @@
427461
- case: regression_manager_scope_foreign
428462
main: |
429463
from myapp.models import MyModel
430-
reveal_type(MyModel.on_site) # N: Revealed type is "myapp.models.MyModel_CurrentSiteManager[myapp.models.MyModel]"
464+
reveal_type(MyModel.on_site) # N: Revealed type is "django.contrib.sites.managers.CurrentSiteManager[myapp.models.MyModel]"
431465
installed_apps:
432466
- myapp
433467
- django.contrib.sites

0 commit comments

Comments
 (0)