diff --git a/mypy/constraints.py b/mypy/constraints.py index aa095316c234..749e0dcab499 100644 --- a/mypy/constraints.py +++ b/mypy/constraints.py @@ -342,7 +342,9 @@ def _infer_constraints( # checkexpr.has_erased_component(). if not type_state.keep_unions and isinstance(template, UnionType): template = mypy.typeops.make_simplified_union(template.items, keep_erased=True) + unsimplified_actual: UnionType | None = None if not type_state.keep_unions and isinstance(actual, UnionType): + unsimplified_actual = actual actual = mypy.typeops.make_simplified_union(actual.items, keep_erased=True) # Ignore Any types from the type suggestion engine to avoid them @@ -428,13 +430,16 @@ def _infer_constraints( # We infer constraints eagerly -- try to find constraints for a type # variable if possible. This seems to help with some real-world # use cases. - return any_constraints( - [ - infer_constraints_if_possible(template, a_item, direction) - for a_item in actual.items - ], - eager=True, - ) + options = [ + infer_constraints_if_possible(template, a_item, direction) for a_item in actual.items + ] + if unsimplified_actual is not None: + options.extend( + _options_lost_by_simplification( + template, actual.items, options, unsimplified_actual.items + ) + ) + return any_constraints(options, eager=True) if direction == SUPERTYPE_OF and isinstance(template, UnionType): # When the template is a union, we are okay with leaving some # type variables indeterminate. This helps with some special @@ -476,6 +481,51 @@ def _unwrap_type_type(tp: TypeType | UnionType) -> ProperType: return UnionType.make_union([cast(TypeType, get_proper_type(o)).item for o in tp.items]) +def _options_lost_by_simplification( + template: ProperType, + simplified_items: Sequence[Type], + simplified_options: list[list[Constraint] | None], + original_items: Sequence[Type], +) -> list[list[Constraint] | None]: + """Constraint options for union items dropped by simplification that we still need. + + Simplification drops an item X when it is a proper subtype of another item Y, + but subtyping is not always transitive through the template: ``MutableSequence[str]`` + is a subtype of ``Hashable``, yet ``list[str]`` is not hashable even though it is + a subtype of ``MutableSequence[str]``. If the template can match X but none of the + items that absorbed X can, keep X as an extra option so the solver does not commit + to a wrong member (e.g. inferring ``T = bool`` from ``list[bool]`` alone). + """ + # Only recover an option when some simplified item already produced + # constraints: recovery exists to offer an alternative to a (possibly + # wrong) commitment, not to invent constraints where the fallback -- + # inferring the literal's type from its items -- already applies. Without + # this, a context like ``Hashable | Sequence[Hashable] | + # dict[Hashable, Hashable]`` would gain a ``T = Hashable`` constraint for + # ``[names]`` and report a new ``list-item`` error where previously only + # an ``assignment`` error was reported (pandas/core/indexes/base.py:2143, + # reported by mypy_primer on #22381). + if not any(option is not None for option in simplified_options): + return [] + simplified_ids = {id(get_proper_type(item)) for item in simplified_items} + extra: list[list[Constraint] | None] = [] + for item in original_items: + proper_item = get_proper_type(item) + if id(proper_item) in simplified_ids or isinstance(proper_item, UnionType): + continue + absorbers = [ + option + for simplified_item, option in zip(simplified_items, simplified_options) + if mypy.subtypes.is_proper_subtype(item, simplified_item, keep_erased_types=True) + ] + if not absorbers or any(option is not None for option in absorbers): + continue + option = infer_constraints_if_possible(template, item, SUBTYPE_OF) + if option is not None: + extra.append(option) + return extra + + def infer_constraints_if_possible( template: Type, actual: Type, direction: int ) -> list[Constraint] | None: diff --git a/test-data/unit/check-inference-context.test b/test-data/unit/check-inference-context.test index 53705ee43be1..888d47c58145 100644 --- a/test-data/unit/check-inference-context.test +++ b/test-data/unit/check-inference-context.test @@ -1680,3 +1680,31 @@ x: Union[B, C] fn: Callable[[Union[A, B]], None] fn = make(x) reveal_type(fn) # N: Revealed type is "def (__main__.B | __main__.C | __main__.A)" + +[case testListLiteralContextWithUnhashableListAndHashableSequence] +# https://github.com/python/mypy/issues/22342 +from typing import Hashable, MutableSequence + +def f(k: list[bool] | Hashable | MutableSequence[str]) -> None: ... + +f(["a", "b"]) +f([True]) +f([1.5]) # E: Argument 1 to "f" has incompatible type "list[float]"; expected "list[bool] | Hashable | MutableSequence[str]" \ + # N: Following member(s) of "list[float]" have conflicts: \ + # N: __hash__: expected "Callable[[], int]", got "None" +[builtins fixtures/list_hashable.pyi] +[typing fixtures/typing-full.pyi] + +[case testListLiteralContextUnionWithUnhashableDictMember] +# mypy_primer regression reported on #22381 for the #22342 fix: no simplified +# union item yields constraints here, so no option may be recovered. The list +# literal must keep the previous behaviour (an `assignment` error, which the +# pandas code suppresses with `# type: ignore[assignment]`), not a new +# `list-item` error. +from typing import Hashable, Sequence + +def g(names: Hashable | Sequence[Hashable] | dict[Hashable, Hashable]) -> None: + names = [names] # E: Incompatible types in assignment (expression has type "list[Hashable | Sequence[Hashable] | dict[Hashable, Hashable]]", variable has type "Hashable | Sequence[Hashable] | dict[Hashable, Hashable]") + +[builtins fixtures/list_hashable.pyi] +[typing fixtures/typing-full.pyi] diff --git a/test-data/unit/fixtures/list_hashable.pyi b/test-data/unit/fixtures/list_hashable.pyi new file mode 100644 index 000000000000..d5ca48057ace --- /dev/null +++ b/test-data/unit/fixtures/list_hashable.pyi @@ -0,0 +1,47 @@ +# Builtins stub used in list tests that need hashability information. + +import types +from typing import Any, ClassVar, Generic, Iterable, Iterator, MutableSequence, TypeVar, overload + +T = TypeVar('T') + +class object: + def __init__(self) -> None: pass + def __eq__(self, other: object) -> bool: pass + def __hash__(self) -> int: pass + +class type: pass + +class list(MutableSequence[T]): + __hash__: ClassVar[None] # type: ignore[assignment] + @overload + def __init__(self) -> None: pass + @overload + def __init__(self, x: Iterable[T]) -> None: pass + def __iter__(self) -> Iterator[T]: pass + def __len__(self) -> int: pass + def __contains__(self, item: object) -> bool: pass + def __add__(self, x: list[T]) -> list[T]: pass + def __mul__(self, x: int) -> list[T]: pass + def __getitem__(self, x: Any) -> T: pass + def __setitem__(self, x: Any, v: T) -> None: pass + def append(self, x: T) -> None: pass + def extend(self, x: Iterable[T]) -> None: pass + +class tuple(Generic[T]): pass +class function: pass +class int: + def __bool__(self) -> bool: pass +class float: + def __bool__(self) -> bool: pass +class str: + def __len__(self) -> bool: pass +class bool(int): pass + +property = object() # Dummy definition. + +K = TypeVar('K') +V = TypeVar('V') + +class dict(Generic[K, V]): + __hash__: ClassVar[None] # type: ignore[assignment]