Skip to content

Add flag to raise error if match statement does not match exaustively #19144

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 23 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
02796d9
Add check for exhaustive match statements
Don-Burns May 23, 2025
3aaa98b
Add flag for exhaustive match statements
Don-Burns May 23, 2025
6b613f5
Update docs for exhaustive match statements
Don-Burns May 23, 2025
3341b4b
Add flag to mypy_primer
Don-Burns May 23, 2025
a186cc8
Revert "Add flag to mypy_primer"
Don-Burns May 24, 2025
ec55c81
Move tests to 3.10 file so only run on >=3.10
Don-Burns May 24, 2025
8ce5b6a
Rename flag to better follow other flags
Don-Burns May 24, 2025
3581ec9
Fix doc gen error
Don-Burns May 24, 2025
0f9ed6a
Set default True for CI run, will revert before merge
Don-Burns May 24, 2025
62e4089
Revert "Set default True for CI run, will revert before merge"
Don-Burns May 24, 2025
abf7e98
Add explicit error code - change [misc] -> [exhaustive-match]
Don-Burns May 24, 2025
6a525f7
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 24, 2025
7dc7c01
Add missed docs
Don-Burns May 24, 2025
3ab9848
fixup! Add missed docs
Don-Burns May 24, 2025
1a3e359
fixup! fixup! Add missed docs
Don-Burns May 24, 2025
fecd37d
Change to error code only for exhaustive match
Don-Burns May 24, 2025
525924f
Merge doc literal paragraph
Don-Burns May 26, 2025
f798b43
Empty commit to trigger CI
Don-Burns May 27, 2025
9b200ac
Apply suggestions to remove whitespace from tests
Don-Burns May 28, 2025
681a327
fixup! Apply suggestions to remove whitespace from tests
Don-Burns May 28, 2025
d219871
Add test for more complex narrowing use case
Don-Burns May 28, 2025
914bff4
fixup! Add test for more complex narrowing use case
Don-Burns May 28, 2025
4cc25fe
Merge branch 'master' into feat/exhaustive-match
Don-Burns May 28, 2025
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
1 change: 1 addition & 0 deletions docs/source/command_line.rst
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,7 @@ of the above sections.
x = 'a string'
x.trim() # error: "str" has no attribute "trim" [attr-defined]


.. _configuring-error-messages:

Configuring error messages
Expand Down
48 changes: 48 additions & 0 deletions docs/source/error_code_list2.rst
Original file line number Diff line number Diff line change
Expand Up @@ -612,3 +612,51 @@ Example:
# mypy: disallow-any-explicit
from typing import Any
x: Any = 1 # Error: Explicit "Any" type annotation [explicit-any]


.. _code-exhaustive-match:
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not sure if the others have duplicated docs but if not could you just link from one of these sections to the other?

Copy link
Author

Choose a reason for hiding this comment

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

Based on feedback below, I moved to only be error code based.
Means the duplication in the CLI command doc page is removed.


Check that match statements match exhaustively [match-exhaustive]
-----------------------------------------------------------------------

If enabled with :option:`--enable-error-code exhaustive-match <mypy --enable-error-code>`,
mypy generates an error if a match statement does not match all possible cases/types.


Example:

.. code-block:: python

import enum


class Color(enum.Enum):
RED = 1
BLUE = 2

val: Color = Color.RED

# without --enable-error-code exhaustive-match
match val:
case Color.RED:
print("red")

# Also no issues without --enable-error-code exhaustive-match, but this is exhaustive
match val:
case Color.RED:
print("red")
case _:
print("other")

# with --enable-error-code exhaustive-match
# error: Cases within match statement do not exhaustively handle all values: "Literal[Color.BLUE]". If not intended to handle all cases, use `case _: pass`
match val:
case Color.RED:
print("red")

# no error with --enable-error-code exhaustive-match since all cases are handled
match val:
case Color.RED:
print("red")
case _:
print("other")
4 changes: 4 additions & 0 deletions docs/source/literal_types.rst
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,10 @@ If we forget to handle one of the cases, mypy will generate an error:
assert_never(direction) # E: Argument 1 to "assert_never" has incompatible type "Direction"; expected "NoReturn"

Exhaustiveness checking is also supported for match statements (Python 3.10 and later).
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this could be expanded instead of adding a new paragraph:

Suggested change
Exhaustiveness checking is also supported for match statements (Python 3.10 and later).
Exhaustiveness checking is also supported for match statements (Python 3.10 and later). With
:option:`--disallow-inexhaustive-match-statements <mypy --disallow-inexhaustive-match-statements>`,
mypy will even warn if match statements are inexhaustive.

(not sure that's better than your current approach now that I write it out, actually)

Copy link
Author

Choose a reason for hiding this comment

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

I think you are right here 👍
Changed to 1 paragraph

For match statements specifically, inexhaustive matches can be caught
without needing to use ``assert_never`` by using
:option:`--enable-error-code exhaustive-match <mypy --enable-error-code>`.


Extra Enum checks
*****************
Expand Down
6 changes: 6 additions & 0 deletions mypy/checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -5452,6 +5452,7 @@ def visit_match_stmt(self, s: MatchStmt) -> None:
inferred_types = self.infer_variable_types_from_type_maps(type_maps)

# The second pass narrows down the types and type checks bodies.
unmatched_types: TypeMap = None
for p, g, b in zip(s.patterns, s.guards, s.bodies):
current_subject_type = self.expr_checker.narrow_type_from_binder(
named_subject, subject_type
Expand Down Expand Up @@ -5508,6 +5509,11 @@ def visit_match_stmt(self, s: MatchStmt) -> None:
else:
self.accept(b)
self.push_type_map(else_map, from_assignment=False)
unmatched_types = else_map
Copy link
Collaborator

Choose a reason for hiding this comment

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

Does it work as intended when typemaps contain more than immediate targets?

For example, what happens in the following fork of an existing test testMatchNarrowingUnionTypedDictViaIndex with this flag enabled? If I remember the surrounding logic correctly, there will be two typemap entries - for d['tag'] and for d, so two diagnostics?

from typing import Literal, TypedDict

class A(TypedDict):
    tag: Literal["a"]
    name: str

class B(TypedDict):
    tag: Literal["b"]
    num: int

d: A | B
match d["tag"]:
    case "a":
        reveal_type(d)  # N: Revealed type is "TypedDict('__main__.A', {'tag': Literal['a'], 'name': builtins.str})"
        reveal_type(d["name"])  # N: Revealed type is "builtins.str"

Copy link
Author

@Don-Burns Don-Burns May 28, 2025

Choose a reason for hiding this comment

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

I would say that yes, it is behaving as I originally intended with 2 diagnostics. Added a test testExhaustiveMatchNarrowingUnionTypedDictViaIndex for the use case you have above.
d219871

My thinking of having multiple diagnostics is it might be a bit easier to read/understand in the case of longer unions, but I don't have a very strong opinion here either way


if unmatched_types is not None:
for typ in set(unmatched_types.values()):
self.msg.match_statement_inexhaustive_match(typ, s)

# This is needed due to a quirk in frame_context. Without it types will stay narrowed
# after the match.
Expand Down
6 changes: 6 additions & 0 deletions mypy/errorcodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,12 @@ def __hash__(self) -> int:
"General",
default_enabled=False,
)
EXHAUSTIVE_MATCH: Final = ErrorCode(
"exhaustive-match",
"Reject match statements that are not exhaustive",
"General",
default_enabled=False,
)

# Syntax errors are often blocking.
SYNTAX: Final[ErrorCode] = ErrorCode("syntax", "Report syntax errors", "General")
Expand Down
8 changes: 8 additions & 0 deletions mypy/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -2486,6 +2486,14 @@ def type_parameters_should_be_declared(self, undeclared: list[str], context: Con
code=codes.VALID_TYPE,
)

def match_statement_inexhaustive_match(self, typ: Type, context: Context) -> None:
type_str = format_type(typ, self.options)
msg = (
f"Cases within match statement do not exhaustively handle all values: {type_str}."
" If not intended to handle all cases, use `case _: pass`"
)
self.fail(msg, context, code=codes.EXHAUSTIVE_MATCH)


def quote_type_string(type_string: str) -> str:
"""Quotes a type representation for use in messages."""
Expand Down
139 changes: 139 additions & 0 deletions test-data/unit/check-python310.test
Original file line number Diff line number Diff line change
Expand Up @@ -2638,3 +2638,142 @@ def f2() -> None:
return
reveal_type(y) # N: Revealed type is "builtins.str"
[builtins fixtures/list.pyi]

[case testExhaustiveMatchNoFlag]

a: int = 5
match a:
case 1:
pass
case _:
pass

b: str = "hello"
match b:
case "bye":
pass
case _:
pass

[case testNonExhaustiveMatchNoFlag]

a: int = 5
match a:
case 1:
pass

b: str = "hello"
match b:
case "bye":
pass


[case testExhaustiveMatchWithFlag]
# flags: --enable-error-code exhaustive-match

a: int = 5
match a:
case 1:
pass
case _:
pass

b: str = "hello"
match b:
case "bye":
pass
case _:
pass

[case testNonExhaustiveMatchWithFlag]
# flags: --enable-error-code exhaustive-match

a: int = 5
match a: # E: Cases within match statement do not exhaustively handle all values: "int". If not intended to handle all cases, use `case _: pass`
case 1:
pass

b: str = "hello"
match b: # E: Cases within match statement do not exhaustively handle all values: "str". If not intended to handle all cases, use `case _: pass`
case "bye":
pass

[case testNonExhaustiveMatchEnumWithFlag]
# flags: --enable-error-code exhaustive-match

import enum

class Color(enum.Enum):
RED = 1
BLUE = 2
GREEN = 3

val: Color = Color.RED

match val: # E: Cases within match statement do not exhaustively handle all values: "Literal[Color.GREEN]". If not intended to handle all cases, use `case _: pass`
case Color.RED:
a = "red"
case Color.BLUE:
a= "blue"

[builtins fixtures/enum.pyi]


[case testExhaustiveMatchEnumWithFlag]
# flags: --enable-error-code exhaustive-match

import enum

class Color(enum.Enum):
RED = 1
BLUE = 2

val: Color = Color.RED

match val:
case Color.RED:
a = "red"
case Color.BLUE:
a= "blue"

[builtins fixtures/enum.pyi]

[case testNonExhaustiveMatchEnumMultipleMissingMatchesWithFlag]
# flags: --enable-error-code exhaustive-match

import enum

class Color(enum.Enum):
RED = 1
BLUE = 2
GREEN = 3

val: Color = Color.RED

match val: # E: Cases within match statement do not exhaustively handle all values: "Literal[Color.BLUE, Color.GREEN]". If not intended to handle all cases, use `case _: pass`
case Color.RED:
a = "red"


[builtins fixtures/enum.pyi]

[case testExhaustiveMatchEnumFallbackWithFlag]
# flags: --enable-error-code exhaustive-match

import enum

class Color(enum.Enum):
RED = 1
BLUE = 2
GREEN = 3

val: Color = Color.RED

match val:
case Color.RED:
a = "red"
case _:
a = "other"


[builtins fixtures/enum.pyi]