From 75cef96b9cf3af813d92917c38a99cfb782c7fee Mon Sep 17 00:00:00 2001 From: Nicolas Forstner Date: Wed, 28 Oct 2026 23:08:19 +0000 Subject: [PATCH 1/2] Avoid false possibly-undefined errors after exhaustive match statements --- mypy/checker.py | 3 +- mypy/nodes.py | 7 ++- mypy/partially_defined.py | 3 + test-data/unit/check-possibly-undefined.test | 58 ++++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 3f2a8476ed934..aaee06e7e8999 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6168,7 +6168,8 @@ def visit_match_stmt(self, s: MatchStmt) -> None: self.push_type_map(else_map, from_assignment=False) unmatched_types = else_map - if not is_unreachable_map(unmatched_types) and not self.current_node_deferred: + s.unreachable_else = is_unreachable_map(unmatched_types) + if not s.unreachable_else and not self.current_node_deferred: for typ in unmatched_types.values(): self.msg.match_statement_inexhaustive_match(typ, s) diff --git a/mypy/nodes.py b/mypy/nodes.py index 20f7a0df804c4..773d409dbff55 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -2198,7 +2198,7 @@ def accept(self, visitor: StatementVisitor[T]) -> T: class MatchStmt(Statement): - __slots__ = ("subject", "subject_dummy", "patterns", "guards", "bodies") + __slots__ = ("subject", "subject_dummy", "patterns", "guards", "bodies", "unreachable_else") __match_args__ = ("subject", "patterns", "guards", "bodies") @@ -2207,6 +2207,10 @@ class MatchStmt(Statement): patterns: list[ConcretePattern] guards: list[Expression | None] bodies: list[Block] + # (Type checking may modify this flag repeatedly to indicate whether the implicit + # fall-through after the last case is unreachable, i.e. whether the match is exhaustive, + # considering the current type information.) + unreachable_else: bool def __init__( self, @@ -2222,6 +2226,7 @@ def __init__( self.patterns = patterns self.guards = guards self.bodies = bodies + self.unreachable_else = False def accept(self, visitor: StatementVisitor[T]) -> T: return visitor.visit_match_stmt(self) diff --git a/mypy/partially_defined.py b/mypy/partially_defined.py index 2bff1669becbb..20bb06f8dd1d3 100644 --- a/mypy/partially_defined.py +++ b/mypy/partially_defined.py @@ -424,6 +424,9 @@ def visit_match_stmt(self, o: MatchStmt) -> None: is_catchall = infer_pattern_value(pattern) == ALWAYS_TRUE if not is_catchall: self.tracker.next_branch() + if o.unreachable_else and i == len(o.patterns) - 1: + # The match is exhaustive, so the implicit fall-through is unreachable. + self.tracker.skip_branch() self.tracker.end_branch_statement() def visit_func_def(self, o: FuncDef) -> None: diff --git a/test-data/unit/check-possibly-undefined.test b/test-data/unit/check-possibly-undefined.test index 42843ec0dc1dc..9fffe21671b65 100644 --- a/test-data/unit/check-possibly-undefined.test +++ b/test-data/unit/check-possibly-undefined.test @@ -1230,3 +1230,61 @@ elif not b: z = 2 y = z y + 1 + +[case testExhaustiveMatchNoFallThrough] +# flags: --enable-error-code possibly-undefined +from enum import Enum +from typing import Literal, Union + +class Color(Enum): + RED = 1 + BLUE = 2 + +def union_subject(x: Union[int, str]) -> None: + match x: + case int(): + y = 1 + case str(): + y = 2 + y # OK + +def enum_subject(c: Color) -> None: + match c: + case Color.RED: + y = 1 + case Color.BLUE: + y = 2 + y # OK + +def literal_subject(l: Literal["a", "b"]) -> None: + match l: + case "a": + y = 1 + case "b": + y = 2 + y # OK + +def non_exhaustive(x: Union[int, str, bytes]) -> None: + match x: + case int(): + y = 1 + case str(): + y = 2 + y # E: Name "y" may be undefined + +def guard_on_last_case(x: Union[int, str], b: bool) -> None: + match x: + case int(): + y = 1 + case str() if b: + y = 2 + y # E: Name "y" may be undefined + +def any_subject(x) -> None: + match x: + case int(): + y = 1 + case str(): + y = 2 + y # E: Name "y" may be undefined +[builtins fixtures/tuple.pyi] From fee4b07ec6bae39b2434ac15fc9bfe246fb1c421 Mon Sep 17 00:00:00 2001 From: Nicolas Forstner Date: Wed, 28 Oct 2026 23:21:30 +0000 Subject: [PATCH 2/2] Rename MatchStmt.unreachable_else to is_exhaustive --- mypy/checker.py | 4 ++-- mypy/nodes.py | 10 +++++----- mypy/partially_defined.py | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index aaee06e7e8999..2d8b4e48548ce 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6168,8 +6168,8 @@ def visit_match_stmt(self, s: MatchStmt) -> None: self.push_type_map(else_map, from_assignment=False) unmatched_types = else_map - s.unreachable_else = is_unreachable_map(unmatched_types) - if not s.unreachable_else and not self.current_node_deferred: + s.is_exhaustive = is_unreachable_map(unmatched_types) + if not s.is_exhaustive and not self.current_node_deferred: for typ in unmatched_types.values(): self.msg.match_statement_inexhaustive_match(typ, s) diff --git a/mypy/nodes.py b/mypy/nodes.py index 773d409dbff55..8e88c6abb10ee 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -2198,7 +2198,7 @@ def accept(self, visitor: StatementVisitor[T]) -> T: class MatchStmt(Statement): - __slots__ = ("subject", "subject_dummy", "patterns", "guards", "bodies", "unreachable_else") + __slots__ = ("subject", "subject_dummy", "patterns", "guards", "bodies", "is_exhaustive") __match_args__ = ("subject", "patterns", "guards", "bodies") @@ -2207,10 +2207,10 @@ class MatchStmt(Statement): patterns: list[ConcretePattern] guards: list[Expression | None] bodies: list[Block] - # (Type checking may modify this flag repeatedly to indicate whether the implicit - # fall-through after the last case is unreachable, i.e. whether the match is exhaustive, + # (Type checking may modify this flag repeatedly to indicate whether the match is + # exhaustive, i.e. whether the implicit fall-through after the last case is unreachable, # considering the current type information.) - unreachable_else: bool + is_exhaustive: bool def __init__( self, @@ -2226,7 +2226,7 @@ def __init__( self.patterns = patterns self.guards = guards self.bodies = bodies - self.unreachable_else = False + self.is_exhaustive = False def accept(self, visitor: StatementVisitor[T]) -> T: return visitor.visit_match_stmt(self) diff --git a/mypy/partially_defined.py b/mypy/partially_defined.py index 20bb06f8dd1d3..1eda95462f59f 100644 --- a/mypy/partially_defined.py +++ b/mypy/partially_defined.py @@ -424,7 +424,7 @@ def visit_match_stmt(self, o: MatchStmt) -> None: is_catchall = infer_pattern_value(pattern) == ALWAYS_TRUE if not is_catchall: self.tracker.next_branch() - if o.unreachable_else and i == len(o.patterns) - 1: + if o.is_exhaustive and i == len(o.patterns) - 1: # The match is exhaustive, so the implicit fall-through is unreachable. self.tracker.skip_branch() self.tracker.end_branch_statement()