Skip to content

Commit a3f2a55

Browse files
authored
Improve typing of choices (#2582)
When `__empty__` is defined, the `.choices` and `.values` properties will add a value for the blank choice, so we expect that `None` should be part of the return type for the value, e.g. `list[str | None]` instead of `list[str]` for `.values` and for `.choices` we expect `list[tuple[str | None, _StrOrPromise]` instead of `list[tuple[str, `_StrOrPromise]]`. This is too dynamic for the stubs and so requires some tweaks in the plugin. * Fixed typing of `Choices` for custom mixed-in type It's easy enough to get decent types for `IntegerChoices` and `TextChoices` in the stubs because of subclassing `IntEnum` and `StrEnum`, as well as having a dedicated base enum type. But for custom choices with mixed-in base types, it's not possible to get sensible typing as enums cannot be generic. As such, the type of the value has to be `Any`. In the plugin, we can find the base type and use that instead.
1 parent 4debb57 commit a3f2a55

File tree

7 files changed

+276
-76
lines changed

7 files changed

+276
-76
lines changed

django-stubs/db/models/enums.pyi

Lines changed: 27 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,83 @@
11
import enum
22
import sys
3-
from typing import Any, TypeVar, overload, type_check_only
3+
from typing import Any, Literal, TypeVar, overload, type_check_only
44

55
from _typeshed import ConvertibleToInt
66
from django.utils.functional import _StrOrPromise
77
from typing_extensions import deprecated
88

9-
_Self = TypeVar("_Self")
10-
119
if sys.version_info >= (3, 11):
12-
_enum_property = enum.property
13-
EnumType = enum.EnumType
14-
IntEnum = enum.IntEnum
15-
StrEnum = enum.StrEnum
10+
from enum import EnumType, IntEnum, StrEnum
11+
from enum import property as enum_property
1612
else:
17-
_enum_property = property
18-
EnumType = enum.EnumMeta
13+
from enum import EnumMeta as EnumType
14+
from types import DynamicClassAttribute as enum_property
1915

2016
class ReprEnum(enum.Enum): ... # type: ignore[misc]
2117
class IntEnum(int, ReprEnum): ... # type: ignore[misc]
2218
class StrEnum(str, ReprEnum): ... # type: ignore[misc]
2319

20+
_Self = TypeVar("_Self", bound=ChoicesType)
21+
2422
class ChoicesType(EnumType):
25-
# There's a contradiction between mypy and PYI019 regarding metaclasses. Where mypy
26-
# disallows 'typing_extensions.Self' on metaclasses, while PYI019 try to enforce
27-
# 'typing_extensions.Self' for '__new__' methods.. We've chosen to ignore the
28-
# linter and trust mypy.
29-
def __new__( # noqa: PYI019
23+
__empty__: _StrOrPromise
24+
def __new__(
3025
metacls: type[_Self], classname: str, bases: tuple[type, ...], classdict: enum._EnumDict, **kwds: Any
3126
) -> _Self: ...
32-
def __contains__(self, member: Any) -> bool: ...
3327
@property
3428
def names(self) -> list[str]: ...
3529
@property
36-
def choices(self) -> list[tuple[Any, str]]: ...
30+
def choices(self) -> list[tuple[Any, _StrOrPromise]]: ...
3731
@property
38-
def labels(self) -> list[str]: ...
32+
def labels(self) -> list[_StrOrPromise]: ...
3933
@property
4034
def values(self) -> list[Any]: ...
35+
if sys.version_info < (3, 12):
36+
def __contains__(self, member: Any) -> bool: ...
4137

4238
@deprecated("ChoicesMeta is deprecated in favor of ChoicesType and will be removed in Django 6.0.")
4339
class ChoicesMeta(ChoicesType): ...
4440

4541
class Choices(enum.Enum, metaclass=ChoicesType): # type: ignore[misc]
46-
@property
47-
def label(self) -> str: ...
48-
@_enum_property
42+
_label_: _StrOrPromise
43+
do_not_call_in_templates: Literal[True]
44+
45+
@enum_property
46+
def label(self) -> _StrOrPromise: ...
47+
@enum_property
4948
def value(self) -> Any: ...
50-
@property
51-
def do_not_call_in_templates(self) -> bool: ...
5249

5350
# fake, to keep simulate class properties
5451
@type_check_only
55-
class _IntegerChoicesMeta(ChoicesType):
52+
class _IntegerChoicesType(ChoicesType):
5653
@property
57-
def choices(self) -> list[tuple[int, str]]: ...
54+
def choices(self) -> list[tuple[int, _StrOrPromise]]: ...
5855
@property
5956
def values(self) -> list[int]: ...
6057

6158
# In reality, the `__init__` overloads provided below should also support
6259
# all the arguments of `int.__new__`/`str.__new__` (e.g. `base`, `encoding`).
6360
# They are omitted on purpose to avoid having convoluted stubs for these enums:
64-
class IntegerChoices(Choices, IntEnum, metaclass=_IntegerChoicesMeta): # type: ignore[misc]
61+
class IntegerChoices(Choices, IntEnum, metaclass=_IntegerChoicesType): # type: ignore[misc]
6562
@overload
6663
def __init__(self, x: ConvertibleToInt) -> None: ...
6764
@overload
6865
def __init__(self, x: ConvertibleToInt, label: _StrOrPromise) -> None: ...
69-
@_enum_property
66+
@enum_property
7067
def value(self) -> int: ...
7168

7269
# fake, to keep simulate class properties
7370
@type_check_only
74-
class _TextChoicesMeta(ChoicesType):
71+
class _TextChoicesType(ChoicesType):
7572
@property
76-
def choices(self) -> list[tuple[str, str]]: ...
73+
def choices(self) -> list[tuple[str, _StrOrPromise]]: ...
7774
@property
7875
def values(self) -> list[str]: ...
7976

80-
class TextChoices(Choices, StrEnum, metaclass=_TextChoicesMeta): # type: ignore[misc]
77+
class TextChoices(Choices, StrEnum, metaclass=_TextChoicesType): # type: ignore[misc]
8178
@overload
8279
def __init__(self, object: str) -> None: ...
8380
@overload
8481
def __init__(self, object: str, label: _StrOrPromise) -> None: ...
85-
@_enum_property
82+
@enum_property
8683
def value(self) -> str: ...

mypy_django_plugin/lib/fullnames.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
MANAGER_CLASS_FULLNAME = "django.db.models.manager.Manager"
1818
RELATED_MANAGER_CLASS = "django.db.models.fields.related_descriptors.RelatedManager"
1919

20+
CHOICES_CLASS_FULLNAME = "django.db.models.enums.Choices"
21+
CHOICES_TYPE_METACLASS_FULLNAME = "django.db.models.enums.ChoicesType"
22+
2023
WITH_ANNOTATIONS_FULLNAME = "django_stubs_ext.annotations.WithAnnotations"
2124
ANNOTATIONS_FULLNAME = "django_stubs_ext.annotations.Annotations"
2225

mypy_django_plugin/main.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from mypy_django_plugin.exceptions import UnregisteredModelError
2525
from mypy_django_plugin.lib import fullnames, helpers
2626
from mypy_django_plugin.transformers import (
27+
choices,
2728
fields,
2829
forms,
2930
init_create,
@@ -275,6 +276,12 @@ def get_attribute_hook(self, fullname: str) -> Optional[Callable[[AttributeConte
275276
if info and info.has_base(fullnames.STR_PROMISE_FULLNAME):
276277
return resolve_str_promise_attribute
277278

279+
if info and info.has_base(fullnames.CHOICES_TYPE_METACLASS_FULLNAME) and attr_name in ("choices", "values"):
280+
return choices.transform_into_proper_attr_type
281+
282+
if info and info.has_base(fullnames.CHOICES_CLASS_FULLNAME) and attr_name == "value":
283+
return choices.transform_into_proper_attr_type
284+
278285
return None
279286

280287
def get_type_analyze_hook(self, fullname: str) -> Optional[Callable[[AnalyzeTypeContext], MypyType]]:
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
from mypy.nodes import MemberExpr, NameExpr, TypeInfo
2+
from mypy.plugin import AttributeContext
3+
from mypy.typeanal import make_optional_type
4+
from mypy.types import AnyType, Instance, TupleType, TypeOfAny, get_proper_type
5+
from mypy.types import Type as MypyType
6+
7+
from mypy_django_plugin.lib import fullnames, helpers
8+
9+
10+
def transform_into_proper_attr_type(ctx: AttributeContext) -> MypyType:
11+
"""
12+
A `get_attribute_hook` to make `.choices` and `.values` optional if `__empty__` is defined.
13+
14+
When the `__empty__` label is specified, the `choices` and `values` properties can return a
15+
blank choice/value. This hook will amend the type returned by those properties.
16+
"""
17+
if isinstance(ctx.context, MemberExpr):
18+
method_name = ctx.context.name
19+
else:
20+
ctx.api.fail("Unable to resolve type of choices property", ctx.context)
21+
return AnyType(TypeOfAny.from_error)
22+
23+
expr = ctx.context.expr
24+
25+
if isinstance(expr, MemberExpr):
26+
expr = expr.expr
27+
28+
if isinstance(expr, NameExpr) and isinstance(expr.node, TypeInfo):
29+
node = expr.node
30+
else:
31+
ctx.api.fail("Unable to resolve type of choices property", ctx.context)
32+
return AnyType(TypeOfAny.from_error)
33+
34+
default_attr_type = get_proper_type(ctx.default_attr_type)
35+
36+
if not node.is_enum or not node.has_base(fullnames.CHOICES_CLASS_FULLNAME):
37+
return default_attr_type
38+
39+
# Enums with more than one base will treat the first base as the mixed-in type.
40+
base_type = node.bases[0] if len(node.bases) > 1 else None
41+
42+
# When `__empty__` is defined, the `.choices` and `.values` properties will include `None` for
43+
# the blank choice which is labelled by the value of `__empty__`.
44+
has_blank_choice = node.get("__empty__") is not None
45+
46+
if (
47+
method_name == "choices"
48+
and isinstance(default_attr_type, Instance)
49+
and default_attr_type.type.fullname == "builtins.list"
50+
and len(default_attr_type.args) == 1
51+
):
52+
choice_arg = get_proper_type(default_attr_type.args[0])
53+
54+
if isinstance(choice_arg, TupleType) and choice_arg.length() == 2:
55+
value_arg, label_arg = choice_arg.items
56+
value_arg = get_proper_type(value_arg)
57+
58+
if isinstance(value_arg, AnyType) and base_type is not None:
59+
new_value_arg = make_optional_type(base_type) if has_blank_choice else base_type
60+
new_choice_arg = choice_arg.copy_modified(items=[new_value_arg, label_arg])
61+
return helpers.reparametrize_instance(default_attr_type, [new_choice_arg])
62+
63+
elif has_blank_choice:
64+
new_value_arg = make_optional_type(value_arg)
65+
new_choice_arg = choice_arg.copy_modified(items=[new_value_arg, label_arg])
66+
return helpers.reparametrize_instance(default_attr_type, [new_choice_arg])
67+
68+
elif (
69+
method_name == "values"
70+
and isinstance(default_attr_type, Instance)
71+
and default_attr_type.type.fullname == "builtins.list"
72+
and len(default_attr_type.args) == 1
73+
):
74+
value_arg = get_proper_type(default_attr_type.args[0])
75+
76+
if isinstance(value_arg, AnyType) and base_type is not None:
77+
new_value_arg = make_optional_type(base_type) if has_blank_choice else base_type
78+
return helpers.reparametrize_instance(default_attr_type, [new_value_arg])
79+
80+
elif has_blank_choice:
81+
new_value_arg = make_optional_type(value_arg)
82+
return helpers.reparametrize_instance(default_attr_type, [new_value_arg])
83+
84+
elif method_name == "value" and isinstance(default_attr_type, AnyType) and base_type is not None:
85+
return base_type
86+
87+
return default_attr_type

tests/assert_type/db/models/check_enums.py

Lines changed: 0 additions & 46 deletions
This file was deleted.

0 commit comments

Comments
 (0)