Skip to content

Commit 2366e47

Browse files
Support related_query_name in Model._meta.get_fields (#2821)
1 parent b564029 commit 2366e47

File tree

4 files changed

+66
-21
lines changed

4 files changed

+66
-21
lines changed

mypy_django_plugin/lib/helpers.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from collections.abc import Iterable, Iterator
2-
from typing import TYPE_CHECKING, Any, Literal, NamedTuple, cast
2+
from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypedDict, cast
33

44
from django.db.models.base import Model
55
from django.db.models.fields import Field
@@ -54,7 +54,7 @@
5454
get_proper_type,
5555
)
5656
from mypy.types import Type as MypyType
57-
from typing_extensions import TypedDict
57+
from typing_extensions import Self
5858

5959
from mypy_django_plugin.lib import fullnames
6060

@@ -209,6 +209,21 @@ class DjangoModel(NamedTuple):
209209
def info(self) -> TypeInfo:
210210
return self.typ.type
211211

212+
@classmethod
213+
def from_model_type(cls, model_type: Instance, django_context: "DjangoContext") -> Self | None:
214+
model_info = model_type.type
215+
is_annotated = is_annotated_model(model_info)
216+
217+
model_cls = (
218+
django_context.get_model_class_by_fullname(model_info.bases[0].type.fullname)
219+
if is_annotated
220+
else django_context.get_model_class_by_fullname(model_info.fullname)
221+
)
222+
if model_cls is None:
223+
return None
224+
225+
return cls(cls=model_cls, typ=model_type, is_annotated=is_annotated)
226+
212227

213228
def extract_model_type_from_queryset(queryset_type: Instance, api: TypeChecker) -> Instance | None:
214229
"""Extract the django model `Instance` associated to a queryset `Instance`"""
@@ -242,18 +257,7 @@ def get_model_info_from_qs_ctx(
242257
if not (isinstance(ctx.type, Instance) and (model_type := extract_model_type_from_queryset(ctx.type, api))):
243258
return None
244259

245-
model_info = model_type.type
246-
is_annotated = is_annotated_model(model_info)
247-
248-
model_cls = (
249-
django_context.get_model_class_by_fullname(model_info.bases[0].type.fullname)
250-
if is_annotated
251-
else django_context.get_model_class_by_fullname(model_info.fullname)
252-
)
253-
if model_cls is None:
254-
return None
255-
256-
return DjangoModel(cls=model_cls, typ=model_type, is_annotated=is_annotated)
260+
return DjangoModel.from_model_type(model_type, django_context)
257261

258262

259263
def _get_class_init_type(call: CallExpr) -> CallableType | None:

mypy_django_plugin/transformers/meta.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
from django.core.exceptions import FieldDoesNotExist
12
from mypy.plugin import MethodContext
23
from mypy.types import AnyType, Instance, TypeOfAny, get_proper_type
34
from mypy.types import Type as MypyType
45

56
from mypy_django_plugin.django.context import DjangoContext, get_field_type_from_model_type_info
67
from mypy_django_plugin.lib import helpers
8+
from mypy_django_plugin.lib.helpers import DjangoModel
79

810

911
def return_proper_field_type_from_get_field(ctx: MethodContext, django_context: DjangoContext) -> MypyType:
@@ -20,5 +22,15 @@ def return_proper_field_type_from_get_field(ctx: MethodContext, django_context:
2022
if field_type is not None:
2123
return field_type
2224

23-
ctx.api.fail(f"{model_type.type.name} has no field named {field_name!r}", ctx.context)
24-
return AnyType(TypeOfAny.from_error)
25+
if (django_model := DjangoModel.from_model_type(model_type, django_context)) is None:
26+
return ctx.default_return_type
27+
28+
try:
29+
field = django_model.cls._meta.get_field(field_name)
30+
if field_info := helpers.lookup_class_typeinfo(helpers.get_typechecker_api(ctx), field.__class__):
31+
return Instance(field_info, [])
32+
except FieldDoesNotExist as e:
33+
ctx.api.fail(str(e), ctx.context)
34+
return AnyType(TypeOfAny.from_error)
35+
36+
return ctx.default_return_type

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,8 @@ ignore = [
130130

131131
[tool.ruff.lint.flake8-tidy-imports.banned-api]
132132
"_typeshed.Self".msg = "Use typing_extensions.Self (PEP 673) instead. If you type a metaclass, add a noqa"
133-
"typing.assert_type".msg = "Use typing_extensions.assert_type instead."
133+
"typing.assert_type".msg = "Only available in Python 3.11 and above. Use `typing_extensions.assert_type` instead."
134+
"typing.Self".msg = "Only available in Python 3.11 and above. Use `typing_extensions.Self` instead."
134135

135136
[tool.ruff.lint.isort]
136137
known-first-party = ["django_stubs_ext", "mypy_django_plugin"]

tests/typecheck/models/test_meta_options.yml

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,11 @@
4444
- case: get_field_with_abstract_inheritance
4545
main: |
4646
from typing_extensions import reveal_type
47-
from myapp.models import AbstractModel
48-
class MyModel(AbstractModel):
49-
pass
47+
from myapp.models import MyModel
5048
51-
MyModel._meta.get_field('field') # E: MyModel has no field named 'field' [misc]
49+
MyModel._meta.get_field('non_existant') # E: MyModel has no field named 'non_existant' [misc]
50+
51+
reveal_type(MyModel._meta.get_field('field')) # N: Revealed type is "django.contrib.postgres.fields.array.ArrayField[typing.Sequence[builtins.float | builtins.int | builtins.str] | django.db.models.expressions.Combinable, builtins.list[builtins.int]]"
5252
5353
field: str
5454
reveal_type(MyModel._meta.get_field(field)) # N: Revealed type is "django.db.models.fields.Field[Any, Any] | django.db.models.fields.reverse_related.ForeignObjectRel | django.contrib.contenttypes.fields.GenericForeignKey"
@@ -65,6 +65,34 @@
6565
class Meta(TypedModelMeta):
6666
abstract = True
6767
68+
class MyModel(AbstractModel):
69+
field = ArrayField(models.IntegerField(), default=[])
70+
71+
- case: get_field_reverse_fk_with_related_query_name
72+
main: |
73+
from typing_extensions import reveal_type
74+
from myapp.models import ModelA
75+
76+
reveal_type(ModelA._meta.get_field("model_b")) # N: Revealed type is "django.db.models.fields.reverse_related.ManyToOneRel"
77+
reveal_type(ModelA.modelb_set.field) # N: Revealed type is "django.db.models.fields.related.ForeignKey[myapp.models.ModelB, myapp.models.ModelB]"
78+
79+
reveal_type(ModelA._meta.get_field("model_b_bis")) # N: Revealed type is "django.db.models.fields.reverse_related.ManyToOneRel"
80+
reveal_type(ModelA.model_b_bis.field) # N: Revealed type is "django.db.models.fields.related.ForeignKey[myapp.models.ModelB, myapp.models.ModelB]"
81+
82+
installed_apps:
83+
- myapp
84+
files:
85+
- path: myapp/__init__.py
86+
- path: myapp/models.py
87+
content: |
88+
from django.db import models
89+
90+
class ModelA(models.Model): ...
91+
92+
class ModelB(models.Model):
93+
model_a = models.ForeignKey(ModelA, on_delete=models.CASCADE, related_query_name="model_b")
94+
model_a_bis = models.ForeignKey(ModelA, on_delete=models.CASCADE, related_name="model_b_bis")
95+
6896
- case: base_model_meta_incompatible_types
6997
main: |
7098
from django.db import models

0 commit comments

Comments
 (0)