From b4f014a790bca8a1123b19db84fbf9f51a3046d3 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Tue, 22 Sep 2026 22:52:18 +0100 Subject: [PATCH 01/13] [WIP] handle diverging protocols --- mypy/constraints.py | 39 ++++++++++++++++++++--------- mypy/subtypes.py | 3 +++ mypy/types.py | 2 ++ test-data/unit/check-protocols.test | 13 ++++++++++ 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/mypy/constraints.py b/mypy/constraints.py index 48cc23f742227..340803929b6af 100644 --- a/mypy/constraints.py +++ b/mypy/constraints.py @@ -22,6 +22,7 @@ TypeInfo, ) from mypy.types import ( + MAX_PROTOCOL_DEPTH, TUPLE_LIKE_INSTANCE_NAMES, AnyType, CallableType, @@ -750,8 +751,13 @@ def visit_instance(self, template: Instance) -> list[Constraint]: if isinstance(actual, (CallableType, Overloaded)) and template.type.is_protocol: if "__call__" in template.type.protocol_members: # Special case: a generic callback protocol - if not any(template == t for t in template.type.inferring): - template.type.inferring.append(template) + inferring = template.type.inferring + if len(inferring) >= MAX_PROTOCOL_DEPTH: + raise ValueError + if len(inferring) < MAX_PROTOCOL_DEPTH and not any( + template == t for t in inferring + ): + inferring.append(template) call = mypy.subtypes.find_member( "__call__", template, actual, is_operator=True ) @@ -763,7 +769,7 @@ def visit_instance(self, template: Instance) -> list[Constraint]: and mypy.subtypes.is_subtype(erase_typevars(call), actual) ): res.extend(infer_constraints(call, actual, self.direction)) - template.type.inferring.pop() + inferring.pop() if isinstance(actual, CallableType) and actual.fallback is not None: if ( actual.is_type_obj() @@ -941,6 +947,9 @@ def visit_instance(self, template: Instance) -> list[Constraint]: res.extend(infer_constraints(template_arg, mapped_arg, SUBTYPE_OF)) res.extend(infer_constraints(template_arg, mapped_arg, SUPERTYPE_OF)) return res + inferring = template.type.inferring + if len(inferring) >= MAX_PROTOCOL_DEPTH: + raise ValueError if ( template.type.is_protocol and self.direction == SUPERTYPE_OF @@ -953,32 +962,34 @@ def visit_instance(self, template: Instance) -> list[Constraint]: # Note that we use is_protocol_implementation instead of is_subtype # because some type may be considered a subtype of a protocol # due to _promote, but still not implement the protocol. - not any(template == t for t in reversed(template.type.inferring)) + len(inferring) < MAX_PROTOCOL_DEPTH + and not any(template == t for t in reversed(inferring)) and mypy.subtypes.is_protocol_implementation(instance, erased, skip=["__call__"]) ): - template.type.inferring.append(template) + inferring.append(template) res.extend( self.infer_constraints_from_protocol_members( instance, template, original_actual, template ) ) - template.type.inferring.pop() + inferring.pop() return res elif ( instance.type.is_protocol and self.direction == SUBTYPE_OF and # We avoid infinite recursion for structural subtypes also here. - not any(instance == i for i in reversed(instance.type.inferring)) + len(inferring) < MAX_PROTOCOL_DEPTH + and not any(instance == i for i in reversed(inferring)) and mypy.subtypes.is_protocol_implementation(erased, instance, skip=["__call__"]) ): - instance.type.inferring.append(instance) + inferring.append(instance) res.extend( self.infer_constraints_from_protocol_members( instance, template, template, instance ) ) - instance.type.inferring.pop() + inferring.pop() return res if res: return res @@ -1010,17 +1021,21 @@ def visit_instance(self, template: Instance) -> list[Constraint]: assert isinstance(erased, ProperType) and isinstance(erased, Instance) # Special-case protocols before using fallback to get more precise constraints # for custom tuple types like NamedTuples. + inferring = template.type.inferring + if len(inferring) >= MAX_PROTOCOL_DEPTH: + raise ValueError if ( template.type.is_protocol and self.direction == SUPERTYPE_OF - and not any(template == t for t in reversed(template.type.inferring)) + and len(inferring) < MAX_PROTOCOL_DEPTH + and not any(template == t for t in reversed(inferring)) and mypy.subtypes.is_protocol_implementation(instance, erased, skip=["__call__"]) ): - template.type.inferring.append(template) + inferring.append(template) res = self.infer_constraints_from_protocol_members( instance, template, original_actual, template ) - template.type.inferring.pop() + inferring.pop() return res return infer_constraints(template, instance, self.direction) elif isinstance(actual, TypeVarType): diff --git a/mypy/subtypes.py b/mypy/subtypes.py index 9c67ebbdf1063..dd9f784124640 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -37,6 +37,7 @@ from mypy.options import Options from mypy.state import state from mypy.types import ( + MAX_PROTOCOL_DEPTH, MYPYC_NATIVE_INT_NAMES, TUPLE_LIKE_INSTANCE_NAMES, TYPED_NAMEDTUPLE_NAMES, @@ -1307,6 +1308,8 @@ def f(self) -> A: ... if not members_right.issubset(members_left): return False assuming = right.type.assuming_proper if proper_subtype else right.type.assuming + if len(assuming) > MAX_PROTOCOL_DEPTH: + raise ValueError for l, r in reversed(assuming): if l == left and r == right: return True diff --git a/mypy/types.py b/mypy/types.py index e01a1f21e8fb2..8d7a1783dbedb 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -222,6 +222,8 @@ class SentinelValue(NamedTuple): # A placeholder for int parameters _dummy_int: Final = -999999 +MAX_PROTOCOL_DEPTH: Final = 3 + class TypeOfAny: """ diff --git a/test-data/unit/check-protocols.test b/test-data/unit/check-protocols.test index f9adfa51688d0..94ac2dcdf14ec 100644 --- a/test-data/unit/check-protocols.test +++ b/test-data/unit/check-protocols.test @@ -4789,3 +4789,16 @@ bad_rep(t) # E: Argument 1 to "bad_rep" has incompatible type "C"; expected "P[ # N: Got: \ # N: def rep(self) -> C [builtins fixtures/tuple.pyi] + +[case testDivergingProtocol-skip] +from typing import Protocol, TypeVar, List + +T = TypeVar("T") +class P(Protocol[T]): + def meth(self) -> P[List[T]]: ... + +class C: + def meth(self) -> C: ... + +x: P = C() +[builtins fixtures/tuple.pyi] From 3daff803b0d4f8876cab85f185f4dcea3713e5e7 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Tue, 22 Sep 2026 23:22:30 +0100 Subject: [PATCH 02/13] Try 4 --- mypy/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy/types.py b/mypy/types.py index 8d7a1783dbedb..bb053f4030970 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -222,7 +222,7 @@ class SentinelValue(NamedTuple): # A placeholder for int parameters _dummy_int: Final = -999999 -MAX_PROTOCOL_DEPTH: Final = 3 +MAX_PROTOCOL_DEPTH: Final = 4 class TypeOfAny: From 492c33a0081fea5208bcfaa5c2e401636b8cc51d Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Tue, 22 Sep 2026 23:42:50 +0100 Subject: [PATCH 03/13] Try 5 --- mypy/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy/types.py b/mypy/types.py index bb053f4030970..608e44d18b9e5 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -222,7 +222,7 @@ class SentinelValue(NamedTuple): # A placeholder for int parameters _dummy_int: Final = -999999 -MAX_PROTOCOL_DEPTH: Final = 4 +MAX_PROTOCOL_DEPTH: Final = 5 class TypeOfAny: From c53935f2ed1c5404cc1f34f75b3acd07cda9de62 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Wed, 23 Sep 2026 00:11:24 +0100 Subject: [PATCH 04/13] Try 8 --- mypy/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy/types.py b/mypy/types.py index 608e44d18b9e5..1046529abf62e 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -222,7 +222,7 @@ class SentinelValue(NamedTuple): # A placeholder for int parameters _dummy_int: Final = -999999 -MAX_PROTOCOL_DEPTH: Final = 5 +MAX_PROTOCOL_DEPTH: Final = 8 class TypeOfAny: From 4bb362a6436c076770793bf0e5b1a1abe03939ae Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Wed, 23 Sep 2026 00:38:40 +0100 Subject: [PATCH 05/13] Try 20 --- mypy/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy/types.py b/mypy/types.py index 1046529abf62e..ea38c90d3eb7e 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -222,7 +222,7 @@ class SentinelValue(NamedTuple): # A placeholder for int parameters _dummy_int: Final = -999999 -MAX_PROTOCOL_DEPTH: Final = 8 +MAX_PROTOCOL_DEPTH: Final = 10 class TypeOfAny: From 3cf6c6d29c198f29809910bfb056a57f1f4f356f Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Wed, 23 Sep 2026 01:05:13 +0100 Subject: [PATCH 06/13] Try 20 for real --- mypy/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy/types.py b/mypy/types.py index ea38c90d3eb7e..ceee91bad707b 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -222,7 +222,7 @@ class SentinelValue(NamedTuple): # A placeholder for int parameters _dummy_int: Final = -999999 -MAX_PROTOCOL_DEPTH: Final = 10 +MAX_PROTOCOL_DEPTH: Final = 20 class TypeOfAny: From f5236ee121423b8f5687ee894356a6663ef8c34e Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Wed, 23 Sep 2026 01:29:42 +0100 Subject: [PATCH 07/13] No simplify --- mypy/checkexpr.py | 2 +- mypy/subtypes.py | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index f8b2fc4e79714..89b6eb0ca155d 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -3311,7 +3311,7 @@ def combine_function_signatures(self, types: list[ProperType]) -> AnyType | Call return AnyType(TypeOfAny.special_form) callables = cast("list[CallableType]", types) - combined = union_function_signatures(callables) + combined = union_function_signatures(callables, simplify_unions=True) if combined is not None: return combined diff --git a/mypy/subtypes.py b/mypy/subtypes.py index dd9f784124640..24da3e3b0e71b 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -2202,7 +2202,9 @@ def report(*args: Any) -> None: return cast(NormalizedCallableType, applied) -def union_function_signatures(callables: list[CallableType]) -> CallableType | None: +def union_function_signatures( + callables: list[CallableType], *, simplify_unions: bool = False +) -> CallableType | None: """Combine a list of functions by taking the union of all the arguments and return types.""" if len(callables) == 1: return callables[0] @@ -2240,10 +2242,17 @@ def union_function_signatures(callables: list[CallableType]) -> CallableType | N new_args[i].append(arg) new_returns.append(target.ret_type) + if simplify_unions: + arg_types = [mypy.typeops.make_simplified_union(args) for args in new_args] + ret_type = mypy.typeops.make_simplified_union(new_returns) + else: + arg_types = [UnionType.make_union(args) for args in new_args] + ret_type = UnionType.make_union(new_returns) + return callables[0].copy_modified( - arg_types=[mypy.typeops.make_simplified_union(args) for args in new_args], + arg_types=arg_types, arg_kinds=new_kinds, - ret_type=mypy.typeops.make_simplified_union(new_returns), + ret_type=ret_type, variables=variables, implicit=True, ) From 061054804279ccc23a3877c0e27eca939471c359 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Wed, 23 Sep 2026 10:13:15 +0100 Subject: [PATCH 08/13] Insight --- mypy/subtypes.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mypy/subtypes.py b/mypy/subtypes.py index 24da3e3b0e71b..82c07cf2b3ba9 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -61,6 +61,7 @@ TypeAliasType, TypedDictType, TypeOfAny, + TypeStrVisitor, TypeType, TypeVarLikeType, TypeVarTupleType, @@ -1309,7 +1310,11 @@ def f(self) -> A: ... return False assuming = right.type.assuming_proper if proper_subtype else right.type.assuming if len(assuming) > MAX_PROTOCOL_DEPTH: - raise ValueError + visitor = TypeStrVisitor(options=options or Options()) + pairs = [] + for l, r in assuming: + pairs.append((l.accept(visitor), r.accept(visitor))) + raise ValueError(pairs) for l, r in reversed(assuming): if l == left and r == right: return True From 293d7fdac0c305a92e6581f04c40a1b8e76ac5fc Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Wed, 23 Sep 2026 11:23:47 +0100 Subject: [PATCH 09/13] Experiment --- mypy/constraints.py | 8 ++++---- mypy/types.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mypy/constraints.py b/mypy/constraints.py index 340803929b6af..79987f6a13d7e 100644 --- a/mypy/constraints.py +++ b/mypy/constraints.py @@ -334,10 +334,10 @@ def _infer_constraints( # Type inference shouldn't be affected by whether union types have been simplified. # We however keep any ErasedType items, so that the caller will see it when using # checkexpr.has_erased_component(). - if isinstance(template, UnionType): - template = mypy.typeops.make_simplified_union(template.items, keep_erased=True) - if isinstance(actual, UnionType): - actual = mypy.typeops.make_simplified_union(actual.items, keep_erased=True) + # if isinstance(template, UnionType): + # template = mypy.typeops.make_simplified_union(template.items, keep_erased=True) + # if isinstance(actual, UnionType): + # actual = mypy.typeops.make_simplified_union(actual.items, keep_erased=True) # Ignore Any types from the type suggestion engine to avoid them # causing us to infer Any in situations where a better job could diff --git a/mypy/types.py b/mypy/types.py index ceee91bad707b..bb053f4030970 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -222,7 +222,7 @@ class SentinelValue(NamedTuple): # A placeholder for int parameters _dummy_int: Final = -999999 -MAX_PROTOCOL_DEPTH: Final = 20 +MAX_PROTOCOL_DEPTH: Final = 4 class TypeOfAny: From 87ecca5c8c12ec6ae194178193a2d252b86fedc7 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Wed, 23 Sep 2026 11:43:48 +0100 Subject: [PATCH 10/13] More experiment --- mypy/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy/types.py b/mypy/types.py index bb053f4030970..60f217146d498 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -222,7 +222,7 @@ class SentinelValue(NamedTuple): # A placeholder for int parameters _dummy_int: Final = -999999 -MAX_PROTOCOL_DEPTH: Final = 4 +MAX_PROTOCOL_DEPTH: Final = 6 class TypeOfAny: From 0ae95fa8058af156989d6b98db8761fc243d2864 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Wed, 23 Sep 2026 18:03:39 +0100 Subject: [PATCH 11/13] Very large --- mypy/constraints.py | 8 ++++---- mypy/types.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mypy/constraints.py b/mypy/constraints.py index 79987f6a13d7e..340803929b6af 100644 --- a/mypy/constraints.py +++ b/mypy/constraints.py @@ -334,10 +334,10 @@ def _infer_constraints( # Type inference shouldn't be affected by whether union types have been simplified. # We however keep any ErasedType items, so that the caller will see it when using # checkexpr.has_erased_component(). - # if isinstance(template, UnionType): - # template = mypy.typeops.make_simplified_union(template.items, keep_erased=True) - # if isinstance(actual, UnionType): - # actual = mypy.typeops.make_simplified_union(actual.items, keep_erased=True) + if isinstance(template, UnionType): + template = mypy.typeops.make_simplified_union(template.items, keep_erased=True) + if isinstance(actual, UnionType): + actual = mypy.typeops.make_simplified_union(actual.items, keep_erased=True) # Ignore Any types from the type suggestion engine to avoid them # causing us to infer Any in situations where a better job could diff --git a/mypy/types.py b/mypy/types.py index 60f217146d498..5d74875ce3054 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -222,7 +222,7 @@ class SentinelValue(NamedTuple): # A placeholder for int parameters _dummy_int: Final = -999999 -MAX_PROTOCOL_DEPTH: Final = 6 +MAX_PROTOCOL_DEPTH: Final = 40 class TypeOfAny: From 050f591b268aaf8e18a246b877c425824adcae49 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Thu, 24 Sep 2026 00:07:53 +0100 Subject: [PATCH 12/13] Critical --- mypy/subtypes.py | 14 +++++++++++--- mypy/types.py | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/mypy/subtypes.py b/mypy/subtypes.py index 82c07cf2b3ba9..445121312ad90 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -2227,15 +2227,23 @@ def union_function_signatures( # confusing and ought to be re-written anyway.) callables, variables = merge_typevars_in_callables_by_name(callables) - new_args: list[list[Type]] = [[] for _ in callables[0].arg_types] - new_kinds = list(callables[0].arg_kinds) + new_callable = callables[0].with_unpacked_kwargs().with_normalized_var_args() + new_args: list[list[Type]] = [[] for _ in new_callable.arg_types] + new_kinds = list(new_callable.arg_kinds) new_returns: list[Type] = [] for target in callables: + target = target.with_unpacked_kwargs().with_normalized_var_args() # TODO: Enhance the merging logic to handle a wider variety of signatures. + # In particular, allow name-only arguments that appear in different order. if len(new_kinds) != len(target.arg_kinds): return None for i, (new_kind, target_kind) in enumerate(zip(new_kinds, target.arg_kinds)): + if target_kind.is_named() and target.arg_names[i] != new_callable.arg_names[i]: + return None + if isinstance(target.arg_types[i], (ParamSpecType, UnpackType)): + # It is risky to put these inside a union. + return None if new_kind == target_kind: continue if new_kind.is_positional() and target_kind.is_positional(): @@ -2254,7 +2262,7 @@ def union_function_signatures( arg_types = [UnionType.make_union(args) for args in new_args] ret_type = UnionType.make_union(new_returns) - return callables[0].copy_modified( + return new_callable.copy_modified( arg_types=arg_types, arg_kinds=new_kinds, ret_type=ret_type, diff --git a/mypy/types.py b/mypy/types.py index 5d74875ce3054..600becb968f32 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -222,7 +222,7 @@ class SentinelValue(NamedTuple): # A placeholder for int parameters _dummy_int: Final = -999999 -MAX_PROTOCOL_DEPTH: Final = 40 +MAX_PROTOCOL_DEPTH: Final = 25 class TypeOfAny: From bf9ca10079b03b130618f3526694edf3488e6632 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Thu, 24 Sep 2026 01:20:17 +0100 Subject: [PATCH 13/13] A bit more --- mypy/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy/types.py b/mypy/types.py index 600becb968f32..8de0727b9cd2f 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -222,7 +222,7 @@ class SentinelValue(NamedTuple): # A placeholder for int parameters _dummy_int: Final = -999999 -MAX_PROTOCOL_DEPTH: Final = 25 +MAX_PROTOCOL_DEPTH: Final = 30 class TypeOfAny: