Skip to content

Commit 5b9318d

Browse files
authored
Add match_args support to attr.s() (#12111)
* Add `match_args` support to `attr.s()` * Improve wording * Serialize `__match_args__` * More tests * Addresses review * Address review
1 parent 129dba4 commit 5b9318d

File tree

4 files changed

+116
-3
lines changed

4 files changed

+116
-3
lines changed

mypy/plugins/attrs.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,19 @@
1818
from mypy.plugin import SemanticAnalyzerPluginInterface
1919
from mypy.plugins.common import (
2020
_get_argument, _get_bool_argument, _get_decorator_bool_argument, add_method,
21-
deserialize_and_fixup_type
21+
deserialize_and_fixup_type, add_attribute_to_class,
2222
)
2323
from mypy.types import (
2424
TupleType, Type, AnyType, TypeOfAny, CallableType, NoneType, TypeVarType,
2525
Overloaded, UnionType, FunctionLike, Instance, get_proper_type,
26+
LiteralType,
2627
)
2728
from mypy.typeops import make_simplified_union, map_type_from_supertype
2829
from mypy.typevars import fill_typevars
2930
from mypy.util import unmangle
3031
from mypy.server.trigger import make_wildcard_trigger
3132

32-
KW_ONLY_PYTHON_2_UNSUPPORTED = "kw_only is not supported in Python 2"
33+
KW_ONLY_PYTHON_2_UNSUPPORTED: Final = "kw_only is not supported in Python 2"
3334

3435
# The names of the different functions that create classes or arguments.
3536
attr_class_makers: Final = {
@@ -278,6 +279,7 @@ def attr_class_maker_callback(ctx: 'mypy.plugin.ClassDefContext',
278279

279280
auto_attribs = _get_decorator_optional_bool_argument(ctx, 'auto_attribs', auto_attribs_default)
280281
kw_only = _get_decorator_bool_argument(ctx, 'kw_only', False)
282+
match_args = _get_decorator_bool_argument(ctx, 'match_args', True)
281283

282284
if ctx.api.options.python_version[0] < 3:
283285
if auto_attribs:
@@ -307,6 +309,10 @@ def attr_class_maker_callback(ctx: 'mypy.plugin.ClassDefContext',
307309
_add_attrs_magic_attribute(ctx, [(attr.name, info[attr.name].type) for attr in attributes])
308310
if slots:
309311
_add_slots(ctx, attributes)
312+
if match_args and ctx.api.options.python_version[:2] >= (3, 10):
313+
# `.__match_args__` is only added for python3.10+, but the argument
314+
# exists for earlier versions as well.
315+
_add_match_args(ctx, attributes)
310316

311317
# Save the attributes so that subclasses can reuse them.
312318
ctx.cls.info.metadata['attrs'] = {
@@ -733,6 +739,7 @@ def _add_attrs_magic_attribute(ctx: 'mypy.plugin.ClassDefContext',
733739
ti.names[name] = SymbolTableNode(MDEF, var, plugin_generated=True)
734740
attributes_type = Instance(ti, [])
735741

742+
# TODO: refactor using `add_attribute_to_class`
736743
var = Var(name=MAGIC_ATTR_NAME, type=TupleType(attributes_types, fallback=attributes_type))
737744
var.info = ctx.cls.info
738745
var.is_classvar = True
@@ -751,6 +758,30 @@ def _add_slots(ctx: 'mypy.plugin.ClassDefContext',
751758
ctx.cls.info.slots = {attr.name for attr in attributes}
752759

753760

761+
def _add_match_args(ctx: 'mypy.plugin.ClassDefContext',
762+
attributes: List[Attribute]) -> None:
763+
if ('__match_args__' not in ctx.cls.info.names
764+
or ctx.cls.info.names['__match_args__'].plugin_generated):
765+
str_type = ctx.api.named_type('builtins.str')
766+
match_args = TupleType(
767+
[
768+
str_type.copy_modified(
769+
last_known_value=LiteralType(attr.name, fallback=str_type),
770+
)
771+
for attr in attributes
772+
if not attr.kw_only and attr.init
773+
],
774+
fallback=ctx.api.named_type('builtins.tuple'),
775+
)
776+
add_attribute_to_class(
777+
api=ctx.api,
778+
cls=ctx.cls,
779+
name='__match_args__',
780+
typ=match_args,
781+
final=True,
782+
)
783+
784+
754785
class MethodAdder:
755786
"""Helper to add methods to a TypeInfo.
756787

mypy/plugins/common.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ def add_attribute_to_class(
162162
name: str,
163163
typ: Type,
164164
final: bool = False,
165+
no_serialize: bool = False,
165166
) -> None:
166167
"""
167168
Adds a new attribute to a class definition.
@@ -180,7 +181,12 @@ def add_attribute_to_class(
180181
node.info = info
181182
node.is_final = final
182183
node._fullname = info.fullname + '.' + name
183-
info.names[name] = SymbolTableNode(MDEF, node, plugin_generated=True)
184+
info.names[name] = SymbolTableNode(
185+
MDEF,
186+
node,
187+
plugin_generated=True,
188+
no_serialize=no_serialize,
189+
)
184190

185191

186192
def deserialize_and_fixup_type(

test-data/unit/check-attr.test

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1465,3 +1465,77 @@ class C:
14651465
self.b = 1 # E: Trying to assign name "b" that is not in "__slots__" of type "__main__.C"
14661466
self.c = 2 # E: Trying to assign name "c" that is not in "__slots__" of type "__main__.C"
14671467
[builtins fixtures/attr.pyi]
1468+
1469+
[case testAttrsWithMatchArgs]
1470+
# flags: --python-version 3.10
1471+
import attr
1472+
1473+
@attr.s(match_args=True, auto_attribs=True)
1474+
class ToMatch:
1475+
x: int
1476+
y: int
1477+
# Not included:
1478+
z: int = attr.field(kw_only=True)
1479+
i: int = attr.field(init=False)
1480+
1481+
reveal_type(ToMatch(x=1, y=2, z=3).__match_args__) # N: Revealed type is "Tuple[Literal['x']?, Literal['y']?]"
1482+
reveal_type(ToMatch(1, 2, z=3).__match_args__) # N: Revealed type is "Tuple[Literal['x']?, Literal['y']?]"
1483+
[builtins fixtures/attr.pyi]
1484+
1485+
[case testAttrsWithMatchArgsDefaultCase]
1486+
# flags: --python-version 3.10
1487+
import attr
1488+
1489+
@attr.s(auto_attribs=True)
1490+
class ToMatch1:
1491+
x: int
1492+
y: int
1493+
1494+
t1: ToMatch1
1495+
reveal_type(t1.__match_args__) # N: Revealed type is "Tuple[Literal['x']?, Literal['y']?]"
1496+
1497+
@attr.define
1498+
class ToMatch2:
1499+
x: int
1500+
y: int
1501+
1502+
t2: ToMatch2
1503+
reveal_type(t2.__match_args__) # N: Revealed type is "Tuple[Literal['x']?, Literal['y']?]"
1504+
[builtins fixtures/attr.pyi]
1505+
1506+
[case testAttrsWithMatchArgsOverrideExisting]
1507+
# flags: --python-version 3.10
1508+
import attr
1509+
from typing import Final
1510+
1511+
@attr.s(match_args=True, auto_attribs=True)
1512+
class ToMatch:
1513+
__match_args__: Final = ('a', 'b')
1514+
x: int
1515+
y: int
1516+
1517+
# It works the same way runtime does:
1518+
reveal_type(ToMatch(x=1, y=2).__match_args__) # N: Revealed type is "Tuple[Literal['a']?, Literal['b']?]"
1519+
1520+
@attr.s(auto_attribs=True)
1521+
class WithoutMatch:
1522+
__match_args__: Final = ('a', 'b')
1523+
x: int
1524+
y: int
1525+
1526+
reveal_type(WithoutMatch(x=1, y=2).__match_args__) # N: Revealed type is "Tuple[Literal['a']?, Literal['b']?]"
1527+
[builtins fixtures/attr.pyi]
1528+
1529+
[case testAttrsWithMatchArgsOldVersion]
1530+
# flags: --python-version 3.9
1531+
import attr
1532+
1533+
@attr.s(match_args=True)
1534+
class NoMatchArgs:
1535+
...
1536+
1537+
n: NoMatchArgs
1538+
1539+
reveal_type(n.__match_args__) # E: "NoMatchArgs" has no attribute "__match_args__" \
1540+
# N: Revealed type is "Any"
1541+
[builtins fixtures/attr.pyi]

test-data/unit/lib-stub/attr/__init__.pyi

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ def attrs(maybe_cls: _C,
9494
cache_hash: bool = ...,
9595
eq: Optional[bool] = ...,
9696
order: Optional[bool] = ...,
97+
match_args: bool = ...,
9798
) -> _C: ...
9899
@overload
99100
def attrs(maybe_cls: None = ...,
@@ -112,6 +113,7 @@ def attrs(maybe_cls: None = ...,
112113
cache_hash: bool = ...,
113114
eq: Optional[bool] = ...,
114115
order: Optional[bool] = ...,
116+
match_args: bool = ...,
115117
) -> Callable[[_C], _C]: ...
116118

117119

0 commit comments

Comments
 (0)