From 2817d285530726e13879be6ccdb7e730ecb8beb0 Mon Sep 17 00:00:00 2001 From: hasancankeles Date: Thu, 27 Aug 2026 20:19:04 +0200 Subject: [PATCH 1/4] Document that nested Any makes overload matching ambiguous Fixes #21768. Sequence[Any] matching both Sequence[int] and object is ambiguous under the typing spec's overload call evaluation rules, so the Any result is intended. Clarify the docs and lock the behavior in with tests. --- docs/source/more_types.rst | 12 ++++- mypy/checkexpr.py | 4 ++ test-data/unit/check-overloading.test | 75 +++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/docs/source/more_types.rst b/docs/source/more_types.rst index 444753757aad9..26195d511649a 100644 --- a/docs/source/more_types.rst +++ b/docs/source/more_types.rst @@ -394,8 +394,9 @@ will have an inferred type of ``float``. The implementer is responsible for making sure ``summarize`` breaks ties in the same way at runtime. However, there are two exceptions to the "pick the first match" rule. -First, if multiple variants match due to an argument being of type -``Any``, mypy will make the inferred type also be ``Any``: +First, if multiple variants match because an argument has type ``Any`` +or *contains* ``Any`` (for example ``list[Any]`` or ``Sequence[Any]``), +mypy will make the inferred type also be ``Any``: .. code-block:: python @@ -404,6 +405,13 @@ First, if multiple variants match due to an argument being of type # output2 is of type 'Any' output2 = summarize(dynamic_var) + nested_any: list[Any] = some_dynamic_function() + + # output2_nested is also 'Any'. A nested Any can stand for different + # concrete types (here, either list[int] or list[str]), so the call + # is ambiguous in the same way as a top-level Any. + output2_nested = summarize(nested_any) + Second, if multiple variants match due to one or more of the arguments being a union, mypy will make the inferred type be the union of the matching variant returns: diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 172d44555b946..df65f348b84a8 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -6855,6 +6855,10 @@ def any_causes_overload_ambiguity( ) -> bool: """May an argument containing 'Any' cause ambiguous result type on call to overloaded function? + This includes nested Any, such as Sequence[Any] or list[Any], not just a + top-level Any. A nested Any can materialize as different concrete types, so + if several overloads remain and their return types differ, the result is Any. + Note that this sometimes returns True even if there is no ambiguity, since a correct implementation would be complex (and the call would be imprecisely typed due to Any types anyway). diff --git a/test-data/unit/check-overloading.test b/test-data/unit/check-overloading.test index 721ca52f59eed..d0f68df470965 100644 --- a/test-data/unit/check-overloading.test +++ b/test-data/unit/check-overloading.test @@ -1906,6 +1906,81 @@ a: Any reveal_type(f(a)) # N: Revealed type is "def (*Any, **Any) -> Any" reveal_type(f(a)(a)) # N: Revealed type is "Any" +[case testOverloadNestedAnyInGenericArgumentIsAmbiguous] +# https://github.com/python/mypy/issues/21768 +# Nested Any is treated like top-level Any for overload matching. +# Sequence[Any] can materialize as Sequence[int] (first overload) or as +# another sequence type (only the object overload), so the result is Any. +from typing import Any, Mapping, Sequence, overload + +@overload +def f(s: Sequence[int]) -> int: ... +@overload +def f(s: object) -> object: ... +def f(s): pass + +concrete: Sequence[int] +nested_any: Sequence[Any] +top_level_any: Any +reveal_type(f(concrete)) # N: Revealed type is "builtins.int" +reveal_type(f(nested_any)) # N: Revealed type is "Any" +reveal_type(f(top_level_any)) # N: Revealed type is "Any" + +@overload +def g(s: Mapping[str, int]) -> int: ... +@overload +def g(s: object) -> object: ... +def g(s): pass + +mapping_any: Mapping[str, Any] +reveal_type(g(mapping_any)) # N: Revealed type is "Any" + +[case testOverloadNestedAnyUnambiguousWhenReturnsMatch] +# If remaining overloads agree on the return type, nested Any is not ambiguous. +from typing import Any, Sequence, overload + +@overload +def f(s: Sequence[int]) -> str: ... +@overload +def f(s: object) -> str: ... +def f(s): pass + +nested_any: Sequence[Any] +reveal_type(f(nested_any)) # N: Revealed type is "builtins.str" + +[case testOverloadNestedAnyDoesNotForceAnyWhenOtherArgSelects] +# Nested Any does not collapse the result when it does not itself cause +# multiple overloads with differing returns to remain. +from typing import Any, Sequence, overload + +@overload +def f(x: Sequence[int], y: int) -> int: ... +@overload +def f(x: Sequence[int], y: str) -> str: ... +def f(x, y): pass + +nested_any: Sequence[Any] +reveal_type(f(nested_any, 1)) # N: Revealed type is "builtins.int" +reveal_type(f(nested_any, "x")) # N: Revealed type is "builtins.str" +a: Any +reveal_type(f(nested_any, a)) # N: Revealed type is "Any" + +[case testOverloadNestedAnyWithIncompatibleOverlappingReturns] +# Issue 21768's original pair also has unsafely overlapping returns. +# The overlap error is separate; the call-site result is still Any. +from typing import Any, Sequence, overload + +@overload +def f(s: Sequence[int]) -> str: ... # E: Overloaded function signatures 1 and 2 overlap with incompatible return types +@overload +def f(s: object) -> int: ... +def f(s): pass + +concrete: Sequence[int] +nested_any: Sequence[Any] +reveal_type(f(concrete)) # N: Revealed type is "builtins.str" +reveal_type(f(nested_any)) # N: Revealed type is "Any" + [case testOverloadOnOverloadWithType] from typing import Any, Type, TypeVar, overload from mod import MyInt From 80a0c419c0291e8d014d13f3eea8a4d07b800f3b Mon Sep 17 00:00:00 2001 From: hasancankeles Date: Thu, 27 Aug 2026 20:32:49 +0200 Subject: [PATCH 2/4] Strengthen #21768 proof with inhabitation and spec-matrix tests The reporter's first-match expectation is unsound: a Sequence[str] inhabits Sequence[Any] and selects the object overload (int). The spec step-5 three-overload example and the existing list[int]/list[str] matrix also require Any. Nested Any still does not collapse when only one overload remains. --- docs/source/more_types.rst | 6 +-- mypy/checkexpr.py | 2 + test-data/unit/check-overloading.test | 66 +++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/docs/source/more_types.rst b/docs/source/more_types.rst index 26195d511649a..d474dcdaa9b8c 100644 --- a/docs/source/more_types.rst +++ b/docs/source/more_types.rst @@ -407,9 +407,9 @@ mypy will make the inferred type also be ``Any``: nested_any: list[Any] = some_dynamic_function() - # output2_nested is also 'Any'. A nested Any can stand for different - # concrete types (here, either list[int] or list[str]), so the call - # is ambiguous in the same way as a top-level Any. + # output2_nested is also 'Any'. A list[Any] is not "really" a list[int]: + # it can hold strings, and list[str] selects the other overload. The + # call is therefore ambiguous in the same way as a top-level Any. output2_nested = summarize(nested_any) Second, if multiple variants match due to one or more of the arguments diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index df65f348b84a8..f894a73213ca7 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -6858,6 +6858,8 @@ def any_causes_overload_ambiguity( This includes nested Any, such as Sequence[Any] or list[Any], not just a top-level Any. A nested Any can materialize as different concrete types, so if several overloads remain and their return types differ, the result is Any. + (If every materialization matched an earlier overload, the spec would drop + later ones; this helper over-approximates that case as ambiguous.) Note that this sometimes returns True even if there is no ambiguity, since a correct implementation would be complex (and the call would be imprecisely typed due to Any diff --git a/test-data/unit/check-overloading.test b/test-data/unit/check-overloading.test index d0f68df470965..34efb526e91d9 100644 --- a/test-data/unit/check-overloading.test +++ b/test-data/unit/check-overloading.test @@ -1981,6 +1981,72 @@ nested_any: Sequence[Any] reveal_type(f(concrete)) # N: Revealed type is "builtins.str" reveal_type(f(nested_any)) # N: Revealed type is "Any" +[case testOverloadNestedAnyInhabitationCounterexample] +# Picking the Sequence[int] overload for Sequence[Any] is unsound: +# a Sequence[str] inhabits Sequence[Any], and that call takes the object +# overload (int), not str. See typing spec overload call evaluation step 5. +from typing import Any, Sequence, overload + +@overload +def f(s: Sequence[int]) -> str: ... # E: Overloaded function signatures 1 and 2 overlap with incompatible return types +@overload +def f(s: object) -> int: ... +def f(s): pass + +as_str: Sequence[str] +nested_any: Sequence[Any] = as_str +reveal_type(f(as_str)) # N: Revealed type is "builtins.int" +reveal_type(f(nested_any)) # N: Revealed type is "Any" + +[case testOverloadNestedAnyOnlyOneOverloadMatches] +# Nested Any is not a blunt "always Any" switch. If only one overload +# is compatible, that overload wins even when the argument contains Any. +from typing import Any, Sequence, overload + +@overload +def f(s: Sequence[int]) -> str: ... +@overload +def f(s: str) -> int: ... +def f(s): pass + +nested_any: Sequence[Any] +reveal_type(f(nested_any)) # N: Revealed type is "builtins.str" +reveal_type(f("x")) # N: Revealed type is "builtins.int" + +[case testOverloadNestedAnySpecExampleThreeOverloads] +# Spec step-5 example: every materialization of list[Any] matches the +# first or second overload, so the Any fallback is dropped. The first +# two remain with different returns, so the call is still Any. +# Picking the first overload here would be the #21768 "fix" and is wrong. +from typing import Any, overload + +@overload +def example(x: list[int]) -> int: ... +@overload +def example(x: list[Any]) -> str: ... +@overload +def example(x: Any) -> bytes: ... +def example(x): pass + +a: list[Any] +reveal_type(example(a)) # N: Revealed type is "Any" + +[case testOverloadNestedAnyMatchesExistingListMatrix] +# Already locked in by testOverloadsUsingAny: invariant list[int]/list[str] +# with a list[Any] argument is Any. Repeated here next to the #21768 cases +# so that matrix cannot drift from the Sequence[Any] rule. +from typing import Any, List, overload + +@overload +def foo(x: List[int]) -> int: ... +@overload +def foo(x: List[str]) -> str: ... +def foo(x): pass + +c: List[Any] +reveal_type(foo(c)) # N: Revealed type is "Any" +[builtins fixtures/list.pyi] + [case testOverloadOnOverloadWithType] from typing import Any, Type, TypeVar, overload from mod import MyInt From 8e2f07bd4e4141502804558d5322548f4ce4d2f0 Mon Sep 17 00:00:00 2001 From: hasancankeles Date: Thu, 27 Aug 2026 20:43:21 +0200 Subject: [PATCH 3/4] Add practical #21768 tests and changelog after version audit The nested-Any overload rule is unchanged from mypy 2.2.0 through current main. Broaden the regression tests to dict values, tuples, methods, and full typeshed (collections.abc.Sequence). Record that type aliases still first-match because alias Any is a special form (#15630); that is not this issue and is not a backport candidate. --- CHANGELOG.md | 4 ++ test-data/unit/check-overloading.test | 58 +++++++++++++++++++++++++++ test-data/unit/pythoneval.test | 36 +++++++++++++++++ 3 files changed, 98 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8883ce517da74..60de87745982c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - No longer provide mypyc-accelerated wheels for macOS x86_64 [mypyc-wheels #119](https://github.com/mypyc/mypy_mypyc-wheels/pull/119) +### Documentation Updates + +- Clarify that nested `Any` in an overload argument is ambiguous, the same as a top-level `Any` (PR [21897](https://github.com/python/mypy/pull/21897)) + ## Mypy 2.3 We've just uploaded mypy 2.3.0 to the Python Package Index ([PyPI](https://pypi.org/project/mypy/)). diff --git a/test-data/unit/check-overloading.test b/test-data/unit/check-overloading.test index 34efb526e91d9..75e40013d936e 100644 --- a/test-data/unit/check-overloading.test +++ b/test-data/unit/check-overloading.test @@ -2047,6 +2047,64 @@ c: List[Any] reveal_type(foo(c)) # N: Revealed type is "Any" [builtins fixtures/list.pyi] +[case testOverloadNestedAnyInDictValues] +from typing import Any, overload + +@overload +def f(s: dict[str, int]) -> int: ... +@overload +def f(s: object) -> object: ... +def f(s): pass + +d: dict[str, Any] +reveal_type(f(d)) # N: Revealed type is "Any" +[builtins fixtures/dict.pyi] + +[case testOverloadNestedAnyInFixedTuple] +from typing import Any, overload + +@overload +def g(s: tuple[int, int]) -> str: ... # E: Overloaded function signatures 1 and 2 overlap with incompatible return types +@overload +def g(s: object) -> int: ... +def g(s): pass + +t: tuple[Any, int] +reveal_type(g(t)) # N: Revealed type is "Any" +[builtins fixtures/tuple.pyi] + +[case testOverloadNestedAnyOnMethod] +from typing import Any, Sequence, overload + +class Box: + @overload + def get(self, key: Sequence[int]) -> int: ... + @overload + def get(self, key: object) -> object: ... + def get(self, key): pass + +nested_any: Sequence[Any] +reveal_type(Box().get(nested_any)) # N: Revealed type is "Any" + +[case testOverloadNestedAnyViaTypeAliasStillFirstMatch] +# Type aliases currently hide Any from the overload-ambiguity check because +# alias targets store Any as a special form (#15630). This is not the #21768 +# rule and is left unchanged; do not "fix" #21768 by assuming aliases agree. +from typing import Any, Sequence, overload + +Nested = Sequence[Any] + +@overload +def f(s: Sequence[int]) -> int: ... +@overload +def f(s: object) -> object: ... +def f(s): pass + +direct: Sequence[Any] +aliased: Nested +reveal_type(f(direct)) # N: Revealed type is "Any" +reveal_type(f(aliased)) # N: Revealed type is "builtins.int" + [case testOverloadOnOverloadWithType] from typing import Any, Type, TypeVar, overload from mod import MyInt diff --git a/test-data/unit/pythoneval.test b/test-data/unit/pythoneval.test index 8c37e48d7c332..9dc6f311745b0 100644 --- a/test-data/unit/pythoneval.test +++ b/test-data/unit/pythoneval.test @@ -1346,6 +1346,42 @@ def print_custom_table() -> None: [out] _testLoadsOfOverloads.py:24: note: Revealed type is "str" +[case testOverloadNestedAnyWithFullStubs] +# Full-stub counterpart of #21768: collections.abc.Sequence, dict, tuple. +from collections.abc import Sequence +from typing import Any, overload + +@overload +def f(s: Sequence[int]) -> int: ... +@overload +def f(s: object) -> object: ... +def f(s: object) -> object: + return 0 + +concrete: Sequence[int] = [1] +nested: Sequence[Any] = [1] +as_str: Sequence[str] = ["x"] +print(f(concrete), f(nested), f(as_str)) +reveal_type(f(concrete)) +reveal_type(f(nested)) +reveal_type(f(as_str)) + +@overload +def g(s: dict[str, int]) -> int: ... +@overload +def g(s: object) -> object: ... +def g(s: object) -> object: + return 0 + +d: dict[str, Any] = {"a": 1} +print(g(d)) +reveal_type(g(d)) +[out] +_testOverloadNestedAnyWithFullStubs.py:16: note: Revealed type is "int" +_testOverloadNestedAnyWithFullStubs.py:17: note: Revealed type is "Any" +_testOverloadNestedAnyWithFullStubs.py:18: note: Revealed type is "object" +_testOverloadNestedAnyWithFullStubs.py:29: note: Revealed type is "Any" + [case testReduceWithAnyInstance] from typing import Iterable from functools import reduce From c5394d83c4b328a17a6644d01efbbd709c7df76f Mon Sep 17 00:00:00 2001 From: hasancankeles Date: Thu, 27 Aug 2026 21:02:11 +0200 Subject: [PATCH 4/4] Count Any inside type aliases for overload ambiguity Alias targets store a written Any as TypeOfAny.special_form, so has_any_type ignored Nested = Sequence[Any] and picked the first overload. That contradicted the new docs and the spec (aliases are transparent). Count special-form Any only while expanding aliases. Docs now include an alias example. Tests cover nested and bare Any aliases, including the #15630 identity-check repro. --- CHANGELOG.md | 4 +++ docs/source/more_types.rst | 10 ++++-- mypy/checkexpr.py | 21 +++++++++++-- test-data/unit/check-overloading.test | 45 ++++++++++++++++++++++++--- 4 files changed, 70 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60de87745982c..365ad2fce3e83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ - Clarify that nested `Any` in an overload argument is ambiguous, the same as a top-level `Any` (PR [21897](https://github.com/python/mypy/pull/21897)) +### Other Notable Fixes and Improvements + +- Treat type aliases that expand to `Any` (or a type containing `Any`) as ambiguous in overload matching, matching a written `Any` (PR [21897](https://github.com/python/mypy/pull/21897)) + ## Mypy 2.3 We've just uploaded mypy 2.3.0 to the Python Package Index ([PyPI](https://pypi.org/project/mypy/)). diff --git a/docs/source/more_types.rst b/docs/source/more_types.rst index d474dcdaa9b8c..3a24e4a5f2d63 100644 --- a/docs/source/more_types.rst +++ b/docs/source/more_types.rst @@ -395,8 +395,9 @@ for making sure ``summarize`` breaks ties in the same way at runtime. However, there are two exceptions to the "pick the first match" rule. First, if multiple variants match because an argument has type ``Any`` -or *contains* ``Any`` (for example ``list[Any]`` or ``Sequence[Any]``), -mypy will make the inferred type also be ``Any``: +or *contains* ``Any`` (for example ``list[Any]`` or ``Sequence[Any]``, +including via a type alias), mypy will make the inferred type also be +``Any``: .. code-block:: python @@ -412,6 +413,11 @@ mypy will make the inferred type also be ``Any``: # call is therefore ambiguous in the same way as a top-level Any. output2_nested = summarize(nested_any) + Nested = list[Any] + aliased: Nested = some_dynamic_function() + # output2_alias is also 'Any': a type alias does not hide the nested Any. + output2_alias = summarize(aliased) + Second, if multiple variants match due to one or more of the arguments being a union, mypy will make the inferred type be the union of the matching variant returns: diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index f894a73213ca7..0c64ab270d65b 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -6638,9 +6638,23 @@ class HasAnyType(types.BoolTypeQuery): def __init__(self, ignore_in_type_obj: bool) -> None: super().__init__(types.ANY_STRATEGY) self.ignore_in_type_obj = ignore_in_type_obj + # Alias targets store a written `Any` as TypeOfAny.special_form. + # Count those as real Any while expanding aliases, so Nested = Sequence[Any] + # matches a direct Sequence[Any] for overload ambiguity. + self.count_alias_special_form_any = False def visit_any(self, t: AnyType) -> bool: - return t.type_of_any != TypeOfAny.special_form # special forms are not real Any types + if t.type_of_any == TypeOfAny.special_form: + return self.count_alias_special_form_any + return True + + def visit_type_alias_type(self, t: TypeAliasType) -> bool: + old = self.count_alias_special_form_any + self.count_alias_special_form_any = True + try: + return super().visit_type_alias_type(t) + finally: + self.count_alias_special_form_any = old def visit_callable_type(self, t: CallableType) -> bool: if self.ignore_in_type_obj and t.is_type_obj(): @@ -6856,8 +6870,9 @@ def any_causes_overload_ambiguity( """May an argument containing 'Any' cause ambiguous result type on call to overloaded function? This includes nested Any, such as Sequence[Any] or list[Any], not just a - top-level Any. A nested Any can materialize as different concrete types, so - if several overloads remain and their return types differ, the result is Any. + top-level Any, and the same types reached through a type alias. A nested + Any can materialize as different concrete types, so if several overloads + remain and their return types differ, the result is Any. (If every materialization matched an earlier overload, the spec would drop later ones; this helper over-approximates that case as ambiguous.) diff --git a/test-data/unit/check-overloading.test b/test-data/unit/check-overloading.test index 75e40013d936e..2067d98938ddb 100644 --- a/test-data/unit/check-overloading.test +++ b/test-data/unit/check-overloading.test @@ -2086,10 +2086,9 @@ class Box: nested_any: Sequence[Any] reveal_type(Box().get(nested_any)) # N: Revealed type is "Any" -[case testOverloadNestedAnyViaTypeAliasStillFirstMatch] -# Type aliases currently hide Any from the overload-ambiguity check because -# alias targets store Any as a special form (#15630). This is not the #21768 -# rule and is left unchanged; do not "fix" #21768 by assuming aliases agree. +[case testOverloadNestedAnyViaTypeAliasIsAmbiguous] +# A type alias must not hide nested Any from overload matching. Alias targets +# store written Any as a special form; that is still Any (#15630, #21768). from typing import Any, Sequence, overload Nested = Sequence[Any] @@ -2103,7 +2102,43 @@ def f(s): pass direct: Sequence[Any] aliased: Nested reveal_type(f(direct)) # N: Revealed type is "Any" -reveal_type(f(aliased)) # N: Revealed type is "builtins.int" +reveal_type(f(aliased)) # N: Revealed type is "Any" + +[case testOverloadBareAnyTypeAliasIsAmbiguous] +# https://github.com/python/mypy/issues/15630 +from typing import Any, overload + +TypeSpec = Any + +@overload +def f(x: int) -> int: ... +@overload +def f(x: object) -> object: ... +def f(x): pass + +a: Any +b: TypeSpec +reveal_type(f(a)) # N: Revealed type is "Any" +reveal_type(f(b)) # N: Revealed type is "Any" + +[case testOverloadBareAnyTypeAliasDoesNotPickFirstForIdentity] +# The #15630 reproducer: an alias to Any must not pick the first overload. +from typing import Any, Union, overload + +@overload +def my_origin(x: type) -> type: ... +@overload +def my_origin(x: Any) -> Any: ... +def my_origin(x): + return x + +TypeSpec = Any + +def call_any(spec: Any) -> bool: + return my_origin(spec) is Union + +def call_alias(spec: TypeSpec) -> bool: + return my_origin(spec) is Union [case testOverloadOnOverloadWithType] from typing import Any, Type, TypeVar, overload