Skip to content

Add explicit overload to Field subclasses #1900

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions django-stubs/contrib/auth/models.pyi
Original file line number Diff line number Diff line change
@@ -8,6 +8,7 @@ from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.db.models import QuerySet
from django.db.models.base import Model
from django.db.models.expressions import Combinable
from django.db.models.manager import EmptyManager
from django.utils.functional import _StrOrPromise
from typing_extensions import Self, TypeAlias
@@ -23,9 +24,9 @@ class Permission(models.Model):
content_type_id: int
objects: ClassVar[PermissionManager]

name = models.CharField(max_length=255)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
codename = models.CharField(max_length=100)
Comment on lines -26 to -28
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having explicit assignments feels a bit weird in a stub file and was causing issues when testing without the plugin (had error code var-annotated)

name: models.CharField[str | int | Combinable, str]
content_type: models.ForeignKey[ContentType | Combinable, ContentType]
codename: models.CharField[str | int | Combinable, str]
def natural_key(self) -> tuple[str, str, str]: ...

class GroupManager(models.Manager[Group]):
@@ -34,7 +35,7 @@ class GroupManager(models.Manager[Group]):
class Group(models.Model):
objects: ClassVar[GroupManager]

name = models.CharField(max_length=150)
name: models.CharField[str | int | Combinable, str]
permissions = models.ManyToManyField(Permission)
Copy link
Contributor Author

@Viicos Viicos Feb 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

... however I can't explicitly annotate permissions: models.ManyToManyField[..., ...] as the second type var is solved by the plugin to a class that doesn't exist in stubs nor at runtime

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

def natural_key(self) -> tuple[str]: ...

1,637 changes: 1,451 additions & 186 deletions django-stubs/db/models/fields/__init__.pyi

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions django-stubs/db/models/fields/json.pyi
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import json
from collections.abc import Callable
from typing import Any, ClassVar, TypeVar
from typing import Any, ClassVar

from django.db.backends.base.base import BaseDatabaseWrapper
from django.db.models import lookups
@@ -9,7 +9,7 @@ from django.db.models.fields import TextField
from django.db.models.lookups import PostgresOperatorLookup, Transform
from django.db.models.sql.compiler import SQLCompiler
from django.utils.functional import _StrOrPromise
from typing_extensions import Self
from typing_extensions import Self, TypeVar

from . import Field
from .mixins import CheckFieldDefaultMixin
38 changes: 15 additions & 23 deletions mypy_django_plugin/transformers/fields.py
Original file line number Diff line number Diff line change
@@ -7,13 +7,13 @@
from mypy.maptype import map_instance_to_supertype
from mypy.nodes import AssignmentStmt, NameExpr, TypeInfo
from mypy.plugin import FunctionContext
from mypy.types import AnyType, Instance, NoneType, ProperType, TypeOfAny, UninhabitedType, UnionType
from mypy.types import AnyType, Instance, ProperType, TypeOfAny, UninhabitedType, UnionType
from mypy.types import Type as MypyType

from mypy_django_plugin.django.context import DjangoContext
from mypy_django_plugin.exceptions import UnregisteredModelError
from mypy_django_plugin.lib import fullnames, helpers
from mypy_django_plugin.lib.helpers import parse_bool
from mypy_django_plugin.lib.helpers import make_optional, parse_bool
from mypy_django_plugin.transformers import manytomany

if TYPE_CHECKING:
@@ -140,35 +140,27 @@ def set_descriptor_types_for_field(
null_expr = helpers.get_call_argument_by_name(ctx, "null")
if null_expr is not None:
is_nullable = parse_bool(null_expr) or False

# Allow setting field value to `None` when a field is primary key and has a default that can produce a value
default_expr = helpers.get_call_argument_by_name(ctx, "default")
primary_key_expr = helpers.get_call_argument_by_name(ctx, "primary_key")
if default_expr is not None and primary_key_expr is not None:
is_set_nullable = parse_bool(primary_key_expr) or False

set_type, get_type = get_field_descriptor_types(
default_return_type.type,
is_set_nullable=is_set_nullable or is_nullable,
is_get_nullable=is_get_nullable or is_nullable,
)

# reconcile set and get types with the base field class
base_field_type = next(base for base in default_return_type.type.mro if base.fullname == fullnames.FIELD_FULLNAME)
mapped_instance = map_instance_to_supertype(default_return_type, base_field_type)
mapped_set_type, mapped_get_type = mapped_instance.args

# bail if either mapped_set_type or mapped_get_type have type Never
if not (isinstance(mapped_set_type, UninhabitedType) or isinstance(mapped_get_type, UninhabitedType)):
# always replace set_type and get_type with (non-Any) mapped types
set_type = helpers.convert_any_to_type(mapped_set_type, set_type)
get_type = helpers.convert_any_to_type(mapped_get_type, get_type)

# the get_type must be optional if the field is nullable
if (is_get_nullable or is_nullable) and not (isinstance(get_type, NoneType) or helpers.is_optional(get_type)):
ctx.api.fail(
f"{default_return_type.type.name} is nullable but its generic get type parameter is not optional",
ctx.context,
)
set_type, get_type = mapped_instance.args

# If the base class wasn't explicitly parametrized, assume `Any`:
if isinstance(set_type, UninhabitedType):
set_type = AnyType(TypeOfAny.from_omitted_generics)
if isinstance(get_type, UninhabitedType):
get_type = AnyType(TypeOfAny.from_omitted_generics)

if is_set_nullable or is_nullable:
set_type = make_optional(set_type)
if is_get_nullable or is_nullable:
get_type = make_optional(get_type)

return helpers.reparametrize_instance(default_return_type, [set_type, get_type])

5 changes: 5 additions & 0 deletions scripts/stubtest/allowlist.txt
Original file line number Diff line number Diff line change
@@ -411,3 +411,8 @@ django.contrib.sessions.base_session.AbstractBaseSession.Meta
# Custom __str__ that we don't want to overcomplicate:
django.forms.utils.RenderableMixin.__str__
django.forms.utils.RenderableMixin.__html__

# Keyword only in stubs
django.db.models.(\w*)Field.__init__
django.db.models.fields.(\w*)Field.__init__
django.contrib.gis.db.models.(\w*)Field.__init__
22 changes: 22 additions & 0 deletions tests/typecheck/fields/test_base.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
- case: test_model_fields_classes_present_as_primitives
parametrized:
- mypy_section: |
[mypy]
plugins =
mypy_django_plugin.main
- mypy_section: |
[mypy]
disable_error_code = var-annotated
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...So I had to disable the error code anyway when testing without the plugin

plugins =
mypy_config: |
{{ mypy_section }}
main: |
from myapp.models import User
user = User(small_int=1, name='user', slug='user', text='user')
@@ -75,6 +86,17 @@
my_pk = models.IntegerField(primary_key=True)
- case: blank_and_null_char_field_allows_none
parametrized:
- mypy_section: |
[mypy]
plugins =
mypy_django_plugin.main
- mypy_section: |
[mypy]
disable_error_code = var-annotated
plugins =
mypy_config: |
{{ mypy_section }}
main: |
from myapp.models import MyModel
MyModel(nulltext="")
32 changes: 13 additions & 19 deletions tests/typecheck/fields/test_custom_fields.yml
Original file line number Diff line number Diff line change
@@ -7,17 +7,15 @@
reveal_type(user.my_custom_field2) # N: Revealed type is "myapp.models.CustomFieldValue"
reveal_type(user.my_custom_field3) # N: Revealed type is "builtins.bool"
reveal_type(user.my_custom_field4) # N: Revealed type is "myapp.models.CustomFieldValue"
reveal_type(user.my_custom_field5) # N: Revealed type is "myapp.models.CustomFieldValue"
reveal_type(user.my_custom_field6) # N: Revealed type is "myapp.models.CustomFieldValue"
reveal_type(user.my_custom_field7) # N: Revealed type is "builtins.bool"
reveal_type(user.my_custom_field8) # N: Revealed type is "myapp.models.CustomFieldValue"
reveal_type(user.my_custom_field9) # N: Revealed type is "myapp.models.CustomFieldValue"
reveal_type(user.my_custom_field10) # N: Revealed type is "builtins.bool"
reveal_type(user.my_custom_field11) # N: Revealed type is "builtins.bool"
reveal_type(user.my_custom_field12) # N: Revealed type is "Union[myapp.models.CustomFieldValue, None]"
reveal_type(user.my_custom_field13) # N: Revealed type is "Union[myapp.models.CustomFieldValue, None]"
reveal_type(user.my_custom_field14) # N: Revealed type is "Union[builtins.bool, None]"
reveal_type(user.my_custom_field15) # N: Revealed type is "None"
reveal_type(user.my_custom_field5) # N: Revealed type is "Union[myapp.models.CustomFieldValue, None]"
reveal_type(user.my_custom_field6) # N: Revealed type is "Union[myapp.models.CustomFieldValue, None]"
reveal_type(user.my_custom_field7) # N: Revealed type is "Union[builtins.bool, None]"
reveal_type(user.my_custom_field8) # N: Revealed type is "Union[myapp.models.CustomFieldValue, None]"
reveal_type(user.my_custom_field9) # N: Revealed type is "Union[myapp.models.CustomFieldValue, None]"
reveal_type(user.my_custom_field10) # N: Revealed type is "Union[myapp.models.CustomFieldValue, None]"
reveal_type(user.my_custom_field11) # N: Revealed type is "Union[myapp.models.CustomFieldValue, None]"
reveal_type(user.my_custom_field12) # N: Revealed type is "Union[builtins.bool, None]"
reveal_type(user.my_custom_field13) # N: Revealed type is "None"
monkeypatch: true
out: |
myapp/models:31: error: GenericField is nullable but its generic get type parameter is not optional [misc]
@@ -67,12 +65,8 @@
my_custom_field8 = AdditionalTypeVarField[Union[CustomFieldValue, int], CustomFieldValue, bool](null=True)
my_custom_field9 = fields.Field[Union[CustomFieldValue, int], CustomFieldValue](null=True)
# test overriding fields that set _pyi_private_set_type or _pyi_private_get_type
my_custom_field10 = fields.SmallIntegerField[bool, bool]()
my_custom_field11 = CustomSmallIntegerField[bool, bool]()
# test null=True on fields with non-optional generic types throw no errors
my_custom_field12 = fields.Field[Union[CustomFieldValue, int], Union[CustomFieldValue, None]](null=True)
my_custom_field13 = GenericField[Union[CustomFieldValue, int], Union[CustomFieldValue, None]](null=True)
my_custom_field14 = SingleTypeField[Union[bool, None]](null=True)
my_custom_field15 = fields.Field[None, None](null=True)
my_custom_field10 = fields.Field[Union[CustomFieldValue, int], Union[CustomFieldValue, None]](null=True)
my_custom_field11 = GenericField[Union[CustomFieldValue, int], Union[CustomFieldValue, None]](null=True)
my_custom_field12 = SingleTypeField[Union[bool, None]](null=True)
my_custom_field13 = fields.Field[None, None](null=True)