Skip to content

Automatically Detect Argument Defaults in attrs-Equivalent Decorators #12775

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 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
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
8 changes: 5 additions & 3 deletions mypy/plugins/attrs.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
_get_argument,
_get_bool_argument,
_get_decorator_bool_argument,
_get_default_bool_value,
add_attribute_to_class,
add_method,
deserialize_and_fixup_type,
Expand Down Expand Up @@ -234,6 +235,9 @@ def _get_decorator_optional_bool_argument(
) -> Optional[bool]:
"""Return the Optional[bool] argument for the decorator.

If the argument was not passed, try to find out the default value of the argument and return
that. If a default value cannot be automatically determined, return the value of the `default`
argument of this function.
This handles both @decorator(...) and @decorator.
"""
if isinstance(ctx.reason, CallExpr):
Expand All @@ -248,9 +252,7 @@ def _get_decorator_optional_bool_argument(
return None
ctx.api.fail(f'"{name}" argument must be True or False.', ctx.reason)
return default
return default
else:
return default
return _get_default_bool_value(ctx.reason, name, default)


def attr_tag_callback(ctx: "mypy.plugin.ClassDefContext") -> None:
Expand Down
62 changes: 52 additions & 10 deletions mypy/plugins/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
Expression,
FuncDef,
JsonDict,
NameExpr,
PassStmt,
RefExpr,
SymbolTableNode,
Expand All @@ -37,24 +38,35 @@ def _get_decorator_bool_argument(ctx: ClassDefContext, name: str, default: bool)

This handles both @decorator(...) and @decorator.
"""
if isinstance(ctx.reason, CallExpr):
if isinstance(ctx.reason, CallExpr): # @decorator(...)
return _get_bool_argument(ctx, ctx.reason, name, default)
else:
return default
# @decorator - no call. Try to get default value from decorator definition.
if isinstance(ctx.reason, NameExpr) and isinstance(ctx.reason.node, FuncDef):
default_value = _get_default_bool_value(ctx.reason.node, name, default)
if default_value is not None:
return default_value
# If we are here, no value was passed in call, default was found in def and it is None.
# Should we ctx.api.fail here?
return default


def _get_bool_argument(ctx: ClassDefContext, expr: CallExpr, name: str, default: bool) -> bool:
"""Return the boolean value for an argument to a call or the
default if it's not found.
"""Return the boolean value for an argument to a call.

If the argument was not passed, try to find out the default value of the argument and return
that. If a default value cannot be automatically determined, return the value of the `default`
argument of this function.
"""
attr_value = _get_argument(expr, name)
if attr_value:
ret = ctx.api.parse_bool(attr_value)
if ret is None:
ctx.api.fail(f'"{name}" argument must be True or False.', expr)
return default
return ret
return default
else:
# This argument was not passed in the call. Try to extract default from function def.
ret = _get_default_bool_value(expr, name, default)
if ret is None:
ctx.api.fail(f'"{name}" argument must be True or False.', expr)
return default
return ret


def _get_argument(call: CallExpr, name: str) -> Optional[Expression]:
Expand Down Expand Up @@ -93,6 +105,36 @@ def _get_argument(call: CallExpr, name: str) -> Optional[Expression]:
return None


def _get_default_bool_value(
expr: Union[CallExpr, FuncDef, Expression], name: str, default: Optional[bool] = None
) -> Optional[bool]:
"""Return the default value for the argument with this name from an expression.

Try to extract the default optional bool value from the definition. If cannot extract
default value from the code, return the analyzer-defined default instead.
"""
if isinstance(expr, CallExpr): # We have a @decorator(...) situation.
if isinstance(expr.callee, RefExpr):
callee_node = expr.callee.node
if isinstance(callee_node, FuncDef):
expr = callee_node # Will enter next if clause.
if isinstance(expr, FuncDef):
try:
initializer = expr.arguments[expr.arg_names.index(name)].initializer
except ValueError: # name not in func_def.arg_names
return default
if initializer is None or not isinstance(initializer, NameExpr):
# No default was defined in the code or it is a complex expression.
return default # Return analyzer-defined default.
if initializer.fullname == "builtins.True":
return True
if initializer.fullname == "builtins.False":
return False
if initializer.fullname == "builtins.None":
return None
return default # Cannot extract default from code, return analyzer-defined default.


def add_method(
ctx: ClassDefContext,
name: str,
Expand Down
71 changes: 71 additions & 0 deletions test-data/unit/check-attr.test
Original file line number Diff line number Diff line change
Expand Up @@ -1745,3 +1745,74 @@ class C:
c = C(x=[C.D()])
reveal_type(c.x) # N: Revealed type is "builtins.list[__main__.C.D]"
[builtins fixtures/list.pyi]

[case testAttrsWrappedDecorators]
# flags: --config-file tmp/mypy-fake-plugin-conf.ini
import attr
from typing import Any

# Test that no errors are raised when the custom decorators are added to the maker lists.
def my_attr_dataclass(cls) -> Any:
return attr.dataclass()(cls)

def my_attr_s(cls) -> Any:
return attr.s()(cls)

def my_attr_ib() -> Any:
return attr.ib()

@my_attr_dataclass
class A:
num: int = my_attr_ib()

@my_attr_s
class B:
string: str = my_attr_ib()

a = A(42)
b = B("Forty two")
[builtins fixtures/attr.pyi]

[file mypy-fake-plugin-conf.ini]
\[mypy]
plugins=<ROOT>/test-data/unit/plugins/fake_attrs_plugin.py


[case testAttrsWrappedDecoratorsWithDifferentDefaults]
# flags: --config-file tmp/mypy-fake-plugin-conf.ini
import attr
from typing import Any

# Test that the attrs plugin detects the custom default values, and no false positives are raised.
def my_attr_dataclass(cls, auto_attribs: bool = True) -> Any:
return attr.dataclass(auto_attribs=auto_attribs)(cls)

def my_attr_s(cls, kw_only: bool = True) -> Any:
return attr.s(kw_only=kw_only)(cls)

def my_attr_ib(**kwargs) -> Any:
return attr.ib(**kwargs)

@my_attr_dataclass
class A:
num: int = 42

@my_attr_s
class B:
optional: str = my_attr_ib(default="This attrib is not required now.")

@my_attr_s
class C(B):
# It is ok to have a required "after" an optional attrib, since kw_only is True.
required: str = my_attr_ib()

a = A(num=42)
b = B()
b.optional
c = C(required="Forty Two")
[builtins fixtures/attr.pyi]
[builtins fixtures/dict.pyi]

[file mypy-fake-plugin-conf.ini]
\[mypy]
plugins=<ROOT>/test-data/unit/plugins/fake_attrs_plugin.py
20 changes: 20 additions & 0 deletions test-data/unit/plugins/fake_attrs_plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from mypy.plugin import Plugin
from mypy.plugins.attrs import (
attr_attrib_makers,
attr_class_makers,
attr_dataclass_makers,
)

# See https://www.attrs.org/en/stable/extending.html#mypy for background.
attr_dataclass_makers.add("__main__.my_attr_dataclass")
attr_class_makers.add("__main__.my_attr_s")
attr_attrib_makers.add("__main__.my_attr_ib")


class MyPlugin(Plugin):
# Our plugin does nothing but it has to exist so this file gets loaded.
pass


def plugin(version):
return MyPlugin