Repository: typeddjango/django-stubs

Issue:
Field[Any, Any]" has no attribute "on_delete" in test file
# Bug report

<!--
Hi, thanks for submitting a bug. We appreciate that.

But, we will need some information about what's wrong to help you.
-->

## What's wrong
while running mypy for the field `ForeignKey`
I got this error :
```console
error: "Field[Any, Any]" has no attribute "on_delete"                                                                                                                     
Found 1 error in 1 file (checked 3 source files)
```

the field:
```python
manager = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        verbose_name=_("Manager assigned"),
        null=True,
    )
```

the test:
```python
def test_foreignkey_manager(self) -> None:
        manager_field = ExperienceLog._meta.get_field("manager")
        self.assertIsInstance(manager_field, models.ForeignKey)
        self.assertEqual(manager_field.related_model, User)
        self.assertEqual(manager_field.remote_field.on_delete, models.SET_NULL)
```

I was able to resolve it by casting the related field with its type:
```python
from typing import cast
from django.db.models.fields import reverse_related

    def test_foreignkey_manager(self) -> None:
        manager_field = ExperienceLog._meta.get_field("manager")
        self.assertIsInstance(manager_field, models.ForeignKey)
        self.assertEqual(manager_field.related_model, User)
        casted_field = cast(reverse_related.ManyToOneRel, manager_field.remote_field)
        self.assertEqual(casted_field.on_delete, models.SET_NULL)
```

## How is that should be

this error should not happen and I should not cast the related field to resolve it

## System information

- OS: Linux
- `python` version: 3.8
- `django` version: 3.0.8
- `mypy` version: 0.770
- `django-stubs` version: 1.5.0



Discussion:

Patch:
diff --git a/mypy_django_plugin/django/context.py b/mypy_django_plugin/django/context.py
index a41796038..2e7967c34 100644
--- a/mypy_django_plugin/django/context.py
+++ b/mypy_django_plugin/django/context.py
@@ -78,7 +78,7 @@ class LookupsAreUnsupported(Exception):
     pass
 
 
-def _get_field_type_from_model_type_info(info: TypeInfo | None, field_name: str) -> Instance | None:
+def get_field_type_from_model_type_info(info: TypeInfo | None, field_name: str) -> Instance | None:
     if info is None:
         return None
     field_node = info.get(field_name)
@@ -95,7 +95,7 @@ def _get_field_type_from_model_type_info(info: TypeInfo | None, field_name: str)
 
 
 def _get_field_set_type_from_model_type_info(info: TypeInfo | None, field_name: str) -> MypyType | None:
-    field_type = _get_field_type_from_model_type_info(info, field_name)
+    field_type = get_field_type_from_model_type_info(info, field_name)
     if field_type is not None:
         return field_type.args[0]
     else:
@@ -103,7 +103,7 @@ def _get_field_set_type_from_model_type_info(info: TypeInfo | None, field_name:
 
 
 def _get_field_get_type_from_model_type_info(info: TypeInfo | None, field_name: str) -> MypyType | None:
-    field_type = _get_field_type_from_model_type_info(info, field_name)
+    field_type = get_field_type_from_model_type_info(info, field_name)
     if field_type is not None:
         return field_type.args[1]
     else:
diff --git a/mypy_django_plugin/transformers/meta.py b/mypy_django_plugin/transformers/meta.py
index ad7e49a2a..e97ccfa80 100644
--- a/mypy_django_plugin/transformers/meta.py
+++ b/mypy_django_plugin/transformers/meta.py
@@ -1,19 +1,11 @@
-from django.core.exceptions import FieldDoesNotExist
 from mypy.plugin import MethodContext
 from mypy.types import AnyType, Instance, TypeOfAny, get_proper_type
 from mypy.types import Type as MypyType
 
-from mypy_django_plugin.django.context import DjangoContext
+from mypy_django_plugin.django.context import DjangoContext, get_field_type_from_model_type_info
 from mypy_django_plugin.lib import helpers
 
 
-def _get_field_instance(ctx: MethodContext, field_fullname: str) -> MypyType:
-    field_info = helpers.lookup_fully_qualified_typeinfo(helpers.get_typechecker_api(ctx), field_fullname)
-    if field_info is None:
-        return AnyType(TypeOfAny.unannotated)
-    return Instance(field_info, [AnyType(TypeOfAny.explicit), AnyType(TypeOfAny.explicit)])
-
-
 def return_proper_field_type_from_get_field(ctx: MethodContext, django_context: DjangoContext) -> MypyType:
     # Options instance
     assert isinstance(ctx.type, Instance)
@@ -26,10 +18,6 @@ def return_proper_field_type_from_get_field(ctx: MethodContext, django_context:
     if not isinstance(model_type, Instance):
         return ctx.default_return_type
 
-    model_cls = django_context.get_model_class_by_fullname(model_type.type.fullname)
-    if model_cls is None:
-        return ctx.default_return_type
-
     field_name_expr = helpers.get_call_argument_by_name(ctx, "field_name")
     if field_name_expr is None:
         return ctx.default_return_type
@@ -38,13 +26,9 @@ def return_proper_field_type_from_get_field(ctx: MethodContext, django_context:
     if field_name is None:
         return ctx.default_return_type
 
-    try:
-        field = model_cls._meta.get_field(field_name)
-    except FieldDoesNotExist as exc:
-        # if model is abstract, do not raise exception, skip false positives
-        if not model_cls._meta.abstract:
-            ctx.api.fail(exc.args[0], ctx.context)
-        return AnyType(TypeOfAny.from_error)
+    field_type = get_field_type_from_model_type_info(model_type.type, field_name)
+    if field_type is not None:
+        return field_type
 
-    field_fullname = helpers.get_class_fullname(field.__class__)
-    return _get_field_instance(ctx, field_fullname)
+    ctx.api.fail(f"{model_type.type.name} has no field named {field_name!r}", ctx.context)
+    return AnyType(TypeOfAny.from_error)


Test patch:
diff --git a/tests/typecheck/models/test_meta_options.yml b/tests/typecheck/models/test_meta_options.yml
index 354035523..b58e3c9e3 100644
--- a/tests/typecheck/models/test_meta_options.yml
+++ b/tests/typecheck/models/test_meta_options.yml
@@ -15,10 +15,14 @@
 -   case: get_field_returns_proper_field_type
     main: |
         from myapp.models import MyUser
-        reveal_type(MyUser._meta.get_field('base_name'))  # N: Revealed type is "django.db.models.fields.CharField[Any, Any]"
-        reveal_type(MyUser._meta.get_field('name'))  # N: Revealed type is "django.db.models.fields.CharField[Any, Any]"
-        reveal_type(MyUser._meta.get_field('age'))  # N: Revealed type is "django.db.models.fields.IntegerField[Any, Any]"
-        reveal_type(MyUser._meta.get_field('to_user'))  # N: Revealed type is "django.db.models.fields.related.ForeignKey[Any, Any]"
+        reveal_type(MyUser._meta.get_field('base_name'))  # N: Revealed type is "django.db.models.fields.CharField[builtins.str | builtins.int | django.db.models.expressions.Combinable, builtins.str]"
+        reveal_type(MyUser.base_name.field)  # N: Revealed type is "django.db.models.fields.CharField[builtins.str | builtins.int | django.db.models.expressions.Combinable, builtins.str]"
+        reveal_type(MyUser._meta.get_field('name'))  # N: Revealed type is "django.db.models.fields.CharField[builtins.str | builtins.int | django.db.models.expressions.Combinable, builtins.str]"
+        reveal_type(MyUser.name.field)  # N: Revealed type is "django.db.models.fields.CharField[builtins.str | builtins.int | django.db.models.expressions.Combinable, builtins.str]"
+        reveal_type(MyUser._meta.get_field('age'))  # N: Revealed type is "django.db.models.fields.IntegerField[builtins.float | builtins.int | builtins.str | django.db.models.expressions.Combinable, builtins.int]"
+        reveal_type(MyUser.age.field)  # N: Revealed type is "django.db.models.fields.IntegerField[builtins.float | builtins.int | builtins.str | django.db.models.expressions.Combinable, builtins.int]"
+        reveal_type(MyUser._meta.get_field('to_user'))  # N: Revealed type is "django.db.models.fields.related.ForeignKey[myapp.models.MyUser | django.db.models.expressions.Combinable, myapp.models.MyUser]"
+        reveal_type(MyUser.to_user.field)  # N: Revealed type is "django.db.models.fields.related.ForeignKey[myapp.models.MyUser | django.db.models.expressions.Combinable, myapp.models.MyUser]"
 
         MyUser._meta.get_field('unknown')  # E: MyUser has no field named 'unknown'  [misc]
     installed_apps:
@@ -40,7 +44,11 @@
         from myapp.models import AbstractModel
         class MyModel(AbstractModel):
             pass
-        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"
+
+        MyModel._meta.get_field('field')  # E: MyModel has no field named 'field'  [misc]
+
+        field: str
+        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"
     installed_apps:
         - myapp
     files:
@@ -53,8 +61,7 @@
                 class AbstractModel(models.Model):
                     class Meta(TypedModelMeta):
                         abstract = True
-                class MyModel(AbstractModel):
-                    field = ArrayField(models.IntegerField(), default=[])
+
 -   case: base_model_meta_incompatible_types
     main: |
         from django.db import models
@@ -90,8 +97,10 @@
             content: |
                 from django.db import models
                 class AbstractModel(models.Model):
+                    base = models.TextField()
+
                     class Meta:
                         abstract = True
-                    base = models.TextField()
+
                 class MyModel(AbstractModel):
                     field = models.IntegerField()