From 7be0e87b58ab30e3c61bb64a475abb2acd70a124 Mon Sep 17 00:00:00 2001 From: Randolf Scholz Date: Thu, 17 Jul 2025 13:49:09 +0200 Subject: [PATCH 1/5] inital commit --- mypy/checker.py | 53 ++++++++++++++++++++++------- test-data/unit/check-python310.test | 16 +++++++++ 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 7579c36a97d0..f6ae27ed3e31 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -7994,20 +7994,47 @@ def conditional_types( ) -> tuple[Type | None, Type | None]: """Takes in the current type and a proposed type of an expression. - Returns a 2-tuple: The first element is the proposed type, if the expression - can be the proposed type. The second element is the type it would hold - if it was not the proposed type, if any. UninhabitedType means unreachable. - None means no new information can be inferred. If default is set it is returned - instead.""" + Returns a 2-tuple: + The first element is the proposed type, if the expression can be the proposed type. + The second element is the type it would hold if it was not the proposed type, if any. + UninhabitedType means unreachable. + None means no new information can be inferred. + If default is set it is returned instead. + """ + if proposed_type_ranges and len(proposed_type_ranges) == 1: + # expand e.g. bool -> Literal[True] | Literal[False] + target = proposed_type_ranges[0].item + target = get_proper_type(target) + if isinstance(target, LiteralType) and ( + target.is_enum_literal() or isinstance(target.value, bool) + ): + enum_name = target.fallback.type.fullname + current_type = try_expanding_sum_type_to_union(current_type, enum_name) + + current_type = get_proper_type(current_type) + if isinstance(current_type, UnionType) and (default == current_type): + # factorize over union types + # if we try to narrow A|B to C, we instead narrow A to C and B to C, and + # return the union of the results + result: list[tuple[Type | None, Type | None]] = [ + conditional_types( + union_item, + proposed_type_ranges, + default=union_item, + consider_runtime_isinstance=consider_runtime_isinstance, + ) + for union_item in get_proper_types(current_type.items) + ] + # separate list of tuples into two lists + yes_types, no_types = zip(*result) + yes_type = make_simplified_union([t for t in yes_types if t is not None]) + no_type = restrict_subtype_away( + current_type, yes_type, consider_runtime_isinstance=consider_runtime_isinstance + ) + + return yes_type, no_type + if proposed_type_ranges: - if len(proposed_type_ranges) == 1: - target = proposed_type_ranges[0].item - target = get_proper_type(target) - if isinstance(target, LiteralType) and ( - target.is_enum_literal() or isinstance(target.value, bool) - ): - enum_name = target.fallback.type.fullname - current_type = try_expanding_sum_type_to_union(current_type, enum_name) proposed_items = [type_range.item for type_range in proposed_type_ranges] proposed_type = make_simplified_union(proposed_items) if isinstance(proposed_type, AnyType): diff --git a/test-data/unit/check-python310.test b/test-data/unit/check-python310.test index bb8f038eb1eb..d8f8fa592204 100644 --- a/test-data/unit/check-python310.test +++ b/test-data/unit/check-python310.test @@ -1760,6 +1760,22 @@ def union(x: str | bool) -> None: reveal_type(x) # N: Revealed type is "Union[builtins.str, Literal[False]]" [builtins fixtures/tuple.pyi] +[case testMatchNarrowDownUnionUsingClassPattern] + +class Foo: ... +class Bar(Foo): ... + +def test_1(bar: Bar) -> None: + match bar: + case Foo() as foo: + reveal_type(foo) # N: Revealed type is "__main__.Bar" + +def test_2(bar: Bar | str) -> None: + match bar: + case Foo() as foo: + reveal_type(foo) # N: Revealed type is "__main__.Bar" + + [case testMatchAssertFalseToSilenceFalsePositives] class C: a: int | str From fecb4966093c860540773b12e7508c1a0c97d278 Mon Sep 17 00:00:00 2001 From: Randolf Scholz Date: Sun, 27 Jul 2025 19:58:39 +0200 Subject: [PATCH 2/5] refactored conditional_types and restrict_subtype_away --- mypy/checker.py | 79 ++++++++++++++-------------- mypy/subtypes.py | 12 ++++- test-data/unit/check-isinstance.test | 3 +- test-data/unit/check-narrowing.test | 2 +- 4 files changed, 53 insertions(+), 43 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index f6ae27ed3e31..49159fce32e3 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -8001,7 +8001,10 @@ def conditional_types( None means no new information can be inferred. If default is set it is returned instead. """ - if proposed_type_ranges and len(proposed_type_ranges) == 1: + if not proposed_type_ranges: + return UninhabitedType(), default + + if len(proposed_type_ranges) == 1: # expand e.g. bool -> Literal[True] | Literal[False] target = proposed_type_ranges[0].item target = get_proper_type(target) @@ -8012,7 +8015,11 @@ def conditional_types( current_type = try_expanding_sum_type_to_union(current_type, enum_name) current_type = get_proper_type(current_type) - if isinstance(current_type, UnionType) and (default == current_type): + if ( + isinstance(current_type, UnionType) + and not any(tr.is_upper_bound for tr in proposed_type_ranges) + and (default in (current_type, None)) + ): # factorize over union types # if we try to narrow A|B to C, we instead narrow A to C and B to C, and # return the union of the results @@ -8027,47 +8034,39 @@ def conditional_types( ] # separate list of tuples into two lists yes_types, no_types = zip(*result) - yes_type = make_simplified_union([t for t in yes_types if t is not None]) - no_type = restrict_subtype_away( - current_type, yes_type, consider_runtime_isinstance=consider_runtime_isinstance - ) - - return yes_type, no_type - - if proposed_type_ranges: + proposed_type = make_simplified_union([t for t in yes_types if t is not None]) + else: proposed_items = [type_range.item for type_range in proposed_type_ranges] proposed_type = make_simplified_union(proposed_items) - if isinstance(proposed_type, AnyType): - # We don't really know much about the proposed type, so we shouldn't - # attempt to narrow anything. Instead, we broaden the expr to Any to - # avoid false positives - return proposed_type, default - elif not any( - type_range.is_upper_bound for type_range in proposed_type_ranges - ) and is_proper_subtype(current_type, proposed_type, ignore_promotions=True): - # Expression is always of one of the types in proposed_type_ranges - return default, UninhabitedType() - elif not is_overlapping_types(current_type, proposed_type, ignore_promotions=True): - # Expression is never of any type in proposed_type_ranges - return UninhabitedType(), default - else: - # we can only restrict when the type is precise, not bounded - proposed_precise_type = UnionType.make_union( - [ - type_range.item - for type_range in proposed_type_ranges - if not type_range.is_upper_bound - ] - ) - remaining_type = restrict_subtype_away( - current_type, - proposed_precise_type, - consider_runtime_isinstance=consider_runtime_isinstance, - ) - return proposed_type, remaining_type + + if isinstance(proposed_type, AnyType): + # We don't really know much about the proposed type, so we shouldn't + # attempt to narrow anything. Instead, we broaden the expr to Any to + # avoid false positives + return proposed_type, default + elif not any( + type_range.is_upper_bound for type_range in proposed_type_ranges + ) and is_proper_subtype(current_type, proposed_type, ignore_promotions=True): + # Expression is always of one of the types in proposed_type_ranges + return default, UninhabitedType() + elif not is_overlapping_types(current_type, proposed_type, ignore_promotions=True): + # Expression is never of any type in proposed_type_ranges + return UninhabitedType(), default else: - # An isinstance check, but we don't understand the type - return current_type, default + # we can only restrict when the type is precise, not bounded + proposed_precise_type = UnionType.make_union( + [ + type_range.item + for type_range in proposed_type_ranges + if not type_range.is_upper_bound + ] + ) + remaining_type = restrict_subtype_away( + current_type, + proposed_precise_type, + consider_runtime_isinstance=consider_runtime_isinstance, + ) + return proposed_type, remaining_type def conditional_types_to_typemaps( diff --git a/mypy/subtypes.py b/mypy/subtypes.py index 7da258a827f3..f12f985cdae1 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -2094,6 +2094,8 @@ def restrict_subtype_away(t: Type, s: Type, *, consider_runtime_isinstance: bool isinstance(). Currently, this just removes elements of a union type. """ p_t = get_proper_type(t) + s_t = get_proper_type(s) + if isinstance(p_t, UnionType): new_items = try_restrict_literal_union(p_t, s) if new_items is None: @@ -2107,7 +2109,15 @@ def restrict_subtype_away(t: Type, s: Type, *, consider_runtime_isinstance: bool [item for item in new_items if not isinstance(get_proper_type(item), UninhabitedType)] ) elif isinstance(p_t, TypeVarType): - return p_t.copy_modified(upper_bound=restrict_subtype_away(p_t.upper_bound, s)) + if isinstance(s_t, TypeVarType): + upper_bound = restrict_subtype_away(p_t.upper_bound, s_t.upper_bound) + upper_bound = get_proper_type(upper_bound) + return ( + upper_bound + if isinstance(upper_bound, UninhabitedType) + else p_t.copy_modified(upper_bound=upper_bound) + ) + return p_t.copy_modified(upper_bound=restrict_subtype_away(p_t.upper_bound, s_t)) if consider_runtime_isinstance: if covers_at_runtime(t, s): diff --git a/test-data/unit/check-isinstance.test b/test-data/unit/check-isinstance.test index 640fc10915d1..131087a8dbdb 100644 --- a/test-data/unit/check-isinstance.test +++ b/test-data/unit/check-isinstance.test @@ -1483,11 +1483,12 @@ def f(x: Union[int, A], a: Type[A]) -> None: [builtins fixtures/isinstancelist.pyi] [case testIsInstanceWithEmtpy2ndArg] +# flags: --warn-unreachable from typing import Union def f(x: Union[int, str]) -> None: if isinstance(x, ()): - reveal_type(x) # N: Revealed type is "Union[builtins.int, builtins.str]" + reveal_type(x) # E: Statement is unreachable else: reveal_type(x) # N: Revealed type is "Union[builtins.int, builtins.str]" [builtins fixtures/isinstancelist.pyi] diff --git a/test-data/unit/check-narrowing.test b/test-data/unit/check-narrowing.test index 7fffd3ce94e5..00d33c86414f 100644 --- a/test-data/unit/check-narrowing.test +++ b/test-data/unit/check-narrowing.test @@ -2639,7 +2639,7 @@ def baz(item: Base) -> None: reveal_type(item) # N: Revealed type is "Union[__main__., __main__.]" if isinstance(item, FooMixin): - reveal_type(item) # N: Revealed type is "__main__.FooMixin" + reveal_type(item) # N: Revealed type is "__main__." item.foo() else: reveal_type(item) # N: Revealed type is "__main__." From d5318cd42415b2d9506d6b035088c99f8cfff1c7 Mon Sep 17 00:00:00 2001 From: Randolf Scholz Date: Sun, 27 Jul 2025 20:12:07 +0200 Subject: [PATCH 3/5] simplify treatment of typevar argument --- mypy/subtypes.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/mypy/subtypes.py b/mypy/subtypes.py index f12f985cdae1..7c45702bd250 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -2095,6 +2095,8 @@ def restrict_subtype_away(t: Type, s: Type, *, consider_runtime_isinstance: bool """ p_t = get_proper_type(t) s_t = get_proper_type(s) + if isinstance(s_t, TypeVarType): + s = s_t.upper_bound if isinstance(p_t, UnionType): new_items = try_restrict_literal_union(p_t, s) @@ -2109,15 +2111,7 @@ def restrict_subtype_away(t: Type, s: Type, *, consider_runtime_isinstance: bool [item for item in new_items if not isinstance(get_proper_type(item), UninhabitedType)] ) elif isinstance(p_t, TypeVarType): - if isinstance(s_t, TypeVarType): - upper_bound = restrict_subtype_away(p_t.upper_bound, s_t.upper_bound) - upper_bound = get_proper_type(upper_bound) - return ( - upper_bound - if isinstance(upper_bound, UninhabitedType) - else p_t.copy_modified(upper_bound=upper_bound) - ) - return p_t.copy_modified(upper_bound=restrict_subtype_away(p_t.upper_bound, s_t)) + return p_t.copy_modified(upper_bound=restrict_subtype_away(p_t.upper_bound, s)) if consider_runtime_isinstance: if covers_at_runtime(t, s): From 0bcf1dd0d192e4492c735d92cb578822b19b9be7 Mon Sep 17 00:00:00 2001 From: Randolf Scholz Date: Sun, 27 Jul 2025 20:38:35 +0200 Subject: [PATCH 4/5] allow get_isinstance_type to return empty list --- mypy/checker.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 2eeb3ee96eb4..88bfe3832c2a 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -7755,11 +7755,8 @@ def get_isinstance_type(self, expr: Expression) -> list[TypeRange] | None: types.append(TypeRange(typ, is_upper_bound=False)) else: # we didn't see an actual type, but rather a variable with unknown value return None - if not types: - # this can happen if someone has empty tuple as 2nd argument to isinstance - # strictly speaking, we should return UninhabitedType but for simplicity we will simply - # refuse to do any type inference for now - return None + # Note: types can be an empty list, for example in `isinstance(x, ())`, + # which always returns False at runtime. return types def is_literal_enum(self, n: Expression) -> bool: @@ -8003,6 +8000,8 @@ def conditional_types( None means no new information can be inferred. If default is set it is returned instead. """ + if proposed_type_ranges is None: + return current_type, default if not proposed_type_ranges: return UninhabitedType(), default From a4f1685404a3e7ba4bc2659a501086ba6cd66c7d Mon Sep 17 00:00:00 2001 From: Randolf Scholz Date: Sun, 27 Jul 2025 20:41:44 +0200 Subject: [PATCH 5/5] added comments --- mypy/checker.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mypy/checker.py b/mypy/checker.py index 88bfe3832c2a..a7275a4064c7 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -8001,8 +8001,10 @@ def conditional_types( If default is set it is returned instead. """ if proposed_type_ranges is None: + # An isinstance check, but we don't understand the type return current_type, default if not proposed_type_ranges: + # This is the case for `if isinstance(x, ())` which always returns False. return UninhabitedType(), default if len(proposed_type_ranges) == 1: