From e00c840c70fcb16018db95d65214e6ab575c27c0 Mon Sep 17 00:00:00 2001 From: xh123637 <3324733895@qq.com> Date: Fri, 6 Nov 2026 15:24:31 +0800 Subject: [PATCH 1/4] Fix false positive list-item for list literal against union with Hashable Fixes #22342. When a list literal is checked against a union such as list[bool] | Hashable | MutableSequence[str], union simplification dropped MutableSequence[str] (a proper subtype of Hashable), and the list template could not match the remaining Hashable member (list is unhashable), so inference committed to list[bool] and reported the items against bool. Constraint inference now recovers options for union items lost to simplification when the template can match the dropped item but none of the items that absorbed it can. I used an AI coding assistant while working on this fix, and verified the change and the tests myself. --- mypy/constraints.py | 53 ++++++++++++++++++--- test-data/unit/check-inference-context.test | 15 ++++++ test-data/unit/fixtures/list_hashable.pyi | 43 +++++++++++++++++ 3 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 test-data/unit/fixtures/list_hashable.pyi diff --git a/mypy/constraints.py b/mypy/constraints.py index aa095316c234..75b308bbfec5 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,40 @@ 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). + """ + 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..71200225008b 100644 --- a/test-data/unit/check-inference-context.test +++ b/test-data/unit/check-inference-context.test @@ -1680,3 +1680,18 @@ 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] + diff --git a/test-data/unit/fixtures/list_hashable.pyi b/test-data/unit/fixtures/list_hashable.pyi new file mode 100644 index 000000000000..acb6845e6681 --- /dev/null +++ b/test-data/unit/fixtures/list_hashable.pyi @@ -0,0 +1,43 @@ +# 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. + +class dict: pass From 5cfa01020a10c96269aaca51bb6bf66124efe0ac Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 07:28:15 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- test-data/unit/check-inference-context.test | 1 - 1 file changed, 1 deletion(-) diff --git a/test-data/unit/check-inference-context.test b/test-data/unit/check-inference-context.test index 71200225008b..ba49c04b5cea 100644 --- a/test-data/unit/check-inference-context.test +++ b/test-data/unit/check-inference-context.test @@ -1694,4 +1694,3 @@ f([1.5]) # E: Argument 1 to "f" has incompatible type "list[float]"; expected " # N: __hash__: expected "Callable[[], int]", got "None" [builtins fixtures/list_hashable.pyi] [typing fixtures/typing-full.pyi] - From 02f793ee4d0c72fd98ee924e257bcac18f0f82b7 Mon Sep 17 00:00:00 2001 From: xh123637 <3324733895@qq.com> Date: Fri, 6 Nov 2026 16:45:51 +0800 Subject: [PATCH 3/4] Only recover dropped union options when a commitment exists The recovery in _options_lost_by_simplification added a constraint even when no simplified union item produced one, which invented a T = Hashable constraint for [names] against Hashable | Sequence[Hashable] | dict[Hashable, Hashable] and turned the previous assignment error into a new list-item error (pandas/core/indexes/base.py:2143, reported by mypy_primer on #22381). Recovery exists to offer an alternative to a (possibly wrong) commitment, not to invent constraints where the fallback already applies, so skip recovery when every simplified option is None. The #22342 case still recovers (list[bool] yields T = bool). --- mypy/constraints.py | 11 +++++++++++ test-data/unit/check-inference-context.test | 15 +++++++++++++++ test-data/unit/fixtures/list_hashable.pyi | 6 +++++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/mypy/constraints.py b/mypy/constraints.py index 75b308bbfec5..749e0dcab499 100644 --- a/mypy/constraints.py +++ b/mypy/constraints.py @@ -496,6 +496,17 @@ def _options_lost_by_simplification( 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: diff --git a/test-data/unit/check-inference-context.test b/test-data/unit/check-inference-context.test index ba49c04b5cea..ffd8a43f0d0e 100644 --- a/test-data/unit/check-inference-context.test +++ b/test-data/unit/check-inference-context.test @@ -1694,3 +1694,18 @@ f([1.5]) # E: Argument 1 to "f" has incompatible type "list[float]"; expected " # 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 index acb6845e6681..d5ca48057ace 100644 --- a/test-data/unit/fixtures/list_hashable.pyi +++ b/test-data/unit/fixtures/list_hashable.pyi @@ -40,4 +40,8 @@ class bool(int): pass property = object() # Dummy definition. -class dict: pass +K = TypeVar('K') +V = TypeVar('V') + +class dict(Generic[K, V]): + __hash__: ClassVar[None] # type: ignore[assignment] From 556eadfb066e487c467ab7cc712f79ba2b345400 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:47:55 +0000 Subject: [PATCH 4/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- test-data/unit/check-inference-context.test | 1 - 1 file changed, 1 deletion(-) diff --git a/test-data/unit/check-inference-context.test b/test-data/unit/check-inference-context.test index ffd8a43f0d0e..888d47c58145 100644 --- a/test-data/unit/check-inference-context.test +++ b/test-data/unit/check-inference-context.test @@ -1708,4 +1708,3 @@ def g(names: Hashable | Sequence[Hashable] | dict[Hashable, Hashable]) -> None: [builtins fixtures/list_hashable.pyi] [typing fixtures/typing-full.pyi] -