From fdb5bae3eae8c467d03f34c22a7a593382d86c98 Mon Sep 17 00:00:00 2001 From: hauntsaninja Date: Sun, 10 May 2026 01:26:37 -0700 Subject: [PATCH 1/5] Improve negative narrowing for membership checks on tuples Related to #21411 --- mypy/checker.py | 47 +++++++++++++-------- test-data/unit/check-narrowing.test | 65 ++++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 19 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 2d23f9c13316..13880ed425e6 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6771,25 +6771,36 @@ def comparison_type_narrowing_helper(self, node: ComparisonExpr) -> tuple[TypeMa else_map = {} if left_index in narrowable_operand_index_to_hash: - collection_item_type = get_proper_type(builtin_item_type(iterable_type)) - if collection_item_type is not None: - if_map, else_map = self.narrow_type_by_identity_equality( - "==", - operands=[operands[left_index], operands[right_index]], - operand_types=[item_type, collection_item_type], - expr_indices=[0, 1], - narrowable_indices={0}, - ) - if else_map and not ( - isinstance(p_typ := get_proper_type(iterable_type), TupleType) - and all( - is_singleton_equality_type(get_proper_type(item)) - for item in p_typ.items + p_iterable_type = get_proper_type(iterable_type) + if isinstance(p_iterable_type, TupleType): + # For some tuples, we can do negative narrowing, e.g. `x not in (None,)` + all_if_maps = [] + all_else_maps = [] + for tuple_item in p_iterable_type.items: + if_map, else_map = self.narrow_type_by_identity_equality( + "==", + operands=[operands[left_index], operands[right_index]], + operand_types=[item_type, tuple_item], + expr_indices=[0, 1], + narrowable_indices={0}, ) - ): - # In general, we can't do negative narrowing, since e.g. the container - # could just be empty. However, we can do negative narrowing for some - # tuples e.g. `x not in (None,)` + all_if_maps.append(if_map) + if is_singleton_equality_type(get_proper_type(tuple_item)): + all_else_maps.append(else_map) + if_map = reduce_or_conditional_type_maps(all_if_maps) + else_map = reduce_and_conditional_type_maps(all_else_maps, use_meet=True) + else: + collection_item_type = get_proper_type(builtin_item_type(iterable_type)) + if collection_item_type is not None: + if_map, else_map = self.narrow_type_by_identity_equality( + "==", + operands=[operands[left_index], operands[right_index]], + operand_types=[item_type, collection_item_type], + expr_indices=[0, 1], + narrowable_indices={0}, + ) + # We can't do negative narrowing, since e.g. the container could + # just be empty. else_map = {} if right_index in narrowable_operand_index_to_hash: diff --git a/test-data/unit/check-narrowing.test b/test-data/unit/check-narrowing.test index 4f23d9147205..2ade662ddcad 100644 --- a/test-data/unit/check-narrowing.test +++ b/test-data/unit/check-narrowing.test @@ -3201,7 +3201,7 @@ class X: [builtins fixtures/dict.pyi] -[case testTypeNarrowingStringInLiteralContainer] +[case testNarrowStringInLiteralContainer] # flags: --strict-equality --warn-unreachable from typing import Literal @@ -3235,6 +3235,69 @@ def narrow_set(x: str, t: set[Literal['a', 'b']]): reveal_type(x) # N: Revealed type is "builtins.str" [builtins fixtures/primitives.pyi] +[case testNarrowLiteralInLiteralContainer] +# flags: --strict-equality --warn-unreachable +from typing import Literal + +def narrow_tuple_exact(x: Literal['a', 'b', 'c'], t: tuple[Literal['a'], Literal['b']]): + if x in t: + reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b']" + else: + reveal_type(x) # N: Revealed type is "Literal['c']" + + if x not in t: + reveal_type(x) # N: Revealed type is "Literal['c']" + else: + reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b']" + +def narrow_tuple_expression(x: Literal['a', 'b', 'c']): + # TODO: this should match above, probably should just change is_singleton_equality_type + if x in ('a', 'b'): + reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b']" + else: + reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b'] | Literal['c']" + + if x not in ('a', 'b'): + reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b'] | Literal['c']" + else: + reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b']" + +def narrow_tuple_union(x: Literal['a', 'b', 'c'], t: tuple[Literal['a', 'b']]): + if x in t: + reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b']" + else: + reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b'] | Literal['c']" + + if x not in t: + reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b'] | Literal['c']" + else: + reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b']" + +def narrow_tuple_with_other_type(x: Literal['a', 'b', 'c'], t: tuple[Literal['a'], int]): + if x in t: + reveal_type(x) # N: Revealed type is "Literal['a']" + else: + reveal_type(x) # N: Revealed type is "Literal['b'] | Literal['c']" + +def narrow_homo_tuple(x: Literal['a', 'b', 'c'], t: tuple[Literal['a', 'b'], ...]): + if x in t: + reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b']" + else: + reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b'] | Literal['c']" + +def narrow_list(x: Literal['a', 'b', 'c'], t: list[Literal['a', 'b']]): + if x in t: + reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b']" + else: + reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b'] | Literal['c']" + +def narrow_set(x: Literal['a', 'b', 'c'], t: set[Literal['a', 'b']]): + if x in t: + reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b']" + else: + reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b'] | Literal['c']" +[builtins fixtures/primitives.pyi] + [case testNarrowingLiteralInLiteralContainer] # flags: --strict-equality --warn-unreachable From 3222a894d69fe50e98034691541eb6be9a81a49a Mon Sep 17 00:00:00 2001 From: hauntsaninja Date: Sun, 10 May 2026 02:07:37 -0700 Subject: [PATCH 2/5] fixup --- mypy/checker.py | 5 ++++- test-data/unit/check-isinstance.test | 12 +++++------- test-data/unit/check-typevar-tuple.test | 9 ++++----- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 13880ed425e6..a502be9a461a 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6772,7 +6772,10 @@ def comparison_type_narrowing_helper(self, node: ComparisonExpr) -> tuple[TypeMa if left_index in narrowable_operand_index_to_hash: p_iterable_type = get_proper_type(iterable_type) - if isinstance(p_iterable_type, TupleType): + if ( + isinstance(p_iterable_type, TupleType) + and find_unpack_in_list(p_iterable_type.items) is None + ): # For some tuples, we can do negative narrowing, e.g. `x not in (None,)` all_if_maps = [] all_else_maps = [] diff --git a/test-data/unit/check-isinstance.test b/test-data/unit/check-isinstance.test index 029224e12216..fe093220aa9d 100644 --- a/test-data/unit/check-isinstance.test +++ b/test-data/unit/check-isinstance.test @@ -2294,18 +2294,16 @@ def f(x: Optional[int], lst: Optional[List[int]], nested_any: List[List[Any]]) - [case testNarrowTypeAfterInTuple] # flags: --warn-unreachable -from typing import Optional class A: pass class B(A): pass class C(A): pass -y: Optional[B] -if y in (B(), C()): - reveal_type(y) # N: Revealed type is "__main__.B" -else: - reveal_type(y) # N: Revealed type is "__main__.B | None" +def f(y: B | None): + if y in (B(), C()): + reveal_type(y) # N: Revealed type is "__main__.B" + else: + reveal_type(y) # N: Revealed type is "__main__.B | None" [builtins fixtures/tuple.pyi] -[out] [case testNarrowTypeAfterInNamedTuple] # flags: --warn-unreachable diff --git a/test-data/unit/check-typevar-tuple.test b/test-data/unit/check-typevar-tuple.test index 703653227e20..7ca21b280aad 100644 --- a/test-data/unit/check-typevar-tuple.test +++ b/test-data/unit/check-typevar-tuple.test @@ -2145,14 +2145,13 @@ match(b) # E: Argument 1 to "match" has incompatible type "Bad"; expected "PC[U [builtins fixtures/tuple.pyi] [case testVariadicTupleCollectionCheck] -from typing import Tuple, Optional from typing_extensions import Unpack -allowed: Tuple[int, Unpack[Tuple[int, ...]]] +allowed: tuple[int, Unpack[tuple[int, ...]]] -x: Optional[int] -if x in allowed: - reveal_type(x) # N: Revealed type is "builtins.int" +def f(x: int | None): + if x in allowed: + reveal_type(x) # N: Revealed type is "builtins.int" [builtins fixtures/tuple.pyi] [case testJoinOfVariadicTupleCallablesNoCrash] From a371e31c01b68e0571c1a89a9dfaf03f30583d27 Mon Sep 17 00:00:00 2001 From: hauntsaninja Date: Sun, 10 May 2026 02:50:31 -0700 Subject: [PATCH 3/5] fix pyspark regression --- mypy/checker.py | 7 ++++--- test-data/unit/check-narrowing.test | 5 ++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index a502be9a461a..a16714da8b90 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6779,16 +6779,17 @@ def comparison_type_narrowing_helper(self, node: ComparisonExpr) -> tuple[TypeMa # For some tuples, we can do negative narrowing, e.g. `x not in (None,)` all_if_maps = [] all_else_maps = [] - for tuple_item in p_iterable_type.items: + for known_item in p_iterable_type.items: + known_item = coerce_to_literal(known_item) if_map, else_map = self.narrow_type_by_identity_equality( "==", operands=[operands[left_index], operands[right_index]], - operand_types=[item_type, tuple_item], + operand_types=[item_type, known_item], expr_indices=[0, 1], narrowable_indices={0}, ) all_if_maps.append(if_map) - if is_singleton_equality_type(get_proper_type(tuple_item)): + if is_singleton_equality_type(get_proper_type(known_item)): all_else_maps.append(else_map) if_map = reduce_or_conditional_type_maps(all_if_maps) else_map = reduce_and_conditional_type_maps(all_else_maps, use_meet=True) diff --git a/test-data/unit/check-narrowing.test b/test-data/unit/check-narrowing.test index 2ade662ddcad..1729d61db3cf 100644 --- a/test-data/unit/check-narrowing.test +++ b/test-data/unit/check-narrowing.test @@ -3251,14 +3251,13 @@ def narrow_tuple_exact(x: Literal['a', 'b', 'c'], t: tuple[Literal['a'], Literal reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b']" def narrow_tuple_expression(x: Literal['a', 'b', 'c']): - # TODO: this should match above, probably should just change is_singleton_equality_type if x in ('a', 'b'): reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b']" else: - reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b'] | Literal['c']" + reveal_type(x) # N: Revealed type is "Literal['c']" if x not in ('a', 'b'): - reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b'] | Literal['c']" + reveal_type(x) # N: Revealed type is "Literal['c']" else: reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b']" From de6cc5513775d58fb43dd643d99b8b8263d9ac38 Mon Sep 17 00:00:00 2001 From: hauntsaninja Date: Sun, 10 May 2026 02:59:13 -0700 Subject: [PATCH 4/5] more coerce --- mypy/checker.py | 6 +++++- test-data/unit/check-narrowing.test | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index a16714da8b90..34051c55800e 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6780,7 +6780,11 @@ def comparison_type_narrowing_helper(self, node: ComparisonExpr) -> tuple[TypeMa all_if_maps = [] all_else_maps = [] for known_item in p_iterable_type.items: - known_item = coerce_to_literal(known_item) + # Match the should_coerce_literals logic from narrow_type_by_identity_equality + if is_literal_type_like(known_item) or ( + isinstance(known_item, Instance) and known_item.type.is_enum + ): + known_item = coerce_to_literal(known_item) if_map, else_map = self.narrow_type_by_identity_equality( "==", operands=[operands[left_index], operands[right_index]], diff --git a/test-data/unit/check-narrowing.test b/test-data/unit/check-narrowing.test index 1729d61db3cf..944161810389 100644 --- a/test-data/unit/check-narrowing.test +++ b/test-data/unit/check-narrowing.test @@ -3251,13 +3251,14 @@ def narrow_tuple_exact(x: Literal['a', 'b', 'c'], t: tuple[Literal['a'], Literal reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b']" def narrow_tuple_expression(x: Literal['a', 'b', 'c']): + # TODO: this should match narrow_tuple_exact if x in ('a', 'b'): reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b']" else: - reveal_type(x) # N: Revealed type is "Literal['c']" + reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b'] | Literal['c']" if x not in ('a', 'b'): - reveal_type(x) # N: Revealed type is "Literal['c']" + reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b'] | Literal['c']" else: reveal_type(x) # N: Revealed type is "Literal['a'] | Literal['b']" From fab33ee1c88b90ab68b7c8839071a2a11330fece Mon Sep 17 00:00:00 2001 From: hauntsaninja Date: Sun, 10 May 2026 13:15:37 -0700 Subject: [PATCH 5/5] fix type check --- mypy/checker.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 34051c55800e..e7c546628bc5 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6781,8 +6781,9 @@ def comparison_type_narrowing_helper(self, node: ComparisonExpr) -> tuple[TypeMa all_else_maps = [] for known_item in p_iterable_type.items: # Match the should_coerce_literals logic from narrow_type_by_identity_equality - if is_literal_type_like(known_item) or ( - isinstance(known_item, Instance) and known_item.type.is_enum + p_known_item = get_proper_type(known_item) + if is_literal_type_like(p_known_item) or ( + isinstance(p_known_item, Instance) and p_known_item.type.is_enum ): known_item = coerce_to_literal(known_item) if_map, else_map = self.narrow_type_by_identity_equality(