Skip to content

[match-case] Fix narrowing of class pattern with union-argument. #19517

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

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
125 changes: 76 additions & 49 deletions mypy/checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -7797,11 +7797,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:
Expand Down Expand Up @@ -8038,53 +8035,83 @@ 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."""
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):
# 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,
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 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
Comment on lines +8048 to +8050
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is actually redundant, it works just as well if we comment it out since make_simplified_union([]) returns UninhabitedType().


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)
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 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
result: list[tuple[Type | None, Type | None]] = [
conditional_types(
union_item,
proposed_type_ranges,
default=union_item,
consider_runtime_isinstance=consider_runtime_isinstance,
)
return proposed_type, remaining_type
for union_item in get_proper_types(current_type.items)
]
# separate list of tuples into two lists
yes_types, no_types = zip(*result)
proposed_type = make_simplified_union([t for t in yes_types if t is not None])
else:
# An isinstance check, but we don't understand the type
return current_type, default
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


def conditional_types_to_typemaps(
Expand Down
4 changes: 4 additions & 0 deletions mypy/subtypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2094,6 +2094,10 @@ 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(s_t, TypeVarType):
s = s_t.upper_bound

if isinstance(p_t, UnionType):
new_items = try_restrict_literal_union(p_t, s)
if new_items is None:
Expand Down
3 changes: 2 additions & 1 deletion test-data/unit/check-isinstance.test
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion test-data/unit/check-narrowing.test
Original file line number Diff line number Diff line change
Expand Up @@ -2639,7 +2639,7 @@ def baz(item: Base) -> None:

reveal_type(item) # N: Revealed type is "Union[__main__.<subclass of "__main__.Base" and "__main__.FooMixin">, __main__.<subclass of "__main__.Base" and "__main__.BarMixin">]"
if isinstance(item, FooMixin):
reveal_type(item) # N: Revealed type is "__main__.FooMixin"
reveal_type(item) # N: Revealed type is "__main__.<subclass of "__main__.Base" and "__main__.FooMixin">"
item.foo()
else:
reveal_type(item) # N: Revealed type is "__main__.<subclass of "__main__.Base" and "__main__.BarMixin">"
Expand Down
16 changes: 16 additions & 0 deletions test-data/unit/check-python310.test
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down