-
-
Notifications
You must be signed in to change notification settings - Fork 2.8k
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
Exhaustive Matching on Enums / Literals #6366
Comments
The def assert_never(x: NoReturn) -> NoReturn:
assert False, "Unhandled type: {}".format(type(x).__name__) But with enums and literals it will probably always give you a false positive. The exhaustive checks are not supported yet for enums and literals. But there are plans to add them, see also #5935. I am not sure about the timeline however, @Michael0x2a will you have time to work on this? |
This diff adds support for performing reachability and narrowing analysis when doing certain enum checks. For example, given the following enum: class Foo(Enum): A = 1 B = 2 ...this pull request will make mypy do the following: x: Foo if x is Foo.A: reveal_type(x) # type: Literal[Foo.A] elif x is Foo.B: reveal_type(x) # type: Literal[Foo.B] else: reveal_type(x) # No output: branch inferred as unreachable This diff does not attempt to perform this same sort of narrowing for equality checks: I suspect implementing those will be harder due to their overridable nature. (E.g. you can define custom `__eq__` methods within Enum subclasses). This pull request also finally adds support for the enum behavior [described in PEP 484][0] and also sort of partially addresses python#6366 [0]: https://www.python.org/dev/peps/pep-0484/#support-for-singleton-types-in-unions
Note that #4223 gives an example where this can cause a false positive |
Fixes #1803 This diff adds support for performing reachability and narrowing analysis when doing certain enum checks. For example, given the following enum: class Foo(Enum): A = 1 B = 2 ...this pull request will make mypy do the following: x: Foo if x is Foo.A: reveal_type(x) # type: Literal[Foo.A] elif x is Foo.B: reveal_type(x) # type: Literal[Foo.B] else: reveal_type(x) # No output: branch inferred as unreachable This diff does not attempt to perform this same sort of narrowing for equality checks: I suspect implementing those will be harder due to their overridable nature. (E.g. you can define custom `__eq__` methods within Enum subclasses). This pull request also finally adds support for the enum behavior [described in PEP 484][0] and also sort of partially addresses #6366 [0]: https://www.python.org/dev/peps/pep-0484/#support-for-singleton-types-in-unions
FWIW a simple Enum example seems to work for me on mypy 0.740:
which gives the type error |
And on mypy 0.780 it seems Literal is working too:
correctly errors:
|
Note that you have to use |
While the Python docs say that members can be compared by identity, the docs also mentioned that equality operators are defined. I think as long as the Enum doesn't use a mixin, like |
I was looking into this more and it seems that not narrowing based on from enum import Enum
from typing import Literal
class Foo(Enum):
a = "a"
b = "b"
x: Foo
A: Literal[Foo.a]
if x == A:
reveal_type(x) # note: Revealed type is 'Literal[main.Foo.a]'
else:
reveal_type(x) # note: Revealed type is 'Literal[main.Foo.b]' this doesn't: if x == Foo.a:
reveal_type(x) # note: Revealed type is 'main.Foo'
else:
reveal_type(x) # note: Revealed type is 'main.Foo' There are some test cases explaining the decision around narrowing: mypy/test-data/unit/check-narrowing.test Lines 783 to 802 in 4d5a1bc
mypy/test-data/unit/check-narrowing.test Lines 681 to 710 in 4d5a1bc
And a related commit: 3dce3fd Maybe narrowing enums via equality could be put behind another flag to prevent breaking existing code? Here's a diff to change the narrowing, but it does require changing some tests: mypy.diffdiff --git a/mypy/checker.py b/mypy/checker.py
index fce7e7d7..cf25bdb2 100644
--- a/mypy/checker.py
+++ b/mypy/checker.py
@@ -4083,7 +4083,7 @@ class TypeChecker(NodeVisitor[None], CheckerPluginInterface):
should_narrow_by_identity = True
else:
def is_exactly_literal_type(t: Type) -> bool:
- return isinstance(get_proper_type(t), LiteralType)
+ return isinstance(coerce_to_literal(get_proper_type(t)), LiteralType)
def has_no_custom_eq_checks(t: Type) -> bool:
return (not custom_special_method(t, '__eq__', check_all=False)
@@ -4451,7 +4451,7 @@ class TypeChecker(NodeVisitor[None], CheckerPluginInterface):
singleton_index = possible_target_indices[-1]
enum_name = None
- target = get_proper_type(target)
+ target = coerce_to_literal(get_proper_type(target))
if isinstance(target, LiteralType) and target.is_enum_literal():
enum_name = target.fallback.type.fullname
diff --git a/test-data/unit/check-enum.test b/test-data/unit/check-enum.test
index 0da3217b..de6c606e 100644
--- a/test-data/unit/check-enum.test
+++ b/test-data/unit/check-enum.test
@@ -855,6 +855,24 @@ else:
reveal_type(z) # No output: this branch is unreachable
[builtins fixtures/bool.pyi]
+[case testEnumReachabilityChecksEquality]
+from enum import Enum
+from typing_extensions import Literal
+
+class Foo(Enum):
+ A = 1
+ B = 2
+ C = 3
+
+x: Foo
+
+if x == Foo.A:
+ reveal_type(x) # N: Revealed type is 'Literal[__main__.Foo.A]'
+else:
+ reveal_type(x) # N: Revealed type is 'Union[Literal[__main__.Foo.B], Literal[__main__.Foo.C]]'
+
+[builtins fixtures/bool.pyi]
+
[case testEnumReachabilityNoNarrowingForUnionMessiness]
from enum import Enum
from typing_extensions import Literal
diff --git a/test-data/unit/check-narrowing.test b/test-data/unit/check-narrowing.test
index 8eb6f643..5eb356c6 100644
--- a/test-data/unit/check-narrowing.test
+++ b/test-data/unit/check-narrowing.test
@@ -688,12 +688,12 @@ def test1(switch: FlipFlopEnum) -> None:
# approach and avoid narrowing anything here.
assert switch.state == State.A
- reveal_type(switch.state) # N: Revealed type is '__main__.State'
+ reveal_type(switch.state) # N: Revealed type is 'Literal[__main__.State.A]'
- switch.mutate()
+ switch.mutate()
- assert switch.state == State.B
- reveal_type(switch.state) # N: Revealed type is '__main__.State'
+ assert switch.state == State.B # E: Non-overlapping equality check (left operand type: "Literal[State.A]", right operand type: "Literal[State.B]")
+ reveal_type(switch.state) # E: Statement is unreachable
def test2(switch: FlipFlopEnum) -> None:
# So strictly speaking, we ought to do the same thing with 'is' comparisons
@@ -713,12 +713,12 @@ def test3(switch: FlipFlopStr) -> None:
# This is the same thing as 'test1', except we try using str literals.
assert switch.state == "state-1"
- reveal_type(switch.state) # N: Revealed type is 'builtins.str'
+ reveal_type(switch.state) # N: Revealed type is 'Literal['state-1']'
switch.mutate()
- assert switch.state == "state-2"
- reveal_type(switch.state) # N: Revealed type is 'builtins.str'
+ assert switch.state == "state-2" # E: Non-overlapping equality check (left operand type: "Literal['state-1']", right operand type: "Literal['state-2']")
+ reveal_type(switch.state) # E: Statement is unreachable
[builtins fixtures/primitives.pyi]
[case testNarrowingEqualityRequiresExplicitStrLiteral]
@@ -733,13 +733,13 @@ A_literal: Literal["A"]
# why more precise inference here is problematic.
x_str: str
if x_str == "A":
- reveal_type(x_str) # N: Revealed type is 'builtins.str'
+ reveal_type(x_str) # N: Revealed type is 'Literal['A']'
else:
reveal_type(x_str) # N: Revealed type is 'builtins.str'
reveal_type(x_str) # N: Revealed type is 'builtins.str'
if x_str == A_final:
- reveal_type(x_str) # N: Revealed type is 'builtins.str'
+ reveal_type(x_str) # N: Revealed type is 'Literal['A']'
else:
reveal_type(x_str) # N: Revealed type is 'builtins.str'
reveal_type(x_str) # N: Revealed type is 'builtins.str'
@@ -784,15 +784,15 @@ A_literal: Literal[Foo.A]
# testNarrowingEqualityFlipFlop for more on why we can't narrow here.
x1: Foo
if x1 == Foo.A:
- reveal_type(x1) # N: Revealed type is '__main__.Foo'
+ reveal_type(x1) # N: Revealed type is 'Literal[__main__.Foo.A]'
else:
- reveal_type(x1) # N: Revealed type is '__main__.Foo'
+ reveal_type(x1) # N: Revealed type is 'Literal[__main__.Foo.B]'
x2: Foo
if x2 == A_final:
- reveal_type(x2) # N: Revealed type is '__main__.Foo'
+ reveal_type(x2) # N: Revealed type is 'Literal[__main__.Foo.A]'
else:
- reveal_type(x2) # N: Revealed type is '__main__.Foo'
+ reveal_type(x2) # N: Revealed type is 'Literal[__main__.Foo.B]'
# But we let this narrow since there's an explicit literal in the RHS.
x3: Foo
diff --git a/test-data/unit/check-optional.test b/test-data/unit/check-optional.test
index 74a27093..6771bb38 100644
--- a/test-data/unit/check-optional.test
+++ b/test-data/unit/check-optional.test
@@ -487,7 +487,7 @@ foo([f]) # E: List item 0 has incompatible type "Callable[[], int]"; expected "
from typing import Optional
x = '' # type: Optional[str]
if x == '<string>':
- reveal_type(x) # N: Revealed type is 'builtins.str'
+ reveal_type(x) # N: Revealed type is 'Literal['<string>']'
else:
reveal_type(x) # N: Revealed type is 'Union[builtins.str, None]'
if x is '<string>':
@@ -500,7 +500,7 @@ else:
from typing import Union
x = '' # type: Union[str, int, None]
if x == '<string>':
- reveal_type(x) # N: Revealed type is 'Union[builtins.str, builtins.int]'
+ reveal_type(x) # N: Revealed type is 'Literal['<string>']'
else:
reveal_type(x) # N: Revealed type is 'Union[builtins.str, builtins.int, None]'
if x is '<string>':
@@ -526,7 +526,7 @@ else:
from typing import Optional
x = '' # type: Optional[str]
if x == 0:
- reveal_type(x) # N: Revealed type is 'Union[builtins.str, None]'
+ reveal_type(x)
else:
reveal_type(x) # N: Revealed type is 'Union[builtins.str, None]'
if x is 0:
@@ -554,8 +554,8 @@ from typing import Optional
x: Optional[int]
y: Optional[int]
if x == y == 1:
- reveal_type(x) # N: Revealed type is 'builtins.int'
- reveal_type(y) # N: Revealed type is 'builtins.int'
+ reveal_type(x) # N: Revealed type is 'Literal[1]'
+ reveal_type(y) # N: Revealed type is 'Literal[1]'
else:
reveal_type(x) # N: Revealed type is 'Union[builtins.int, None]'
reveal_type(y) # N: Revealed type is 'Union[builtins.int, None]' |
`NoReturn` is Python's `never` which we can use to help ensure we handle all cases. I tried looking for other places where we could use this, but didn't really find any easy places to add it. Also we need to use `is` when comparing enum variants due to mypy's backwards compatible refinement outlined in: python/mypy#6366
`NoReturn` is Python's `never` which we can use to help ensure we handle all cases. I tried looking for other places where we could use this, but didn't really find any easy places to add it. Also we need to use `is` when comparing enum variants due to mypy's backwards compatible refinement outlined in: python/mypy#6366
This feature is not-really known from my experience. I had to explain it several times to other devs. But, I think that this technique should be widely recognised! It is awesome! Refs #6366
This appears to work in the latest version of mypy, so I think this could be closed. |
Is the lack of
|
Interestingly I can compare the integers with
|
Comparing enum values with |
Cool, thanks Jelle! So this seems working as intended, then. |
Everything in this issue works, and in #11521 I changed mypy to make == narrow enums in addition to is |
Asking a question. I'd like to know if there is a standard idiom for enforcing exhaustive matching of an Enum / Literal type.
Suppose there are three types of pets: cats, dogs and iguanas. I want to write a function that makes the appropriate pet sound, depending on the type of pet. I'd also like to make sure that I've caught every possible type of pet. Here was my first attempt, using the
NoReturn
trick mentioned by @bluetech here. It looks like this works for Unions, but not for Enums / Literals? Apologies if this has been covered before.If I comment out the iguana branch and check this file with:
mypy has no complaints. If I try the same deal with an enum:
and check again with
--strict
, I still get no complaints.What are the versions of mypy and Python you are using?
mypy 0.660
Python 3.6.5
Do you see the same issue after installing mypy from Git master?
Yes, got the same behavior with
mypy 0.680+dev.21c8a812c697baf7394eafe360188ededcec6d9c
--strict
The text was updated successfully, but these errors were encountered: