From 07f81122df2ab4a31ac900e26e2eb5493bd2c2fe Mon Sep 17 00:00:00 2001 From: Haegan McGarry Date: Tue, 1 Sep 2026 14:55:56 -0700 Subject: [PATCH] Report error for irrefutable patterns that make remaining match patterns unreachable CPython rejects a match statement at compile time when an unguarded irrefutable pattern (a capture or wildcard) appears in any case except the last one, and when an irrefutable alternative appears in a non-final position of an or pattern. mypy accepted both without any error, so a file that cannot even be imported checked clean. Add the check to semantic analysis, where every pattern is visited unconditionally, so it also fires in unchecked functions the same way the runtime SyntaxError does. Fixes #21925 --- mypy/message_registry.py | 6 ++ mypy/patterns.py | 16 ++++ mypy/semanal.py | 24 ++++++ test-data/unit/check-python310.test | 117 ++++++++++++++++++++++++++++ 4 files changed, 163 insertions(+) diff --git a/mypy/message_registry.py b/mypy/message_registry.py index 2e40048cbb58b..9cea9e8383bc9 100644 --- a/mypy/message_registry.py +++ b/mypy/message_registry.py @@ -281,6 +281,12 @@ def with_additional_msg(self, info: str) -> ErrorMessage: # Match Statement MISSING_MATCH_ARGS: Final = 'Class "{}" doesn\'t define "__match_args__"' OR_PATTERN_ALTERNATIVE_NAMES: Final = "Alternative patterns bind different names" +NAME_CAPTURE_MAKES_REMAINING_UNREACHABLE: Final = ( + 'Name capture "{}" makes remaining patterns unreachable' +) +WILDCARD_MAKES_REMAINING_UNREACHABLE: Final = ( + "Wildcard pattern makes remaining patterns unreachable" +) CLASS_PATTERN_GENERIC_TYPE_ALIAS: Final = ( "Class pattern class must not be a type alias with type parameters" ) diff --git a/mypy/patterns.py b/mypy/patterns.py index a01bf6acc8766..2917a5c6e5d9d 100644 --- a/mypy/patterns.py +++ b/mypy/patterns.py @@ -148,3 +148,19 @@ def __init__( def accept(self, visitor: PatternVisitor[T]) -> T: return visitor.visit_class_pattern(self) + + +def get_irrefutable_pattern(pattern: Pattern) -> AsPattern | None: + """Return the capture or wildcard pattern that makes this pattern irrefutable. + + An irrefutable pattern matches any subject: a capture pattern, a wildcard + pattern, an as pattern whose subpattern is irrefutable, or an or pattern + whose last alternative is irrefutable. Returns None for refutable patterns. + """ + if isinstance(pattern, AsPattern): + if pattern.pattern is None: + return pattern + return get_irrefutable_pattern(pattern.pattern) + if isinstance(pattern, OrPattern): + return get_irrefutable_pattern(pattern.patterns[-1]) + return None diff --git a/mypy/semanal.py b/mypy/semanal.py index cd1b0a738974f..86d4abf8d3de3 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -207,6 +207,7 @@ SingletonPattern, StarredPattern, ValuePattern, + get_irrefutable_pattern, ) from mypy.plugin import ( ClassDefContext, @@ -5785,12 +5786,28 @@ def visit_match_stmt(self, s: MatchStmt) -> None: infer_reachability_of_match_statement(s, self.options) s.subject.accept(self) for i in range(len(s.patterns)): + # An unguarded irrefutable pattern is only allowed in the last case, + # otherwise the remaining cases could never match. CPython rejects + # such match statements at compile time with a SyntaxError. + if i < len(s.patterns) - 1 and s.guards[i] is None: + irrefutable = get_irrefutable_pattern(s.patterns[i]) + if irrefutable is not None: + self.fail_irrefutable_pattern(irrefutable) s.patterns[i].accept(self) guard = s.guards[i] if guard is not None: guard.accept(self) self.visit_block(s.bodies[i]) + def fail_irrefutable_pattern(self, pattern: AsPattern) -> None: + if pattern.name is None: + msg = message_registry.WILDCARD_MAKES_REMAINING_UNREACHABLE + else: + msg = message_registry.NAME_CAPTURE_MAKES_REMAINING_UNREACHABLE.format( + pattern.name.name + ) + self.fail(msg, pattern, serious=True) + def visit_type_alias_stmt(self, s: TypeAliasStmt) -> None: if s.invalid_recursive_alias: return @@ -6555,6 +6572,13 @@ def visit_as_pattern(self, p: AsPattern) -> None: self.analyze_lvalue(p.name) def visit_or_pattern(self, p: OrPattern) -> None: + # An irrefutable alternative is only allowed in the last position, + # regardless of where the or pattern appears. CPython rejects other + # placements at compile time with a SyntaxError. + for pattern in p.patterns[:-1]: + irrefutable = get_irrefutable_pattern(pattern) + if irrefutable is not None: + self.fail_irrefutable_pattern(irrefutable) for pattern in p.patterns: pattern.accept(self) diff --git a/test-data/unit/check-python310.test b/test-data/unit/check-python310.test index 01e490d0da507..1ef9a3c985005 100644 --- a/test-data/unit/check-python310.test +++ b/test-data/unit/check-python310.test @@ -4005,3 +4005,120 @@ def enum_then_dummy_class(arg: DummyClass | Literal[MyEnum.RELEVANT]): case _: pass # E: Statement is unreachable [builtins fixtures/tuple.pyi] + + +[case testMatchIrrefutablePatternNotLastCase] +# Irrefutable patterns before the last case are a SyntaxError at runtime +def capture(x: int) -> None: + match x: + case y: # E: Name capture "y" makes remaining patterns unreachable + pass + case 1: + pass + +def wildcard(x: int) -> None: + match x: + case _: # E: Wildcard pattern makes remaining patterns unreachable + pass + case 1: + pass + +def as_capture(x: int) -> None: + match x: + case (y as z): # E: Name capture "y" makes remaining patterns unreachable + pass + case 1: + pass + +def or_ending_irrefutable(x: int) -> None: + match x: + case 1 | _: # E: Wildcard pattern makes remaining patterns unreachable + pass + case 2: + pass +[builtins fixtures/tuple.pyi] + +[case testMatchIrrefutablePatternAllowed] +def capture_last(x: int) -> None: + match x: + case 1: + pass + case y: + pass + +def wildcard_last(x: int) -> None: + match x: + case 1: + pass + case _: + pass + +def check(v: int) -> bool: ... + +def guarded_capture(x: int) -> None: + match x: + case y if check(y): + pass + case _: + pass + +def guarded_wildcard(x: int) -> None: + match x: + case _ if check(x): + pass + case 1: + pass + +def matches_anything_but_refutable(x: int) -> None: + match x: + case int(): + pass + case _: + pass +[builtins fixtures/tuple.pyi] + +[case testMatchIrrefutableOrPatternAlternativeNotLast] +# An irrefutable or pattern alternative is only allowed in the last position, +# even in the last case, in a guarded case, or nested in another pattern +def wildcard_alternative_last_case(x: int) -> None: + match x: + case 1: + pass + case _ | 2: # E: Wildcard pattern makes remaining patterns unreachable + pass + +def check(v: int) -> bool: ... + +def wildcard_alternative_guarded(x: int) -> None: + match x: + case _ | 1 if check(x): # E: Wildcard pattern makes remaining patterns unreachable + pass + case 2: + pass + +def nested_in_sequence(x: object) -> None: + match x: + case [_ | 1, y]: # E: Wildcard pattern makes remaining patterns unreachable + pass + +def nested_in_or(x: int) -> None: + match x: + case 1 | (2 | _) | 3: # E: Wildcard pattern makes remaining patterns unreachable + pass + +def or_ending_irrefutable_last_case(x: int) -> None: + match x: + case 1: + pass + case 2 | _: + pass +[builtins fixtures/tuple.pyi] + +[case testMatchIrrefutablePatternUncheckedFunction] +def f(x): + match x: + case y: # E: Name capture "y" makes remaining patterns unreachable + pass + case 1: + pass +[builtins fixtures/tuple.pyi]