From 0f6606f2e5b6a15059009caa207ba269be3f5ec5 Mon Sep 17 00:00:00 2001 From: Saul Shanabrook Date: Sun, 3 Aug 2025 03:18:28 +0000 Subject: [PATCH 01/12] test: add failing TypeGuard test for temporary object (__call__), refs #19575 Add a regression test to check-typeguard.test for TypeGuard narrowing on temporary objects using __call__. This documents the current bug (see https://github.com/python/mypy/issues/19575). --- test-data/unit/check-typeguard.test | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test-data/unit/check-typeguard.test b/test-data/unit/check-typeguard.test index fdcfcc969adc..3b8d1d9d9555 100644 --- a/test-data/unit/check-typeguard.test +++ b/test-data/unit/check-typeguard.test @@ -731,6 +731,19 @@ assert a(x=x) reveal_type(x) # N: Revealed type is "builtins.int" [builtins fixtures/tuple.pyi] + +# https://github.com/python/mypy/issues/19575 +[case testNoCrashOnDunderCallTypeGuardTemporaryObject] +from typing_extensions import TypeGuard +class E: + def __init__(self) -> None: ... + def __call__(self, o: object) -> TypeGuard[int]: + return True +x = object() +if E()(x): + reveal_type(x) # N: Revealed type is "builtins.int" +[builtins fixtures/tuple.pyi] + [case testTypeGuardRestrictAwaySingleInvariant] from typing import List from typing_extensions import TypeGuard From 48ed8401c49768c4a8a13c2d4c13a1454d6090fc Mon Sep 17 00:00:00 2001 From: Saul Shanabrook Date: Sun, 3 Aug 2025 04:37:32 +0000 Subject: [PATCH 02/12] Fix TypeGaurd on classes not saved to variables Code from @Copilot at https://github.com/saulshanabrook/mypy/pull/1/files#diff-f96a2d6138bc6cdf2a07c4d37f6071cc25c1631afc107e277a28d5b59fc0ef04R6239-R6295 --- mypy/checker.py | 65 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/mypy/checker.py b/mypy/checker.py index dfbfa753d5f2..60882ae8687e 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6156,6 +6156,9 @@ def find_isinstance_check_helper( if isinstance(node, CallExpr) and len(node.args) != 0: expr = collapse_walrus(node.args[0]) + print("[TypeGuard Debug] --- find_isinstance_check_helper ---") + print(f"[TypeGuard Debug] {node=}") + print(f"[TypeGuard Debug] {node.callee=}") if refers_to_fullname(node.callee, "builtins.isinstance"): if len(node.args) != 2: # the error will be reported elsewhere return {}, {} @@ -6236,6 +6239,68 @@ def find_isinstance_check_helper( consider_runtime_isinstance=False, ), ) + elif isinstance(node.callee, CallExpr): + # Handle case where callee is a call expression like E()(x) + # where E() returns an object with __call__ method that has TypeGuard + if len(node.args) == 0: + return {}, {} + callee_type = get_proper_type(self.lookup_type(node.callee)) + if isinstance(callee_type, Instance): + call_member = find_member( + "__call__", callee_type, callee_type, is_operator=True + ) + if call_member is not None: + call_type = get_proper_type(call_member) + # Check if the __call__ method has type_guard or type_is + if isinstance(call_type, CallableType) and ( + call_type.type_guard is not None or call_type.type_is is not None + ): + # Handle keyword arguments similar to RefExpr case + expr = collapse_walrus(node.args[0]) # Default to first positional arg + if node.arg_kinds[0] != nodes.ARG_POS: + # the first argument might be used as a kwarg + if isinstance(call_type, (CallableType, Overloaded)): + if isinstance(call_type, Overloaded): + # Use first overload for argument name lookup + first_callable = call_type.items[0] + else: + first_callable = call_type + + if first_callable.arg_names: + name = first_callable.arg_names[0] + if name in node.arg_names: + idx = node.arg_names.index(name) + # we want the idx-th variable to be narrowed + expr = collapse_walrus(node.args[idx]) + else: + kind = ( + "guard" + if call_type.type_guard is not None + else "narrower" + ) + self.fail( + message_registry.TYPE_GUARD_POS_ARG_REQUIRED.format( + kind + ), + node, + ) + return {}, {} + + if literal(expr) == LITERAL_TYPE: + # Apply the same TypeGuard narrowing logic + if call_type.type_guard is not None: + return {expr: TypeGuardedType(call_type.type_guard)}, {} + else: + assert call_type.type_is is not None + return conditional_types_to_typemaps( + expr, + *self.conditional_types_with_intersection( + self.lookup_type(expr), + [TypeRange(call_type.type_is, is_upper_bound=False)], + expr, + consider_runtime_isinstance=False, + ), + ) elif isinstance(node, ComparisonExpr): return self.comparison_type_narrowing_helper(node) elif isinstance(node, AssignmentExpr): From 8eafaba92bd3af41f0447ece156f35207c65aecc Mon Sep 17 00:00:00 2001 From: Saul Shanabrook Date: Sun, 3 Aug 2025 04:38:29 +0000 Subject: [PATCH 03/12] Add TypeIs test --- test-data/unit/check-typeguard.test | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test-data/unit/check-typeguard.test b/test-data/unit/check-typeguard.test index 3b8d1d9d9555..117dcd6ee98b 100644 --- a/test-data/unit/check-typeguard.test +++ b/test-data/unit/check-typeguard.test @@ -740,6 +740,15 @@ class E: def __call__(self, o: object) -> TypeGuard[int]: return True x = object() +if E()(x): + reveal_type(x) # N: Revealed type is "builtins.int" +[case testNoCrashOnDunderCallTypeIsTemporaryObject] +from typing_extensions import TypeIs +class E: + def __init__(self) -> None: ... + def __call__(self, o: object) -> TypeIs[int]: + return True +x = object() if E()(x): reveal_type(x) # N: Revealed type is "builtins.int" [builtins fixtures/tuple.pyi] From effa78f07d5599e0522e04813211ca9b6af5cb40 Mon Sep 17 00:00:00 2001 From: Saul Shanabrook Date: Sun, 3 Aug 2025 04:42:32 +0000 Subject: [PATCH 04/12] Remove print statements --- mypy/checker.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 60882ae8687e..97152cf400a1 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6156,9 +6156,6 @@ def find_isinstance_check_helper( if isinstance(node, CallExpr) and len(node.args) != 0: expr = collapse_walrus(node.args[0]) - print("[TypeGuard Debug] --- find_isinstance_check_helper ---") - print(f"[TypeGuard Debug] {node=}") - print(f"[TypeGuard Debug] {node.callee=}") if refers_to_fullname(node.callee, "builtins.isinstance"): if len(node.args) != 2: # the error will be reported elsewhere return {}, {} From 7bab569574f029e4b65e22157f9703249ccd1add Mon Sep 17 00:00:00 2001 From: Saul Shanabrook Date: Sun, 3 Aug 2025 04:57:42 +0000 Subject: [PATCH 05/12] Fix tests --- test-data/unit/check-typeguard.test | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test-data/unit/check-typeguard.test b/test-data/unit/check-typeguard.test index 117dcd6ee98b..001c638984d9 100644 --- a/test-data/unit/check-typeguard.test +++ b/test-data/unit/check-typeguard.test @@ -731,7 +731,6 @@ assert a(x=x) reveal_type(x) # N: Revealed type is "builtins.int" [builtins fixtures/tuple.pyi] - # https://github.com/python/mypy/issues/19575 [case testNoCrashOnDunderCallTypeGuardTemporaryObject] from typing_extensions import TypeGuard @@ -742,6 +741,8 @@ class E: x = object() if E()(x): reveal_type(x) # N: Revealed type is "builtins.int" +[builtins fixtures/tuple.pyi] + [case testNoCrashOnDunderCallTypeIsTemporaryObject] from typing_extensions import TypeIs class E: From 7f4eb002997ed6df1865377ca75488fb9e167d9d Mon Sep 17 00:00:00 2001 From: Saul Shanabrook Date: Sun, 3 Aug 2025 05:00:38 +0000 Subject: [PATCH 06/12] Fix type errors --- mypy/checker.py | 48 +++++++++++++++++++----------------------------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 97152cf400a1..009f49456fd9 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6236,11 +6236,9 @@ def find_isinstance_check_helper( consider_runtime_isinstance=False, ), ) - elif isinstance(node.callee, CallExpr): + elif isinstance(node.callee, CallExpr) and len(node.args) != 0: # Handle case where callee is a call expression like E()(x) # where E() returns an object with __call__ method that has TypeGuard - if len(node.args) == 0: - return {}, {} callee_type = get_proper_type(self.lookup_type(node.callee)) if isinstance(callee_type, Instance): call_member = find_member( @@ -6255,33 +6253,25 @@ def find_isinstance_check_helper( # Handle keyword arguments similar to RefExpr case expr = collapse_walrus(node.args[0]) # Default to first positional arg if node.arg_kinds[0] != nodes.ARG_POS: - # the first argument might be used as a kwarg - if isinstance(call_type, (CallableType, Overloaded)): - if isinstance(call_type, Overloaded): - # Use first overload for argument name lookup - first_callable = call_type.items[0] + if call_type.arg_names: + name = call_type.arg_names[0] + if name in node.arg_names: + idx = node.arg_names.index(name) + # we want the idx-th variable to be narrowed + expr = collapse_walrus(node.args[idx]) else: - first_callable = call_type - - if first_callable.arg_names: - name = first_callable.arg_names[0] - if name in node.arg_names: - idx = node.arg_names.index(name) - # we want the idx-th variable to be narrowed - expr = collapse_walrus(node.args[idx]) - else: - kind = ( - "guard" - if call_type.type_guard is not None - else "narrower" - ) - self.fail( - message_registry.TYPE_GUARD_POS_ARG_REQUIRED.format( - kind - ), - node, - ) - return {}, {} + kind = ( + "guard" + if call_type.type_guard is not None + else "narrower" + ) + self.fail( + message_registry.TYPE_GUARD_POS_ARG_REQUIRED.format( + kind + ), + node, + ) + return {}, {} if literal(expr) == LITERAL_TYPE: # Apply the same TypeGuard narrowing logic From fa84504c331289ae25cbc90ab0ab656158c3b53e Mon Sep 17 00:00:00 2001 From: Saul Shanabrook Date: Sun, 3 Aug 2025 05:48:51 +0000 Subject: [PATCH 07/12] De-duplicate code --- mypy/checker.py | 93 +++++++++++-------------------------------------- 1 file changed, 21 insertions(+), 72 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 009f49456fd9..6b6afc4d6d3e 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6183,21 +6183,23 @@ def find_isinstance_check_helper( attr = try_getting_str_literals(node.args[1], self.lookup_type(node.args[1])) if literal(expr) == LITERAL_TYPE and attr and len(attr) == 1: return self.hasattr_type_maps(expr, self.lookup_type(expr), attr[0]) - elif isinstance(node.callee, RefExpr): - if node.callee.type_guard is not None or node.callee.type_is is not None: + elif isinstance(node.callee, (RefExpr, CallExpr)): + type_is, type_guard = None, None + # the first argument might be used as a kwarg + called_type = get_proper_type(self.lookup_type(node.callee)) + + # TODO: there are some more cases in check_call() to handle. + if isinstance(called_type, Instance): + call = find_member("__call__", called_type, called_type, is_operator=True) + if call is not None: + called_type = get_proper_type(call) + if isinstance(called_type, CallableType): + type_is, type_guard = called_type.type_is, called_type.type_guard + if isinstance(node.callee, RefExpr): + type_is, type_guard = node.callee.type_is, node.callee.type_guard + if type_guard is not None or type_is is not None: # TODO: Follow *args, **kwargs if node.arg_kinds[0] != nodes.ARG_POS: - # the first argument might be used as a kwarg - called_type = get_proper_type(self.lookup_type(node.callee)) - - # TODO: there are some more cases in check_call() to handle. - if isinstance(called_type, Instance): - call = find_member( - "__call__", called_type, called_type, is_operator=True - ) - if call is not None: - called_type = get_proper_type(call) - # *assuming* the overloaded function is correct, there's a couple cases: # 1) The first argument has different names, but is pos-only. We don't # care about this case, the argument must be passed positionally. @@ -6210,9 +6212,7 @@ def find_isinstance_check_helper( # we want the idx-th variable to be narrowed expr = collapse_walrus(node.args[idx]) else: - kind = ( - "guard" if node.callee.type_guard is not None else "narrower" - ) + kind = "guard" if type_guard is not None else "narrower" self.fail( message_registry.TYPE_GUARD_POS_ARG_REQUIRED.format(kind), node ) @@ -6223,71 +6223,20 @@ def find_isinstance_check_helper( # considered "always right" (i.e. even if the types are not overlapping). # Also note that a care must be taken to unwrap this back at read places # where we use this to narrow down declared type. - if node.callee.type_guard is not None: - return {expr: TypeGuardedType(node.callee.type_guard)}, {} + if type_guard is not None: + return {expr: TypeGuardedType(type_guard)}, {} else: - assert node.callee.type_is is not None + assert type_is is not None return conditional_types_to_typemaps( expr, *self.conditional_types_with_intersection( self.lookup_type(expr), - [TypeRange(node.callee.type_is, is_upper_bound=False)], + [TypeRange(type_is, is_upper_bound=False)], expr, consider_runtime_isinstance=False, ), ) - elif isinstance(node.callee, CallExpr) and len(node.args) != 0: - # Handle case where callee is a call expression like E()(x) - # where E() returns an object with __call__ method that has TypeGuard - callee_type = get_proper_type(self.lookup_type(node.callee)) - if isinstance(callee_type, Instance): - call_member = find_member( - "__call__", callee_type, callee_type, is_operator=True - ) - if call_member is not None: - call_type = get_proper_type(call_member) - # Check if the __call__ method has type_guard or type_is - if isinstance(call_type, CallableType) and ( - call_type.type_guard is not None or call_type.type_is is not None - ): - # Handle keyword arguments similar to RefExpr case - expr = collapse_walrus(node.args[0]) # Default to first positional arg - if node.arg_kinds[0] != nodes.ARG_POS: - if call_type.arg_names: - name = call_type.arg_names[0] - if name in node.arg_names: - idx = node.arg_names.index(name) - # we want the idx-th variable to be narrowed - expr = collapse_walrus(node.args[idx]) - else: - kind = ( - "guard" - if call_type.type_guard is not None - else "narrower" - ) - self.fail( - message_registry.TYPE_GUARD_POS_ARG_REQUIRED.format( - kind - ), - node, - ) - return {}, {} - - if literal(expr) == LITERAL_TYPE: - # Apply the same TypeGuard narrowing logic - if call_type.type_guard is not None: - return {expr: TypeGuardedType(call_type.type_guard)}, {} - else: - assert call_type.type_is is not None - return conditional_types_to_typemaps( - expr, - *self.conditional_types_with_intersection( - self.lookup_type(expr), - [TypeRange(call_type.type_is, is_upper_bound=False)], - expr, - consider_runtime_isinstance=False, - ), - ) + elif isinstance(node, ComparisonExpr): return self.comparison_type_narrowing_helper(node) elif isinstance(node, AssignmentExpr): From db44b6c4132ace95b1d8ab4055696524938b9371 Mon Sep 17 00:00:00 2001 From: Saul Shanabrook Date: Sun, 3 Aug 2025 05:51:40 +0000 Subject: [PATCH 08/12] Add generic test --- test-data/unit/check-typeguard.test | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test-data/unit/check-typeguard.test b/test-data/unit/check-typeguard.test index 001c638984d9..7846afa266d1 100644 --- a/test-data/unit/check-typeguard.test +++ b/test-data/unit/check-typeguard.test @@ -754,6 +754,19 @@ if E()(x): reveal_type(x) # N: Revealed type is "builtins.int" [builtins fixtures/tuple.pyi] +[case testNoCrashOnDunderCallTypeIsTemporaryObjectGeneric] +from typing import Generic, TypeVar +from typing_extensions import TypeIs +T = TypeVar("T") +class E(Generic[T]): + def __init__(self) -> None: ... + def __call__(self, o: object) -> TypeIs[T]: + return True +x = object() +if E[int]()(x): + reveal_type(x) # N: Revealed type is "builtins.int" +[builtins fixtures/tuple.pyi] + [case testTypeGuardRestrictAwaySingleInvariant] from typing import List from typing_extensions import TypeGuard From 01dc0f48ae60aa1253a260ba1f2a7686a92f681d Mon Sep 17 00:00:00 2001 From: Saul Shanabrook Date: Sun, 3 Aug 2025 05:55:54 +0000 Subject: [PATCH 09/12] Remove newline --- mypy/checker.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mypy/checker.py b/mypy/checker.py index 6b6afc4d6d3e..ea6be14b0437 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6236,7 +6236,6 @@ def find_isinstance_check_helper( consider_runtime_isinstance=False, ), ) - elif isinstance(node, ComparisonExpr): return self.comparison_type_narrowing_helper(node) elif isinstance(node, AssignmentExpr): From 8b16f8dfb55cc0b1e2af9c7b0aab549c3725ab95 Mon Sep 17 00:00:00 2001 From: Saul Shanabrook Date: Sun, 3 Aug 2025 06:06:10 +0000 Subject: [PATCH 10/12] Add comments and another test --- mypy/checker.py | 7 +++++-- test-data/unit/check-typeguard.test | 11 +++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index ea6be14b0437..a0dbb16dd882 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6184,17 +6184,20 @@ def find_isinstance_check_helper( if literal(expr) == LITERAL_TYPE and attr and len(attr) == 1: return self.hasattr_type_maps(expr, self.lookup_type(expr), attr[0]) elif isinstance(node.callee, (RefExpr, CallExpr)): + # We support both named callables (RefExpr) and temporaries (CallExpr). + # For temporaries (e.g., E()(x)), we extract type_is/type_guard from the __call__ method. + # For named callables (e.g., is_int(x)), we extract type_is/type_guard directly from the RefExpr. type_is, type_guard = None, None - # the first argument might be used as a kwarg called_type = get_proper_type(self.lookup_type(node.callee)) - # TODO: there are some more cases in check_call() to handle. + # If the callee is an instance, try to extract TypeGuard/TypeIs from its __call__ method. if isinstance(called_type, Instance): call = find_member("__call__", called_type, called_type, is_operator=True) if call is not None: called_type = get_proper_type(call) if isinstance(called_type, CallableType): type_is, type_guard = called_type.type_is, called_type.type_guard + # If the callee is a RefExpr, extract TypeGuard/TypeIs directly. if isinstance(node.callee, RefExpr): type_is, type_guard = node.callee.type_is, node.callee.type_guard if type_guard is not None or type_is is not None: diff --git a/test-data/unit/check-typeguard.test b/test-data/unit/check-typeguard.test index 7846afa266d1..93e665e4548c 100644 --- a/test-data/unit/check-typeguard.test +++ b/test-data/unit/check-typeguard.test @@ -767,6 +767,17 @@ if E[int]()(x): reveal_type(x) # N: Revealed type is "builtins.int" [builtins fixtures/tuple.pyi] +[case testTypeGuardTemporaryObjectWithKeywordArg] +from typing_extensions import TypeGuard +class E: + def __init__(self) -> None: ... + def __call__(self, o: object) -> TypeGuard[int]: + return True +x = object() +if E()(o=x): + reveal_type(x) # N: Revealed type is "builtins.int" +[builtins fixtures/tuple.pyi] + [case testTypeGuardRestrictAwaySingleInvariant] from typing import List from typing_extensions import TypeGuard From 5912dedee4bd29687de61b42bc08461138a097a3 Mon Sep 17 00:00:00 2001 From: Saul Shanabrook Date: Sun, 3 Aug 2025 06:19:58 +0000 Subject: [PATCH 11/12] fix failing test --- mypy/checker.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index a0dbb16dd882..afe7c774090a 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6188,10 +6188,13 @@ def find_isinstance_check_helper( # For temporaries (e.g., E()(x)), we extract type_is/type_guard from the __call__ method. # For named callables (e.g., is_int(x)), we extract type_is/type_guard directly from the RefExpr. type_is, type_guard = None, None - called_type = get_proper_type(self.lookup_type(node.callee)) + try: + called_type = get_proper_type(self.lookup_type(node.callee)) + except KeyError: + called_type = None # TODO: there are some more cases in check_call() to handle. # If the callee is an instance, try to extract TypeGuard/TypeIs from its __call__ method. - if isinstance(called_type, Instance): + if called_type and isinstance(called_type, Instance): call = find_member("__call__", called_type, called_type, is_operator=True) if call is not None: called_type = get_proper_type(call) From bfb906002f280d75de0c50179128b494405c3026 Mon Sep 17 00:00:00 2001 From: Saul Shanabrook Date: Mon, 4 Aug 2025 11:34:02 -0400 Subject: [PATCH 12/12] Apply patch from @hauntsaninja https://github.com/python/mypy/pull/19577#issuecomment-3148761839 --- mypy/checker.py | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index afe7c774090a..442e09b62a2c 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6183,23 +6183,20 @@ def find_isinstance_check_helper( attr = try_getting_str_literals(node.args[1], self.lookup_type(node.args[1])) if literal(expr) == LITERAL_TYPE and attr and len(attr) == 1: return self.hasattr_type_maps(expr, self.lookup_type(expr), attr[0]) - elif isinstance(node.callee, (RefExpr, CallExpr)): - # We support both named callables (RefExpr) and temporaries (CallExpr). - # For temporaries (e.g., E()(x)), we extract type_is/type_guard from the __call__ method. - # For named callables (e.g., is_int(x)), we extract type_is/type_guard directly from the RefExpr. + else: type_is, type_guard = None, None - try: - called_type = get_proper_type(self.lookup_type(node.callee)) - except KeyError: - called_type = None - # TODO: there are some more cases in check_call() to handle. - # If the callee is an instance, try to extract TypeGuard/TypeIs from its __call__ method. - if called_type and isinstance(called_type, Instance): - call = find_member("__call__", called_type, called_type, is_operator=True) - if call is not None: - called_type = get_proper_type(call) - if isinstance(called_type, CallableType): - type_is, type_guard = called_type.type_is, called_type.type_guard + called_type = self.lookup_type_or_none(node.callee) + if called_type is not None: + called_type = get_proper_type(called_type) + # TODO: there are some more cases in check_call() to handle. + # If the callee is an instance, try to extract TypeGuard/TypeIs from its __call__ method. + if isinstance(called_type, Instance): + call = find_member("__call__", called_type, called_type, is_operator=True) + if call is not None: + called_type = get_proper_type(call) + if isinstance(called_type, CallableType): + type_is, type_guard = called_type.type_is, called_type.type_guard + # If the callee is a RefExpr, extract TypeGuard/TypeIs directly. if isinstance(node.callee, RefExpr): type_is, type_guard = node.callee.type_is, node.callee.type_guard