Skip to content

Commit d661abb

Browse files
committed
Automatically detect decorator argument default.
Instead of relying only on hardcoded default value, first try to find out the actuall default value of a bool argument to a the class/attrib-maker decorators, and only if the value could not be extracted from the code, fall back to the default value defined in mypy. This enables the correct handling of decorators which wrap attrs decorators but set different defaults, for example to kw_only. This commit also adds to test cases. testAttrsWrappedDecorators tests the existing support for wrapping attrs decorators, via a "fake plugin" and testAttrsWrappedDecoratorsWithDifferentDefaults tests the new support for decorators that have different default values than the attrs decorators.
1 parent 5039c0f commit d661abb

File tree

4 files changed

+148
-15
lines changed

4 files changed

+148
-15
lines changed

mypy/plugins/attrs.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from mypy.plugin import SemanticAnalyzerPluginInterface
1818
from mypy.plugins.common import (
1919
_get_argument, _get_bool_argument, _get_decorator_bool_argument, add_method,
20-
deserialize_and_fixup_type, add_attribute_to_class,
20+
deserialize_and_fixup_type, add_attribute_to_class, _get_default_bool_value,
2121
)
2222
from mypy.types import (
2323
TupleType, Type, AnyType, TypeOfAny, CallableType, NoneType, TypeVarType,
@@ -205,6 +205,9 @@ def _get_decorator_optional_bool_argument(
205205
) -> Optional[bool]:
206206
"""Return the Optional[bool] argument for the decorator.
207207
208+
If the argument was not passed, try to find out the default value of the argument and return
209+
that. If a default value cannot be automatically determined, return the value of the `default`
210+
argument of this function.
208211
This handles both @decorator(...) and @decorator.
209212
"""
210213
if isinstance(ctx.reason, CallExpr):
@@ -219,9 +222,7 @@ def _get_decorator_optional_bool_argument(
219222
return None
220223
ctx.api.fail(f'"{name}" argument must be True or False.', ctx.reason)
221224
return default
222-
return default
223-
else:
224-
return default
225+
return _get_default_bool_value(ctx.reason, name, default)
225226

226227

227228
def attr_tag_callback(ctx: 'mypy.plugin.ClassDefContext') -> None:

mypy/plugins/common.py

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from mypy.nodes import (
44
ARG_POS, MDEF, Argument, Block, CallExpr, ClassDef, Expression, SYMBOL_FUNCBASE_TYPES,
5-
FuncDef, PassStmt, RefExpr, SymbolTableNode, Var, JsonDict,
5+
FuncDef, PassStmt, RefExpr, SymbolTableNode, Var, JsonDict, NameExpr,
66
)
77
from mypy.plugin import CheckerPluginInterface, ClassDefContext, SemanticAnalyzerPluginInterface
88
from mypy.semanal import set_callable_name, ALLOW_INCOMPATIBLE_OVERRIDE
@@ -24,25 +24,36 @@ def _get_decorator_bool_argument(
2424
2525
This handles both @decorator(...) and @decorator.
2626
"""
27-
if isinstance(ctx.reason, CallExpr):
27+
if isinstance(ctx.reason, CallExpr): # @decorator(...)
2828
return _get_bool_argument(ctx, ctx.reason, name, default)
29-
else:
30-
return default
29+
# @decorator - no call. Try to get default value from decorator definition.
30+
if isinstance(ctx.reason, NameExpr) and isinstance(ctx.reason.node, FuncDef):
31+
default_value = _get_default_bool_value(ctx.reason.node, name, default)
32+
if default_value is not None:
33+
return default_value
34+
# If we are here, no value was passed in call, default was found in def and it is None.
35+
# Should we ctx.api.fail here?
36+
return default
3137

3238

3339
def _get_bool_argument(ctx: ClassDefContext, expr: CallExpr,
3440
name: str, default: bool) -> bool:
35-
"""Return the boolean value for an argument to a call or the
36-
default if it's not found.
41+
"""Return the boolean value for an argument to a call.
42+
43+
If the argument was not passed, try to find out the default value of the argument and return
44+
that. If a default value cannot be automatically determined, return the value of the `default`
45+
argument of this function.
3746
"""
3847
attr_value = _get_argument(expr, name)
3948
if attr_value:
4049
ret = ctx.api.parse_bool(attr_value)
41-
if ret is None:
42-
ctx.api.fail(f'"{name}" argument must be True or False.', expr)
43-
return default
44-
return ret
45-
return default
50+
else:
51+
# This argument was not passed in the call. Try to extract default from function def.
52+
ret = _get_default_bool_value(expr, name, default)
53+
if ret is None:
54+
ctx.api.fail(f'"{name}" argument must be True or False.', expr)
55+
return default
56+
return ret
4657

4758

4859
def _get_argument(call: CallExpr, name: str) -> Optional[Expression]:
@@ -82,6 +93,36 @@ def _get_argument(call: CallExpr, name: str) -> Optional[Expression]:
8293
return None
8394

8495

96+
def _get_default_bool_value(
97+
expr: Union[CallExpr, FuncDef, Expression], name: str, default: Optional[bool] = None
98+
) -> Optional[bool]:
99+
"""Return the default value for the argument with this name from an expression.
100+
101+
Try to extract the default optional bool value from the definition. If cannot extract
102+
default value from the code, return the analyzer-defined default instead.
103+
"""
104+
if isinstance(expr, CallExpr): # We have a @decorator(...) situation.
105+
if isinstance(expr.callee, RefExpr):
106+
callee_node = expr.callee.node
107+
if isinstance(callee_node, FuncDef):
108+
expr = callee_node # Will enter next if clause.
109+
if isinstance(expr, FuncDef):
110+
try:
111+
initializer = expr.arguments[expr.arg_names.index(name)].initializer
112+
except ValueError: # name not in func_def.arg_names
113+
return default
114+
if initializer is None or not isinstance(initializer, NameExpr):
115+
# No default was defined in the code or it is a complex expression.
116+
return default # Return analyzer-defined default.
117+
if initializer.fullname == 'builtins.True':
118+
return True
119+
if initializer.fullname == 'builtins.False':
120+
return False
121+
if initializer.fullname == 'builtins.None':
122+
return None
123+
return default # Cannot extract default from code, return analyzer-defined default.
124+
125+
85126
def add_method(
86127
ctx: ClassDefContext,
87128
name: str,

test-data/unit/check-attr.test

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1778,3 +1778,74 @@ class C:
17781778
c = C(x=[C.D()])
17791779
reveal_type(c.x) # N: Revealed type is "builtins.list[__main__.C.D]"
17801780
[builtins fixtures/list.pyi]
1781+
1782+
[case testAttrsWrappedDecorators]
1783+
# flags: --config-file tmp/mypy-fake-plugin-conf.ini
1784+
import attr
1785+
from typing import Any
1786+
1787+
# Test that no errors are raised when the custom decorators are added to the maker lists.
1788+
def my_attr_dataclass(cls) -> Any:
1789+
return attr.dataclass()(cls)
1790+
1791+
def my_attr_s(cls) -> Any:
1792+
return attr.s()(cls)
1793+
1794+
def my_attr_ib() -> Any:
1795+
return attr.ib()
1796+
1797+
@my_attr_dataclass
1798+
class A:
1799+
num: int = my_attr_ib()
1800+
1801+
@my_attr_s
1802+
class B:
1803+
string: str = my_attr_ib()
1804+
1805+
a = A(42)
1806+
b = B("Forty two")
1807+
[builtins fixtures/attr.pyi]
1808+
1809+
[file mypy-fake-plugin-conf.ini]
1810+
\[mypy]
1811+
plugins=<ROOT>/test-data/unit/plugins/fake_attrs_plugin.py
1812+
1813+
1814+
[case testAttrsWrappedDecoratorsWithDifferentDefaults]
1815+
# flags: --config-file tmp/mypy-fake-plugin-conf.ini
1816+
import attr
1817+
from typing import Any
1818+
1819+
# Test that the attrs plugin detects the custom default values, and no false positives are raised.
1820+
def my_attr_dataclass(cls, auto_attribs: bool = True) -> Any:
1821+
return attr.dataclass(auto_attribs=auto_attribs)(cls)
1822+
1823+
def my_attr_s(cls, kw_only: bool = True) -> Any:
1824+
return attr.s(kw_only=kw_only)(cls)
1825+
1826+
def my_attr_ib(**kwargs) -> Any:
1827+
return attr.ib(**kwargs)
1828+
1829+
@my_attr_dataclass
1830+
class A:
1831+
num: int = 42
1832+
1833+
@my_attr_s
1834+
class B:
1835+
optional: str = my_attr_ib(default="This attrib is not required now.")
1836+
1837+
@my_attr_s
1838+
class C(B):
1839+
# It is ok to have a required "after" an optional attrib, since kw_only is True.
1840+
required: str = my_attr_ib()
1841+
1842+
a = A(num=42)
1843+
b = B()
1844+
b.optional
1845+
c = C(required="Forty Two")
1846+
[builtins fixtures/attr.pyi]
1847+
[builtins fixtures/dict.pyi]
1848+
1849+
[file mypy-fake-plugin-conf.ini]
1850+
\[mypy]
1851+
plugins=<ROOT>/test-data/unit/plugins/fake_attrs_plugin.py
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from mypy.plugin import Plugin
2+
from mypy.plugins.attrs import (
3+
attr_attrib_makers,
4+
attr_class_makers,
5+
attr_dataclass_makers,
6+
)
7+
8+
# See https://www.attrs.org/en/stable/extending.html#mypy for background.
9+
attr_dataclass_makers.add("__main__.my_attr_dataclass")
10+
attr_class_makers.add("__main__.my_attr_s")
11+
attr_attrib_makers.add("__main__.my_attr_ib")
12+
13+
14+
class MyPlugin(Plugin):
15+
# Our plugin does nothing but it has to exist so this file gets loaded.
16+
pass
17+
18+
19+
def plugin(version):
20+
return MyPlugin

0 commit comments

Comments
 (0)