Skip to content

Commit 813458e

Browse files
authored
Use field generic types for descriptors (#2048)
* Use field generic types for descriptors * Fix type annotations for older python versions * Enforce get type is optional when field is nullable and don't implicitly convert to optional * Fix imports and add field name to error message
1 parent 9ef08a6 commit 813458e

File tree

3 files changed

+103
-1
lines changed

3 files changed

+103
-1
lines changed

mypy_django_plugin/lib/helpers.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,10 @@ def make_optional(typ: MypyType) -> MypyType:
204204
return UnionType.make_union([typ, NoneTyp()])
205205

206206

207+
def is_optional(typ: MypyType) -> bool:
208+
return isinstance(typ, UnionType) and any(isinstance(item, NoneTyp) for item in typ.items)
209+
210+
207211
# Duplicating mypy.semanal_shared.parse_bool because importing it directly caused ImportError (#1784)
208212
def parse_bool(expr: Expression) -> Optional[bool]:
209213
if isinstance(expr, NameExpr):

mypy_django_plugin/transformers/fields.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
from django.db.models.fields import AutoField, Field
55
from django.db.models.fields.related import RelatedField
66
from django.db.models.fields.reverse_related import ForeignObjectRel
7+
from mypy.maptype import map_instance_to_supertype
78
from mypy.nodes import AssignmentStmt, NameExpr, TypeInfo
89
from mypy.plugin import FunctionContext
9-
from mypy.types import AnyType, Instance, ProperType, TypeOfAny, UnionType
10+
from mypy.types import AnyType, Instance, NoneType, ProperType, TypeOfAny, UninhabitedType, UnionType
1011
from mypy.types import Type as MypyType
1112

1213
from mypy_django_plugin.django.context import DjangoContext
@@ -150,6 +151,25 @@ def set_descriptor_types_for_field(
150151
is_set_nullable=is_set_nullable or is_nullable,
151152
is_get_nullable=is_get_nullable or is_nullable,
152153
)
154+
155+
# reconcile set and get types with the base field class
156+
base_field_type = next(base for base in default_return_type.type.mro if base.fullname == fullnames.FIELD_FULLNAME)
157+
mapped_instance = map_instance_to_supertype(default_return_type, base_field_type)
158+
mapped_set_type, mapped_get_type = mapped_instance.args
159+
160+
# bail if either mapped_set_type or mapped_get_type have type Never
161+
if not (isinstance(mapped_set_type, UninhabitedType) or isinstance(mapped_get_type, UninhabitedType)):
162+
# always replace set_type and get_type with (non-Any) mapped types
163+
set_type = helpers.convert_any_to_type(mapped_set_type, set_type)
164+
get_type = helpers.convert_any_to_type(mapped_get_type, get_type)
165+
166+
# the get_type must be optional if the field is nullable
167+
if (is_get_nullable or is_nullable) and not (isinstance(get_type, NoneType) or helpers.is_optional(get_type)):
168+
ctx.api.fail(
169+
f"{default_return_type.type.name} is nullable but its generic get type parameter is not optional",
170+
ctx.context,
171+
)
172+
153173
return helpers.reparametrize_instance(default_return_type, [set_type, get_type])
154174

155175

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
- case: test_custom_model_fields_with_generic_type
2+
main: |
3+
from myapp.models import User, CustomFieldValue
4+
user = User()
5+
reveal_type(user.id) # N: Revealed type is "builtins.int"
6+
reveal_type(user.my_custom_field1) # N: Revealed type is "myapp.models.CustomFieldValue"
7+
reveal_type(user.my_custom_field2) # N: Revealed type is "myapp.models.CustomFieldValue"
8+
reveal_type(user.my_custom_field3) # N: Revealed type is "builtins.bool"
9+
reveal_type(user.my_custom_field4) # N: Revealed type is "myapp.models.CustomFieldValue"
10+
reveal_type(user.my_custom_field5) # N: Revealed type is "myapp.models.CustomFieldValue"
11+
reveal_type(user.my_custom_field6) # N: Revealed type is "myapp.models.CustomFieldValue"
12+
reveal_type(user.my_custom_field7) # N: Revealed type is "builtins.bool"
13+
reveal_type(user.my_custom_field8) # N: Revealed type is "myapp.models.CustomFieldValue"
14+
reveal_type(user.my_custom_field9) # N: Revealed type is "myapp.models.CustomFieldValue"
15+
reveal_type(user.my_custom_field10) # N: Revealed type is "builtins.bool"
16+
reveal_type(user.my_custom_field11) # N: Revealed type is "builtins.bool"
17+
reveal_type(user.my_custom_field12) # N: Revealed type is "Union[myapp.models.CustomFieldValue, None]"
18+
reveal_type(user.my_custom_field13) # N: Revealed type is "Union[myapp.models.CustomFieldValue, None]"
19+
reveal_type(user.my_custom_field14) # N: Revealed type is "Union[builtins.bool, None]"
20+
reveal_type(user.my_custom_field15) # N: Revealed type is "None"
21+
monkeypatch: true
22+
out: |
23+
myapp/models:31: error: GenericField is nullable but its generic get type parameter is not optional [misc]
24+
myapp/models:32: error: CustomValueField is nullable but its generic get type parameter is not optional [misc]
25+
myapp/models:33: error: SingleTypeField is nullable but its generic get type parameter is not optional [misc]
26+
myapp/models:34: error: AdditionalTypeVarField is nullable but its generic get type parameter is not optional [misc]
27+
myapp/models:35: error: Field is nullable but its generic get type parameter is not optional [misc]
28+
installed_apps:
29+
- myapp
30+
files:
31+
- path: myapp/__init__.py
32+
- path: myapp/models.py
33+
content: |
34+
from django.db import models
35+
from django.db.models import fields
36+
37+
from typing import Any, TypeVar, Generic, Union
38+
39+
_ST = TypeVar("_ST", contravariant=True)
40+
_GT = TypeVar("_GT", covariant=True)
41+
42+
T = TypeVar("T")
43+
44+
class CustomFieldValue: ...
45+
46+
class GenericField(fields.Field[_ST, _GT]): ...
47+
48+
class SingleTypeField(fields.Field[T, T]): ...
49+
50+
class CustomValueField(fields.Field[Union[CustomFieldValue, int], CustomFieldValue]): ...
51+
52+
class AdditionalTypeVarField(fields.Field[_ST, _GT], Generic[_ST, _GT, T]): ...
53+
54+
class CustomSmallIntegerField(fields.SmallIntegerField[_ST, _GT]): ...
55+
56+
class User(models.Model):
57+
id = models.AutoField(primary_key=True)
58+
my_custom_field1 = GenericField[Union[CustomFieldValue, int], CustomFieldValue]()
59+
my_custom_field2 = CustomValueField()
60+
my_custom_field3 = SingleTypeField[bool]()
61+
my_custom_field4 = AdditionalTypeVarField[Union[CustomFieldValue, int], CustomFieldValue, bool]()
62+
63+
# test null=True on fields with non-optional generic types throw error
64+
my_custom_field5 = GenericField[Union[CustomFieldValue, int], CustomFieldValue](null=True)
65+
my_custom_field6 = CustomValueField(null=True)
66+
my_custom_field7 = SingleTypeField[bool](null=True)
67+
my_custom_field8 = AdditionalTypeVarField[Union[CustomFieldValue, int], CustomFieldValue, bool](null=True)
68+
my_custom_field9 = fields.Field[Union[CustomFieldValue, int], CustomFieldValue](null=True)
69+
70+
# test overriding fields that set _pyi_private_set_type or _pyi_private_get_type
71+
my_custom_field10 = fields.SmallIntegerField[bool, bool]()
72+
my_custom_field11 = CustomSmallIntegerField[bool, bool]()
73+
74+
# test null=True on fields with non-optional generic types throw no errors
75+
my_custom_field12 = fields.Field[Union[CustomFieldValue, int], Union[CustomFieldValue, None]](null=True)
76+
my_custom_field13 = GenericField[Union[CustomFieldValue, int], Union[CustomFieldValue, None]](null=True)
77+
my_custom_field14 = SingleTypeField[Union[bool, None]](null=True)
78+
my_custom_field15 = fields.Field[None, None](null=True)

0 commit comments

Comments
 (0)