Repository: python/mypy

Issue:
Add `__match_args__` to Node types
I am working on a project which makes heavy use of Mypy AST trees/nodes. and it would be nice if there was less boilerplate when doing pattern matching against nodes (especially for more complex scenarios).

For example, parsing the following expression:

```python
1 + 2
```

Results in an `OpExpr` expression node (ignoring setup, see below), which I can then pattern match like so:

```python
match o:
    case OpExpr(
        op="+",
        left=IntExpr(value=1),
        right=IntExpr(value=2),
    ):
        print("found")

    case _:
        print("not found")
```

This works, but what would be really nice is being able to do this:

```python
match o:
    case OpExpr("+", IntExpr(1), IntExpr(2)):
        print("found")

    case _:
        print("not found")
```

But doing so causes the following exception:

```
Traceback (most recent call last):
  File "[redacted]/main.py", line 39, in <module>
    tree.accept(Visitor())
  File "mypy/nodes.py", line 347, in accept
  File "mypy/traverser.py", line 42, in visit_mypy_file
  File "mypy/nodes.py", line 1128, in accept
  File "mypy/traverser.py", line 89, in visit_expression_stmt
  File "mypy/nodes.py", line 1888, in accept
  File "[redacted]/main.py", line 30, in visit_op_expr
    case OpExpr(IntExpr(1), "+", IntExpr(2)):
TypeError: OpExpr() accepts 0 positional sub-patterns (3 given)
```

All that would need to done to fix this (I think) is the following:

```diff
 class OpExpr(Expression):
     """Binary operation (other than . or [] or comparison operators,
     which have specific nodes)."""
 
     __slots__ = ('op', 'left', 'right',
                  'method_type', 'right_always', 'right_unreachable')
+
+    __match_args__ ('op', 'left', 'right')
```

And then do that for every node we would (feasibly) pattern match against. I am willing to make a PR for this, assuming all that needs to be done is add a `__match_args__` line to a few classes.

<details>
<summary>Full code example</summary>

```python
from mypy.build import build
from mypy.main import process_options
from mypy.nodes import IntExpr, OpExpr
from mypy.traverser import TraverserVisitor

files, opt = process_options(["-c", "1 + 2"])
opt.fine_grained_incremental = True

result = build(files, options=opt)


class Visitor(TraverserVisitor):
    def visit_op_expr(self, o: OpExpr) -> None:
        super().visit_op_expr(o)

        match o:
            case OpExpr(
                left=IntExpr(value=1),
                op="+",
                right=IntExpr(value=2),
            ):
                print("found")

            case _:
                print("not found")


tree = result.graph['__main__'].tree

if tree:
    tree.accept(Visitor())
```
</details>


Discussion:
PR is welcome!

Resolving patch:
diff --git a/mypy/nodes.py b/mypy/nodes.py
--- a/mypy/nodes.py
+++ b/mypy/nodes.py
@@ -298,6 +298,8 @@ class MypyFile(SymbolNode):
         "future_import_flags",
     )
 
+    __match_args__ = ("name", "path", "defs")
+
     # Fully qualified module name
     _fullname: Bogus[str]
     # Path to the file (empty string if not known)
@@ -433,6 +435,8 @@ class Import(ImportBase):
 
     __slots__ = ("ids",)
 
+    __match_args__ = ("ids",)
+
     ids: list[tuple[str, str | None]]  # (module id, as id)
 
     def __init__(self, ids: list[tuple[str, str | None]]) -> None:
@@ -448,6 +452,8 @@ class ImportFrom(ImportBase):
 
     __slots__ = ("id", "names", "relative")
 
+    __match_args__ = ("id", "names", "relative")
+
     id: str
     relative: int
     names: list[tuple[str, str | None]]  # Tuples (name, as name)
@@ -467,6 +473,8 @@ class ImportAll(ImportBase):
 
     __slots__ = ("id", "relative", "imported_names")
 
+    __match_args__ = ("id", "relative")
+
     id: str
     relative: int
     # NOTE: Only filled and used by old semantic analyzer.
@@ -652,6 +660,8 @@ class Argument(Node):
 
     __slots__ = ("variable", "type_annotation", "initializer", "kind", "pos_only")
 
+    __match_args__ = ("variable", "type_annotation", "initializer", "kind", "pos_only")
+
     def __init__(
         self,
         variable: Var,
@@ -790,6 +800,8 @@ class FuncDef(FuncItem, SymbolNode, Statement):
         "is_mypy_only",
     )
 
+    __match_args__ = ("name", "arguments", "type", "body")
+
     # Note that all __init__ args must have default values
     def __init__(
         self,
@@ -880,6 +892,8 @@ class Decorator(SymbolNode, Statement):
 
     __slots__ = ("func", "decorators", "original_decorators", "var", "is_overload")
 
+    __match_args__ = ("decorators", "var", "func")
+
     func: FuncDef  # Decorated function
     decorators: list[Expression]  # Decorators (may be empty)
     # Some decorators are removed by semanal, keep the original here.
@@ -991,6 +1005,8 @@ class Var(SymbolNode):
         "invalid_partial_type",
     )
 
+    __match_args__ = ("name", "type", "final_value")
+
     def __init__(self, name: str, type: mypy.types.Type | None = None) -> None:
         super().__init__()
         self._name = name  # Name without module prefix
@@ -1099,6 +1115,8 @@ class ClassDef(Statement):
         "deco_line",
     )
 
+    __match_args__ = ("name", "defs")
+
     name: str  # Name of the class without module prefix
     fullname: Bogus[str]  # Fully qualified name of the class
     defs: Block
@@ -1176,6 +1194,8 @@ class GlobalDecl(Statement):
 
     __slots__ = ("names",)
 
+    __match_args__ = ("names",)
+
     names: list[str]
 
     def __init__(self, names: list[str]) -> None:
@@ -1191,6 +1211,8 @@ class NonlocalDecl(Statement):
 
     __slots__ = ("names",)
 
+    __match_args__ = ("names",)
+
     names: list[str]
 
     def __init__(self, names: list[str]) -> None:
@@ -1204,6 +1226,8 @@ def accept(self, visitor: StatementVisitor[T]) -> T:
 class Block(Statement):
     __slots__ = ("body", "is_unreachable")
 
+    __match_args__ = ("body", "is_unreachable")
+
     def __init__(self, body: list[Statement]) -> None:
         super().__init__()
         self.body = body
@@ -1226,6 +1250,8 @@ class ExpressionStmt(Statement):
 
     __slots__ = ("expr",)
 
+    __match_args__ = ("expr",)
+
     expr: Expression
 
     def __init__(self, expr: Expression) -> None:
@@ -1258,6 +1284,8 @@ class AssignmentStmt(Statement):
         "invalid_recursive_alias",
     )
 
+    __match_args__ = ("lvalues", "rvalues", "type")
+
     lvalues: list[Lvalue]
     # This is a TempNode if and only if no rvalue (x: t).
     rvalue: Expression
@@ -1306,6 +1334,8 @@ class OperatorAssignmentStmt(Statement):
 
     __slots__ = ("op", "lvalue", "rvalue")
 
+    __match_args__ = ("lvalue", "op", "rvalue")
+
     op: str  # TODO: Enum?
     lvalue: Lvalue
     rvalue: Expression
@@ -1323,6 +1353,8 @@ def accept(self, visitor: StatementVisitor[T]) -> T:
 class WhileStmt(Statement):
     __slots__ = ("expr", "body", "else_body")
 
+    __match_args__ = ("expr", "body", "else_body")
+
     expr: Expression
     body: Block
     else_body: Block | None
@@ -1350,6 +1382,8 @@ class ForStmt(Statement):
         "is_async",
     )
 
+    __match_args__ = ("index", "index_type", "expr", "body", "else_body")
+
     # Index variables
     index: Lvalue
     # Type given by type comments for index, can be None
@@ -1392,6 +1426,8 @@ def accept(self, visitor: StatementVisitor[T]) -> T:
 class ReturnStmt(Statement):
     __slots__ = ("expr",)
 
+    __match_args__ = ("expr",)
+
     expr: Expression | None
 
     def __init__(self, expr: Expression | None) -> None:
@@ -1405,6 +1441,8 @@ def accept(self, visitor: StatementVisitor[T]) -> T:
 class AssertStmt(Statement):
     __slots__ = ("expr", "msg")
 
+    __match_args__ = ("expr", "msg")
+
     expr: Expression
     msg: Expression | None
 
@@ -1420,6 +1458,8 @@ def accept(self, visitor: StatementVisitor[T]) -> T:
 class DelStmt(Statement):
     __slots__ = ("expr",)
 
+    __match_args__ = ("expr",)
+
     expr: Lvalue
 
     def __init__(self, expr: Lvalue) -> None:
@@ -1454,6 +1494,8 @@ def accept(self, visitor: StatementVisitor[T]) -> T:
 class IfStmt(Statement):
     __slots__ = ("expr", "body", "else_body")
 
+    __match_args__ = ("expr", "body", "else_body")
+
     expr: list[Expression]
     body: list[Block]
     else_body: Block | None
@@ -1471,6 +1513,8 @@ def accept(self, visitor: StatementVisitor[T]) -> T:
 class RaiseStmt(Statement):
     __slots__ = ("expr", "from_expr")
 
+    __match_args__ = ("expr", "from_expr")
+
     # Plain 'raise' is a valid statement.
     expr: Expression | None
     from_expr: Expression | None
@@ -1487,6 +1531,8 @@ def accept(self, visitor: StatementVisitor[T]) -> T:
 class TryStmt(Statement):
     __slots__ = ("body", "types", "vars", "handlers", "else_body", "finally_body")
 
+    __match_args__ = ("body", "types", "vars", "handlers", "else_body", "finally_body")
+
     body: Block  # Try body
     # Plain 'except:' also possible
     types: list[Expression | None]  # Except type expressions
@@ -1519,6 +1565,8 @@ def accept(self, visitor: StatementVisitor[T]) -> T:
 class WithStmt(Statement):
     __slots__ = ("expr", "target", "unanalyzed_type", "analyzed_types", "body", "is_async")
 
+    __match_args__ = ("expr", "target", "body")
+
     expr: list[Expression]
     target: list[Lvalue | None]
     # Type given by type comments for target, can be None
@@ -1548,6 +1596,10 @@ def accept(self, visitor: StatementVisitor[T]) -> T:
 
 
 class MatchStmt(Statement):
+    __slots__ = ("subject", "patterns", "guards", "bodies")
+
+    __match_args__ = ("subject", "patterns", "guards", "bodies")
+
     subject: Expression
     patterns: list[Pattern]
     guards: list[Expression | None]
@@ -1579,6 +1631,8 @@ class IntExpr(Expression):
 
     __slots__ = ("value",)
 
+    __match_args__ = ("value",)
+
     value: int  # 0 by default
 
     def __init__(self, value: int) -> None:
@@ -1600,6 +1654,8 @@ class StrExpr(Expression):
 
     __slots__ = ("value",)
 
+    __match_args__ = ("value",)
+
     value: str  # '' by default
 
     def __init__(self, value: str) -> None:
@@ -1615,6 +1671,8 @@ class BytesExpr(Expression):
 
     __slots__ = ("value",)
 
+    __match_args__ = ("value",)
+
     # Note: we deliberately do NOT use bytes here because it ends up
     # unnecessarily complicating a lot of the result logic. For example,
     # we'd have to worry about converting the bytes into a format we can
@@ -1639,6 +1697,8 @@ class FloatExpr(Expression):
 
     __slots__ = ("value",)
 
+    __match_args__ = ("value",)
+
     value: float  # 0.0 by default
 
     def __init__(self, value: float) -> None:
@@ -1654,6 +1714,8 @@ class ComplexExpr(Expression):
 
     __slots__ = ("value",)
 
+    __match_args__ = ("value",)
+
     value: complex
 
     def __init__(self, value: complex) -> None:
@@ -1678,6 +1740,8 @@ class StarExpr(Expression):
 
     __slots__ = ("expr", "valid")
 
+    __match_args__ = ("expr", "valid")
+
     expr: Expression
     valid: bool
 
@@ -1734,6 +1798,8 @@ class NameExpr(RefExpr):
 
     __slots__ = ("name", "is_special_form")
 
+    __match_args__ = ("name", "node")
+
     def __init__(self, name: str) -> None:
         super().__init__()
         self.name = name  # Name referred to (may be qualified)
@@ -1752,6 +1818,8 @@ class MemberExpr(RefExpr):
 
     __slots__ = ("expr", "name", "def_var")
 
+    __match_args__ = ("expr", "name", "node")
+
     def __init__(self, expr: Expression, name: str) -> None:
         super().__init__()
         self.expr = expr
@@ -1813,6 +1881,8 @@ class CallExpr(Expression):
 
     __slots__ = ("callee", "args", "arg_kinds", "arg_names", "analyzed")
 
+    __match_args__ = ("callee", "args", "arg_kinds", "arg_names")
+
     def __init__(
         self,
         callee: Expression,
@@ -1841,6 +1911,8 @@ def accept(self, visitor: ExpressionVisitor[T]) -> T:
 class YieldFromExpr(Expression):
     __slots__ = ("expr",)
 
+    __match_args__ = ("expr",)
+
     expr: Expression
 
     def __init__(self, expr: Expression) -> None:
@@ -1854,6 +1926,8 @@ def accept(self, visitor: ExpressionVisitor[T]) -> T:
 class YieldExpr(Expression):
     __slots__ = ("expr",)
 
+    __match_args__ = ("expr",)
+
     expr: Expression | None
 
     def __init__(self, expr: Expression | None) -> None:
@@ -1872,6 +1946,8 @@ class IndexExpr(Expression):
 
     __slots__ = ("base", "index", "method_type", "analyzed")
 
+    __match_args__ = ("base", "index")
+
     base: Expression
     index: Expression
     # Inferred __getitem__ method type
@@ -1896,6 +1972,8 @@ class UnaryExpr(Expression):
 
     __slots__ = ("op", "expr", "method_type")
 
+    __match_args__ = ("op", "expr")
+
     op: str  # TODO: Enum?
     expr: Expression
     # Inferred operator method type
@@ -1916,6 +1994,8 @@ class AssignmentExpr(Expression):
 
     __slots__ = ("target", "value")
 
+    __match_args__ = ("target", "value")
+
     def __init__(self, target: Expression, value: Expression) -> None:
         super().__init__()
         self.target = target
@@ -1931,6 +2011,8 @@ class OpExpr(Expression):
 
     __slots__ = ("op", "left", "right", "method_type", "right_always", "right_unreachable")
 
+    __match_args__ = ("left", "op", "right")
+
     op: str  # TODO: Enum?
     left: Expression
     right: Expression
@@ -1959,6 +2041,8 @@ class ComparisonExpr(Expression):
 
     __slots__ = ("operators", "operands", "method_types")
 
+    __match_args__ = ("operands", "operators")
+
     operators: list[str]
     operands: list[Expression]
     # Inferred type for the operator methods (when relevant; None for 'is').
@@ -1989,6 +2073,8 @@ class SliceExpr(Expression):
 
     __slots__ = ("begin_index", "end_index", "stride")
 
+    __match_args__ = ("begin_index", "end_index", "stride")
+
     begin_index: Expression | None
     end_index: Expression | None
     stride: Expression | None
@@ -2013,6 +2099,8 @@ class CastExpr(Expression):
 
     __slots__ = ("expr", "type")
 
+    __match_args__ = ("expr", "type")
+
     expr: Expression
     type: mypy.types.Type
 
@@ -2030,6 +2118,8 @@ class AssertTypeExpr(Expression):
 
     __slots__ = ("expr", "type")
 
+    __match_args__ = ("expr", "type")
+
     expr: Expression
     type: mypy.types.Type
 
@@ -2047,6 +2137,8 @@ class RevealExpr(Expression):
 
     __slots__ = ("expr", "kind", "local_nodes")
 
+    __match_args__ = ("expr", "kind", "local_nodes")
+
     expr: Expression | None
     kind: int
     local_nodes: list[Var] | None
@@ -2068,6 +2160,8 @@ class SuperExpr(Expression):
 
     __slots__ = ("name", "info", "call")
 
+    __match_args__ = ("name", "call", "info")
+
     name: str
     info: TypeInfo | None  # Type that contains this super expression
     call: CallExpr  # The expression super(...)
@@ -2085,6 +2179,8 @@ def accept(self, visitor: ExpressionVisitor[T]) -> T:
 class LambdaExpr(FuncItem, Expression):
     """Lambda expression"""
 
+    __match_args__ = ("arguments", "arg_names", "arg_kinds", "body")
+
     @property
     def name(self) -> str:
         return "<lambda>"
@@ -2108,6 +2204,8 @@ class ListExpr(Expression):
 
     __slots__ = ("items",)
 
+    __match_args__ = ("items",)
+
     items: list[Expression]
 
     def __init__(self, items: list[Expression]) -> None:
@@ -2123,6 +2221,8 @@ class DictExpr(Expression):
 
     __slots__ = ("items",)
 
+    __match_args__ = ("items",)
+
     items: list[tuple[Expression | None, Expression]]
 
     def __init__(self, items: list[tuple[Expression | None, Expression]]) -> None:
@@ -2140,6 +2240,8 @@ class TupleExpr(Expression):
 
     __slots__ = ("items",)
 
+    __match_args__ = ("items",)
+
     items: list[Expression]
 
     def __init__(self, items: list[Expression]) -> None:
@@ -2155,6 +2257,8 @@ class SetExpr(Expression):
 
     __slots__ = ("items",)
 
+    __match_args__ = ("items",)
+
     items: list[Expression]
 
     def __init__(self, items: list[Expression]) -> None:
@@ -2170,6 +2274,8 @@ class GeneratorExpr(Expression):
 
     __slots__ = ("left_expr", "sequences", "condlists", "is_async", "indices")
 
+    __match_args__ = ("left_expr", "indices", "sequences", "condlists")
+
     left_expr: Expression
     sequences: list[Expression]
     condlists: list[list[Expression]]
@@ -2200,6 +2306,8 @@ class ListComprehension(Expression):
 
     __slots__ = ("generator",)
 
+    __match_args__ = ("generator",)
+
     generator: GeneratorExpr
 
     def __init__(self, generator: GeneratorExpr) -> None:
@@ -2215,6 +2323,8 @@ class SetComprehension(Expression):
 
     __slots__ = ("generator",)
 
+    __match_args__ = ("generator",)
+
     generator: GeneratorExpr
 
     def __init__(self, generator: GeneratorExpr) -> None:
@@ -2230,6 +2340,8 @@ class DictionaryComprehension(Expression):
 
     __slots__ = ("key", "value", "sequences", "condlists", "is_async", "indices")
 
+    __match_args__ = ("key", "value", "indices", "sequences", "condlists")
+
     key: Expression
     value: Expression
     sequences: list[Expression]
@@ -2263,6 +2375,8 @@ class ConditionalExpr(Expression):
 
     __slots__ = ("cond", "if_expr", "else_expr")
 
+    __match_args__ = ("if_expr", "cond", "else_expr")
+
     cond: Expression
     if_expr: Expression
     else_expr: Expression
@@ -2282,6 +2396,8 @@ class TypeApplication(Expression):
 
     __slots__ = ("expr", "types")
 
+    __match_args__ = ("expr", "types")
+
     expr: Expression
     types: list[mypy.types.Type]
 
@@ -2359,6 +2475,8 @@ class TypeVarExpr(TypeVarLikeExpr):
 
     __slots__ = ("values",)
 
+    __match_args__ = ("name", "values", "upper_bound")
+
     # Value restriction: only types in the list are valid as values. If the
     # list is empty, there is no restriction.
     values: list[mypy.types.Type]
@@ -2402,6 +2520,8 @@ def deserialize(cls, data: JsonDict) -> TypeVarExpr:
 class ParamSpecExpr(TypeVarLikeExpr):
     __slots__ = ()
 
+    __match_args__ = ("name", "upper_bound")
+
     def accept(self, visitor: ExpressionVisitor[T]) -> T:
         return visitor.visit_paramspec_expr(self)
 
@@ -2430,6 +2550,8 @@ class TypeVarTupleExpr(TypeVarLikeExpr):
 
     __slots__ = ()
 
+    __match_args__ = ("name", "upper_bound")
+
     def accept(self, visitor: ExpressionVisitor[T]) -> T:
         return visitor.visit_type_var_tuple_expr(self)
 
@@ -2458,6 +2580,8 @@ class TypeAliasExpr(Expression):
 
     __slots__ = ("type", "tvars", "no_args", "node")
 
+    __match_args__ = ("type", "tvars", "no_args", "node")
+
     # The target type.
     type: mypy.types.Type
     # Names of unbound type variables used to define the alias
@@ -2486,6 +2610,8 @@ class NamedTupleExpr(Expression):
 
     __slots__ = ("info", "is_typed")
 
+    __match_args__ = ("info",)
+
     # The class representation of this named tuple (its tuple_type attribute contains
     # the tuple item types)
     info: TypeInfo
@@ -2505,6 +2631,8 @@ class TypedDictExpr(Expression):
 
     __slots__ = ("info",)
 
+    __match_args__ = ("info",)
+
     # The class representation of this typed dict
     info: TypeInfo
 
@@ -2521,6 +2649,8 @@ class EnumCallExpr(Expression):
 
     __slots__ = ("info", "items", "values")
 
+    __match_args__ = ("info", "items", "values")
+
     # The class representation of this enumerated type
     info: TypeInfo
     # The item names (for debugging)
@@ -2557,6 +2687,8 @@ class NewTypeExpr(Expression):
 
     __slots__ = ("name", "old_type", "info")
 
+    __match_args__ = ("name", "old_type", "info")
+
     name: str
     # The base type (the second argument to NewType)
     old_type: mypy.types.Type | None
@@ -2580,6 +2712,8 @@ class AwaitExpr(Expression):
 
     __slots__ = ("expr",)
 
+    __match_args__ = ("expr",)
+
     expr: Expression
 
     def __init__(self, expr: Expression) -> None:
@@ -3283,6 +3417,8 @@ def f(x: B[T]) -> T: ...  # without T, Any would be used here
         "eager",
     )
 
+    __match_args__ = ("name", "target", "alias_tvars", "no_args")
+
     def __init__(
         self,
         target: mypy.types.Type,
diff --git a/mypyc/irbuild/classdef.py b/mypyc/irbuild/classdef.py
--- a/mypyc/irbuild/classdef.py
+++ b/mypyc/irbuild/classdef.py
@@ -629,7 +629,7 @@ def find_attr_initializers(
                 and not isinstance(stmt.rvalue, TempNode)
             ):
                 name = stmt.lvalues[0].name
-                if name == "__slots__":
+                if name in ("__slots__", "__match_args__"):
                     continue
 
                 if name == "__deletable__":
diff --git a/mypyc/irbuild/prepare.py b/mypyc/irbuild/prepare.py
--- a/mypyc/irbuild/prepare.py
+++ b/mypyc/irbuild/prepare.py
@@ -226,7 +226,11 @@ def prepare_class_def(
 
         if isinstance(node.node, Var):
             assert node.node.type, "Class member %s missing type" % name
-            if not node.node.is_classvar and name not in ("__slots__", "__deletable__"):
+            if not node.node.is_classvar and name not in (
+                "__slots__",
+                "__deletable__",
+                "__match_args__",
+            ):
                 ir.attributes[name] = mapper.type_to_rtype(node.node.type)
         elif isinstance(node.node, (FuncDef, Decorator)):
             prepare_method_def(ir, module_name, cdef, mapper, node.node)


Test patch: