Skip to content

Commit 32d568d

Browse files
committed
Add hide_queryset_annotations helper
It's useful to be able to tell mypy to ignore the queryset annotations when we've only added an annotation for a filter applied in that place. For example when we want to return a non annotated queryset from a function, but require an annotation within that function to determine what's in the queryset Currently when we do this we need to specify those annotations in the return type of such a function even when they aren't used outside that function This is a response to typeddjango/django-stubs#3382
1 parent b9b5a8b commit 32d568d

8 files changed

Lines changed: 376 additions & 5 deletions

File tree

‎docs/api/changelog.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Changelog
1010
* Dropped support for mypy<2.1.0
1111
* Dropped support for python<3.13
1212
* Dropped support for django-stubs<6.0.5,django<6.0.5
13+
* Added ``extended_mypy_django_plugin.hide_queryset_annotations`` helper
1314

1415
.. _release-0.8.2:
1516

‎docs/api/usage.rst‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,34 @@ This also works on the concrete models themselves:
193193
194194
qs1: models.QuerySet[Concrete1]
195195
qs2: Concrete2QuerySet
196+
197+
Hiding queryset annotations
198+
---------------------------
199+
200+
Sometimes it can be useful to create a queryset using an annotation that isn't
201+
useful to callers.
202+
203+
For example:
204+
205+
.. code-block:: python
206+
207+
def some_filter(qs: MyQuerySet[MyModel]) -> MyQuerySet[MyModel]:
208+
# Imagine a useful example
209+
# The problem is that this fails because mypy believes the annotated
210+
# type this resolves to should result in a `return-value` error here
211+
return qs.annotate(max_value=Max("value")).filter(max_value__gt=10)
212+
213+
In this case we can remove the queryset annotations:
214+
215+
.. code-block:: python
216+
217+
from extended_django_mypy_plugin import hide_queryset_annotations
218+
219+
def some_filter(qs: MyQuerySet[MyModel]) -> MyQuerySet[MyModel]:
220+
return hide_queryset_annotations(
221+
qs.annotate(max_value=Max("value")).filter(max_value__gt=10)
222+
)
223+
224+
This helper does nothing at runtime and at static times errors will be produced
225+
if it's given something that isn't an instance of a queryset with a model we
226+
can determine.
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
from .annotations import Concrete, DefaultQuerySet
1+
from .annotations import Concrete, DefaultQuerySet, hide_queryset_annotations
22

3-
__all__ = ["Concrete", "DefaultQuerySet"]
3+
__all__ = ["Concrete", "DefaultQuerySet", "hide_queryset_annotations"]

‎extended_mypy_django_plugin/_plugin/plugin.py‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import functools
22
from typing import Generic, TypeVar
33

4+
from mypy import checker
45
from mypy.nodes import Import, ImportAll, ImportFrom, MypyFile
56
from mypy.options import Options
6-
from mypy.plugin import AnalyzeTypeContext, MethodContext, ReportConfigContext
7+
from mypy.plugin import (
8+
AnalyzeTypeContext,
9+
FunctionSigContext,
10+
MethodContext,
11+
ReportConfigContext,
12+
)
13+
from mypy.types import FunctionLike
714
from mypy.types import Type as MypyType
815
from mypy_django_plugin import main
916

@@ -48,6 +55,9 @@ class ExtendedMypyStubs(Generic[T_Report], main.NewSemanalDjangoPlugin):
4855
4956
.. autoattribute:: get_method_hook
5057
:no-index:
58+
59+
.. autoattribute:: get_function_signature_hook
60+
:no-index:
5161
"""
5262

5363
@classmethod
@@ -201,3 +211,27 @@ def choose(
201211

202212
def run(self, ctx: MethodContext) -> MypyType:
203213
return self.plugin.type_checker.modify_cast_as_concrete(ctx)
214+
215+
@hook.hook
216+
class get_function_signature_hook(
217+
PlainHook[protocols.Report, FunctionSigContext, FunctionLike]
218+
):
219+
"""
220+
Used to implement the ``hide_queryset_annotations`` helper.
221+
222+
We use this hook to change the signature of that function where it is used such that it takes in the type it is
223+
given and returns the same type minus any django-stubs queryset annotation information.
224+
"""
225+
226+
def choose(
227+
self, *, fullname: str, super_hook: hook.MypyHook[FunctionSigContext, FunctionLike]
228+
) -> bool:
229+
return fullname == "extended_mypy_django_plugin.annotations.hide_queryset_annotations"
230+
231+
def run(self, ctx: FunctionSigContext) -> FunctionLike:
232+
return self.plugin.type_checker.modify_hide_queryset_annotations(
233+
ctx=ctx,
234+
django_context=self.plugin.django_context,
235+
lookup_fully_qualified=self.plugin.lookup_fully_qualified,
236+
scope=None if not isinstance(ctx.api, checker.TypeChecker) else ctx.api.scope,
237+
)

‎extended_mypy_django_plugin/_plugin/type_checker.py‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1+
import mypy_django_plugin.django.context
2+
import mypy_django_plugin.lib.helpers
3+
from mypy import checker_shared
4+
from mypy.nodes import TypeInfo
15
from mypy.plugin import (
26
FunctionContext,
7+
FunctionSigContext,
38
MethodContext,
49
)
510
from mypy.types import (
611
AnyType,
12+
FunctionLike,
713
Instance,
14+
TypeAliasType,
15+
TypedDictType,
816
TypeOfAny,
917
TypeType,
1018
TypeVarType,
@@ -20,6 +28,75 @@ class TypeChecking:
2028
def __init__(self, *, make_resolver: protocols.ResolverMaker) -> None:
2129
self.make_resolver = make_resolver
2230

31+
def modify_hide_queryset_annotations(
32+
self,
33+
*,
34+
ctx: FunctionSigContext,
35+
django_context: mypy_django_plugin.django.context.DjangoContext,
36+
lookup_fully_qualified: protocols.LookupFullyQualified,
37+
scope: checker_shared.CheckerScope | None = None,
38+
) -> FunctionLike:
39+
if len(ctx.args) != 1:
40+
ctx.api.fail("hide_queryset_annotations takes only one argument", ctx.context)
41+
return ctx.default_signature
42+
43+
if not ctx.args[0]:
44+
ctx.api.fail("Mypy failed to tell us the type of the first argument", ctx.context)
45+
return ctx.default_signature
46+
47+
first_arg = get_proper_type(ctx.api.get_expression_type(ctx.args[0][0]))
48+
if isinstance(first_arg, AnyType):
49+
ctx.api.fail("Failed to determine the type of the first argument", ctx.context)
50+
return ctx.default_signature
51+
52+
if isinstance(first_arg, TypeAliasType) and first_arg.alias:
53+
first_arg = get_proper_type(first_arg.alias.target)
54+
55+
if isinstance(first_arg, TypeVarType) and first_arg.name == "Self":
56+
if (
57+
scope is None
58+
or (enclosing := scope.enclosing_class()) is None
59+
or not enclosing.has_base("django.db.models.query.QuerySet")
60+
):
61+
ctx.api.fail("First argument must be a queryset", ctx.context)
62+
63+
return ctx.default_signature
64+
65+
if not isinstance(first_arg, Instance):
66+
ctx.api.fail("First argument must be an instance", ctx.context)
67+
return ctx.default_signature
68+
69+
model = mypy_django_plugin.lib.helpers.extract_model_type_from_queryset(first_arg)
70+
if model is None:
71+
ctx.api.fail(
72+
"Failed to determine a django model from the first argument (or it's not a queryset)",
73+
ctx.context,
74+
)
75+
return ctx.default_signature
76+
77+
if mypy_django_plugin.lib.helpers.is_annotated_model(model.type):
78+
fullname = model.type.bases[0].type.fullname
79+
else:
80+
fullname = model.type.fullname
81+
82+
if (sym := lookup_fully_qualified(fullname)) is None or not isinstance(sym.node, TypeInfo):
83+
ctx.api.fail(f"Failed to determine a django model from '{fullname}'", ctx.context)
84+
return ctx.default_signature
85+
86+
django_model = Instance(sym.node, [])
87+
queryset_args: list[MypyType] = [django_model]
88+
if len(first_arg.args) == 2:
89+
second_arg = get_proper_type(first_arg.args[1])
90+
if isinstance(second_arg, TypeAliasType) and second_arg.alias:
91+
second_arg = get_proper_type(second_arg.alias.target)
92+
93+
if isinstance(second_arg, TypedDictType):
94+
queryset_args.append(first_arg.args[1])
95+
96+
return ctx.default_signature.copy_modified(
97+
arg_types=[first_arg], ret_type=Instance(first_arg.type, queryset_args)
98+
)
99+
23100
def modify_cast_as_concrete(self, ctx: FunctionContext | MethodContext) -> MypyType:
24101
if len(ctx.arg_types) != 1:
25102
ctx.api.fail("Concrete.cast_as_concrete takes only one argument", ctx.context)

‎extended_mypy_django_plugin/annotations.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,33 @@
88
T_Obj = TypeVar("T_Obj")
99

1010

11+
def hide_queryset_annotations[T_QuerySet](queryset: T_QuerySet) -> T_QuerySet:
12+
"""
13+
This helper is seen by the mypy plugin and is used to transform a queryset
14+
type with annotations into a queryset type without annotations
15+
16+
At runtime it does nothing.
17+
18+
The purpose of this is when a queryset is annotated only for the filter that is
19+
immediately applied to it when we don't need to share the annotation outside of
20+
that context.
21+
22+
For example:
23+
24+
.. code-block::
25+
26+
from extended_mypy_django_plugin import hide_queryset_annotations
27+
28+
def filter_my_queryset(qs: MySpecialQuerySet[MyModel]) -> MySpecialQuerySet[MyModel]:
29+
# Without the `hide_queryset_annotations` mypy will complain that we are returning
30+
# MySpecialQuerySet[Annotated[MyModel, TypedDict({"some_field": ...})]]
31+
return hide_queryset_annotations(
32+
qs.annotate(some_field=...).filter(some_field__gt=3)
33+
)
34+
"""
35+
return queryset
36+
37+
1138
class Concrete(Generic[T_Parent]):
1239
"""
1340
The ``Concrete`` annotation exists as a class with functionality for both
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
from .follower1 import Follower1
1+
from .follower1 import Follower1, Follower1QuerySet
22

3-
__all__ = ["Follower1"]
3+
__all__ = ["Follower1", "Follower1QuerySet"]

0 commit comments

Comments
 (0)