diff --git a/mypy/checker.py b/mypy/checker.py index 2cb3d69b2d77..f51a1e81572d 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6831,6 +6831,16 @@ def comparison_type_narrowing_helper(self, node: ComparisonExpr) -> tuple[TypeMa # We can't do negative narrowing, since e.g. the container could # just be empty. else_map = {} + # The `in` operator uses __eq__, not isinstance. Knowing that + # value == some_container_element does not imply value has the + # runtime type of that element. Discard any narrowing that would + # widen item_type to a non-subtype (e.g. tuple[int,int,int] must + # not be widened to a subclass Size just because they compare equal). + left_expr = collapse_walrus(operands[left_index]) + if left_expr in if_map and not is_proper_subtype( + if_map[left_expr], item_type, ignore_promotions=True + ): + del if_map[left_expr] if right_index in narrowable_operand_index_to_hash: # E.g. narrows the right operand in `if "key" in typed_dict` diff --git a/test-data/unit/check-narrowing.test b/test-data/unit/check-narrowing.test index 7bab7baa6ceb..ec068d5210dc 100644 --- a/test-data/unit/check-narrowing.test +++ b/test-data/unit/check-narrowing.test @@ -4281,3 +4281,17 @@ def func(y: H) -> H: else: return y [builtins fixtures/primitives.pyi] + +[case testValueInContainerNoWideningToNonSubtype] +# Regression test for https://github.com/python/mypy/issues/21512 +# Narrowing x in `if x in container` must not widen x to the container item +# type when that item type is not a subtype of x's declared type. +class Size(tuple[int, ...]): + def numel(self) -> int: ... + +sizes: list[Size] +value = (1, 2, 3) +if value in sizes: + reveal_type(value) # N: Revealed type is "tuple[builtins.int, builtins.int, builtins.int]" + value.numel() # E: "tuple[int, int, int]" has no attribute "numel" +[builtins fixtures/primitives.pyi]