diff --git a/docs/source/command_line.rst b/docs/source/command_line.rst index dfed280d12ed..390f2ac196be 100644 --- a/docs/source/command_line.rst +++ b/docs/source/command_line.rst @@ -845,6 +845,7 @@ of the above sections. x = 'a string' x.trim() # error: "str" has no attribute "trim" [attr-defined] + .. _configuring-error-messages: Configuring error messages diff --git a/docs/source/error_code_list2.rst b/docs/source/error_code_list2.rst index dfe2e30874f7..141aa4490c0b 100644 --- a/docs/source/error_code_list2.rst +++ b/docs/source/error_code_list2.rst @@ -612,3 +612,44 @@ Example: # mypy: disallow-any-explicit from typing import Any x: Any = 1 # Error: Explicit "Any" type annotation [explicit-any] + + +.. _code-exhaustive-match: + +Check that match statements match exhaustively [match-exhaustive] +----------------------------------------------------------------------- + +If enabled with :option:`--enable-error-code exhaustive-match `, +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 + + # OK without --enable-error-code exhaustive-match + match val: + case Color.RED: + print("red") + + # With --enable-error-code exhaustive-match + # Error: Match statement has unhandled case for values of type "Literal[Color.BLUE]" + match val: + case Color.RED: + print("red") + + # OK with or without --enable-error-code exhaustive-match, since all cases are handled + match val: + case Color.RED: + print("red") + case _: + print("other") diff --git a/docs/source/literal_types.rst b/docs/source/literal_types.rst index 877ab5de9087..e449589ddb4d 100644 --- a/docs/source/literal_types.rst +++ b/docs/source/literal_types.rst @@ -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). +For match statements specifically, inexhaustive matches can be caught +without needing to use ``assert_never`` by using +:option:`--enable-error-code exhaustive-match `. + Extra Enum checks ***************** diff --git a/mypy/checker.py b/mypy/checker.py index aceb0291926a..3a58e8c9835c 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -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 @@ -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 + + if unmatched_types is not None: + for typ in list(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. diff --git a/mypy/errorcodes.py b/mypy/errorcodes.py index 8f650aa30605..c22308e4a754 100644 --- a/mypy/errorcodes.py +++ b/mypy/errorcodes.py @@ -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") diff --git a/mypy/messages.py b/mypy/messages.py index 2e07d7f63498..d233621c1263 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -2486,6 +2486,16 @@ 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"Match statement has unhandled case for values of type {type_str}" + self.fail(msg, context, code=codes.EXHAUSTIVE_MATCH) + self.note( + "If match statement is intended to be non-exhaustive, add `case _: pass`", + context, + code=codes.EXHAUSTIVE_MATCH, + ) + def quote_type_string(type_string: str) -> str: """Quotes a type representation for use in messages.""" diff --git a/test-data/unit/check-python310.test b/test-data/unit/check-python310.test index af3982f6accd..6a0906a0fefe 100644 --- a/test-data/unit/check-python310.test +++ b/test-data/unit/check-python310.test @@ -2639,6 +2639,164 @@ def f2() -> None: 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: Match statement has unhandled case for values of type "int" \ + # N: If match statement is intended to be non-exhaustive, add `case _: pass` + case 1: + pass + +b: str = "hello" +match b: # E: Match statement has unhandled case for values of type "str" \ + # N: If match statement is intended to be non-exhaustive, add `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: Match statement has unhandled case for values of type "Literal[Color.GREEN]" \ + # N: If match statement is intended to be non-exhaustive, add `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: Match statement has unhandled case for values of type "Literal[Color.BLUE, Color.GREEN]" \ + # N: If match statement is intended to be non-exhaustive, add `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] + +# Fork of testMatchNarrowingUnionTypedDictViaIndex to check behaviour with exhaustive match flag +[case testExhaustiveMatchNarrowingUnionTypedDictViaIndex] +# flags: --enable-error-code exhaustive-match + +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"]: # E: Match statement has unhandled case for values of type "Literal['b']" \ + # N: If match statement is intended to be non-exhaustive, add `case _: pass` \ + # E: Match statement has unhandled case for values of type "B" + 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" +[typing fixtures/typing-typeddict.pyi] + [case testEnumTypeObjectMember] import enum from typing import NoReturn