From 4eed7eeb67f84417ea2fd07cbf365854035fad8e Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sun, 27 Sep 2026 12:36:13 +0100 Subject: [PATCH 1/2] Make expression cache robust for nested generic calls --- mypy/checker.py | 8 +++--- mypy/checkexpr.py | 40 ++++++++++++++++++++++-------- test-data/unit/check-classes.test | 8 +++--- test-data/unit/check-generics.test | 25 +++++++++++++++++++ 4 files changed, 62 insertions(+), 19 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index f03d06d965c37..9ae1d1de83f3b 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -2855,7 +2855,7 @@ def visit_class_def(self, defn: ClassDef) -> None: old_allow_abstract_call = self.allow_abstract_call self.allow_abstract_call = True sig, _ = self.expr_checker.check_call( - dec, [temp], [nodes.ARG_POS], defn, callable_name=fullname + dec, [temp], [nodes.ARG_POS], decorator, callable_name=fullname ) self.allow_abstract_call = old_allow_abstract_call # TODO: Apply the sig to the actual TypeInfo so we can handle decorators @@ -3345,8 +3345,8 @@ def visit_block(self, b: Block) -> None: continue self.accept(s) - # Clear expression cache after each statement to avoid unlimited growth. - self.expr_checker.expr_cache.clear() + # Clear expression caches after each statement to avoid unlimited growth. + self.expr_checker.reset() def should_report_unreachable_issues(self) -> bool: return ( @@ -5906,7 +5906,7 @@ def visit_decorator_inner( fullname = self.expr_checker.method_fullname(object_type, d.name) self.check_for_untyped_decorator(e.func, dec, d) sig, t2 = self.expr_checker.check_call( - dec, [temp], [nodes.ARG_POS], e, callable_name=fullname, object_type=object_type + dec, [temp], [nodes.ARG_POS], d, callable_name=fullname, object_type=object_type ) if non_trivial_decorator: self.check_untyped_after_decorator(sig, e.func) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 6cdd64e84302f..1f18f26ad841d 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -334,6 +334,7 @@ def __init__( tuple[Expression, Type | None], tuple[int, Type, list[ErrorInfo], dict[Expression, Type]], ] = {} + self.freshen_cache: dict[tuple[Context, CallableType], CallableType] = {} self.in_lambda_expr = False self._literal_true: Instance | None = None @@ -341,6 +342,7 @@ def __init__( def reset(self) -> None: self.expr_cache.clear() + self.freshen_cache.clear() def visit_name_expr(self, e: NameExpr) -> Type: """Type check a name expression. @@ -1568,7 +1570,10 @@ def check_call( args: actual argument expressions arg_kinds: contains nodes.ARG_* constant for each argument in args describing whether the argument is positional, *arg, etc. - context: current expression context, used for inference. + context: current expression context, used for inference. Note: for "synthetic" + calls (such as decorators or comprehensions), pass the exact context, so + that two conceptually different calls will not accidentally have same + context (since it is used as a cache key for inference). arg_names: names of arguments (optional) callable_node: associate the inferred callable type to this node, if specified @@ -1757,18 +1762,31 @@ def check_callable_call( return AnyType(TypeOfAny.from_error), callee seen_unpack = True - # This is tricky: return type may contain its own type variables, like in - # def [S] (S) -> def [T] (T) -> tuple[S, T], so we need to update their ids - # to avoid possible id clashes if this call itself appears in a generic - # function body. - ret_type = get_proper_type(callee.ret_type) - if isinstance(ret_type, CallableType) and ret_type.variables: - fresh_ret_type = freshen_all_functions_type_vars(callee.ret_type) - freeze_all_type_vars(fresh_ret_type) - callee = callee.copy_modified(ret_type=fresh_ret_type) + # If the callable is generic, we need to replace its type variables with unique + # meta variables. We however do this at most once per callable, so that expression + # cache stays efficient in absence of outer type context. + original_callee = callee + if (context, callee) in self.freshen_cache: + callee = self.freshen_cache[(context, callee)] + else: + should_cache = False + ret_type = get_proper_type(callee.ret_type) + if isinstance(ret_type, CallableType) and ret_type.variables: + # This is tricky: return type may contain its own type variables, like in + # def [S] (S) -> def [T] (T) -> tuple[S, T], so we need to update their ids + # to avoid possible id clashes if this call itself appears in a generic + # function body. + fresh_ret_type = freshen_all_functions_type_vars(callee.ret_type) + freeze_all_type_vars(fresh_ret_type) + callee = callee.copy_modified(ret_type=fresh_ret_type) + should_cache = True + if callee.is_generic(): + callee = freshen_function_type_vars(callee) + should_cache = True + if should_cache: + self.freshen_cache[(context, original_callee)] = callee if callee.is_generic(): - callee = freshen_function_type_vars(callee) callee = self.infer_function_type_arguments_using_context(callee, context) formal_to_actual = map_actuals_to_formals( diff --git a/test-data/unit/check-classes.test b/test-data/unit/check-classes.test index fa742f3571f82..9b4e7b409e00d 100644 --- a/test-data/unit/check-classes.test +++ b/test-data/unit/check-classes.test @@ -6151,15 +6151,15 @@ class A2: pass [case testClassDecoratorIncorrect] def not_a_class_decorator(x: int) -> int: ... -@not_a_class_decorator(7) -class A3: pass # E: "int" not callable +@not_a_class_decorator(7) # E: "int" not callable +class A3: pass not_a_function = 17 @not_a_function() # E: "int" not callable class B: pass -@not_a_function -class B2: pass # E: "int" not callable +@not_a_function # E: "int" not callable +class B2: pass b = object() @b.nothing # E: "object" has no attribute "nothing" diff --git a/test-data/unit/check-generics.test b/test-data/unit/check-generics.test index 48b0beff03d33..414a748d42dd2 100644 --- a/test-data/unit/check-generics.test +++ b/test-data/unit/check-generics.test @@ -3737,3 +3737,28 @@ def test(tp: type[T]) -> T: ... class C(Generic[T]): ... reveal_type(test(C)) # N: Revealed type is "__main__.C[Any]" + +[case testExpressionCacheWorksInNestedCallWithoutOuterTypeContext] +from typing import overload, TypeVar + +T = TypeVar("T") +S = TypeVar("S") + +@overload +def f(x: None) -> None: ... +@overload +def f(x: tuple[T, S]) -> tuple[S, T]: ... +@overload +def f(x: T) -> T: ... +def f(x): + pass + +x = f(f(f(f(f(f(f(f(f(f(f(f(f(f(f(1))))))))))))))) +reveal_type(x) # N: Revealed type is "builtins.int" + +class C: + def meth(self, x: S) -> S: ... + +# The type variable id below must be small (few dozens at most). +reveal_type(C().meth) # N: Revealed type is "def [S] (x: S`46) -> S`46" +[builtins fixtures/tuple.pyi] From 589beb808ba63c90dcb5816803d6cc97302f171a Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Wed, 7 Oct 2026 01:03:23 +0100 Subject: [PATCH 2/2] Address CR; handle methods as well --- mypy/checkexpr.py | 37 ++++++++++++++---------------- test-data/unit/check-generics.test | 29 ++++++++++++++++++++++- 2 files changed, 45 insertions(+), 21 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 08fbdf4a74b7d..04e2f71024787 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -1771,25 +1771,22 @@ def check_callable_call( # If the callable is generic, we need to replace its type variables with unique # meta variables. We however do this at most once per callable, so that expression # cache stays efficient in absence of outer type context. - original_callee = callee - if (context, callee) in self.freshen_cache: - callee = self.freshen_cache[(context, callee)] - else: - should_cache = False - ret_type = get_proper_type(callee.ret_type) - if isinstance(ret_type, CallableType) and ret_type.variables: - # This is tricky: return type may contain its own type variables, like in - # def [S] (S) -> def [T] (T) -> tuple[S, T], so we need to update their ids - # to avoid possible id clashes if this call itself appears in a generic - # function body. - fresh_ret_type = freshen_all_functions_type_vars(callee.ret_type) - freeze_all_type_vars(fresh_ret_type) - callee = callee.copy_modified(ret_type=fresh_ret_type) - should_cache = True - if callee.is_generic(): - callee = freshen_function_type_vars(callee) - should_cache = True - if should_cache: + ret_type = get_proper_type(callee.ret_type) + if callee.is_generic() or isinstance(ret_type, CallableType) and ret_type.is_generic(): + if (context, callee) in self.freshen_cache: + callee = self.freshen_cache[(context, callee)] + else: + original_callee = callee + if isinstance(ret_type, CallableType) and ret_type.is_generic(): + # This is tricky: return type may contain its own type variables, like in + # def [S] (S) -> def [T] (T) -> tuple[S, T], so we need to update their ids + # to avoid possible id clashes if this call itself appears in a generic + # function body. + fresh_ret_type = freshen_all_functions_type_vars(callee.ret_type) + freeze_all_type_vars(fresh_ret_type) + callee = callee.copy_modified(ret_type=fresh_ret_type) + if callee.is_generic(): + callee = freshen_function_type_vars(callee) self.freshen_cache[(context, original_callee)] = callee if callee.is_generic(): @@ -6266,7 +6263,7 @@ def accept( # context, and use enclosing one, see infer_lambda_type_using_context(). # TODO: consider using cache for more expression kinds. elif ( - isinstance(node, (CallExpr, ListExpr, TupleExpr, DictExpr, OpExpr)) + isinstance(node, (CallExpr, ListExpr, TupleExpr, DictExpr, OpExpr, MemberExpr)) and not (self.in_lambda_expr or self.chk.current_node_deferred) and not self.chk.options.disable_expression_cache ): diff --git a/test-data/unit/check-generics.test b/test-data/unit/check-generics.test index 414a748d42dd2..1570249c0dab1 100644 --- a/test-data/unit/check-generics.test +++ b/test-data/unit/check-generics.test @@ -3738,7 +3738,7 @@ def test(tp: type[T]) -> T: ... class C(Generic[T]): ... reveal_type(test(C)) # N: Revealed type is "__main__.C[Any]" -[case testExpressionCacheWorksInNestedCallWithoutOuterTypeContext] +[case testExpressionCacheWorksInNestedCallWithoutOuterTypeContextFunction] from typing import overload, TypeVar T = TypeVar("T") @@ -3762,3 +3762,30 @@ class C: # The type variable id below must be small (few dozens at most). reveal_type(C().meth) # N: Revealed type is "def [S] (x: S`46) -> S`46" [builtins fixtures/tuple.pyi] + +[case testExpressionCacheWorksInNestedCallWithoutOuterTypeContextMethod] +from typing import overload, TypeVar + +T = TypeVar("T") +S = TypeVar("S") + +class A: + @overload + def f(self, x: None) -> None: ... + @overload + def f(self, x: tuple[T, S]) -> tuple[S, T]: ... + @overload + def f(self, x: T) -> T: ... + def f(self, x): + pass + +a: A +x = a.f(a.f(a.f(a.f(a.f(a.f(a.f(a.f(a.f(a.f(a.f(a.f(a.f(a.f(a.f(1))))))))))))))) +reveal_type(x) # N: Revealed type is "builtins.int" + +class C: + def meth(self, x: S) -> S: ... + +# The type variable id below must be small (say under a hundred). +reveal_type(C().meth) # N: Revealed type is "def [S] (x: S`91) -> S`91" +[builtins fixtures/tuple.pyi]