Skip to content

Commit 84eff75

Browse files
ljodalPetter Fribergsobolevn
authored
Resolve all queryset methods on managers as attributes (#1028)
* Add test case reproducing Sequence name not defined issue * Resolve all manager methods as attribute This changes to logic for resolving methods from the base QuerySet class on managers from copying the methods to use the attribute approach that's already used for methods from custom querysets. This resolves the phantom type errors that stem from the copying. * Disable cache in test case Make sure the test will fail regardless of which mypy.ini file is being using. Co-authored-by: Petter Friberg <[email protected]> * Update comments related to copying methods * Use a predefined list of manager methods to update The list of manager methods that returns a queryset, and thus need to have it's return type changed, is small and well defined. Using a predefined list of methods rather than trying to detect these at runtime makes the code much more readable and probably faster as well. Also add `extra()` to the methods tested in from_queryset_includes_methods_returning_queryset, and sort the methods alphabetically. * Revert changes in .github/workflows/tests.yml With cache_disable: true on the test case this is no longer needed to reproduce the bug. * Remove unsued imports and change type of constant - Remove unused imports left behind - Change MANAGER_METHODS_RETURNING_QUERYSET to Final[FrozenSet[str]] * Import Final from typing_extensions Was added in 3.8, we still support 3.7 * Sort imports properly * Remove explicit typing of final frozenset Co-authored-by: Nikita Sobolev <[email protected]> * Add comment for test case * Fix typo * Rename variable Co-authored-by: Petter Friberg <[email protected]> Co-authored-by: Nikita Sobolev <[email protected]>
1 parent f8cc99c commit 84eff75

File tree

2 files changed

+113
-71
lines changed

2 files changed

+113
-71
lines changed

mypy_django_plugin/transformers/managers.py

Lines changed: 58 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,47 @@
1919
from mypy.plugin import AttributeContext, ClassDefContext, DynamicClassDefContext, MethodContext
2020
from mypy.types import AnyType, CallableType, Instance, ProperType
2121
from mypy.types import Type as MypyType
22-
from mypy.types import TypeOfAny, TypeVarType, UnboundType, get_proper_type
22+
from mypy.types import TypeOfAny
23+
from typing_extensions import Final
2324

2425
from mypy_django_plugin import errorcodes
2526
from mypy_django_plugin.lib import fullnames, helpers
2627

28+
MANAGER_METHODS_RETURNING_QUERYSET: Final = frozenset(
29+
(
30+
"alias",
31+
"all",
32+
"annotate",
33+
"complex_filter",
34+
"defer",
35+
"difference",
36+
"distinct",
37+
"exclude",
38+
"extra",
39+
"filter",
40+
"intersection",
41+
"none",
42+
"only",
43+
"order_by",
44+
"prefetch_related",
45+
"reverse",
46+
"select_for_update",
47+
"select_related",
48+
"union",
49+
"using",
50+
)
51+
)
52+
2753

2854
def get_method_type_from_dynamic_manager(
29-
api: TypeChecker, method_name: str, manager_type_info: TypeInfo
55+
api: TypeChecker, method_name: str, manager_instance: Instance
3056
) -> Optional[ProperType]:
3157
"""
3258
Attempt to resolve a method on a manager that was built from '.from_queryset'
3359
"""
60+
61+
manager_type_info = manager_instance.type
62+
3463
if (
3564
"django" not in manager_type_info.metadata
3665
or "from_queryset_manager" not in manager_type_info.metadata["django"]
@@ -56,11 +85,24 @@ def get_funcdef_type(definition: Union[FuncBase, Decorator, None]) -> Optional[P
5685
return None
5786

5887
assert isinstance(method_type, CallableType)
88+
89+
variables = method_type.variables
90+
ret_type = method_type.ret_type
91+
92+
# For methods on the manager that return a queryset we need to override the
93+
# return type to be the actual queryset class, not the base QuerySet that's
94+
# used by the typing stubs.
95+
if method_name in MANAGER_METHODS_RETURNING_QUERYSET:
96+
ret_type = Instance(queryset_info, manager_instance.args)
97+
variables = []
98+
5999
# Drop any 'self' argument as our manager is already initialized
60100
return method_type.copy_modified(
61101
arg_types=method_type.arg_types[1:],
62102
arg_kinds=method_type.arg_kinds[1:],
63103
arg_names=method_type.arg_names[1:],
104+
variables=variables,
105+
ret_type=ret_type,
64106
)
65107

66108

@@ -90,17 +132,18 @@ def get_method_type_from_reverse_manager(
90132
assert isinstance(model_info.names["_default_manager"].node, Var)
91133
manager_instance = model_info.names["_default_manager"].node.type
92134
return (
93-
get_method_type_from_dynamic_manager(api, method_name, manager_instance.type)
135+
get_method_type_from_dynamic_manager(api, method_name, manager_instance)
94136
# TODO: Can we assert on None and Instance?
95137
if manager_instance is not None and isinstance(manager_instance, Instance)
96138
else None
97139
)
98140

99141

100142
def resolve_manager_method_from_instance(instance: Instance, method_name: str, ctx: AttributeContext) -> MypyType:
143+
101144
api = helpers.get_typechecker_api(ctx)
102145
method_type = get_method_type_from_dynamic_manager(
103-
api, method_name, instance.type
146+
api, method_name, instance
104147
) or get_method_type_from_reverse_manager(api, method_name, instance.type)
105148

106149
return method_type if method_type is not None else ctx.default_attr_type
@@ -232,60 +275,17 @@ def create_new_manager_class_from_from_queryset_method(ctx: DynamicClassDefConte
232275
sym_type=AnyType(TypeOfAny.special_form),
233276
)
234277

235-
# we need to copy all methods in MRO before django.db.models.query.QuerySet
236-
# Gather names of all BaseManager methods
237-
manager_method_names = []
238-
for manager_mro_info in new_manager_info.mro:
239-
if manager_mro_info.fullname == fullnames.BASE_MANAGER_CLASS_FULLNAME:
240-
for name, sym in manager_mro_info.names.items():
241-
manager_method_names.append(name)
242-
243-
# Copy/alter all methods in common between BaseManager/QuerySet over to the new manager if their return type is
244-
# the QuerySet's self-type. Alter the return type to be the custom queryset, parameterized by the manager's model
245-
# type variable.
246-
for class_mro_info in derived_queryset_info.mro:
247-
if class_mro_info.fullname != fullnames.QUERYSET_CLASS_FULLNAME:
248-
continue
249-
for name, sym in class_mro_info.names.items():
250-
if name not in manager_method_names:
251-
continue
252-
253-
if isinstance(sym.node, FuncDef):
254-
func_node = sym.node
255-
elif isinstance(sym.node, Decorator):
256-
func_node = sym.node.func
257-
else:
258-
continue
259-
260-
method_type = func_node.type
261-
if not isinstance(method_type, CallableType):
262-
if not semanal_api.final_iteration:
263-
semanal_api.defer()
264-
return None
265-
original_return_type = method_type.ret_type
266-
267-
# Skip any method that doesn't return _QS
268-
original_return_type = get_proper_type(original_return_type)
269-
if isinstance(original_return_type, UnboundType):
270-
if original_return_type.name != "_QS":
271-
continue
272-
elif isinstance(original_return_type, TypeVarType):
273-
if original_return_type.name != "_QS":
274-
continue
275-
else:
276-
continue
277-
278-
# Return the custom queryset parameterized by the manager's type vars
279-
return_type = Instance(derived_queryset_info, self_type.args)
280-
281-
helpers.copy_method_to_another_class(
282-
class_def_context,
283-
self_type,
284-
new_method_name=name,
285-
method_node=func_node,
286-
return_type=return_type,
287-
original_module_name=class_mro_info.module_name,
288-
)
278+
# For methods on BaseManager that return a queryset we need to update the
279+
# return type to be the actual queryset subclass used. This is done by
280+
# adding the methods as attributes with type Any to the manager class,
281+
# similar to how custom queryset methods are handled above. The actual type
282+
# of these methods are resolved in resolve_manager_method.
283+
for name in MANAGER_METHODS_RETURNING_QUERYSET:
284+
helpers.add_new_sym_for_info(
285+
new_manager_info,
286+
name=name,
287+
sym_type=AnyType(TypeOfAny.special_form),
288+
)
289289

290290
# Insert the new manager (dynamic) class
291291
assert semanal_api.add_symbol_table_node(ctx.name, SymbolTableNode(GDEF, new_manager_info, plugin_generated=True))

tests/typecheck/managers/querysets/test_from_queryset.yml

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -350,24 +350,25 @@
350350
- case: from_queryset_includes_methods_returning_queryset
351351
main: |
352352
from myapp.models import MyModel
353-
reveal_type(MyModel.objects.none) # N: Revealed type is "def () -> myapp.models.MyQuerySet[myapp.models.MyModel]"
353+
reveal_type(MyModel.objects.alias) # N: Revealed type is "def (*args: Any, **kwargs: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
354354
reveal_type(MyModel.objects.all) # N: Revealed type is "def () -> myapp.models.MyQuerySet[myapp.models.MyModel]"
355-
reveal_type(MyModel.objects.filter) # N: Revealed type is "def (*args: Any, **kwargs: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
356-
reveal_type(MyModel.objects.exclude) # N: Revealed type is "def (*args: Any, **kwargs: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
355+
reveal_type(MyModel.objects.annotate) # N: Revealed type is "def (*args: Any, **kwargs: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
357356
reveal_type(MyModel.objects.complex_filter) # N: Revealed type is "def (filter_obj: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
358-
reveal_type(MyModel.objects.union) # N: Revealed type is "def (*other_qs: Any, *, all: builtins.bool =) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
359-
reveal_type(MyModel.objects.intersection) # N: Revealed type is "def (*other_qs: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
357+
reveal_type(MyModel.objects.defer) # N: Revealed type is "def (*fields: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
360358
reveal_type(MyModel.objects.difference) # N: Revealed type is "def (*other_qs: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
361-
reveal_type(MyModel.objects.select_for_update) # N: Revealed type is "def (nowait: builtins.bool =, skip_locked: builtins.bool =, of: typing.Sequence[builtins.str] =, no_key: builtins.bool =) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
362-
reveal_type(MyModel.objects.select_related) # N: Revealed type is "def (*fields: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
363-
reveal_type(MyModel.objects.prefetch_related) # N: Revealed type is "def (*lookups: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
364-
reveal_type(MyModel.objects.annotate) # N: Revealed type is "def (*args: Any, **kwargs: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
365-
reveal_type(MyModel.objects.alias) # N: Revealed type is "def (*args: Any, **kwargs: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
366-
reveal_type(MyModel.objects.order_by) # N: Revealed type is "def (*field_names: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
367359
reveal_type(MyModel.objects.distinct) # N: Revealed type is "def (*field_names: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
368-
reveal_type(MyModel.objects.reverse) # N: Revealed type is "def () -> myapp.models.MyQuerySet[myapp.models.MyModel]"
369-
reveal_type(MyModel.objects.defer) # N: Revealed type is "def (*fields: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
360+
reveal_type(MyModel.objects.exclude) # N: Revealed type is "def (*args: Any, **kwargs: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
361+
reveal_type(MyModel.objects.extra) # N: Revealed type is "def (select: Union[builtins.dict[builtins.str, Any], None] =, where: Union[builtins.list[builtins.str], None] =, params: Union[builtins.list[Any], None] =, tables: Union[builtins.list[builtins.str], None] =, order_by: Union[typing.Sequence[builtins.str], None] =, select_params: Union[typing.Sequence[Any], None] =) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
362+
reveal_type(MyModel.objects.filter) # N: Revealed type is "def (*args: Any, **kwargs: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
363+
reveal_type(MyModel.objects.intersection) # N: Revealed type is "def (*other_qs: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
364+
reveal_type(MyModel.objects.none) # N: Revealed type is "def () -> myapp.models.MyQuerySet[myapp.models.MyModel]"
370365
reveal_type(MyModel.objects.only) # N: Revealed type is "def (*fields: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
366+
reveal_type(MyModel.objects.order_by) # N: Revealed type is "def (*field_names: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
367+
reveal_type(MyModel.objects.prefetch_related) # N: Revealed type is "def (*lookups: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
368+
reveal_type(MyModel.objects.reverse) # N: Revealed type is "def () -> myapp.models.MyQuerySet[myapp.models.MyModel]"
369+
reveal_type(MyModel.objects.select_for_update) # N: Revealed type is "def (nowait: builtins.bool =, skip_locked: builtins.bool =, of: typing.Sequence[builtins.str] =, no_key: builtins.bool =) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
370+
reveal_type(MyModel.objects.select_related) # N: Revealed type is "def (*fields: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
371+
reveal_type(MyModel.objects.union) # N: Revealed type is "def (*other_qs: Any, *, all: builtins.bool =) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
371372
reveal_type(MyModel.objects.using) # N: Revealed type is "def (alias: Union[builtins.str, None]) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
372373
installed_apps:
373374
- myapp
@@ -384,3 +385,44 @@
384385
MyManager = BaseManager.from_queryset(MyQuerySet)
385386
class MyModel(models.Model):
386387
objects = MyManager()
388+
389+
390+
# This tests a regression where mypy would generate phantom warnings about
391+
# undefined types due to unresolved types when copying methods from QuerySet to
392+
# a manager dynamically created using Manager.from_queryset().
393+
#
394+
# For details see: https://github.com/typeddjango/django-stubs/issues/1022
395+
- case: from_queryset_custom_auth_user_model
396+
# Cache needs to be disabled to consistenly reproduce the bug
397+
disable_cache: true
398+
main: |
399+
from users.models import User
400+
custom_settings: |
401+
AUTH_USER_MODEL = "users.User"
402+
INSTALLED_APPS = ("django.contrib.auth", "django.contrib.contenttypes", "users")
403+
files:
404+
- path: users/__init__.py
405+
- path: users/models.py
406+
content: |
407+
from django.contrib.auth.models import AbstractBaseUser
408+
from django.db import models
409+
410+
from .querysets import UserQuerySet
411+
412+
UserManager = models.Manager.from_queryset(UserQuerySet)
413+
414+
class User(AbstractBaseUser):
415+
email = models.EmailField(unique=True)
416+
objects = UserManager()
417+
USERNAME_FIELD = "email"
418+
419+
- path: users/querysets.py
420+
content: |
421+
from django.db import models
422+
from typing import Optional, TYPE_CHECKING
423+
424+
if TYPE_CHECKING:
425+
from .models import User
426+
427+
class UserQuerySet(models.QuerySet["User"]):
428+
pass

0 commit comments

Comments
 (0)