Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
14 changes: 14 additions & 0 deletions doc/data/messages/m/match-class-bind-self/bad.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class Book:
__match_args__ = ("title", "year")

def __init__(self, title, year):
self.title = title
self.year = year


def func(item: Book):
match item:
case Book(title=str(title)): # [match-class-bind-self]
...
case Book(year=int(year)): # [match-class-bind-self]
...
14 changes: 14 additions & 0 deletions doc/data/messages/m/match-class-bind-self/good.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class Book:
__match_args__ = ("title", "year")

def __init__(self, title, year):
self.title = title
self.year = year


def func(item: Book):
match item:
case Book(title=str() as title):
...
case Book(year=int() as year):
...
1 change: 1 addition & 0 deletions doc/data/messages/m/match-class-bind-self/related.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `Python documentation <https://docs.python.org/3/reference/compound_stmts.html#class-patterns>`_
12 changes: 12 additions & 0 deletions doc/data/messages/m/match-class-positional-attributes/bad.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class Book:
__match_args__ = ("title", "year")

def __init__(self, title, year):
self.title = title
self.year = year


def func(item: Book):
match item:
case Book("abc", 2000): # [match-class-positional-attributes]
...
12 changes: 12 additions & 0 deletions doc/data/messages/m/match-class-positional-attributes/good.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class Book:
__match_args__ = ("title", "year")

def __init__(self, title, year):
self.title = title
self.year = year


def func(item: Book):
match item:
case Book(title="abc", year=2000):
...
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `Python documentation <https://docs.python.org/3/reference/compound_stmts.html#class-patterns>`_
6 changes: 6 additions & 0 deletions doc/user_guide/checkers/features.rst
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,12 @@ Match Statements checker Messages
:multiple-class-sub-patterns (E1904): *Multiple sub-patterns for attribute %s*
Emitted when there is more than one sub-pattern for a specific attribute in
a class pattern.
:match-class-bind-self (R1905): *Use '%s' instead*
Match class patterns are faster if the name binding happens for the whole
pattern and any lookup for `__match_args__` can be avoided.
:match-class-positional-attributes (R1906): *Use keyword attributes instead of positional ones*
Keyword attributes are more explicit and slightly faster since CPython can
skip the `__match_args__` lookup.


Method Args checker
Expand Down
2 changes: 2 additions & 0 deletions doc/user_guide/messages/messages_overview.rst
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,8 @@ All messages in the refactor category:
refactor/inconsistent-return-statements
refactor/literal-comparison
refactor/magic-value-comparison
refactor/match-class-bind-self
refactor/match-class-positional-attributes
refactor/no-classmethod-decorator
refactor/no-else-break
refactor/no-else-continue
Expand Down
8 changes: 8 additions & 0 deletions doc/whatsnew/fragments/10586.new_check
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Add additional checks for suboptimal uses of class patterns in :keyword:`match`.

* :ref:`match-class-bind-self` is emitted if a name is bound to ``self`` instead of
using an ``as`` pattern.
* :ref:`match-class-positional-attributes` is emitted if a class pattern has positional
attributes when keywords could be used.

Refs #10586
66 changes: 66 additions & 0 deletions pylint/checkers/match_statements_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@
from pylint.lint import PyLinter


# List of builtin classes which match self
# https://docs.python.org/3/reference/compound_stmts.html#class-patterns
MATCH_CLASS_SELF_NAMES = {
"builtins.bool",
"builtins.bytearray",
"builtins.bytes",
"builtins.dict",
"builtins.float",
"builtins.frozenset",
"builtins.int",
"builtins.list",
"builtins.set",
"builtins.str",
"builtins.tuple",
}


class MatchStatementChecker(BaseChecker):
name = "match_statements"
msgs = {
Expand Down Expand Up @@ -46,6 +63,19 @@ class MatchStatementChecker(BaseChecker):
"Emitted when there is more than one sub-pattern for a specific "
"attribute in a class pattern.",
),
"R1905": (
"Use '%s' instead",
"match-class-bind-self",
"Match class patterns are faster if the name binding happens "
"for the whole pattern and any lookup for `__match_args__` "
"can be avoided.",
),
"R1906": (
"Use keyword attributes instead of positional ones",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"Use keyword attributes instead of positional ones",
"Use keyword attributes instead of positional ones ('%s')",

I think we can construct the expected here too. (And one day use it to autofix?)

"match-class-positional-attributes",
"Keyword attributes are more explicit and slightly faster "
"since CPython can skip the `__match_args__` lookup.",
),
}

@only_required_for_messages("invalid-match-args-definition")
Expand Down Expand Up @@ -86,6 +116,26 @@ def visit_match(self, node: nodes.Match) -> None:
confidence=HIGH,
)

@only_required_for_messages("match-class-bind-self")
def visit_matchas(self, node: nodes.MatchAs) -> None:
match node:
case nodes.MatchAs(
parent=nodes.MatchClass(cls=nodes.Name() as cls_name),
name=nodes.AssignName(name=name),
pattern=None,
):
inferred = safe_infer(cls_name)
if (
isinstance(inferred, nodes.ClassDef)
and inferred.qname() in MATCH_CLASS_SELF_NAMES
):
self.add_message(
"match-class-bind-self",
node=node,
args=(f"{cls_name.name}() as {name}",),
confidence=HIGH,
)

@staticmethod
def get_match_args_for_class(node: nodes.NodeNG) -> list[str] | None:
"""Infer __match_args__ from class name."""
Expand All @@ -95,6 +145,8 @@ def get_match_args_for_class(node: nodes.NodeNG) -> list[str] | None:
try:
match_args = inferred.getattr("__match_args__")
except astroid.exceptions.NotFoundError:
if inferred.qname() in MATCH_CLASS_SELF_NAMES:
return ["<self>"]
return None

match match_args:
Expand Down Expand Up @@ -124,13 +176,27 @@ def check_duplicate_sub_patterns(
attrs.add(name)

@only_required_for_messages(
"match-class-positional-attributes",
"multiple-class-sub-patterns",
"too-many-positional-sub-patterns",
)
def visit_matchclass(self, node: nodes.MatchClass) -> None:
attrs: set[str] = set()
dups: set[str] = set()

if node.patterns:
if isinstance(node, nodes.MatchClass) and isinstance(node.cls, nodes.Name):
inferred = safe_infer(node.cls)
if not (
isinstance(inferred, nodes.ClassDef)
and inferred.qname() in MATCH_CLASS_SELF_NAMES
):
self.add_message(
"match-class-positional-attributes",
node=node,
confidence=HIGH,
)
Copy link
Member Author

@cdce8p cdce8p Sep 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the sentry results, I do wonder if this is to strict and we'd end up emitting too many false positives. self matches also work for every subclass of the special builtin classes (as long as they don't implement __match_args__). Most notably NamedTuple.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added some special casing for tuple that should handle it.


if (
node.patterns
and (match_args := self.get_match_args_for_class(node.cls)) is not None
Expand Down
4 changes: 2 additions & 2 deletions tests/functional/ext/mccabe/mccabe.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,9 +327,9 @@ def nested_match_case(data): # [too-complex]
match data:
case {"type": "user", "data": user_data}:
match user_data: # Nested match adds complexity
case {"name": str(name)}:
case {"name": str() as name}:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check already making pylint better 😄

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed most of the pylint code in #10580 already. That's why there are "only" two instances in a test case left here.

return f"User: {name}"
case {"id": int(user_id)}:
case {"id": int() as user_id}:
return f"User ID: {user_id}"
case _:
return "Unknown user format"
Expand Down
26 changes: 26 additions & 0 deletions tests/functional/m/match_class_pattern.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# pylint: disable=missing-docstring,unused-variable,too-few-public-methods
# pylint: disable=match-class-positional-attributes

# -- Check __match_args__ definitions --
class A:
Expand All @@ -25,6 +26,8 @@ def f1(x):
case A(1, 2): ... # [too-many-positional-sub-patterns]
case B(1, 2): ...
case B(1, 2, 3): ... # [too-many-positional-sub-patterns]
case int(1): ...
case int(1, 2): ... # [too-many-positional-sub-patterns]

def f2(x):
"""Check multiple sub-patterns for attribute"""
Expand All @@ -38,3 +41,26 @@ def f2(x):

# If class name is undefined, we can't get __match_args__
case NotDefined(1, x=1): ... # [undefined-variable]

def f3(x):
"""Check class pattern with name binding to self."""
match x:
case int(y): ... # [match-class-bind-self]
case int() as y: ...
case int(2 as y): ...
case str(y): ... # [match-class-bind-self]
case str() as y: ...
case str("Hello" as y): ...
case tuple(y): ... # [match-class-bind-self]
case tuple() as y: ...

def f4(x):
"""Check for positional attributes if keywords could be used."""
# pylint: enable=match-class-positional-attributes
match x:
case int(2): ...
case bool(True): ...
case A(1): ... # [match-class-positional-attributes]
case A(x=1): ...
case B(1, 2): ... # [match-class-positional-attributes]
case B(x=1, y=2): ...
20 changes: 13 additions & 7 deletions tests/functional/m/match_class_pattern.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
invalid-match-args-definition:11:21:11:31:C:`__match_args__` must be a tuple of strings.:HIGH
invalid-match-args-definition:14:21:14:29:D:`__match_args__` must be a tuple of strings.:HIGH
too-many-positional-sub-patterns:25:13:25:20:f1:A expects 1 positional sub-patterns (given 2):INFERENCE
too-many-positional-sub-patterns:27:13:27:23:f1:B expects 2 positional sub-patterns (given 3):INFERENCE
multiple-class-sub-patterns:32:13:32:22:f2:Multiple sub-patterns for attribute x:INFERENCE
multiple-class-sub-patterns:34:13:34:29:f2:Multiple sub-patterns for attribute x:INFERENCE
undefined-variable:40:13:40:23:f2:Undefined variable 'NotDefined':UNDEFINED
invalid-match-args-definition:12:21:12:31:C:`__match_args__` must be a tuple of strings.:HIGH
invalid-match-args-definition:15:21:15:29:D:`__match_args__` must be a tuple of strings.:HIGH
too-many-positional-sub-patterns:26:13:26:20:f1:A expects 1 positional sub-patterns (given 2):INFERENCE
too-many-positional-sub-patterns:28:13:28:23:f1:B expects 2 positional sub-patterns (given 3):INFERENCE
too-many-positional-sub-patterns:30:13:30:22:f1:int expects 1 positional sub-patterns (given 2):INFERENCE
multiple-class-sub-patterns:35:13:35:22:f2:Multiple sub-patterns for attribute x:INFERENCE
multiple-class-sub-patterns:37:13:37:29:f2:Multiple sub-patterns for attribute x:INFERENCE
undefined-variable:43:13:43:23:f2:Undefined variable 'NotDefined':UNDEFINED
match-class-bind-self:48:17:48:18:f3:Use 'int() as y' instead:HIGH
match-class-bind-self:51:17:51:18:f3:Use 'str() as y' instead:HIGH
match-class-bind-self:54:19:54:20:f3:Use 'tuple() as y' instead:HIGH
match-class-positional-attributes:63:13:63:17:f4:Use keyword attributes instead of positional ones:HIGH
match-class-positional-attributes:65:13:65:20:f4:Use keyword attributes instead of positional ones:HIGH
1 change: 1 addition & 0 deletions tests/functional/p/pattern_matching.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# pylint: disable=missing-docstring,invalid-name,too-few-public-methods
# pylint: disable=match-class-positional-attributes

class Point2D:
__match_args__ = ("x", "y")
Expand Down
Loading