Skip to content
Merged
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
6 changes: 6 additions & 0 deletions doc/data/messages/i/invalid-match-args-definition/bad.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class Book:
__match_args__ = ["title", "year"] # [invalid-match-args-definition]

def __init__(self, title, year):
self.title = title
self.year = year
6 changes: 6 additions & 0 deletions doc/data/messages/i/invalid-match-args-definition/good.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class Book:
__match_args__ = ("title", "year")

def __init__(self, title, year):
self.title = title
self.year = year
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `Python documentation <https://docs.python.org/3/reference/datamodel.html#customizing-positional-arguments-in-class-pattern-matching>`_
14 changes: 14 additions & 0 deletions doc/data/messages/m/multiple-class-sub-patterns/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("abc", title="abc"): # [multiple-class-sub-patterns]
...
case Book(year=2000, year=2001): # [multiple-class-sub-patterns]
...
14 changes: 14 additions & 0 deletions doc/data/messages/m/multiple-class-sub-patterns/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="abc"):
...
case Book(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>`_
13 changes: 13 additions & 0 deletions doc/data/messages/t/too-many-positional-sub-patterns/bad.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class Book:
__match_args__ = ("title", "year")

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


def func(item: Book):
match item:
case Book("title", 2000, "author"): # [too-many-positional-sub-patterns]
...
13 changes: 13 additions & 0 deletions doc/data/messages/t/too-many-positional-sub-patterns/good.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class Book:
__match_args__ = ("title", "year")

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


def func(item: Book):
match item:
case Book("title", 2000, author="author"):
...
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>`_
1 change: 1 addition & 0 deletions doc/data/ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ line-length = 103

extend-exclude = [
"messages/d/duplicate-argument-name/bad.py",
"messages/m/multiple-class-sub-patterns/bad.py",
"messages/s/syntax-error/bad.py",
# syntax error in newer python versions
"messages/b/bare-name-capture-pattern/bad.py",
Expand Down
8 changes: 8 additions & 0 deletions doc/user_guide/checkers/features.rst
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,14 @@ Match Statements checker Messages
:bare-name-capture-pattern (E1901): *The name capture `case %s` makes the remaining patterns unreachable. Use a dotted name (for example an enum) to fix this.*
Emitted when a name capture pattern is used in a match statement and there
are case statements below it.
:invalid-match-args-definition (E1902): *`__match_args__` must be a tuple of strings.*
Emitted if `__match_args__` isn't a tuple of strings required for match.
:too-many-positional-sub-patterns (E1903): *%s excepts %d positional sub-patterns (given %d)*
Emitted when the number of allowed positional sub-patterns exceeds the the
number of allowed sub-patterns specified in `__match_args__`.
:multiple-class-sub-patterns (E1904): *Multiple sub-patterns for attribute %s*
Emitted when there are more than one sub-patterns for a specific attribute in
a class pattern.


Method Args checker
Expand Down
3 changes: 3 additions & 0 deletions doc/user_guide/messages/messages_overview.rst
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ All messages in the error category:
error/invalid-index-returned
error/invalid-length-hint-returned
error/invalid-length-returned
error/invalid-match-args-definition
error/invalid-metaclass
error/invalid-repr-returned
error/invalid-sequence-index
Expand All @@ -123,6 +124,7 @@ All messages in the error category:
error/mixed-format-string
error/modified-iterating-dict
error/modified-iterating-set
error/multiple-class-sub-patterns
error/no-member
error/no-method-argument
error/no-name-in-module
Expand Down Expand Up @@ -157,6 +159,7 @@ All messages in the error category:
error/too-few-format-args
error/too-many-format-args
error/too-many-function-args
error/too-many-positional-sub-patterns
error/too-many-star-expressions
error/truncated-format-string
error/undefined-all-variable
Expand Down
6 changes: 6 additions & 0 deletions doc/whatsnew/fragments/10559.new_check
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Add new checks for invalid uses of class patterns in ``match``.
* ``invalid-match-args-definition`` is emitted if ``__match_args__`` isn't a tuple of strings.
* ``too-many-positional-sub-patterns`` if there are more positional sub-patterns than specified in ``__match_args__``.
* ``multiple-class-sub-patterns`` if there are multiple sub-patterns for the same attribute.

Refs #10559
112 changes: 108 additions & 4 deletions pylint/checkers/match_statements_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@

from typing import TYPE_CHECKING

import astroid.exceptions
from astroid import nodes

from pylint.checkers import BaseChecker
from pylint.checkers.utils import only_required_for_messages
from pylint.interfaces import HIGH
from pylint.checkers.utils import only_required_for_messages, safe_infer
from pylint.interfaces import HIGH, INFERENCE

if TYPE_CHECKING:
from pylint.lint import PyLinter
Expand All @@ -27,9 +28,47 @@ class MatchStatementChecker(BaseChecker):
"bare-name-capture-pattern",
"Emitted when a name capture pattern is used in a match statement "
"and there are case statements below it.",
)
),
"E1902": (
"`__match_args__` must be a tuple of strings.",
"invalid-match-args-definition",
"Emitted if `__match_args__` isn't a tuple of strings required for match.",
),
"E1903": (
"%s excepts %d positional sub-patterns (given %d)",
"too-many-positional-sub-patterns",
"Emitted when the number of allowed positional sub-patterns exceeds the "
"the number of allowed sub-patterns specified in `__match_args__`.",
),
"E1904": (
"Multiple sub-patterns for attribute %s",
"multiple-class-sub-patterns",
"Emitted when there are more than one sub-patterns for a specific "
"attribute in a class pattern.",
),
}

@only_required_for_messages("invalid-match-args-definition")
def visit_assignname(self, node: nodes.AssignName) -> None:
if (
node.name == "__match_args__"
and isinstance(node.frame(), nodes.ClassDef)
and isinstance(node.parent, nodes.Assign)
and not (
isinstance(node.parent.value, nodes.Tuple)
and all(
isinstance(el, nodes.Const) and isinstance(el.value, str)
for el in node.parent.value.elts
)
)
):
self.add_message(
"invalid-match-args-definition",
node=node.parent.value,
args=(),
confidence=HIGH,
)

@only_required_for_messages("bare-name-capture-pattern")
def visit_match(self, node: nodes.Match) -> None:
"""Check if a name capture pattern prevents the other cases from being
Expand All @@ -43,10 +82,75 @@ def visit_match(self, node: nodes.Match) -> None:
self.add_message(
"bare-name-capture-pattern",
node=case.pattern,
args=name,
args=(name,),
confidence=HIGH,
)

@staticmethod
def get_match_args_for_class(node: nodes.NodeNG) -> list[str] | None:
"""Infer __match_args__ from class name."""
inferred = safe_infer(node)
if not isinstance(inferred, nodes.ClassDef):
return None
try:
match_args = inferred.getattr("__match_args__")
except astroid.exceptions.NotFoundError:
return None

match match_args:
case [
nodes.AssignName(parent=nodes.Assign(value=nodes.Tuple(elts=elts))),
*_,
] if all(
isinstance(el, nodes.Const) and isinstance(el.value, str) for el in elts
):
return [el.value for el in elts]
case _:
return None

def check_duplicate_sub_patterns(
self, name: str, node: nodes.NodeNG, *, attrs: set[str], dups: set[str]
) -> None:
"""Track attribute names and emit error if name is given more than once."""
if name in attrs and name not in dups:
dups.add(name)
self.add_message(
"multiple-class-sub-patterns",
node=node,
args=(name,),
confidence=INFERENCE,
)
else:
attrs.add(name)

@only_required_for_messages(
"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
and (match_args := self.get_match_args_for_class(node.cls)) is not None
):
if len(node.patterns) > len(match_args):
self.add_message(
"too-many-positional-sub-patterns",
node=node,
args=(node.cls.as_string(), len(match_args), len(node.patterns)),
confidence=INFERENCE,
)
return

for i in range(len(node.patterns)):
name = match_args[i]
self.check_duplicate_sub_patterns(name, node, attrs=attrs, dups=dups)

for kw_name in node.kwd_attrs:
self.check_duplicate_sub_patterns(kw_name, node, attrs=attrs, dups=dups)


def register(linter: PyLinter) -> None:
linter.register_checker(MatchStatementChecker(linter))
40 changes: 40 additions & 0 deletions tests/functional/m/match_class_pattern.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# pylint: disable=missing-docstring,unused-variable,too-few-public-methods

# -- Check __match_args__ definitions --
class A:
__match_args__ = ("x",)

class B(A):
__match_args__ = ("x", "y")

class C:
__match_args__ = ["x", "y"] # [invalid-match-args-definition]

class D:
__match_args__ = ("x", 1) # [invalid-match-args-definition]

class E:
def f(self):
__match_args__ = ["x"]


def f1(x):
"""Check too many positional sub-patterns"""
match x:
case A(1): ...
case A(1, 2): ... # [too-many-positional-sub-patterns]
case B(1, 2): ...
case B(1, 2, 3): ... # [too-many-positional-sub-patterns]

def f2(x):
"""Check multiple sub-patterns for attribute"""
match x:
case A(1, x=1): ... # [multiple-class-sub-patterns]
case A(1, y=1): ...
case A(x=1, x=2, x=3): ... # [multiple-class-sub-patterns]

# with invalid __match_args__ we can't detect duplicates with positional patterns
case D(1, x=1): ...

# If class name is undefined, we can't get __match_args__
case NotDefined(1, x=1): ... # [undefined-variable]
7 changes: 7 additions & 0 deletions tests/functional/m/match_class_pattern.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
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 excepts 1 positional sub-patterns (given 2):INFERENCE
too-many-positional-sub-patterns:27:13:27:23:f1:B excepts 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
Loading