Skip to content

Commit ca4389e

Browse files
authored
Reparametrize implicit generic QuerySet subclasses (#3217)
When a custom QuerySet subclass is defined without explicit type parameters (e.g. `class MyQS(QuerySet): ...`), make it implicitly generic by copying the parent QuerySet's type variables, analogous to the existing `reparametrize_any_manager_hook` for Manager subclasses. This enables the annotate plugin hook to propagate annotation type information through custom querysets via `copy_modified(args=...)`, which requires the queryset class to have type variables.
1 parent 9afc8c0 commit ca4389e

File tree

6 files changed

+119
-3
lines changed

6 files changed

+119
-3
lines changed

mypy_django_plugin/main.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
add_as_manager_to_queryset_class,
4444
create_new_manager_class_from_from_queryset_method,
4545
reparametrize_any_manager_hook,
46+
reparametrize_any_queryset_hook,
4647
resolve_manager_method,
4748
)
4849
from mypy_django_plugin.transformers.models import (
@@ -232,6 +233,8 @@ def get_customize_class_mro_hook(self, fullname: str) -> Callable[[ClassDefConte
232233
info = self._get_typeinfo_or_none(fullname)
233234
if info and info.has_base(fullnames.BASE_MANAGER_CLASS_FULLNAME):
234235
return reparametrize_any_manager_hook
236+
if info and info.has_base(fullnames.QUERYSET_CLASS_FULLNAME):
237+
return reparametrize_any_queryset_hook
235238
return None
236239

237240
@override

mypy_django_plugin/transformers/managers.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,58 @@ def _defer() -> None:
589589
)
590590

591591

592+
def reparametrize_any_queryset_hook(ctx: ClassDefContext) -> None:
593+
"""
594+
Add implicit generics to QuerySet subclasses that are defined without generic.
595+
596+
Eg.
597+
598+
class MyQuerySet(models.QuerySet): ...
599+
600+
is interpreted as:
601+
602+
_Model = TypeVar('_Model', bound=Model, covariant=True)
603+
_Row = TypeVar('_Row', covariant=True, default=_Model)
604+
class MyQuerySet(models.QuerySet[_Model, _Row]): ...
605+
606+
Note that this does not happen if mypy is run with disallow_any_generics = True,
607+
as not specifying the generic type is then considered an error.
608+
"""
609+
queryset = ctx.api.lookup_fully_qualified_or_none(ctx.cls.fullname)
610+
if queryset is None or queryset.node is None:
611+
return
612+
assert isinstance(queryset.node, TypeInfo)
613+
614+
if queryset.node.type_vars:
615+
# We've already been here
616+
return
617+
618+
parent_queryset = next(
619+
(base for base in queryset.node.bases if base.type.has_base(fullnames.QUERYSET_CLASS_FULLNAME)),
620+
None,
621+
)
622+
if parent_queryset is None or len(parent_queryset.args) != 2:
623+
return
624+
625+
model_param = get_proper_type(parent_queryset.args[0])
626+
if not isinstance(model_param, AnyType) or model_param.type_of_any is not TypeOfAny.from_omitted_generics:
627+
return
628+
629+
type_vars = tuple(parent_queryset.type.defn.type_vars)
630+
631+
# If we end up with placeholders we need to defer so the placeholders are
632+
# resolved in a future iteration
633+
if any(has_placeholder(type_var) for type_var in type_vars):
634+
if not ctx.api.final_iteration:
635+
ctx.api.defer()
636+
else:
637+
return
638+
639+
parent_queryset.args = type_vars
640+
queryset.node.defn.type_vars = list(type_vars)
641+
queryset.node.add_type_vars()
642+
643+
592644
def reparametrize_any_manager_hook(ctx: ClassDefContext) -> None:
593645
"""
594646
Add implicit generics to manager classes that are defined without generic.

tests/typecheck/fields/test_related.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -718,9 +718,9 @@
718718
reveal_type(user.article_set) # N: Revealed type is "myapp.models.Article_RelatedManager"
719719
reveal_type(user.book_set.add) # N: Revealed type is "def (*objs: myapp.models.Book | builtins.int, bulk: builtins.bool =)"
720720
reveal_type(user.article_set.add) # N: Revealed type is "def (*objs: myapp.models.Article | builtins.int, bulk: builtins.bool =)"
721-
reveal_type(user.book_set.filter) # N: Revealed type is "def (*args: Any, **kwargs: Any) -> myapp.models.LibraryEntityQuerySet"
721+
reveal_type(user.book_set.filter) # N: Revealed type is "def (*args: Any, **kwargs: Any) -> myapp.models.LibraryEntityQuerySet[myapp.models.Book, myapp.models.Book]"
722722
reveal_type(user.book_set.get()) # N: Revealed type is "myapp.models.Book"
723-
reveal_type(user.article_set.filter) # N: Revealed type is "def (*args: Any, **kwargs: Any) -> myapp.models.LibraryEntityQuerySet"
723+
reveal_type(user.article_set.filter) # N: Revealed type is "def (*args: Any, **kwargs: Any) -> myapp.models.LibraryEntityQuerySet[myapp.models.Article, myapp.models.Article]"
724724
reveal_type(user.article_set.get()) # N: Revealed type is "myapp.models.Article"
725725
reveal_type(user.book_set.queryset_method()) # N: Revealed type is "builtins.int"
726726
reveal_type(user.article_set.queryset_method()) # N: Revealed type is "builtins.int"

tests/typecheck/managers/querysets/test_annotate.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,3 +552,35 @@
552552
from django.db import models
553553
class MyModel(models.Model):
554554
name = models.CharField(max_length=100)
555+
556+
- case: annotate_on_implicitly_generic_custom_queryset
557+
main: |
558+
from typing_extensions import reveal_type
559+
from myapp.models import MyModel
560+
from django.db.models import Count
561+
562+
qs = MyModel.objects.all().annotate(num_items=Count("id"))
563+
obj = qs.get()
564+
reveal_type(obj.num_items) # N: Revealed type is "Any"
565+
obj.nonexistent # E: "MyModel@AnnotatedWith[TypedDict({'num_items': Any})]" has no attribute "nonexistent" [attr-defined]
566+
567+
# Custom queryset methods remain available after annotate
568+
qs.custom_method()
569+
installed_apps:
570+
- myapp
571+
files:
572+
- path: myapp/__init__.py
573+
- path: myapp/models.py
574+
content: |
575+
from django.db import models
576+
from typing_extensions import Self
577+
578+
class MyQuerySet(models.QuerySet):
579+
def custom_method(self) -> Self:
580+
return self.filter(name="test")
581+
582+
MyManager = models.Manager.from_queryset(MyQuerySet)
583+
584+
class MyModel(models.Model):
585+
name = models.CharField(max_length=100)
586+
objects = MyManager()

tests/typecheck/managers/querysets/test_from_queryset.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1042,3 +1042,32 @@
10421042
# Forward-referenced metaclass defined after use
10431043
class ForwardMCS(type):
10441044
pass
1045+
1046+
- case: subclass_queryset_without_type_parameters_disallow_any_generics
1047+
main: |
1048+
from typing_extensions import reveal_type
1049+
from myapp.models import MyModel
1050+
reveal_type(MyModel.objects)
1051+
reveal_type(MyModel.objects.get())
1052+
installed_apps:
1053+
- myapp
1054+
mypy_config: |
1055+
[mypy-myapp.models]
1056+
disallow_any_generics = true
1057+
out: |
1058+
main:3: note: Revealed type is "myapp.models.ManagerFromMyQuerySet[myapp.models.MyModel]"
1059+
main:4: note: Revealed type is "myapp.models.MyModel"
1060+
myapp/models:3: error: Missing type parameters for generic type "QuerySet" [type-arg]
1061+
files:
1062+
- path: myapp/__init__.py
1063+
- path: myapp/models.py
1064+
content: |
1065+
from django.db import models
1066+
1067+
class MyQuerySet(models.QuerySet):
1068+
pass
1069+
1070+
MyManager = models.Manager.from_queryset(MyQuerySet)
1071+
1072+
class MyModel(models.Model):
1073+
objects = MyManager()

tests/typecheck/managers/querysets/test_union_type.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
1111
reveal_type(model_cls) # N: Revealed type is "type[myapp.models.Order] | type[myapp.models.User]"
1212
reveal_type(model_cls.objects) # N: Revealed type is "myapp.models.ManagerFromMyQuerySet[myapp.models.Order] | myapp.models.ManagerFromMyQuerySet[myapp.models.User]"
13-
reveal_type(model_cls.objects.my_method()) # N: Revealed type is "myapp.models.MyQuerySet"
13+
reveal_type(model_cls.objects.my_method()) # N: Revealed type is "myapp.models.MyQuerySet[Any, Any]"
1414
installed_apps:
1515
- myapp
1616
files:

0 commit comments

Comments
 (0)