Skip to content

Commit c37850b

Browse files
ngnpopedelfick
andauthored
Fixes to choices support in plugin (#2646)
* Revert "fix referencing `.value` of a Choices variable (#2644)" This reverts commit 7b4145d. * Add `__init__.py` files to `tests/assert_type/` tree Without these we cannot import from other modules properly which is useful for testing the behaviour of resolving types in the plugin. * Add comments to assertions for choices tests Add some comments to explain what is being tested in each block of `assert_type()` calls. * Fix type resolution for choices in plugin (1) The resolution of the attribute type was too naive and didn't handle where a module is imported and the choices type is accessed as an attribute from that module. Eliminates 11,087 errors in a large repository that were raised when upgrading from django-stubs v5.1.3 to v5.2.0 which included the changes in a3f2a55. Co-authored-by: Stephen Moore <[email protected]> * Fix type resolution for choices in plugin (2) The resolution of the attribute type was too naive and didn't handle where a choices type is aliased and then used via that alias. Eliminates 53 errors in a large repository that were raised when upgrading from django-stubs v5.1.3 to v5.2.0 which included the changes in a3f2a55. Co-authored-by: Stephen Moore <[email protected]> * Fix type resolution for choices in plugin (3) The resolution of the attribute type was too naive and didn't handle where a choices type is aliased by type and then used via that alias. Eliminates 37 errors in a large repository that were raised when upgrading from django-stubs v5.1.3 to v5.2.0 which included the changes in a3f2a55. Co-authored-by: Stephen Moore <[email protected]> * Fix type resolution for choices in plugin (4) The resolution of the attribute type was too naive and didn't handle where a choices type is bound in a type variable. Eliminates 10 errors in a large repository that were raised when upgrading from django-stubs v5.1.3 to v5.2.0 which included the changes in a3f2a55. Co-authored-by: Stephen Moore <[email protected]> * Fix type resolution for choices in plugin (5) The resolution of the attribute type was too naive and didn't handle where a choices type is decomposed to a union of enum literals when a comparison to a member is made in a branch. Eliminates 33 errors in a large repository that were raised when upgrading from django-stubs v5.1.3 to v5.2.0 which included the changes in a3f2a55. Co-authored-by: Stephen Moore <[email protected]> * Fix type resolution for choices in plugin (6) The resolution of the attribute type was too naive and didn't handle where a choices type overrides a property, e.g. label, and makes use of `super()`. Eliminates 1 error in a large repository that was raised when upgrading from django-stubs v5.1.3 to v5.2.0 which included the changes in a3f2a55. * Fix type resolution for choices in plugin (7) The resolution of the attribute type was too naive and didn't handle where a choices type is decomposed to a single enum literal when a comparison to a member is made in a branch. Eliminates 1 error in a large repository that was raised when upgrading from django-stubs v5.1.3 to v5.2.0 which included the changes in a3f2a55. * Restructure value replacement in choices plugin Ahead of a future commit that alters handling of labels, perform this simplification. * Add non-lazy label handling for choices in plugin Because of the change to use `django.utils.functional._StrOrPromise` for labels to improve accuracy, it can be a little painful - especially in a very large codebase where translations / lazy strings are not used. This change keeps the stubs unchanged for correctness, e.g. in pyright for where there is no alternative, but tweaks the mypy plugin to handle introspecting the members to determine whether there are no lazy strings such that the types can be narrowed. Eliminates 1,166 errors in a large repository that were raised when upgrading from django-stubs v5.1.3 to v5.2.0 which included the changes in a3f2a55. * Suppress some errors in choices plugin This case is a little complex as we have a mix of choices types. In theory we could handle this, but it would require more rework to handle multiple types. For now, just make no changes and return the original type. Eliminates 11 errors in a large repository that were raised when upgrading from django-stubs v5.1.3 to v5.2.0 which included the changes in a3f2a55. --------- Co-authored-by: Stephen Moore <[email protected]>
1 parent 01c1c03 commit c37850b

File tree

13 files changed

+362
-61
lines changed

13 files changed

+362
-61
lines changed

mypy_django_plugin/main.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -277,10 +277,14 @@ def get_attribute_hook(self, fullname: str) -> Callable[[AttributeContext], Mypy
277277
if info and info.has_base(fullnames.STR_PROMISE_FULLNAME):
278278
return resolve_str_promise_attribute
279279

280-
if info and info.has_base(fullnames.CHOICES_TYPE_METACLASS_FULLNAME) and attr_name in ("choices", "values"):
280+
if (
281+
info
282+
and info.has_base(fullnames.CHOICES_TYPE_METACLASS_FULLNAME)
283+
and attr_name in ("choices", "labels", "values", "__empty__")
284+
):
281285
return choices.transform_into_proper_attr_type
282286

283-
if info and info.has_base(fullnames.CHOICES_CLASS_FULLNAME) and attr_name == "value":
287+
if info and info.has_base(fullnames.CHOICES_CLASS_FULLNAME) and attr_name in ("label", "value"):
284288
return choices.transform_into_proper_attr_type
285289

286290
return None
+188-38
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,123 @@
1-
from mypy.nodes import MemberExpr, NameExpr, TypeInfo, Var
1+
from mypy.nodes import MemberExpr, NameExpr, SuperExpr, TypeAlias, TypeInfo
22
from mypy.plugin import AttributeContext
33
from mypy.typeanal import make_optional_type
4-
from mypy.types import AnyType, Instance, TupleType, TypeOfAny, get_proper_type
4+
from mypy.types import (
5+
AnyType,
6+
Instance,
7+
LiteralType,
8+
ProperType,
9+
TupleType,
10+
TypeOfAny,
11+
TypeType,
12+
TypeVarType,
13+
UnionType,
14+
get_proper_type,
15+
)
516
from mypy.types import Type as MypyType
617

718
from mypy_django_plugin.lib import fullnames, helpers
819

920

21+
def _has_lazy_label(node: TypeInfo) -> bool:
22+
"""
23+
Check whether a choices type has any lazy strings for labels.
24+
25+
This is used to determine choices types that do not use lazy strings for labels such that a
26+
more simple type can be used instead of the default in the stubs.
27+
"""
28+
assert node.is_enum
29+
30+
if (sym := node.get("__empty__")) is not None:
31+
_empty_type = get_proper_type(sym.type)
32+
if isinstance(_empty_type, Instance) and _empty_type.type.has_base(fullnames.STR_PROMISE_FULLNAME):
33+
# If the empty label is lazy, then we don't need to check all the members.
34+
return True
35+
36+
for member_name in node.enum_members:
37+
if (sym := node.get(member_name)) is None:
38+
continue
39+
40+
_member_type = get_proper_type(sym.type)
41+
if not isinstance(_member_type, TupleType):
42+
# Member has auto-generated plain string label - enum.auto() or no explicit label.
43+
continue
44+
45+
if _member_type.length() < 2:
46+
# There need to be at least two items in the tuple.
47+
continue
48+
49+
_label_type = get_proper_type(_member_type.items[-1])
50+
if isinstance(_label_type, Instance) and _label_type.type.has_base(fullnames.STR_PROMISE_FULLNAME):
51+
# If any member label is lazy, then we don't need to check the remaining members.
52+
return True
53+
54+
return False
55+
56+
57+
def _try_replace_label(typ: ProperType, has_lazy_label: bool) -> MypyType:
58+
"""
59+
Attempt to replace a label with a modified version.
60+
61+
If there are no lazy strings for labels, remove the lazy string type.
62+
"""
63+
if has_lazy_label:
64+
return typ
65+
66+
if not isinstance(typ, UnionType):
67+
# If it's not a union, then it already is likely just `str` and not lazy.
68+
return typ
69+
70+
items = [
71+
t
72+
for t in map(get_proper_type, typ.items)
73+
if isinstance(t, Instance) and not t.type.has_base(fullnames.STR_PROMISE_FULLNAME)
74+
]
75+
76+
# If we only have one item use that, otherwise make a new union.
77+
return UnionType.make_union(items) if len(items) > 1 else items[0]
78+
79+
80+
def _try_replace_value(typ: ProperType, base_type: Instance | None, has_empty_label: bool) -> MypyType:
81+
"""
82+
Attempt to replace a label with a modified version.
83+
84+
If the value is of any type, then attempt to use the base type of the choices type.
85+
86+
If the choices type has `__empty__` defined, then make the value type optional.
87+
"""
88+
has_unknown_base = isinstance(typ, AnyType)
89+
90+
if has_empty_label and has_unknown_base and base_type is not None:
91+
return make_optional_type(base_type)
92+
93+
if has_empty_label:
94+
return make_optional_type(typ)
95+
96+
if has_unknown_base and base_type is not None:
97+
return base_type
98+
99+
return typ
100+
101+
102+
def _get_enum_type_from_union_of_literals(typ: UnionType) -> MypyType:
103+
"""
104+
Attempts to resolve a single enum type from a union of enum literals.
105+
106+
If this cannot be resolved, the original is returned.
107+
"""
108+
types = set()
109+
110+
for item in typ.items:
111+
item = get_proper_type(item)
112+
if not isinstance(item, LiteralType) or not item.fallback.type.is_enum:
113+
# If anything that isn't a literal of an enum type is encountered, return the original.
114+
return typ
115+
types.add(item.fallback)
116+
117+
# If there is only one enum type seen, return that otherwise return the original.
118+
return types.pop() if len(types) == 1 else typ
119+
120+
10121
def transform_into_proper_attr_type(ctx: AttributeContext) -> MypyType:
11122
"""
12123
A `get_attribute_hook` to make `.choices` and `.values` optional if `__empty__` is defined.
@@ -15,26 +126,53 @@ def transform_into_proper_attr_type(ctx: AttributeContext) -> MypyType:
15126
blank choice/value. This hook will amend the type returned by those properties.
16127
"""
17128
if isinstance(ctx.context, MemberExpr):
18-
method_name = ctx.context.name
129+
expr = ctx.context.expr
130+
name = ctx.context.name
131+
elif isinstance(ctx.context, SuperExpr):
132+
expr = ctx.context
133+
name = ctx.context.name
19134
else:
20-
ctx.api.fail("Unable to resolve type of choices property", ctx.context)
135+
ctx.api.fail("Unable to resolve type of property", ctx.context)
21136
return AnyType(TypeOfAny.from_error)
22137

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-
elif (
31-
isinstance(expr, NameExpr)
32-
and isinstance(expr.node, Var)
33-
and isinstance(var_node_type := get_proper_type(expr.node.type), Instance)
34-
):
35-
node = var_node_type.type
36-
else:
37-
ctx.api.fail("Unable to resolve type of choices property", ctx.context)
138+
node: TypeInfo | None = None
139+
140+
if isinstance(expr, MemberExpr | NameExpr):
141+
if isinstance(expr.node, TypeInfo):
142+
node = expr.node
143+
elif isinstance(expr.node, TypeAlias):
144+
alias = get_proper_type(expr.node.target)
145+
if isinstance(alias, Instance):
146+
node = alias.type
147+
elif isinstance(expr, SuperExpr):
148+
node = expr.info
149+
150+
_node_type: ProperType | None = None
151+
152+
if node is None:
153+
_node_type = get_proper_type(ctx.api.get_expression_type(expr))
154+
if isinstance(_node_type, UnionType):
155+
# If this is a union of enum literals, check if they're all of the same enum type and
156+
# use that type instead. This situation often occurs where there is comparison to an
157+
# enum member in a branch.
158+
_node_type = get_proper_type(_get_enum_type_from_union_of_literals(_node_type))
159+
if isinstance(_node_type, LiteralType) and _node_type.is_enum_literal():
160+
_node_type = _node_type.fallback
161+
if isinstance(_node_type, TypeType):
162+
_node_type = _node_type.item
163+
if isinstance(_node_type, TypeVarType):
164+
_node_type = get_proper_type(_node_type.upper_bound)
165+
if isinstance(_node_type, Instance):
166+
node = _node_type.type
167+
168+
if node is None:
169+
if isinstance(_node_type, UnionType):
170+
# Suppress the error for a known case where there are multiple base choices types in a
171+
# union. In theory this is something that could be handled by extracting all of the
172+
# base types and making the following code consider all of these types, but that's
173+
# quite a bit of effort.
174+
return ctx.default_attr_type
175+
ctx.api.fail(f"Unable to resolve type of {name} property", ctx.context)
38176
return AnyType(TypeOfAny.from_error)
39177

40178
default_attr_type = get_proper_type(ctx.default_attr_type)
@@ -47,10 +185,16 @@ def transform_into_proper_attr_type(ctx: AttributeContext) -> MypyType:
47185

48186
# When `__empty__` is defined, the `.choices` and `.values` properties will include `None` for
49187
# the blank choice which is labelled by the value of `__empty__`.
50-
has_blank_choice = node.get("__empty__") is not None
188+
has_empty_label = node.get("__empty__") is not None
189+
190+
# When `__empty__` is not a lazy string and the labels on all members are not lazy strings, the
191+
# label can be simplified to only be a simple string type. This keeps the benefits of accurate
192+
# typing when the lazy labels are being used, but reduces the pain of having to manage a union
193+
# of a simple and lazy string type where it's not necessary.
194+
has_lazy_label = _has_lazy_label(node)
51195

52196
if (
53-
method_name == "choices"
197+
name == "choices"
54198
and isinstance(default_attr_type, Instance)
55199
and default_attr_type.type.fullname == "builtins.list"
56200
and len(default_attr_type.args) == 1
@@ -59,35 +203,41 @@ def transform_into_proper_attr_type(ctx: AttributeContext) -> MypyType:
59203

60204
if isinstance(choice_arg, TupleType) and choice_arg.length() == 2:
61205
value_arg, label_arg = choice_arg.items
206+
label_arg = get_proper_type(label_arg)
62207
value_arg = get_proper_type(value_arg)
63-
64-
if isinstance(value_arg, AnyType) and base_type is not None:
65-
new_value_arg = make_optional_type(base_type) if has_blank_choice else base_type
66-
new_choice_arg = choice_arg.copy_modified(items=[new_value_arg, label_arg])
208+
new_label_arg = _try_replace_label(label_arg, has_lazy_label)
209+
new_value_arg = _try_replace_value(value_arg, base_type, has_empty_label)
210+
if new_label_arg is not label_arg or new_value_arg is not value_arg:
211+
new_choice_arg = choice_arg.copy_modified(items=[new_value_arg, new_label_arg])
67212
return helpers.reparametrize_instance(default_attr_type, [new_choice_arg])
68213

69-
elif has_blank_choice:
70-
new_value_arg = make_optional_type(value_arg)
71-
new_choice_arg = choice_arg.copy_modified(items=[new_value_arg, label_arg])
72-
return helpers.reparametrize_instance(default_attr_type, [new_choice_arg])
214+
elif (
215+
name == "labels"
216+
and isinstance(default_attr_type, Instance)
217+
and default_attr_type.type.fullname == "builtins.list"
218+
and len(default_attr_type.args) == 1
219+
):
220+
label_arg = get_proper_type(default_attr_type.args[0])
221+
new_label_arg = _try_replace_label(label_arg, has_lazy_label)
222+
if new_label_arg is not label_arg:
223+
return helpers.reparametrize_instance(default_attr_type, [new_label_arg])
73224

74225
elif (
75-
method_name == "values"
226+
name == "values"
76227
and isinstance(default_attr_type, Instance)
77228
and default_attr_type.type.fullname == "builtins.list"
78229
and len(default_attr_type.args) == 1
79230
):
80231
value_arg = get_proper_type(default_attr_type.args[0])
81-
82-
if isinstance(value_arg, AnyType) and base_type is not None:
83-
new_value_arg = make_optional_type(base_type) if has_blank_choice else base_type
232+
new_value_arg = _try_replace_value(value_arg, base_type, has_empty_label)
233+
if new_value_arg is not value_arg:
84234
return helpers.reparametrize_instance(default_attr_type, [new_value_arg])
85235

86-
elif has_blank_choice:
87-
new_value_arg = make_optional_type(value_arg)
88-
return helpers.reparametrize_instance(default_attr_type, [new_value_arg])
236+
elif name in ("__empty__", "label"):
237+
return _try_replace_label(default_attr_type, has_lazy_label)
89238

90-
elif method_name == "value" and isinstance(default_attr_type, AnyType) and base_type is not None:
91-
return base_type
239+
elif name == "value":
240+
# Pass in `False` because `.value` will never return `None`.
241+
return _try_replace_value(default_attr_type, base_type, False)
92242

93243
return default_attr_type

tests/__init__.py

Whitespace-only changes.

tests/assert_type/__init__.py

Whitespace-only changes.

tests/assert_type/apps/__init__.py

Whitespace-only changes.

tests/assert_type/contrib/admin/__init__.py

Whitespace-only changes.

tests/assert_type/db/__init__.py

Whitespace-only changes.

tests/assert_type/db/models/__init__.py

Whitespace-only changes.

tests/assert_type/db/models/_enums.py

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from typing import Literal
2+
3+
from django.db.models import TextChoices
4+
from typing_extensions import assert_type
5+
6+
7+
class Direction(TextChoices):
8+
NORTH = "N", "North"
9+
EAST = "E", "East"
10+
SOUTH = "S", "South"
11+
WEST = "W", "West"
12+
13+
14+
# Note: Suppress errors from pyright as the mypy plugin narrows the type of labels if non-lazy.
15+
assert_type(Direction.names, list[str])
16+
assert_type(Direction.labels, list[str]) # pyright: ignore[reportAssertTypeFailure]
17+
assert_type(Direction.values, list[str])
18+
assert_type(Direction.choices, list[tuple[str, str]]) # pyright: ignore[reportAssertTypeFailure]
19+
assert_type(Direction.NORTH, Literal[Direction.NORTH])
20+
assert_type(Direction.NORTH.name, Literal["NORTH"])
21+
assert_type(Direction.NORTH.label, str) # pyright: ignore[reportAssertTypeFailure]
22+
assert_type(Direction.NORTH.value, str)
23+
assert_type(Direction.NORTH.do_not_call_in_templates, Literal[True])

tests/assert_type/db/models/fields/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)