From 87033df59a21e606c320252d7e92250f00982174 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Fri, 27 Feb 2026 01:19:55 +0000 Subject: [PATCH 1/4] Improve our support for hasattr() --- mypy/binder.py | 9 ++++++++- test-data/unit/check-redefine2.test | 10 ++++++++++ test-data/unit/fixtures/isinstancelist.pyi | 1 + 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/mypy/binder.py b/mypy/binder.py index 4de939e27501b..01ef5fe5e8c53 100644 --- a/mypy/binder.py +++ b/mypy/binder.py @@ -390,7 +390,14 @@ def update_from_options(self, frames: list[Frame]) -> bool: ) if simplified == self.declarations[key]: type = simplified - if current_value is None or not is_same_type(type, current_value.type): + if ( + current_value is None + or not is_same_type(type, current_value.type) + # Manually carry over any narrowing from hasattr() from inner frames. This is + # a bit ad-hoc, but our handling of hasattr() is on best effort basis anyway. + or isinstance(p_type := get_proper_type(type), Instance) + and p_type.extra_attrs + ): self._put(key, type, from_assignment=True) if current_value is not None or extract_var_from_literal_hash(key) is None: # We definitely learned something new diff --git a/test-data/unit/check-redefine2.test b/test-data/unit/check-redefine2.test index 1b65719282a99..55cb985e3a656 100644 --- a/test-data/unit/check-redefine2.test +++ b/test-data/unit/check-redefine2.test @@ -114,6 +114,16 @@ def f1() -> None: reveal_type(x) # N: Revealed type is "builtins.str" reveal_type(x) # N: Revealed type is "builtins.str | None" +[case testNewRedefineHasAttr] +# flags: --allow-redefinition-new --local-partial-types + +def test(lst: list[object]) -> None: + for cls in lst: + if not hasattr(cls, "module"): + break + reveal_type(cls.module) # N: Revealed type is "Any" +[builtins fixtures/isinstancelist.pyi] + [case testNewRedefineGlobalVariableSimple] # flags: --allow-redefinition-new --local-partial-types if int(): diff --git a/test-data/unit/fixtures/isinstancelist.pyi b/test-data/unit/fixtures/isinstancelist.pyi index 2a43606f361a3..54d6e9e38b499 100644 --- a/test-data/unit/fixtures/isinstancelist.pyi +++ b/test-data/unit/fixtures/isinstancelist.pyi @@ -18,6 +18,7 @@ Ellipsis = ellipsis() def isinstance(x: object, t: Union[type, Tuple]) -> bool: pass def issubclass(x: object, t: Union[type, Tuple]) -> bool: pass +def hasattr(x: object, name: str) -> bool: pass class int: def __add__(self, x: int) -> int: pass From 321fb978c4d240969285f0a5e25b10b910ccd11d Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Fri, 27 Feb 2026 01:31:16 +0000 Subject: [PATCH 2/4] Better test --- test-data/unit/check-redefine2.test | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test-data/unit/check-redefine2.test b/test-data/unit/check-redefine2.test index 55cb985e3a656..191e3fd375aa3 100644 --- a/test-data/unit/check-redefine2.test +++ b/test-data/unit/check-redefine2.test @@ -122,6 +122,11 @@ def test(lst: list[object]) -> None: if not hasattr(cls, "module"): break reveal_type(cls.module) # N: Revealed type is "Any" + + other = object() + if not hasattr(other, "foo"): + return + reveal_type(other.foo) # N: Revealed type is "Any" [builtins fixtures/isinstancelist.pyi] [case testNewRedefineGlobalVariableSimple] From 58bd005238c53e302f6ae76ee74d308e687505ef Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Fri, 27 Feb 2026 14:09:41 +0000 Subject: [PATCH 3/4] Fix another edge case (+ micro perf opt) --- mypy/typeops.py | 27 ++++++++++++++++++++------- test-data/unit/check-redefine2.test | 6 ++++++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/mypy/typeops.py b/mypy/typeops.py index e3657c2fc4d57..b9c3523eeaf92 100644 --- a/mypy/typeops.py +++ b/mypy/typeops.py @@ -629,10 +629,23 @@ def make_simplified_union( else: extra_attrs_set.add(instance.extra_attrs) - if extra_attrs_set is not None and len(extra_attrs_set) > 1: - fallback = try_getting_instance_fallback(result) - if fallback: - fallback.extra_attrs = None + # Code below is awkward, because we don't want the extra checks to affect + # performance in the common case. + erase_extra = False + if extra_attrs_set is not None: + if len(extra_attrs_set) > 1: # This case is too tricky to handle. + erase_extra = True + else: + # Check that all relevant items have the extra attributes. + for item in items: + instance = try_getting_instance_fallback(item) + if instance and not instance.extra_attrs: + erase_extra = True + break + if erase_extra: + fallback = try_getting_instance_fallback(result) + if fallback: + fallback.extra_attrs = None return result @@ -1220,16 +1233,16 @@ def try_getting_instance_fallback(typ: Type) -> Instance | None: return typ elif isinstance(typ, LiteralType): return typ.fallback - elif isinstance(typ, NoneType): + elif isinstance(typ, (NoneType, AnyType)): return None # Fast path for None, which is common elif isinstance(typ, FunctionLike): return typ.fallback + elif isinstance(typ, TypeVarType): + return try_getting_instance_fallback(typ.upper_bound) elif isinstance(typ, TupleType): return typ.partial_fallback elif isinstance(typ, TypedDictType): return typ.fallback - elif isinstance(typ, TypeVarType): - return try_getting_instance_fallback(typ.upper_bound) return None diff --git a/test-data/unit/check-redefine2.test b/test-data/unit/check-redefine2.test index 33602cb5dbc07..50a6fb307583f 100644 --- a/test-data/unit/check-redefine2.test +++ b/test-data/unit/check-redefine2.test @@ -127,6 +127,12 @@ def test(lst: list[object]) -> None: if not hasattr(other, "foo"): return reveal_type(other.foo) # N: Revealed type is "Any" + + x = object() + if bool(): + assert hasattr(x, "bar") + x.bar # OK + x.bar # E: "object" has no attribute "bar" [builtins fixtures/isinstancelist.pyi] [case testNewRedefineGlobalVariableSimple] From 0a4debf834cf742b5f95ac91de05c51591a944c7 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Fri, 27 Feb 2026 14:59:11 +0000 Subject: [PATCH 4/4] Fix some primer --- mypy/typeops.py | 9 +++++---- test-data/unit/check-redefine2.test | 12 ++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/mypy/typeops.py b/mypy/typeops.py index b9c3523eeaf92..839c6454ca28f 100644 --- a/mypy/typeops.py +++ b/mypy/typeops.py @@ -633,19 +633,20 @@ def make_simplified_union( # performance in the common case. erase_extra = False if extra_attrs_set is not None: + fallback = try_getting_instance_fallback(result) + if fallback is None: + return result if len(extra_attrs_set) > 1: # This case is too tricky to handle. erase_extra = True else: # Check that all relevant items have the extra attributes. for item in items: instance = try_getting_instance_fallback(item) - if instance and not instance.extra_attrs: + if instance and instance.type == fallback.type and not instance.extra_attrs: erase_extra = True break if erase_extra: - fallback = try_getting_instance_fallback(result) - if fallback: - fallback.extra_attrs = None + fallback.extra_attrs = None return result diff --git a/test-data/unit/check-redefine2.test b/test-data/unit/check-redefine2.test index 50a6fb307583f..b1a30784b99cb 100644 --- a/test-data/unit/check-redefine2.test +++ b/test-data/unit/check-redefine2.test @@ -116,6 +116,7 @@ def f1() -> None: [case testNewRedefineHasAttr] # flags: --allow-redefinition-new --local-partial-types +from typing import Union def test(lst: list[object]) -> None: for cls in lst: @@ -133,6 +134,17 @@ def test(lst: list[object]) -> None: assert hasattr(x, "bar") x.bar # OK x.bar # E: "object" has no attribute "bar" + +class C: + x: int + +u: Union[C, object] +if hasattr(u, "x"): + # Ideally we should have Any | int here and below, but this is tricky + reveal_type(u.x) # N: Revealed type is "Any" + +y = hasattr(u, "x") and u.x +reveal_type(y) # N: Revealed type is "Literal[False] | Any" [builtins fixtures/isinstancelist.pyi] [case testNewRedefineGlobalVariableSimple]