Skip to content

Commit 2f287e6

Browse files
asottile-sentryasottilepre-commit-ci[bot]
authored
use field annotations for values_list types (typeddjango#2248) (#20)
Co-authored-by: Anthony Sottile <asottile@umich.edu> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 634fa60 commit 2f287e6

File tree

3 files changed

+75
-23
lines changed

3 files changed

+75
-23
lines changed

mypy_django_plugin/django/context.py

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,35 @@ class LookupsAreUnsupported(Exception):
9191
pass
9292

9393

94+
def _get_field_type_from_model_type_info(info: Optional[TypeInfo], field_name: str) -> Optional[Instance]:
95+
if info is None:
96+
return None
97+
field_node = info.get(field_name)
98+
if field_node is None or not isinstance(field_node.type, Instance):
99+
return None
100+
# Field declares a set and a get type arg. Fallback to `None` when we can't find any args
101+
elif len(field_node.type.args) != 2:
102+
return None
103+
else:
104+
return field_node.type
105+
106+
107+
def _get_field_set_type_from_model_type_info(info: Optional[TypeInfo], field_name: str) -> Optional[MypyType]:
108+
field_type = _get_field_type_from_model_type_info(info, field_name)
109+
if field_type is not None:
110+
return field_type.args[0]
111+
else:
112+
return None
113+
114+
115+
def _get_field_get_type_from_model_type_info(info: Optional[TypeInfo], field_name: str) -> Optional[MypyType]:
116+
field_type = _get_field_type_from_model_type_info(info, field_name)
117+
if field_type is not None:
118+
return field_type.args[1]
119+
else:
120+
return None
121+
122+
94123
class DjangoContext:
95124
def __init__(self, django_settings_module: str) -> None:
96125
self.django_settings_module = django_settings_module
@@ -152,13 +181,13 @@ def get_field_lookup_exact_type(
152181
) -> MypyType:
153182
if isinstance(field, (RelatedField, ForeignObjectRel)):
154183
related_model_cls = self.get_field_related_model_cls(field)
155-
primary_key_field = self.get_primary_key_field(related_model_cls)
156-
primary_key_type = self.get_field_get_type(api, primary_key_field, method="init")
157-
158184
rel_model_info = helpers.lookup_class_typeinfo(api, related_model_cls)
159185
if rel_model_info is None:
160186
return AnyType(TypeOfAny.explicit)
161187

188+
primary_key_field = self.get_primary_key_field(related_model_cls)
189+
primary_key_type = self.get_field_get_type(api, rel_model_info, primary_key_field, method="init")
190+
162191
model_and_primary_key_type = UnionType.make_union([Instance(rel_model_info, []), primary_key_type])
163192
return helpers.make_optional(model_and_primary_key_type)
164193

@@ -200,19 +229,6 @@ def get_expected_types(self, api: TypeChecker, model_cls: Type[Model], *, method
200229
field_set_type = self.get_field_set_type(api, primary_key_field, method=method)
201230
expected_types["pk"] = field_set_type
202231

203-
def get_field_set_type_from_model_type_info(info: Optional[TypeInfo], field_name: str) -> Optional[MypyType]:
204-
if info is None:
205-
return None
206-
field_node = info.get(field_name)
207-
if field_node is None or not isinstance(field_node.type, Instance):
208-
return None
209-
elif not field_node.type.args:
210-
# Field declares a set and a get type arg. Fallback to `None` when we can't find any args
211-
return None
212-
213-
set_type = field_node.type.args[0]
214-
return set_type
215-
216232
model_info = helpers.lookup_class_typeinfo(api, model_cls)
217233
for field in model_cls._meta.get_fields():
218234
if isinstance(field, Field):
@@ -223,7 +239,7 @@ def get_field_set_type_from_model_type_info(info: Optional[TypeInfo], field_name
223239
# Try to retrieve set type from a model's TypeInfo object and fallback to retrieving it manually
224240
# from django-stubs own declaration. This is to align with the setter types declared for
225241
# assignment.
226-
field_set_type = get_field_set_type_from_model_type_info(
242+
field_set_type = _get_field_set_type_from_model_type_info(
227243
model_info, field_name
228244
) or self.get_field_set_type(api, field, method=method)
229245
expected_types[field_name] = field_set_type
@@ -340,20 +356,31 @@ def get_field_set_type(
340356
return field_set_type
341357

342358
def get_field_get_type(
343-
self, api: TypeChecker, field: Union["Field[Any, Any]", ForeignObjectRel], *, method: str
359+
self,
360+
api: TypeChecker,
361+
model_info: Optional[TypeInfo],
362+
field: Union["Field[Any, Any]", ForeignObjectRel],
363+
*,
364+
method: str,
344365
) -> MypyType:
345366
"""Get a type of __get__ for this specific Django field."""
367+
if isinstance(field, Field):
368+
get_type = _get_field_get_type_from_model_type_info(model_info, field.attname)
369+
if get_type is not None:
370+
return get_type
371+
346372
field_info = helpers.lookup_class_typeinfo(api, field.__class__)
347373
if field_info is None:
348374
return AnyType(TypeOfAny.unannotated)
349375

350376
is_nullable = self.get_field_nullability(field, method)
351377
if isinstance(field, RelatedField):
352378
related_model_cls = self.get_field_related_model_cls(field)
379+
rel_model_info = helpers.lookup_class_typeinfo(api, related_model_cls)
353380

354381
if method in ("values", "values_list"):
355382
primary_key_field = self.get_primary_key_field(related_model_cls)
356-
return self.get_field_get_type(api, primary_key_field, method=method)
383+
return self.get_field_get_type(api, rel_model_info, primary_key_field, method=method)
357384

358385
model_info = helpers.lookup_class_typeinfo(api, related_model_cls)
359386
if model_info is None:

mypy_django_plugin/transformers/querysets.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,12 @@ def get_field_type_from_lookup(
6666
elif (isinstance(lookup_field, RelatedField) and lookup_field.column == lookup) or isinstance(
6767
lookup_field, ForeignObjectRel
6868
):
69-
related_model_cls = django_context.get_field_related_model_cls(lookup_field)
70-
lookup_field = django_context.get_primary_key_field(related_model_cls)
69+
model_cls = django_context.get_field_related_model_cls(lookup_field)
70+
lookup_field = django_context.get_primary_key_field(model_cls)
7171

72-
field_get_type = django_context.get_field_get_type(helpers.get_typechecker_api(ctx), lookup_field, method=method)
72+
api = helpers.get_typechecker_api(ctx)
73+
model_info = helpers.lookup_class_typeinfo(api, model_cls)
74+
field_get_type = django_context.get_field_get_type(api, model_info, lookup_field, method=method)
7375
return field_get_type
7476

7577

@@ -87,6 +89,7 @@ def get_values_list_row_type(
8789
return AnyType(TypeOfAny.from_error)
8890

8991
typechecker_api = helpers.get_typechecker_api(ctx)
92+
model_info = helpers.lookup_class_typeinfo(typechecker_api, model_cls)
9093
if len(field_lookups) == 0:
9194
if flat:
9295
primary_key_field = django_context.get_primary_key_field(model_cls)
@@ -98,7 +101,9 @@ def get_values_list_row_type(
98101
elif named:
99102
column_types: OrderedDict[str, MypyType] = OrderedDict()
100103
for field in django_context.get_model_fields(model_cls):
101-
column_type = django_context.get_field_get_type(typechecker_api, field, method="values_list")
104+
column_type = django_context.get_field_get_type(
105+
typechecker_api, model_info, field, method="values_list"
106+
)
102107
column_types[field.attname] = column_type
103108
if is_annotated:
104109
# Return a NamedTuple with a fallback so that it's possible to access any field

tests/typecheck/managers/querysets/test_values_list.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,26 @@
4747
name = models.CharField(max_length=100)
4848
age = models.IntegerField()
4949
50+
- case: values_list_types_are_field_types
51+
main: |
52+
from myapp.models import Concrete
53+
ret = list(Concrete.objects.values_list('id', 'data'))
54+
reveal_type(ret) # N: Revealed type is "builtins.list[Tuple[builtins.int, builtins.dict[builtins.str, builtins.str]]]"
55+
installed_apps:
56+
- myapp
57+
files:
58+
- path: myapp/__init__.py
59+
- path: myapp/models.py
60+
content: |
61+
from __future__ import annotations
62+
from django.db import models
63+
64+
class JSONField(models.TextField): pass # incomplete
65+
66+
class Concrete(models.Model):
67+
id = models.IntegerField()
68+
data: models.Field[dict[str, str], dict[str, str]] = JSONField()
69+
5070
- case: values_list_supports_queryset_methods
5171
main: |
5272
from myapp.models import MyUser

0 commit comments

Comments
 (0)