From 67c6753b12f9c7c7cc7f5cd450f9fb7d94d0d7ec Mon Sep 17 00:00:00 2001 From: bxff <51504045+bxff@users.noreply.github.com> Date: Sun, 1 Mar 2026 23:13:36 +0530 Subject: [PATCH 1/2] Fix narrowing of union types containing StrEnum/IntEnum and Literal When a union contains both StrEnum/IntEnum and Literal/None types, the ambiguity guard in narrow_type_by_identity_equality skips all narrowing. This processes Literal/None union items individually via conditional_types while keeping enum items as-is. Fixes #20915 --- mypy/checker.py | 34 +++++++++++++++++++++++++++ mypy/stubtest.py | 4 ++++ mypy/test/teststubtest.py | 43 ++++++++++++++++++++++++++++++++++ test-data/unit/check-enum.test | 5 ++-- 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index fc636e9a7218c..c5eb8c9ff20a7 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6783,6 +6783,40 @@ def narrow_type_by_identity_equality( enum_comparison_is_ambiguous and len(expr_enum_keys | ambiguous_enum_equality_keys(target_type)) > 1 ): + # For unions with both StrEnum/IntEnum and Literal/None items, + # narrow the Literal/None items while keeping enum items as-is. + orig_type = get_proper_type(coerce_to_literal(operand_types[i])) + if isinstance(orig_type, UnionType): + yes_items: list[Type] = [] + no_items: list[Type] = [] + has_narrowable = False + target = TypeRange(target_type, is_upper_bound=False) + for item in orig_type.items: + p_item = get_proper_type(item) + is_enum = bool( + ambiguous_enum_equality_keys(item) - {""} + ) + if not is_enum and isinstance(p_item, (LiteralType, NoneType)): + has_narrowable = True + y, n = conditional_types( + item, [target], default=item, from_equality=True + ) + yes_items.append(y) + no_items.append(n) + else: + yes_items.append(item) + no_items.append(item) + if has_narrowable: + if_map, else_map = conditional_types_to_typemaps( + operands[i], + UnionType.make_union(yes_items), + UnionType.make_union(no_items), + ) + all_if_maps.append(if_map) + if is_target_for_value_narrowing( + get_proper_type(target_type) + ): + all_else_maps.append(else_map) continue target = TypeRange(target_type, is_upper_bound=False) diff --git a/mypy/stubtest.py b/mypy/stubtest.py index 9dd630d61d4a9..5973d82f23832 100644 --- a/mypy/stubtest.py +++ b/mypy/stubtest.py @@ -2049,6 +2049,10 @@ def _named_type(name: str) -> mypy.types.Instance: return mypy.types.TupleType(items, fallback) fallback = mypy.types.Instance(type_info, [anytype() for _ in type_info.type_vars]) + if type(runtime) != runtime.__class__: + # Since `__class__` is redefined for an instance, we can't trust + # its `isinstance` checks, it can be dynamic. See #20919 + return fallback value: bool | int | str if isinstance(runtime, enum.Enum) and isinstance(runtime.name, str): diff --git a/mypy/test/teststubtest.py b/mypy/test/teststubtest.py index 4023a191b548f..d373b15602716 100644 --- a/mypy/test/teststubtest.py +++ b/mypy/test/teststubtest.py @@ -1564,6 +1564,49 @@ def f(): return 3 error=None, ) + @collect_cases + def test_proxy_object(self) -> Iterator[Case]: + yield Case( + stub=""" + class LazyObject: + def __init__(self, func: object) -> None: ... + def __bool__(self) -> bool: ... + """, + runtime=""" + class LazyObject: + def __init__(self, func): + self.__dict__["_wrapped"] = None + self.__dict__["_setupfunc"] = func + def _setup(self): + self.__dict__["_wrapped"] = self._setupfunc() + @property + def __class__(self): + if self._wrapped is None: + self._setup() + return type(self._wrapped) + def __bool__(self): + if self._wrapped is None: + self._setup() + return bool(self._wrapped) + """, + error="test_module.LazyObject.__class__", + ) + yield Case( + stub=""" + def default_value() -> bool: ... + + DEFAULT_VALUE: bool + """, + runtime=""" + def default_value(): + return True + + DEFAULT_VALUE = LazyObject(default_value) + bool(DEFAULT_VALUE) # evaluate the lazy object + """, + error="test_module.DEFAULT_VALUE", + ) + @collect_cases def test_all_at_runtime_not_stub(self) -> Iterator[Case]: yield Case( diff --git a/test-data/unit/check-enum.test b/test-data/unit/check-enum.test index c05dfdef2bf7f..151e031d7cee4 100644 --- a/test-data/unit/check-enum.test +++ b/test-data/unit/check-enum.test @@ -2784,9 +2784,8 @@ def f1(a: Foo | Literal['foo']) -> Foo: reveal_type(a) # N: Revealed type is "__main__.Foo | Literal['foo']" return Foo.FOO - # Ideally this passes - reveal_type(a) # N: Revealed type is "__main__.Foo | Literal['foo']" - return a # E: Incompatible return value type (got "Foo | Literal['foo']", expected "Foo") + reveal_type(a) # N: Revealed type is "Literal[__main__.Foo.FOO]" + return a [builtins fixtures/primitives.pyi] [case testStrEnumEqualityAlias] From 19ccaddfa25be652f26424f5976ddbfce1bdc131 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 17:45:18 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mypy/checker.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index c5eb8c9ff20a7..323875d473040 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6793,9 +6793,7 @@ def narrow_type_by_identity_equality( target = TypeRange(target_type, is_upper_bound=False) for item in orig_type.items: p_item = get_proper_type(item) - is_enum = bool( - ambiguous_enum_equality_keys(item) - {""} - ) + is_enum = bool(ambiguous_enum_equality_keys(item) - {""}) if not is_enum and isinstance(p_item, (LiteralType, NoneType)): has_narrowable = True y, n = conditional_types( @@ -6813,9 +6811,7 @@ def narrow_type_by_identity_equality( UnionType.make_union(no_items), ) all_if_maps.append(if_map) - if is_target_for_value_narrowing( - get_proper_type(target_type) - ): + if is_target_for_value_narrowing(get_proper_type(target_type)): all_else_maps.append(else_map) continue