Skip to content

Commit 42c9149

Browse files
committed
Do not narrow Flag enums by member equality
narrow_type_by_identity_equality expands enum instances into a union of member literals and rules out the compared-away member in the negative branch. For enum.Flag subclasses this is unsound: a Flag value can hold any combination of members, so the declared members are not an exhaustive value set. After `programs != Programs.P2`, programs can still be `Programs.P1 | Programs.P2`, so narrowing away the P2 member rejects `Programs.P2 in programs` -- valid at runtime -- against typeshed's `Flag.__contains__(self, other: Self)` (gh-21937). Skip equality/identity narrowing when either operand is a Flag value; the sound type for such a value is the enum class itself. Regular enum narrowing is unchanged, and existing Flag bitwise-operator behavior is untouched.
1 parent 0ff707d commit 42c9149

3 files changed

Lines changed: 86 additions & 1 deletion

File tree

mypy/checker.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ def __init__(self) -> None:
246246
false_only,
247247
fixup_partial_type,
248248
function_type,
249+
is_flag_enum_type,
249250
is_literal_type_like,
250251
is_singleton_equality_type,
251252
is_singleton_identity_type,
@@ -7075,6 +7076,15 @@ def narrow_type_by_identity_equality(
70757076
if should_coerce_literals:
70767077
target_type = coerce_to_literal(target_type)
70777078

7079+
# A Flag value can hold any combination of members, so the declared
7080+
# members are not an exhaustive set of values: ruling out e.g.
7081+
# `Programs.P2` does not rule out `Programs.P1 | Programs.P2`. Narrowing
7082+
# by member equality is therefore unsound for Flag enums.
7083+
if is_flag_enum_type(get_proper_type(expr_type)) or is_flag_enum_type(
7084+
get_proper_type(target_type)
7085+
):
7086+
continue
7087+
70787088
# Morally what we want to do is narrow for each branch based on:
70797089
# `if_type, else_type = conditional_types(expr_type, target)`
70807090
# What we actually do is first munge expr_type based on target_type to handle some

mypy/typeops.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import itertools
1111
from collections.abc import Callable, Iterable, Sequence
12-
from typing import Any, TypeVar, cast
12+
from typing import Any, Final, TypeVar, cast
1313

1414
from mypy.checker_state import checker_state
1515
from mypy.copytype import copy_type
@@ -1076,6 +1076,24 @@ def is_singleton_equality_type(typ: ProperType) -> bool:
10761076
return isinstance(typ, LiteralType) or is_singleton_identity_type(typ)
10771077

10781078

1079+
FLAG_ENUM_BASES: Final = ("enum.Flag", "enum.IntFlag")
1080+
1081+
1082+
def is_flag_enum_type(typ: ProperType) -> bool:
1083+
"""Is this type an instance of, or a member of, an enum.Flag subclass?
1084+
1085+
A Flag value can hold any combination of members, so the declared members
1086+
are not an exhaustive enumeration of the possible values.
1087+
"""
1088+
if isinstance(typ, LiteralType) and typ.is_enum_literal():
1089+
typ = typ.fallback
1090+
return isinstance(typ, Instance) and typ.type.is_enum and is_flag_enum_class(typ.type)
1091+
1092+
1093+
def is_flag_enum_class(info: TypeInfo) -> bool:
1094+
return any(base.fullname in FLAG_ENUM_BASES for base in info.mro)
1095+
1096+
10791097
def try_expanding_sum_type_to_union(typ: Type, target_fullname: str | None) -> Type:
10801098
"""Attempts to recursively expand any enum Instances with the given target_fullname
10811099
into a Union of all of its component LiteralTypes.

test-data/unit/check-enum.test

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,63 @@ if int():
485485
x = x | C.b
486486
[builtins fixtures/enum.pyi]
487487

488+
[case testFlagEnumEqualityNarrowing]
489+
from enum import Flag, auto
490+
491+
class Programs(Flag):
492+
NONE = 0
493+
P1 = auto()
494+
P2 = auto()
495+
P3 = auto()
496+
ALL = P1 | P2 | P3
497+
498+
def f1(programs: Programs) -> None:
499+
if programs == Programs.NONE:
500+
return
501+
if programs == Programs.P2:
502+
return
503+
# Flag values can hold any combination of members, so narrowing by member
504+
# equality would be unsound here (e.g. `Programs.P1 | Programs.P2`).
505+
reveal_type(programs) # N: Revealed type is "__main__.Programs"
506+
507+
def f2(programs: Programs) -> None:
508+
if programs != Programs.P2:
509+
reveal_type(programs) # N: Revealed type is "__main__.Programs"
510+
[builtins fixtures/primitives.pyi]
511+
512+
[case testFlagEnumIdentityNarrowing]
513+
from enum import Flag, auto
514+
515+
class Programs(Flag):
516+
NONE = 0
517+
P1 = auto()
518+
P2 = auto()
519+
P3 = auto()
520+
ALL = P1 | P2 | P3
521+
522+
def f(programs: Programs) -> None:
523+
if programs is not Programs.P2:
524+
reveal_type(programs) # N: Revealed type is "__main__.Programs"
525+
[builtins fixtures/primitives.pyi]
526+
527+
[case testIntFlagEnumEqualityNarrowing]
528+
from enum import IntFlag, auto
529+
530+
class Programs(IntFlag):
531+
NONE = 0
532+
P1 = auto()
533+
P2 = auto()
534+
P3 = auto()
535+
ALL = P1 | P2 | P3
536+
537+
def f(programs: Programs) -> None:
538+
if programs == Programs.NONE:
539+
return
540+
if programs == Programs.P2:
541+
return
542+
reveal_type(programs) # N: Revealed type is "__main__.Programs"
543+
[builtins fixtures/primitives.pyi]
544+
488545
[case testAnonymousEnum]
489546
from enum import Enum
490547
class A:

0 commit comments

Comments
 (0)