From d3d6e649ede390943a0907c86bac85b7909d02ce Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 6 Jan 2026 04:43:24 +0100 Subject: [PATCH 01/69] Resolve first positional param, required to be annotated --- Lib/functools.py | 38 ++++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 836eb680ccd4d4..6ed3b3c5258c3f 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -888,6 +888,31 @@ def _find_impl(cls, registry): match = t return registry.get(match) + +def _get_dispatch_param_name(func, *, skip_first=False): + func_code = func.__code__ + pos_param_count = func_code.co_argcount + params = func_code.co_varnames + return next(iter(params[skip_first:pos_param_count]), None) + + +def _get_dispatch_annotation(func, param): + import annotationlib + annotations = annotationlib.get_annotations(func, format=annotationlib.Format.FORWARDREF) + try: + return annotations[param] + except KeyError: + raise TypeError( + f"Invalid first argument to `register()`: {param!r}. " + f"Add missing annotation to parameter {param!r} of {func.__qualname__!r} or use `@register(some_class)`." + ) from None + + +def _get_dispatch_param_and_annotation(func, *, skip_first=False): + param = _get_dispatch_param_name(func, skip_first=skip_first) + return param, _get_dispatch_annotation(func, param) + + def singledispatch(func): """Single-dispatch generic function decorator. @@ -935,7 +960,7 @@ def _is_valid_dispatch_type(cls): return (isinstance(cls, UnionType) and all(isinstance(arg, type) for arg in cls.__args__)) - def register(cls, func=None): + def register(cls, func=None, _func_is_method=False): """generic_func.register(cls, func) -> func Registers a new implementation for the given *cls* on a *generic_func*. @@ -960,10 +985,11 @@ def register(cls, func=None): ) func = cls - # only import typing if annotation parsing is necessary - from typing import get_type_hints - from annotationlib import Format, ForwardRef - argname, cls = next(iter(get_type_hints(func, format=Format.FORWARDREF).items())) + argname, cls = _get_dispatch_param_and_annotation( + func, skip_first=_func_is_method) + + from annotationlib import ForwardRef + if not _is_valid_dispatch_type(cls): if isinstance(cls, UnionType): raise TypeError( @@ -1027,7 +1053,7 @@ def register(self, cls, method=None): Registers a new implementation for the given *cls* on a *generic_method*. """ - return self.dispatcher.register(cls, func=method) + return self.dispatcher.register(cls, func=method, _func_is_method=True) def __get__(self, obj, cls=None): return _singledispatchmethod_get(self, obj, cls) From 6ea2a4acd740b644b3667d33ce0bf3e4532b203c Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 6 Jan 2026 05:47:19 +0100 Subject: [PATCH 02/69] Special-case strings for forward refs similarly to typing --- Lib/functools.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Lib/functools.py b/Lib/functools.py index 6ed3b3c5258c3f..201609cae6689c 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -890,6 +890,9 @@ def _find_impl(cls, registry): def _get_dispatch_param_name(func, *, skip_first=False): + if not hasattr(func, '__code__'): + skip_first = not isinstance(func, staticmethod) + func = func.__func__ func_code = func.__code__ pos_param_count = func_code.co_argcount params = func_code.co_varnames @@ -900,12 +903,20 @@ def _get_dispatch_annotation(func, param): import annotationlib annotations = annotationlib.get_annotations(func, format=annotationlib.Format.FORWARDREF) try: - return annotations[param] + ref_or_type = annotations[param] except KeyError: raise TypeError( f"Invalid first argument to `register()`: {param!r}. " f"Add missing annotation to parameter {param!r} of {func.__qualname__!r} or use `@register(some_class)`." ) from None + if isinstance(ref_or_type, str): + ref_or_type = annotationlib.ForwardRef(ref_or_type, owner=func) + if isinstance(ref_or_type, annotationlib.ForwardRef): + try: + return ref_or_type.evaluate(owner=func) + except Exception: + pass + return ref_or_type def _get_dispatch_param_and_annotation(func, *, skip_first=False): From 0a39278943976986fac18a545272003f96f13715 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 6 Jan 2026 05:48:26 +0100 Subject: [PATCH 03/69] Rename `ref_or_type` to `ref_or_typeform` --- Lib/functools.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 201609cae6689c..0c332db1740b45 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -903,20 +903,20 @@ def _get_dispatch_annotation(func, param): import annotationlib annotations = annotationlib.get_annotations(func, format=annotationlib.Format.FORWARDREF) try: - ref_or_type = annotations[param] + ref_or_typeform = annotations[param] except KeyError: raise TypeError( f"Invalid first argument to `register()`: {param!r}. " f"Add missing annotation to parameter {param!r} of {func.__qualname__!r} or use `@register(some_class)`." ) from None - if isinstance(ref_or_type, str): - ref_or_type = annotationlib.ForwardRef(ref_or_type, owner=func) - if isinstance(ref_or_type, annotationlib.ForwardRef): + if isinstance(ref_or_typeform, str): + ref_or_typeform = annotationlib.ForwardRef(ref_or_typeform, owner=func) + if isinstance(ref_or_typeform, annotationlib.ForwardRef): try: - return ref_or_type.evaluate(owner=func) + return ref_or_typeform.evaluate(owner=func) except Exception: pass - return ref_or_type + return ref_or_typeform def _get_dispatch_param_and_annotation(func, *, skip_first=False): From 096fc3ba5d8ff316e209f195d3c4fe992ac740ba Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 6 Jan 2026 05:49:47 +0100 Subject: [PATCH 04/69] Add comment --- Lib/functools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/functools.py b/Lib/functools.py index 0c332db1740b45..146e28a1031dfa 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -915,7 +915,7 @@ def _get_dispatch_annotation(func, param): try: return ref_or_typeform.evaluate(owner=func) except Exception: - pass + pass # Forward reference is unresolved. return ref_or_typeform From c8a5cdcd6bd7860bb245a409e725bb3552e4f922 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 6 Jan 2026 05:50:20 +0100 Subject: [PATCH 05/69] Shorten error message string line --- Lib/functools.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/functools.py b/Lib/functools.py index 146e28a1031dfa..7261fe845dbcc9 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -907,7 +907,8 @@ def _get_dispatch_annotation(func, param): except KeyError: raise TypeError( f"Invalid first argument to `register()`: {param!r}. " - f"Add missing annotation to parameter {param!r} of {func.__qualname__!r} or use `@register(some_class)`." + f"Add missing annotation to parameter {param!r} of {func.__qualname__!r} " + f"or use `@register(some_class)`." ) from None if isinstance(ref_or_typeform, str): ref_or_typeform = annotationlib.ForwardRef(ref_or_typeform, owner=func) From e1cde592d4a05ea3661207b7139451f22bea540d Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 6 Jan 2026 05:51:49 +0100 Subject: [PATCH 06/69] Adjust formatting to functools style --- Lib/functools.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 7261fe845dbcc9..dc4e83890887d0 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -888,7 +888,6 @@ def _find_impl(cls, registry): match = t return registry.get(match) - def _get_dispatch_param_name(func, *, skip_first=False): if not hasattr(func, '__code__'): skip_first = not isinstance(func, staticmethod) @@ -898,7 +897,6 @@ def _get_dispatch_param_name(func, *, skip_first=False): params = func_code.co_varnames return next(iter(params[skip_first:pos_param_count]), None) - def _get_dispatch_annotation(func, param): import annotationlib annotations = annotationlib.get_annotations(func, format=annotationlib.Format.FORWARDREF) @@ -919,12 +917,10 @@ def _get_dispatch_annotation(func, param): pass # Forward reference is unresolved. return ref_or_typeform - def _get_dispatch_param_and_annotation(func, *, skip_first=False): param = _get_dispatch_param_name(func, skip_first=skip_first) return param, _get_dispatch_annotation(func, param) - def singledispatch(func): """Single-dispatch generic function decorator. From 6bc698bec956fae84f6cf29b17af583c64e0106a Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 6 Jan 2026 08:44:55 +0100 Subject: [PATCH 07/69] Normalize `None` to a type, strip annotations --- Lib/functools.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Lib/functools.py b/Lib/functools.py index dc4e83890887d0..a7a4852611006e 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -912,9 +912,14 @@ def _get_dispatch_annotation(func, param): ref_or_typeform = annotationlib.ForwardRef(ref_or_typeform, owner=func) if isinstance(ref_or_typeform, annotationlib.ForwardRef): try: - return ref_or_typeform.evaluate(owner=func) + ref_or_typeform = ref_or_typeform.evaluate(owner=func) except Exception: pass # Forward reference is unresolved. + if ref_or_typeform is None: + ref_or_typeform = type(None) + if not isinstance(ref_or_typeform, annotationlib.ForwardRef): + import typing + ref_or_typeform = typing._strip_annotations(ref_or_typeform) return ref_or_typeform def _get_dispatch_param_and_annotation(func, *, skip_first=False): From 4ef7c7cc980cb0b52fa36235dd1942f8915f22a4 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 6 Jan 2026 08:50:03 +0100 Subject: [PATCH 08/69] Rename `ref_or_typeform` to `fwdref_or_typeform` --- Lib/functools.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index a7a4852611006e..0970eb9db05843 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -901,26 +901,26 @@ def _get_dispatch_annotation(func, param): import annotationlib annotations = annotationlib.get_annotations(func, format=annotationlib.Format.FORWARDREF) try: - ref_or_typeform = annotations[param] + fwdref_or_typeform = annotations[param] except KeyError: raise TypeError( f"Invalid first argument to `register()`: {param!r}. " f"Add missing annotation to parameter {param!r} of {func.__qualname__!r} " f"or use `@register(some_class)`." ) from None - if isinstance(ref_or_typeform, str): - ref_or_typeform = annotationlib.ForwardRef(ref_or_typeform, owner=func) - if isinstance(ref_or_typeform, annotationlib.ForwardRef): + if isinstance(fwdref_or_typeform, str): + fwdref_or_typeform = annotationlib.ForwardRef(fwdref_or_typeform, owner=func) + if isinstance(fwdref_or_typeform, annotationlib.ForwardRef): try: - ref_or_typeform = ref_or_typeform.evaluate(owner=func) + fwdref_or_typeform = fwdref_or_typeform.evaluate(owner=func) except Exception: pass # Forward reference is unresolved. - if ref_or_typeform is None: - ref_or_typeform = type(None) - if not isinstance(ref_or_typeform, annotationlib.ForwardRef): + if fwdref_or_typeform is None: + fwdref_or_typeform = type(None) + if not isinstance(fwdref_or_typeform, annotationlib.ForwardRef): import typing - ref_or_typeform = typing._strip_annotations(ref_or_typeform) - return ref_or_typeform + fwdref_or_typeform = typing._strip_annotations(fwdref_or_typeform) + return fwdref_or_typeform def _get_dispatch_param_and_annotation(func, *, skip_first=False): param = _get_dispatch_param_name(func, skip_first=skip_first) From 004c85207cc7e3b5506b1b0030885204c39d1c92 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 6 Jan 2026 09:09:01 +0100 Subject: [PATCH 09/69] Rename `skip_first` to `skip_first_param` --- Lib/functools.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 0970eb9db05843..5925dcd8610a2c 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -888,14 +888,14 @@ def _find_impl(cls, registry): match = t return registry.get(match) -def _get_dispatch_param_name(func, *, skip_first=False): +def _get_dispatch_param_name(func, *, skip_first_param=False): if not hasattr(func, '__code__'): - skip_first = not isinstance(func, staticmethod) + skip_first_param = not isinstance(func, staticmethod) func = func.__func__ func_code = func.__code__ pos_param_count = func_code.co_argcount params = func_code.co_varnames - return next(iter(params[skip_first:pos_param_count]), None) + return next(iter(params[skip_first_param:pos_param_count]), None) def _get_dispatch_annotation(func, param): import annotationlib @@ -922,8 +922,8 @@ def _get_dispatch_annotation(func, param): fwdref_or_typeform = typing._strip_annotations(fwdref_or_typeform) return fwdref_or_typeform -def _get_dispatch_param_and_annotation(func, *, skip_first=False): - param = _get_dispatch_param_name(func, skip_first=skip_first) +def _get_dispatch_param_and_annotation(func, *, skip_first_param=False): + param = _get_dispatch_param_name(func, skip_first_param=skip_first_param) return param, _get_dispatch_annotation(func, param) def singledispatch(func): @@ -999,7 +999,7 @@ def register(cls, func=None, _func_is_method=False): func = cls argname, cls = _get_dispatch_param_and_annotation( - func, skip_first=_func_is_method) + func, skip_first_param=_func_is_method) from annotationlib import ForwardRef From 9bc1436583cc2dacc74c6561c7e9945dba4d2695 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 6 Jan 2026 09:17:25 +0100 Subject: [PATCH 10/69] Add news entry --- .../next/Library/2026-01-06-09-13-53.gh-issue-84644.V_cYP3.rst | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-01-06-09-13-53.gh-issue-84644.V_cYP3.rst diff --git a/Misc/NEWS.d/next/Library/2026-01-06-09-13-53.gh-issue-84644.V_cYP3.rst b/Misc/NEWS.d/next/Library/2026-01-06-09-13-53.gh-issue-84644.V_cYP3.rst new file mode 100644 index 00000000000000..67bee9d7b11d1f --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-01-06-09-13-53.gh-issue-84644.V_cYP3.rst @@ -0,0 +1,3 @@ +:func:`functools.singledispatch` and :func:`functools.singledispatchmethod` +no longer mistakenly honor a misplaced annotation (e.g. the return type) +as a type of the argument dispatched. Contributed by Bartosz Sławecki. From 3115fd7176966cfc51e23886bdeb3d4b8c731213 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Sat, 3 Jan 2026 01:42:08 +0100 Subject: [PATCH 11/69] Add GH-130827 test --- Lib/test/test_functools.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 090926fd8d8b61..3fca1a8ceca8a4 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -3518,6 +3518,20 @@ def _(self, arg): self.assertEqual(a.v(0), ('special', 0)) self.assertEqual(a.v(2.5), ('general', 2.5)) + def test_method_self_annotation(self): + """See GH-130827.""" + class A: + @functools.singledispatchmethod + def u(self: typing.Self, arg: int | str) -> int | str: ... + + @u.register + def _(self: typing.Self, arg: int) -> int: + return arg + + a = A() + self.assertEqual(a.u(42), 42) + self.assertEqual(a.u("hello"), "hello") + class CachedCostItem: _cost = 1 From f6c102fb3afb267281f2fc4f48b5d8670c746f45 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 6 Jan 2026 09:47:33 +0100 Subject: [PATCH 12/69] Fix test --- Lib/test/test_functools.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 3fca1a8ceca8a4..f9a67f8a969fb6 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -3522,7 +3522,8 @@ def test_method_self_annotation(self): """See GH-130827.""" class A: @functools.singledispatchmethod - def u(self: typing.Self, arg: int | str) -> int | str: ... + def u(self: typing.Self, arg: int | str) -> int | str: + return None @u.register def _(self: typing.Self, arg: int) -> int: @@ -3530,7 +3531,7 @@ def _(self: typing.Self, arg: int) -> int: a = A() self.assertEqual(a.u(42), 42) - self.assertEqual(a.u("hello"), "hello") + self.assertEqual(a.u("hello"), None) class CachedCostItem: From 79685705893608e7375b67ccec22edf56e5dbedc Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 6 Jan 2026 09:48:05 +0100 Subject: [PATCH 13/69] Remove the `get_annotations` dance for now --- Lib/functools.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 5925dcd8610a2c..7509010b028843 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -898,8 +898,8 @@ def _get_dispatch_param_name(func, *, skip_first_param=False): return next(iter(params[skip_first_param:pos_param_count]), None) def _get_dispatch_annotation(func, param): - import annotationlib - annotations = annotationlib.get_annotations(func, format=annotationlib.Format.FORWARDREF) + import annotationlib, typing + annotations = typing.get_type_hints(func, format=annotationlib.Format.FORWARDREF) try: fwdref_or_typeform = annotations[param] except KeyError: @@ -908,18 +908,6 @@ def _get_dispatch_annotation(func, param): f"Add missing annotation to parameter {param!r} of {func.__qualname__!r} " f"or use `@register(some_class)`." ) from None - if isinstance(fwdref_or_typeform, str): - fwdref_or_typeform = annotationlib.ForwardRef(fwdref_or_typeform, owner=func) - if isinstance(fwdref_or_typeform, annotationlib.ForwardRef): - try: - fwdref_or_typeform = fwdref_or_typeform.evaluate(owner=func) - except Exception: - pass # Forward reference is unresolved. - if fwdref_or_typeform is None: - fwdref_or_typeform = type(None) - if not isinstance(fwdref_or_typeform, annotationlib.ForwardRef): - import typing - fwdref_or_typeform = typing._strip_annotations(fwdref_or_typeform) return fwdref_or_typeform def _get_dispatch_param_and_annotation(func, *, skip_first_param=False): From a808a1eb7f2346bfffc3d19f725472f995f0e25a Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 16:12:27 +0100 Subject: [PATCH 14/69] Fix incorrect `regster()` calls in `TestSingleDispatch.test_method_signatures` See https://github.com/python/cpython/pull/130309#discussion_r2663516538 --- Lib/test/test_functools.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index f9a67f8a969fb6..e489e27e8ae523 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -3448,34 +3448,34 @@ def _(item: int, arg: bytes) -> str: def test_method_signatures(self): class A: - def m(self, item, arg: int) -> str: + def m(self, item: int, arg) -> str: return str(item) @classmethod - def cm(cls, item, arg: int) -> str: + def cm(cls, item: int, arg) -> str: return str(item) @functools.singledispatchmethod - def func(self, item, arg: int) -> str: + def func(self, item: int, arg) -> str: return str(item) @func.register - def _(self, item, arg: bytes) -> str: + def _(self, item: bytes, arg) -> str: return str(item) @functools.singledispatchmethod @classmethod - def cls_func(cls, item, arg: int) -> str: + def cls_func(cls, item: int, arg) -> str: return str(arg) @func.register @classmethod - def _(cls, item, arg: bytes) -> str: + def _(cls, item: bytes, arg) -> str: return str(item) @functools.singledispatchmethod @staticmethod - def static_func(item, arg: int) -> str: + def static_func(item: int, arg) -> str: return str(arg) @func.register @staticmethod - def _(item, arg: bytes) -> str: + def _(item: bytes, arg) -> str: return str(item) self.assertEqual(str(Signature.from_callable(A.func)), From 69b9978df4d60e5595e6057cd753c4a986c89431 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 16:12:55 +0100 Subject: [PATCH 15/69] Fix string signatures accordingly --- Lib/test/test_functools.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index e489e27e8ae523..8f18663199f3e6 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -3479,13 +3479,13 @@ def _(item: bytes, arg) -> str: return str(item) self.assertEqual(str(Signature.from_callable(A.func)), - '(self, item, arg: int) -> str') + '(self, item: int, arg) -> str') self.assertEqual(str(Signature.from_callable(A().func)), - '(self, item, arg: int) -> str') + '(self, item: int, arg) -> str') self.assertEqual(str(Signature.from_callable(A.cls_func)), - '(cls, item, arg: int) -> str') + '(cls, item: int, arg) -> str') self.assertEqual(str(Signature.from_callable(A.static_func)), - '(item, arg: int) -> str') + '(item: int, arg) -> str') def test_method_non_descriptor(self): class Callable: From ebdb68d3bf9c73176a0dbc926258ead19b0fe3b7 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 16:17:49 +0100 Subject: [PATCH 16/69] Raise exception if positional argument not found --- Lib/functools.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 7509010b028843..365869278b52ca 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -895,7 +895,13 @@ def _get_dispatch_param_name(func, *, skip_first_param=False): func_code = func.__code__ pos_param_count = func_code.co_argcount params = func_code.co_varnames - return next(iter(params[skip_first_param:pos_param_count]), None) + try: + return params[skip_first_param:pos_param_count][0] + except IndexError: + raise TypeError( + f"Invalid first argument to `register()`: function {func!r}" + f"does not accept positional arguments." + ) def _get_dispatch_annotation(func, param): import annotationlib, typing @@ -905,7 +911,7 @@ def _get_dispatch_annotation(func, param): except KeyError: raise TypeError( f"Invalid first argument to `register()`: {param!r}. " - f"Add missing annotation to parameter {param!r} of {func.__qualname__!r} " + f"Add missing annotation to parameter {param!r} of {func!r} " f"or use `@register(some_class)`." ) from None return fwdref_or_typeform From 8d86f9e426f17b990058ffeecd5d73047d353aa0 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 16:18:05 +0100 Subject: [PATCH 17/69] Break the exception chain --- Lib/functools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/functools.py b/Lib/functools.py index 365869278b52ca..a53ef57499a5f2 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -901,7 +901,7 @@ def _get_dispatch_param_name(func, *, skip_first_param=False): raise TypeError( f"Invalid first argument to `register()`: function {func!r}" f"does not accept positional arguments." - ) + ) from None def _get_dispatch_annotation(func, param): import annotationlib, typing From 82616f9197e53c6f5ca8578c29aed23c140ed264 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 17:41:31 +0100 Subject: [PATCH 18/69] Support all callables --- Lib/functools.py | 41 +++++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index a53ef57499a5f2..1cbfc7964dab45 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -19,7 +19,7 @@ # import weakref # Deferred to single_dispatch() from operator import itemgetter from reprlib import recursive_repr -from types import GenericAlias, MethodType, MappingProxyType, UnionType +from types import FunctionType, GenericAlias, MethodType, MappingProxyType, UnionType from _thread import RLock ################################################################################ @@ -888,20 +888,24 @@ def _find_impl(cls, registry): match = t return registry.get(match) -def _get_dispatch_param_name(func, *, skip_first_param=False): - if not hasattr(func, '__code__'): - skip_first_param = not isinstance(func, staticmethod) +def _get_dispatch_param(func, *, pos=0): + if isinstance(func, (MethodType, classmethod, staticmethod)): func = func.__func__ - func_code = func.__code__ - pos_param_count = func_code.co_argcount - params = func_code.co_varnames - try: - return params[skip_first_param:pos_param_count][0] - except IndexError: - raise TypeError( - f"Invalid first argument to `register()`: function {func!r}" - f"does not accept positional arguments." - ) from None + if isinstance(func, FunctionType): + func_code = func.__code__ + try: + return func_code.co_varnames[:func_code.co_argcount][pos] + except IndexError: + pass + import inspect + for insp_param in list(inspect.signature(func).parameters.values())[pos:]: + if insp_param.KEYWORD_ONLY or insp_param.VAR_KEYWORD: + break + return insp_param.name + raise TypeError( + f"Invalid first argument to `register()`: {func!r}" + f"does not accept positional arguments." + ) from None def _get_dispatch_annotation(func, param): import annotationlib, typing @@ -916,8 +920,8 @@ def _get_dispatch_annotation(func, param): ) from None return fwdref_or_typeform -def _get_dispatch_param_and_annotation(func, *, skip_first_param=False): - param = _get_dispatch_param_name(func, skip_first_param=skip_first_param) +def _get_dispatch_arg_from_annotations(func, *, pos=0): + param = _get_dispatch_param(func, pos=pos) return param, _get_dispatch_annotation(func, param) def singledispatch(func): @@ -992,8 +996,9 @@ def register(cls, func=None, _func_is_method=False): ) func = cls - argname, cls = _get_dispatch_param_and_annotation( - func, skip_first_param=_func_is_method) + # 0 for functions, 1 for methods + argpos = _func_is_method and not isinstance(func, staticmethod) + argname, cls = _get_dispatch_arg_from_annotations(func, pos=argpos) from annotationlib import ForwardRef From c9a1f1a223aadbdb857cb2784ce756a9246cb33d Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 17:42:11 +0100 Subject: [PATCH 19/69] Clarify comment --- Lib/functools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/functools.py b/Lib/functools.py index 1cbfc7964dab45..cadb5670a52b65 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -996,7 +996,7 @@ def register(cls, func=None, _func_is_method=False): ) func = cls - # 0 for functions, 1 for methods + # 0 for functions, 1 for methods where first argument should be skipped argpos = _func_is_method and not isinstance(func, staticmethod) argname, cls = _get_dispatch_arg_from_annotations(func, pos=argpos) From e878207f93e4c38281bf7afa1628b7aaa5b66523 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 18:36:19 +0100 Subject: [PATCH 20/69] Fiat lux, inline validation --- Lib/functools.py | 51 ++++++++++++++++++++++++------------------------ 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index cadb5670a52b65..c52c929264fb32 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -888,41 +888,23 @@ def _find_impl(cls, registry): match = t return registry.get(match) -def _get_dispatch_param(func, *, pos=0): +def _get_positional_param(func, *, pos=0): if isinstance(func, (MethodType, classmethod, staticmethod)): func = func.__func__ - if isinstance(func, FunctionType): + if isinstance(func, FunctionType) and not hasattr(func, "__wrapped__"): func_code = func.__code__ try: return func_code.co_varnames[:func_code.co_argcount][pos] except IndexError: pass + # Fallback path for ambiguous callables. + # Follows __wrapped__, checks __signature__, __text_signature__, etc. import inspect for insp_param in list(inspect.signature(func).parameters.values())[pos:]: - if insp_param.KEYWORD_ONLY or insp_param.VAR_KEYWORD: + if insp_param.kind in (insp_param.KEYWORD_ONLY, insp_param.VAR_KEYWORD): break return insp_param.name - raise TypeError( - f"Invalid first argument to `register()`: {func!r}" - f"does not accept positional arguments." - ) from None - -def _get_dispatch_annotation(func, param): - import annotationlib, typing - annotations = typing.get_type_hints(func, format=annotationlib.Format.FORWARDREF) - try: - fwdref_or_typeform = annotations[param] - except KeyError: - raise TypeError( - f"Invalid first argument to `register()`: {param!r}. " - f"Add missing annotation to parameter {param!r} of {func!r} " - f"or use `@register(some_class)`." - ) from None - return fwdref_or_typeform - -def _get_dispatch_arg_from_annotations(func, *, pos=0): - param = _get_dispatch_param(func, pos=pos) - return param, _get_dispatch_annotation(func, param) + return None def singledispatch(func): """Single-dispatch generic function decorator. @@ -998,9 +980,26 @@ def register(cls, func=None, _func_is_method=False): # 0 for functions, 1 for methods where first argument should be skipped argpos = _func_is_method and not isinstance(func, staticmethod) - argname, cls = _get_dispatch_arg_from_annotations(func, pos=argpos) - from annotationlib import ForwardRef + argname = _get_positional_param(func, pos=argpos) + if argname is None: + raise TypeError( + f"Invalid first argument to `register()`: {func!r} " + f"does not accept positional arguments." + ) from None + + from annotationlib import Format, ForwardRef + import typing + annotations = typing.get_type_hints(func, format=Format.FORWARDREF) + + try: + cls = annotations[argname] + except KeyError: + raise TypeError( + f"Invalid first argument to `register()`: {func!r}. " + f"Add missing type annotation to parameter {argname!r} " + "of this function or use `@register(some_class)`." + ) from None if not _is_valid_dispatch_type(cls): if isinstance(cls, UnionType): From d3240a3c67e106751ae65347279980df32c55d3e Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 18:36:56 +0100 Subject: [PATCH 21/69] Add more test cases (mainly wrappers) --- Lib/test/test_functools.py | 81 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 8f18663199f3e6..cc873b03107207 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -2895,6 +2895,28 @@ def add(self, x, y): Abstract() def test_type_ann_register(self): + @functools.singledispatch + def t(arg): + return "base" + @t.register + def _(arg: int): + return "int" + @t.register + def _(arg: str): + return "str" + def _(arg: bytes): + return "bytes" + @t.register + @functools.wraps(_) + def wrapper(*args, **kwargs): + return _(*args, **kwargs) + self.assertEqual(t(0), "int") + self.assertEqual(t(''), "str") + self.assertEqual(t(0.0), "base") + self.assertEqual(t(b''), "bytes") + + def test_method_type_ann_register(self): + class A: @functools.singledispatchmethod def t(self, arg): @@ -2905,13 +2927,28 @@ def _(self, arg: int): @t.register def _(self, arg: str): return "str" + def _(self, arg: bytes): + return "bytes" + @t.register + @functools.wraps(_) + def wrapper(self, *args, **kwargs): + return self._(*args, **kwargs) + a = A() self.assertEqual(a.t(0), "int") self.assertEqual(a.t(''), "str") self.assertEqual(a.t(0.0), "base") + self.assertEqual(a.t(b''), "bytes") def test_staticmethod_type_ann_register(self): + def wrapper_decorator(func): + wrapped = func.__func__ + @staticmethod + @functools.wraps(wrapped) + def wrapper(*args, **kwargs): + return wrapped(*args, **kwargs) + return wrapper class A: @functools.singledispatchmethod @staticmethod @@ -2925,6 +2962,11 @@ def _(arg: int): @staticmethod def _(arg: str): return isinstance(arg, str) + @t.register + @wrapper_decorator + @staticmethod + def _(arg: bytes): + return isinstance(arg, bytes) a = A() self.assertTrue(A.t(0)) @@ -2932,6 +2974,13 @@ def _(arg: str): self.assertEqual(A.t(0.0), 0.0) def test_classmethod_type_ann_register(self): + def wrapper_decorator(func): + wrapped = func.__func__ + @classmethod + @functools.wraps(wrapped) + def wrapper(*args, **kwargs): + return wrapped(*args, **kwargs) + return wrapper class A: def __init__(self, arg): self.arg = arg @@ -2948,10 +2997,16 @@ def _(cls, arg: int): @classmethod def _(cls, arg: str): return cls("str") + @t.register + @wrapper_decorator + @classmethod + def _(cls, arg: bytes): + return cls("bytes") self.assertEqual(A.t(0).arg, "int") self.assertEqual(A.t('').arg, "str") self.assertEqual(A.t(0.0).arg, "base") + self.assertEqual(A.t(b'').arg, "bytes") def test_method_wrapping_attributes(self): class A: @@ -3170,12 +3225,27 @@ def test_invalid_registrations(self): @functools.singledispatch def i(arg): return "base" + with self.assertRaises(TypeError) as exc: + @i.register + def _() -> None: + return "My function doesn't take arguments" + self.assertStartsWith(str(exc.exception), msg_prefix) + self.assertEndsWith(str(exc.exception), "does not accept positional arguments.") + + with self.assertRaises(TypeError) as exc: + @i.register + def _(*, foo: str) -> None: + return "My function takes keyword-only arguments" + self.assertStartsWith(str(exc.exception), msg_prefix) + self.assertEndsWith(str(exc.exception), "does not accept positional arguments.") + with self.assertRaises(TypeError) as exc: @i.register(42) def _(arg): return "I annotated with a non-type" self.assertStartsWith(str(exc.exception), msg_prefix + "42") self.assertEndsWith(str(exc.exception), msg_suffix) + with self.assertRaises(TypeError) as exc: @i.register def _(arg): @@ -3185,6 +3255,17 @@ def _(arg): ) self.assertEndsWith(str(exc.exception), msg_suffix) + with self.assertRaises(TypeError) as exc: + @i.register + def _(arg, extra: int): + return "I did not annotate the right param" + self.assertStartsWith(str(exc.exception), msg_prefix + + "._" + ) + self.assertEndsWith(str(exc.exception), + "Add missing type annotation to parameter 'arg' " + "of this function or use `@register(some_class)`.") + with self.assertRaises(TypeError) as exc: @i.register def _(arg: typing.Iterable[str]): From 9240b0ddbc9a81df6bc2d9729cb3a6320ab82156 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 18:40:39 +0100 Subject: [PATCH 22/69] More comments! --- Lib/functools.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index c52c929264fb32..ae537b25fe538e 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -889,6 +889,7 @@ def _find_impl(cls, registry): return registry.get(match) def _get_positional_param(func, *, pos=0): + # Fast path for typical callables. if isinstance(func, (MethodType, classmethod, staticmethod)): func = func.__func__ if isinstance(func, FunctionType) and not hasattr(func, "__wrapped__"): @@ -900,10 +901,10 @@ def _get_positional_param(func, *, pos=0): # Fallback path for ambiguous callables. # Follows __wrapped__, checks __signature__, __text_signature__, etc. import inspect - for insp_param in list(inspect.signature(func).parameters.values())[pos:]: - if insp_param.kind in (insp_param.KEYWORD_ONLY, insp_param.VAR_KEYWORD): + for param in list(inspect.signature(func).parameters.values())[pos:]: + if param.kind in (param.KEYWORD_ONLY, param.VAR_KEYWORD): break - return insp_param.name + return param.name return None def singledispatch(func): @@ -988,8 +989,9 @@ def register(cls, func=None, _func_is_method=False): f"does not accept positional arguments." ) from None - from annotationlib import Format, ForwardRef + # only import typing if annotation parsing is necessary import typing + from annotationlib import Format, ForwardRef annotations = typing.get_type_hints(func, format=Format.FORWARDREF) try: From f6ccb973a5d1318ba90e9621e85837b100861d47 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 18:41:55 +0100 Subject: [PATCH 23/69] Less history pollution --- Lib/functools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index ae537b25fe538e..7c656705cd6d6d 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -990,9 +990,9 @@ def register(cls, func=None, _func_is_method=False): ) from None # only import typing if annotation parsing is necessary - import typing + from typing import get_type_hints from annotationlib import Format, ForwardRef - annotations = typing.get_type_hints(func, format=Format.FORWARDREF) + annotations = get_type_hints(func, format=Format.FORWARDREF) try: cls = annotations[argname] From 664232111e0ec9280c6304a0d9e001e535e1589a Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 18:46:11 +0100 Subject: [PATCH 24/69] Document `_get_positional_param` --- Lib/functools.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Lib/functools.py b/Lib/functools.py index 7c656705cd6d6d..fcfe84452a435c 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -889,6 +889,11 @@ def _find_impl(cls, registry): return registry.get(match) def _get_positional_param(func, *, pos=0): + """Finds the first user-specified parameter of a callable at position *pos*. + + Used by singledispatch for registration by type annotation. + *pos* should either be 0 (for functions and staticmethods) or 1 (for methods). + """ # Fast path for typical callables. if isinstance(func, (MethodType, classmethod, staticmethod)): func = func.__func__ From 0eaaa5bff55d4325be4c6db2fcb1547394c7262a Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 18:47:55 +0100 Subject: [PATCH 25/69] Better comments! --- Lib/functools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index fcfe84452a435c..af0fcdbb7a4c8a 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -894,7 +894,7 @@ def _get_positional_param(func, *, pos=0): Used by singledispatch for registration by type annotation. *pos* should either be 0 (for functions and staticmethods) or 1 (for methods). """ - # Fast path for typical callables. + # Fast path for typical callable objects. if isinstance(func, (MethodType, classmethod, staticmethod)): func = func.__func__ if isinstance(func, FunctionType) and not hasattr(func, "__wrapped__"): @@ -903,7 +903,7 @@ def _get_positional_param(func, *, pos=0): return func_code.co_varnames[:func_code.co_argcount][pos] except IndexError: pass - # Fallback path for ambiguous callables. + # Fallback path for ambiguous objects. # Follows __wrapped__, checks __signature__, __text_signature__, etc. import inspect for param in list(inspect.signature(func).parameters.values())[pos:]: From 345b7e931d9358cc458bbb31888b6c816c6d50ea Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 18:48:21 +0100 Subject: [PATCH 26/69] Shorten a comment --- Lib/functools.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index af0fcdbb7a4c8a..a405b1f40df0bc 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -903,8 +903,7 @@ def _get_positional_param(func, *, pos=0): return func_code.co_varnames[:func_code.co_argcount][pos] except IndexError: pass - # Fallback path for ambiguous objects. - # Follows __wrapped__, checks __signature__, __text_signature__, etc. + # Fallback path for ambiguous objects with more sophisticated inspection. import inspect for param in list(inspect.signature(func).parameters.values())[pos:]: if param.kind in (param.KEYWORD_ONLY, param.VAR_KEYWORD): From 8a46f3f625135530fd05e9c3f3fba5ac4252c9d6 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 18:49:55 +0100 Subject: [PATCH 27/69] Rephrase the fallback path comment --- Lib/functools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/functools.py b/Lib/functools.py index a405b1f40df0bc..4ccea4a4113c66 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -903,7 +903,7 @@ def _get_positional_param(func, *, pos=0): return func_code.co_varnames[:func_code.co_argcount][pos] except IndexError: pass - # Fallback path for ambiguous objects with more sophisticated inspection. + # Fallback path for more nuanced inspection of ambiguous callable objects. import inspect for param in list(inspect.signature(func).parameters.values())[pos:]: if param.kind in (param.KEYWORD_ONLY, param.VAR_KEYWORD): From 16f83ee8c06f0114a69bcdcd94577b95de6a06bf Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 18:55:58 +0100 Subject: [PATCH 28/69] Improve the error message when missing an annotation --- Lib/functools.py | 4 ++-- Lib/test/test_functools.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 4ccea4a4113c66..65ac5a37d2d291 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -1003,8 +1003,8 @@ def register(cls, func=None, _func_is_method=False): except KeyError: raise TypeError( f"Invalid first argument to `register()`: {func!r}. " - f"Add missing type annotation to parameter {argname!r} " - "of this function or use `@register(some_class)`." + "Use either `@register(some_class)` or add a type " + f"annotation to parameter {argname!r} of your callable." ) from None if not _is_valid_dispatch_type(cls): diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index cc873b03107207..70196642cdd3d6 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -3263,8 +3263,8 @@ def _(arg, extra: int): "._" ) self.assertEndsWith(str(exc.exception), - "Add missing type annotation to parameter 'arg' " - "of this function or use `@register(some_class)`.") + "Use either `@register(some_class)` or add a type annotation " + f"to parameter 'arg' of your callable.") with self.assertRaises(TypeError) as exc: @i.register From 552daaf87c101fbcedbe982e7243f298c238cea5 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 19:00:07 +0100 Subject: [PATCH 29/69] Correct the docstring --- Lib/functools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/functools.py b/Lib/functools.py index 65ac5a37d2d291..e35ad59f418d87 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -889,7 +889,7 @@ def _find_impl(cls, registry): return registry.get(match) def _get_positional_param(func, *, pos=0): - """Finds the first user-specified parameter of a callable at position *pos*. + """Finds the first positional user-specified parameter of a callable at position *pos*. Used by singledispatch for registration by type annotation. *pos* should either be 0 (for functions and staticmethods) or 1 (for methods). From 113cc290d794ae0d2b2e39a1cb310d07ec3fc55e Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 19:06:34 +0100 Subject: [PATCH 30/69] Rephrase the documentation again --- Lib/functools.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index e35ad59f418d87..395999aec40b06 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -889,12 +889,13 @@ def _find_impl(cls, registry): return registry.get(match) def _get_positional_param(func, *, pos=0): - """Finds the first positional user-specified parameter of a callable at position *pos*. + """Finds the first positional user-specified parameter at position *pos* + of a callable or descriptor. Used by singledispatch for registration by type annotation. *pos* should either be 0 (for functions and staticmethods) or 1 (for methods). """ - # Fast path for typical callable objects. + # Fast path for typical callables and descriptors. if isinstance(func, (MethodType, classmethod, staticmethod)): func = func.__func__ if isinstance(func, FunctionType) and not hasattr(func, "__wrapped__"): @@ -903,7 +904,7 @@ def _get_positional_param(func, *, pos=0): return func_code.co_varnames[:func_code.co_argcount][pos] except IndexError: pass - # Fallback path for more nuanced inspection of ambiguous callable objects. + # Fallback path for more nuanced inspection of ambiguous callables. import inspect for param in list(inspect.signature(func).parameters.values())[pos:]: if param.kind in (param.KEYWORD_ONLY, param.VAR_KEYWORD): From 7c1bceaa250c08cb4ecf1a7e0d063b49d26c1343 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 19:10:12 +0100 Subject: [PATCH 31/69] Rename the function to `_get_dispatch_param` --- Lib/functools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 395999aec40b06..6cbfa1252fe4cd 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -888,7 +888,7 @@ def _find_impl(cls, registry): match = t return registry.get(match) -def _get_positional_param(func, *, pos=0): +def _get_dispatch_param(func, *, pos=0): """Finds the first positional user-specified parameter at position *pos* of a callable or descriptor. @@ -987,7 +987,7 @@ def register(cls, func=None, _func_is_method=False): # 0 for functions, 1 for methods where first argument should be skipped argpos = _func_is_method and not isinstance(func, staticmethod) - argname = _get_positional_param(func, pos=argpos) + argname = _get_dispatch_param(func, pos=argpos) if argname is None: raise TypeError( f"Invalid first argument to `register()`: {func!r} " From 444425c1a18333dc1cd342e1c3ead7fabb7c865c Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 19:15:15 +0100 Subject: [PATCH 32/69] Rewrite the news entry using precise language --- .../Library/2026-01-06-09-13-53.gh-issue-84644.V_cYP3.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Misc/NEWS.d/next/Library/2026-01-06-09-13-53.gh-issue-84644.V_cYP3.rst b/Misc/NEWS.d/next/Library/2026-01-06-09-13-53.gh-issue-84644.V_cYP3.rst index 67bee9d7b11d1f..95190f88b16e60 100644 --- a/Misc/NEWS.d/next/Library/2026-01-06-09-13-53.gh-issue-84644.V_cYP3.rst +++ b/Misc/NEWS.d/next/Library/2026-01-06-09-13-53.gh-issue-84644.V_cYP3.rst @@ -1,3 +1,5 @@ :func:`functools.singledispatch` and :func:`functools.singledispatchmethod` -no longer mistakenly honor a misplaced annotation (e.g. the return type) -as a type of the argument dispatched. Contributed by Bartosz Sławecki. +now require callables to be correctly annotated if registering without a type explicitly +specified in the decorator. The first user-specified positional parameter of a callable +must always be annotated. Before, a callable could be registered based on its return type +annotation or based on an irrelevant parameter type annotation. Contributed by Bartosz Sławecki. From 9b26fb175fbdbb6ff7b1051f26c51cd1bbd96760 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 19:35:07 +0100 Subject: [PATCH 33/69] Add a test for positional-only parameter --- Lib/test/test_functools.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 70196642cdd3d6..530e0fd34559db 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -2904,6 +2904,9 @@ def _(arg: int): @t.register def _(arg: str): return "str" + @t.register + def _(arg: float, /): + return "float" def _(arg: bytes): return "bytes" @t.register @@ -2912,7 +2915,8 @@ def wrapper(*args, **kwargs): return _(*args, **kwargs) self.assertEqual(t(0), "int") self.assertEqual(t(''), "str") - self.assertEqual(t(0.0), "base") + self.assertEqual(t(0.0), "float") + self.assertEqual(t(NotImplemented), "base") self.assertEqual(t(b''), "bytes") def test_method_type_ann_register(self): From 17dfb364c6dea0d9fe5ec02a927ccfb27e18fe57 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 19:36:40 +0100 Subject: [PATCH 34/69] Add a mixed parameter types test case --- Lib/test/test_functools.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 530e0fd34559db..8834ad30e372f5 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -2907,6 +2907,9 @@ def _(arg: str): @t.register def _(arg: float, /): return "float" + @t.register + def _(a1: list, a2: None, /, a3: None, *, a4: None): + return "list" def _(arg: bytes): return "bytes" @t.register @@ -2916,6 +2919,7 @@ def wrapper(*args, **kwargs): self.assertEqual(t(0), "int") self.assertEqual(t(''), "str") self.assertEqual(t(0.0), "float") + self.assertEqual(t([], None, None, a4=None), "list") self.assertEqual(t(NotImplemented), "base") self.assertEqual(t(b''), "bytes") From fbce76d180b21c38f301c028f6a017ecb4c976b5 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 19:42:26 +0100 Subject: [PATCH 35/69] Do not break exception chain unnecessarily --- Lib/functools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/functools.py b/Lib/functools.py index 6cbfa1252fe4cd..6e3a0d6c05b47e 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -992,7 +992,7 @@ def register(cls, func=None, _func_is_method=False): raise TypeError( f"Invalid first argument to `register()`: {func!r} " f"does not accept positional arguments." - ) from None + ) # only import typing if annotation parsing is necessary from typing import get_type_hints From 44b8bba3bc08ad8653475a1f9b408d9bdb5698dc Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 19:43:14 +0100 Subject: [PATCH 36/69] Improve the docstring --- Lib/functools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/functools.py b/Lib/functools.py index 6e3a0d6c05b47e..af1996e68a6bcd 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -892,7 +892,7 @@ def _get_dispatch_param(func, *, pos=0): """Finds the first positional user-specified parameter at position *pos* of a callable or descriptor. - Used by singledispatch for registration by type annotation. + Used by singledispatch for registration by type annotation of the parameter. *pos* should either be 0 (for functions and staticmethods) or 1 (for methods). """ # Fast path for typical callables and descriptors. From ec01821b1cd608a84a8a1491e0514403c3888f51 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 20:23:50 +0100 Subject: [PATCH 37/69] Add precedent case for GH-84644 --- Lib/test/test_functools.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 8834ad30e372f5..55c575bb524df2 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -3274,6 +3274,22 @@ def _(arg, extra: int): "Use either `@register(some_class)` or add a type annotation " f"to parameter 'arg' of your callable.") + with self.assertRaises(TypeError) as exc: + # See GH-84644. + + @functools.singledispatch + def func(arg):... + + @func.register + def _int(arg) -> int:... + + self.assertStartsWith(str(exc.exception), msg_prefix + + "._" + ) + self.assertEndsWith(str(exc.exception), + "Use either `@register(some_class)` or add a type annotation " + f"to parameter 'arg' of your callable.") + with self.assertRaises(TypeError) as exc: @i.register def _(arg: typing.Iterable[str]): From 57faa34fd63cf53998478cf42acb0f6f6c28b306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20S=C5=82awecki?= Date: Wed, 7 Jan 2026 20:41:22 +0100 Subject: [PATCH 38/69] Fix GH-84644 test --- Lib/test/test_functools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 55c575bb524df2..19cab93e2b04a4 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -3284,7 +3284,7 @@ def func(arg):... def _int(arg) -> int:... self.assertStartsWith(str(exc.exception), msg_prefix + - "._" + "._int" ) self.assertEndsWith(str(exc.exception), "Use either `@register(some_class)` or add a type annotation " From e4fb514f03aa2c3a83a45bda3c689e55ce5b4b82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20S=C5=82awecki?= Date: Wed, 7 Jan 2026 20:42:41 +0100 Subject: [PATCH 39/69] Reword the documentation of `_get_dispatch_param` --- Lib/functools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/functools.py b/Lib/functools.py index af1996e68a6bcd..50f0b74990af07 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -889,7 +889,7 @@ def _find_impl(cls, registry): return registry.get(match) def _get_dispatch_param(func, *, pos=0): - """Finds the first positional user-specified parameter at position *pos* + """Finds the positional user-specified parameter at position *pos* of a callable or descriptor. Used by singledispatch for registration by type annotation of the parameter. From 57965a90bd401aeb1cc78dbcffb1e31744b19589 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 20:48:11 +0100 Subject: [PATCH 40/69] Merge GH-130827 test into `test_method_type_ann_register` --- Lib/test/test_functools.py | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 19cab93e2b04a4..68b73b1032c80c 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -2933,9 +2933,10 @@ def t(self, arg): def _(self, arg: int): return "int" @t.register - def _(self, arg: str): + def _(self, /, arg: str): return "str" - def _(self, arg: bytes): + # See GH-130827. + def _(self: typing.Self, arg: bytes): return "bytes" @t.register @functools.wraps(_) @@ -3623,21 +3624,6 @@ def _(self, arg): self.assertEqual(a.v(0), ('special', 0)) self.assertEqual(a.v(2.5), ('general', 2.5)) - def test_method_self_annotation(self): - """See GH-130827.""" - class A: - @functools.singledispatchmethod - def u(self: typing.Self, arg: int | str) -> int | str: - return None - - @u.register - def _(self: typing.Self, arg: int) -> int: - return arg - - a = A() - self.assertEqual(a.u(42), 42) - self.assertEqual(a.u("hello"), None) - class CachedCostItem: _cost = 1 From eadc38fe3077cb48c63af2a20f16d074581c9e87 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 22:24:50 +0100 Subject: [PATCH 41/69] Add case this PR broke -- registering bound methods --- Lib/test/test_functools.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 68b73b1032c80c..bf45eb05598481 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -2916,12 +2916,20 @@ def _(arg: bytes): @functools.wraps(_) def wrapper(*args, **kwargs): return _(*args, **kwargs) + + class SomeClass: + def method(self, arg: dict): + return "dict" + + t.register(SomeClass().method) + self.assertEqual(t(0), "int") self.assertEqual(t(''), "str") self.assertEqual(t(0.0), "float") self.assertEqual(t([], None, None, a4=None), "list") self.assertEqual(t(NotImplemented), "base") self.assertEqual(t(b''), "bytes") + self.assertEqual(t({}), "dict") def test_method_type_ann_register(self): From 682c41e9ff090ba43834d83f6361c71efe4296a2 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 23:05:37 +0100 Subject: [PATCH 42/69] Add bound methods to slow path --- Lib/functools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/functools.py b/Lib/functools.py index 50f0b74990af07..7e72dd7300ae18 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -896,7 +896,7 @@ def _get_dispatch_param(func, *, pos=0): *pos* should either be 0 (for functions and staticmethods) or 1 (for methods). """ # Fast path for typical callables and descriptors. - if isinstance(func, (MethodType, classmethod, staticmethod)): + if isinstance(func, (classmethod, staticmethod)): func = func.__func__ if isinstance(func, FunctionType) and not hasattr(func, "__wrapped__"): func_code = func.__code__ From c4067558c7374ce05f2a4d85335842d8318ff73e Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 23:18:15 +0100 Subject: [PATCH 43/69] Optimize instance checks in the fast path --- Lib/functools.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 7e72dd7300ae18..589d539fecc83f 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -888,25 +888,29 @@ def _find_impl(cls, registry): match = t return registry.get(match) -def _get_dispatch_param(func, *, pos=0): - """Finds the positional user-specified parameter at position *pos* - of a callable or descriptor. +def _get_dispatch_param(func, *, _dispatchmethod=False): + """Finds the first positional and user-specified parameter in a callable + or descriptor. Used by singledispatch for registration by type annotation of the parameter. - *pos* should either be 0 (for functions and staticmethods) or 1 (for methods). """ # Fast path for typical callables and descriptors. - if isinstance(func, (classmethod, staticmethod)): + # 0 from singledispatch(), 1 from singledispatchmethod() + idx = _dispatchmethod + if isinstance(func, staticmethod): + idx = 0 + func = func.__func__ + elif isinstance(func, classmethod): func = func.__func__ if isinstance(func, FunctionType) and not hasattr(func, "__wrapped__"): func_code = func.__code__ try: - return func_code.co_varnames[:func_code.co_argcount][pos] + return func_code.co_varnames[:func_code.co_argcount][idx] except IndexError: pass # Fallback path for more nuanced inspection of ambiguous callables. import inspect - for param in list(inspect.signature(func).parameters.values())[pos:]: + for param in list(inspect.signature(func).parameters.values())[idx:]: if param.kind in (param.KEYWORD_ONLY, param.VAR_KEYWORD): break return param.name @@ -959,7 +963,7 @@ def _is_valid_dispatch_type(cls): return (isinstance(cls, UnionType) and all(isinstance(arg, type) for arg in cls.__args__)) - def register(cls, func=None, _func_is_method=False): + def register(cls, func=None, _dispatchmethod=False): """generic_func.register(cls, func) -> func Registers a new implementation for the given *cls* on a *generic_func*. @@ -984,10 +988,7 @@ def register(cls, func=None, _func_is_method=False): ) func = cls - # 0 for functions, 1 for methods where first argument should be skipped - argpos = _func_is_method and not isinstance(func, staticmethod) - - argname = _get_dispatch_param(func, pos=argpos) + argname = _get_dispatch_param(func, _dispatchmethod=_dispatchmethod) if argname is None: raise TypeError( f"Invalid first argument to `register()`: {func!r} " @@ -1071,7 +1072,7 @@ def register(self, cls, method=None): Registers a new implementation for the given *cls* on a *generic_method*. """ - return self.dispatcher.register(cls, func=method, _func_is_method=True) + return self.dispatcher.register(cls, func=method, _dispatchmethod=True) def __get__(self, obj, cls=None): return _singledispatchmethod_get(self, obj, cls) From e238e6acae7a253558c96543fb97975ced3f89a2 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 23:30:18 +0100 Subject: [PATCH 44/69] Use a match statement instead of a for loop --- Lib/functools.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 589d539fecc83f..188a3f401d6ca5 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -895,7 +895,7 @@ def _get_dispatch_param(func, *, _dispatchmethod=False): Used by singledispatch for registration by type annotation of the parameter. """ # Fast path for typical callables and descriptors. - # 0 from singledispatch(), 1 from singledispatchmethod() + # idx is 0 when singledispatch() and 1 when singledispatchmethod() idx = _dispatchmethod if isinstance(func, staticmethod): idx = 0 @@ -910,10 +910,9 @@ def _get_dispatch_param(func, *, _dispatchmethod=False): pass # Fallback path for more nuanced inspection of ambiguous callables. import inspect - for param in list(inspect.signature(func).parameters.values())[idx:]: - if param.kind in (param.KEYWORD_ONLY, param.VAR_KEYWORD): - break - return param.name + match list(inspect.signature(func).parameters.values())[idx:]: + case [param] if param.kind < 3: # (*, param) or (**param) + return param.name return None def singledispatch(func): From 1e61429773f2d68f4173301c766312d968a31470 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 23:40:12 +0100 Subject: [PATCH 45/69] Rewrite to a try-except --- Lib/functools.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 188a3f401d6ca5..c95d1973773b56 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -910,9 +910,12 @@ def _get_dispatch_param(func, *, _dispatchmethod=False): pass # Fallback path for more nuanced inspection of ambiguous callables. import inspect - match list(inspect.signature(func).parameters.values())[idx:]: - case [param] if param.kind < 3: # (*, param) or (**param) + try: + param = list(inspect.signature(func).parameters.values())[idx] + if param.kind < 3: # (*, param) or (**param) return param.name + except IndexError: + pass return None def singledispatch(func): From 19458fc6f576b1292b548e89dd5c544a4e51db51 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 23:45:18 +0100 Subject: [PATCH 46/69] Improve comment --- Lib/functools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/functools.py b/Lib/functools.py index c95d1973773b56..669886158ae588 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -912,7 +912,7 @@ def _get_dispatch_param(func, *, _dispatchmethod=False): import inspect try: param = list(inspect.signature(func).parameters.values())[idx] - if param.kind < 3: # (*, param) or (**param) + if param.kind < 3: # (*, arg) or (**args) return param.name except IndexError: pass From 6390a829b262bc6e8634ef4287414307c16376f3 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 23:45:40 +0100 Subject: [PATCH 47/69] Add more bound method tests --- Lib/test/test_functools.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index bf45eb05598481..48d11834979e1b 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -2918,10 +2918,18 @@ def wrapper(*args, **kwargs): return _(*args, **kwargs) class SomeClass: - def method(self, arg: dict): + def for_dict(self, arg: dict): return "dict" - t.register(SomeClass().method) + def for_set(self, arg: set, arg2: None): + return "set" + + def for_complex(self: object, arg: complex, arg2: None): + return "complex" + + t.register(SomeClass().for_dict) + t.register(SomeClass().for_set) + t.register(SomeClass().for_complex) self.assertEqual(t(0), "int") self.assertEqual(t(''), "str") @@ -2930,6 +2938,8 @@ def method(self, arg: dict): self.assertEqual(t(NotImplemented), "base") self.assertEqual(t(b''), "bytes") self.assertEqual(t({}), "dict") + self.assertEqual(t(set(), None), "set") + self.assertEqual(t(0j, None), "complex") def test_method_type_ann_register(self): From 3e330404e4ee15723dade4ce383f8a4832893aae Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 23:46:09 +0100 Subject: [PATCH 48/69] Reuse one instance of test class --- Lib/test/test_functools.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 48d11834979e1b..8bae007653c933 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -2927,9 +2927,10 @@ def for_set(self, arg: set, arg2: None): def for_complex(self: object, arg: complex, arg2: None): return "complex" - t.register(SomeClass().for_dict) - t.register(SomeClass().for_set) - t.register(SomeClass().for_complex) + inst = SomeClass() + t.register(inst.for_dict) + t.register(inst.for_set) + t.register(inst.for_complex) self.assertEqual(t(0), "int") self.assertEqual(t(''), "str") From c50d3440806e410f37e25d41855b953d455bee82 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 7 Jan 2026 23:47:59 +0100 Subject: [PATCH 49/69] Test instance validity in bound method tests --- Lib/test/test_functools.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 8bae007653c933..0358268bff408f 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -2918,19 +2918,24 @@ def wrapper(*args, **kwargs): return _(*args, **kwargs) class SomeClass: - def for_dict(self, arg: dict): + def for_dict(this, arg: dict): + self.assertIs(this, inst1) return "dict" - def for_set(self, arg: set, arg2: None): + def for_set(this, arg: set, arg2: None): + self.assertIs(this, inst1) return "set" - def for_complex(self: object, arg: complex, arg2: None): + def for_complex(this: object, arg: complex, arg2: None): + self.assertIs(this, inst2) return "complex" - inst = SomeClass() - t.register(inst.for_dict) - t.register(inst.for_set) - t.register(inst.for_complex) + inst1 = SomeClass() + t.register(inst1.for_dict) + t.register(inst1.for_set) + + inst2 = SomeClass() + t.register(inst2.for_complex) self.assertEqual(t(0), "int") self.assertEqual(t(''), "str") From 32910f30f8205f0901478a97dccf6378158c39c5 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Thu, 8 Jan 2026 00:36:04 +0100 Subject: [PATCH 50/69] Tests and fixes for staticmethod --- Lib/functools.py | 14 +++++++------- Lib/test/test_functools.py | 19 +++++++++++++++++-- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 669886158ae588..b1c59426bc6fcf 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -888,7 +888,7 @@ def _find_impl(cls, registry): match = t return registry.get(match) -def _get_dispatch_param(func, *, _dispatchmethod=False): +def _get_dispatch_param(func, *, _insideclass=False): """Finds the first positional and user-specified parameter in a callable or descriptor. @@ -896,7 +896,7 @@ def _get_dispatch_param(func, *, _dispatchmethod=False): """ # Fast path for typical callables and descriptors. # idx is 0 when singledispatch() and 1 when singledispatchmethod() - idx = _dispatchmethod + idx = _insideclass if isinstance(func, staticmethod): idx = 0 func = func.__func__ @@ -965,7 +965,7 @@ def _is_valid_dispatch_type(cls): return (isinstance(cls, UnionType) and all(isinstance(arg, type) for arg in cls.__args__)) - def register(cls, func=None, _dispatchmethod=False): + def register(cls, func=None, _insideclass=False): """generic_func.register(cls, func) -> func Registers a new implementation for the given *cls* on a *generic_func*. @@ -990,7 +990,7 @@ def register(cls, func=None, _dispatchmethod=False): ) func = cls - argname = _get_dispatch_param(func, _dispatchmethod=_dispatchmethod) + argname = _get_dispatch_param(func, _insideclass=_insideclass) if argname is None: raise TypeError( f"Invalid first argument to `register()`: {func!r} " @@ -1069,12 +1069,12 @@ def __init__(self, func): self.dispatcher = singledispatch(func) self.func = func - def register(self, cls, method=None): + def register(self, cls, method=None, _insideclass=True): """generic_method.register(cls, func) -> func Registers a new implementation for the given *cls* on a *generic_method*. """ - return self.dispatcher.register(cls, func=method, _dispatchmethod=True) + return self.dispatcher.register(cls, func=method, _insideclass=_insideclass) def __get__(self, obj, cls=None): return _singledispatchmethod_get(self, obj, cls) @@ -1149,7 +1149,7 @@ def __wrapped__(self): @property def register(self): - return self._unbound.register + return partial(self._unbound.register, _insideclass=False) ################################################################################ diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 0358268bff408f..bddee402e1c9b6 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -2993,18 +2993,33 @@ def _(arg: int): return isinstance(arg, int) @t.register @staticmethod - def _(arg: str): + def _(arg: str, /): return isinstance(arg, str) @t.register @wrapper_decorator @staticmethod - def _(arg: bytes): + def _(arg: bytes) -> bool: return isinstance(arg, bytes) + @wrapper_decorator + @staticmethod + def outer1(arg: complex): + return isinstance(arg, complex) + @wrapper_decorator + @staticmethod + def outer2(arg: bool): + return isinstance(arg, bool) + + A.t.register(staticmethod(A.outer1)) a = A() + a.t.register(staticmethod(a.outer2)) self.assertTrue(A.t(0)) self.assertTrue(A.t('')) self.assertEqual(A.t(0.0), 0.0) + self.assertTrue(A.t(0j)) + self.assertTrue(a.t(42j)) + self.assertTrue(A.t(True)) + self.assertTrue(a.t(False)) def test_classmethod_type_ann_register(self): def wrapper_decorator(func): From 4283fba19737160586cf95672827d04789c4d3e5 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Thu, 8 Jan 2026 00:41:28 +0100 Subject: [PATCH 51/69] Add more tests for classmethod --- Lib/test/test_functools.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index bddee402e1c9b6..6c87ba17d88d20 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -3050,11 +3050,25 @@ def _(cls, arg: str): @classmethod def _(cls, arg: bytes): return cls("bytes") + @wrapper_decorator + @classmethod + def outer1(cls, arg: list): + return cls("list") + @wrapper_decorator + @classmethod + def outer2(cls, arg: complex): + return cls("complex") + + A.t.register(A.outer1) + a = A(None) + a.t.register(a.outer2) self.assertEqual(A.t(0).arg, "int") - self.assertEqual(A.t('').arg, "str") + self.assertEqual(a.t('').arg, "str") self.assertEqual(A.t(0.0).arg, "base") - self.assertEqual(A.t(b'').arg, "bytes") + self.assertEqual(a.t(b'').arg, "bytes") + self.assertEqual(A.t([]).arg, "list") + self.assertEqual(a.t(0j).arg, "complex") def test_method_wrapping_attributes(self): class A: From 17b5088186e9de4249d5200cdac1d6d9a55242b9 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Thu, 8 Jan 2026 01:15:11 +0100 Subject: [PATCH 52/69] Disambiguate a comment --- Lib/functools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/functools.py b/Lib/functools.py index b1c59426bc6fcf..026a0e219bc02d 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -912,7 +912,7 @@ def _get_dispatch_param(func, *, _insideclass=False): import inspect try: param = list(inspect.signature(func).parameters.values())[idx] - if param.kind < 3: # (*, arg) or (**args) + if param.kind < 3: # Discard (*, arg) and (**args) return param.name except IndexError: pass From cdb7cca873e3696d27184f502abc8b30cda62e62 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Thu, 8 Jan 2026 01:31:07 +0100 Subject: [PATCH 53/69] Always respect descriptors, fallback to assumptions on function-like objects --- Lib/functools.py | 1 + Lib/test/test_functools.py | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 026a0e219bc02d..30149e7768c3e6 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -902,6 +902,7 @@ def _get_dispatch_param(func, *, _insideclass=False): func = func.__func__ elif isinstance(func, classmethod): func = func.__func__ + idx = 1 if isinstance(func, FunctionType) and not hasattr(func, "__wrapped__"): func_code = func.__code__ try: diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 6c87ba17d88d20..1145207e587105 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -3008,10 +3008,15 @@ def outer1(arg: complex): @staticmethod def outer2(arg: bool): return isinstance(arg, bool) + @wrapper_decorator + @staticmethod + def outer3(arg: bytearray): + return isinstance(arg, bytearray) A.t.register(staticmethod(A.outer1)) + A.t.register(staticmethod(A.__dict__['outer2'])) a = A() - a.t.register(staticmethod(a.outer2)) + a.t.register(staticmethod(a.outer3)) self.assertTrue(A.t(0)) self.assertTrue(A.t('')) @@ -3020,6 +3025,8 @@ def outer2(arg: bool): self.assertTrue(a.t(42j)) self.assertTrue(A.t(True)) self.assertTrue(a.t(False)) + self.assertTrue(A.t(bytearray([1]))) + self.assertTrue(a.t(bytearray())) def test_classmethod_type_ann_register(self): def wrapper_decorator(func): @@ -3058,10 +3065,15 @@ def outer1(cls, arg: list): @classmethod def outer2(cls, arg: complex): return cls("complex") + @wrapper_decorator + @classmethod + def outer3(cls, arg: bytearray): + return cls("bytearray") A.t.register(A.outer1) + A.t.register(A.__dict__['outer2']) a = A(None) - a.t.register(a.outer2) + a.t.register(a.outer3) self.assertEqual(A.t(0).arg, "int") self.assertEqual(a.t('').arg, "str") @@ -3069,6 +3081,7 @@ def outer2(cls, arg: complex): self.assertEqual(a.t(b'').arg, "bytes") self.assertEqual(A.t([]).arg, "list") self.assertEqual(a.t(0j).arg, "complex") + self.assertEqual(A.t(bytearray()).arg, "bytearray") def test_method_wrapping_attributes(self): class A: From 62088c7a5a34d3e0a366f34ee7b3eee71071d2ad Mon Sep 17 00:00:00 2001 From: johnslavik Date: Thu, 8 Jan 2026 01:37:49 +0100 Subject: [PATCH 54/69] Add more comments --- Lib/functools.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Lib/functools.py b/Lib/functools.py index 30149e7768c3e6..6226e2032e2c33 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -895,7 +895,7 @@ def _get_dispatch_param(func, *, _insideclass=False): Used by singledispatch for registration by type annotation of the parameter. """ # Fast path for typical callables and descriptors. - # idx is 0 when singledispatch() and 1 when singledispatchmethod() + # idx is 0 when singledispatch() and 1 when singledispatchmethod(). idx = _insideclass if isinstance(func, staticmethod): idx = 0 @@ -904,6 +904,7 @@ def _get_dispatch_param(func, *, _insideclass=False): func = func.__func__ idx = 1 if isinstance(func, FunctionType) and not hasattr(func, "__wrapped__"): + # Method from inspect._signature_from_function. func_code = func.__code__ try: return func_code.co_varnames[:func_code.co_argcount][idx] @@ -1150,6 +1151,7 @@ def __wrapped__(self): @property def register(self): + # This is called from outside of the class with singledispatchmethod. return partial(self._unbound.register, _insideclass=False) From c49785728f8e6cb7bb65a439f921ab2f3c4ec939 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Thu, 8 Jan 2026 01:52:53 +0100 Subject: [PATCH 55/69] Specialcase bound methods in singledispatchmethods Removed _dispatchmethod=False from register property, because it was an incorrect assumption. --- Lib/functools.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 6226e2032e2c33..d125cc2057288b 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -888,7 +888,7 @@ def _find_impl(cls, registry): match = t return registry.get(match) -def _get_dispatch_param(func, *, _insideclass=False): +def _get_dispatch_param(func, *, _dispatchmethod=False): """Finds the first positional and user-specified parameter in a callable or descriptor. @@ -896,13 +896,16 @@ def _get_dispatch_param(func, *, _insideclass=False): """ # Fast path for typical callables and descriptors. # idx is 0 when singledispatch() and 1 when singledispatchmethod(). - idx = _insideclass if isinstance(func, staticmethod): idx = 0 func = func.__func__ elif isinstance(func, classmethod): func = func.__func__ idx = 1 + elif _dispatchmethod and isinstance(func, MethodType): + idx = 0 + else: + idx = _dispatchmethod if isinstance(func, FunctionType) and not hasattr(func, "__wrapped__"): # Method from inspect._signature_from_function. func_code = func.__code__ @@ -967,7 +970,7 @@ def _is_valid_dispatch_type(cls): return (isinstance(cls, UnionType) and all(isinstance(arg, type) for arg in cls.__args__)) - def register(cls, func=None, _insideclass=False): + def register(cls, func=None, _dispatchmethod=False): """generic_func.register(cls, func) -> func Registers a new implementation for the given *cls* on a *generic_func*. @@ -992,7 +995,7 @@ def register(cls, func=None, _insideclass=False): ) func = cls - argname = _get_dispatch_param(func, _insideclass=_insideclass) + argname = _get_dispatch_param(func, _dispatchmethod=_dispatchmethod) if argname is None: raise TypeError( f"Invalid first argument to `register()`: {func!r} " @@ -1071,12 +1074,12 @@ def __init__(self, func): self.dispatcher = singledispatch(func) self.func = func - def register(self, cls, method=None, _insideclass=True): + def register(self, cls, method=None, _dispatchmethod=True): """generic_method.register(cls, func) -> func Registers a new implementation for the given *cls* on a *generic_method*. """ - return self.dispatcher.register(cls, func=method, _insideclass=_insideclass) + return self.dispatcher.register(cls, func=method, _dispatchmethod=_dispatchmethod) def __get__(self, obj, cls=None): return _singledispatchmethod_get(self, obj, cls) @@ -1151,8 +1154,7 @@ def __wrapped__(self): @property def register(self): - # This is called from outside of the class with singledispatchmethod. - return partial(self._unbound.register, _insideclass=False) + return self._unbound.register ################################################################################ From 0f75d98a63feb6c2e41c13b35b06b12e0c3013b1 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Thu, 8 Jan 2026 01:55:18 +0100 Subject: [PATCH 56/69] Finalize the logic --- Lib/functools.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index d125cc2057288b..0ed2d5fc13dfa3 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -900,12 +900,13 @@ def _get_dispatch_param(func, *, _dispatchmethod=False): idx = 0 func = func.__func__ elif isinstance(func, classmethod): + idx = 1 func = func.__func__ + elif _dispatchmethod and not isinstance(func, MethodType): idx = 1 - elif _dispatchmethod and isinstance(func, MethodType): - idx = 0 else: - idx = _dispatchmethod + idx = 0 + if isinstance(func, FunctionType) and not hasattr(func, "__wrapped__"): # Method from inspect._signature_from_function. func_code = func.__code__ From 8350e71823784e4b35167eae990d77662c542ae2 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Thu, 8 Jan 2026 02:03:45 +0100 Subject: [PATCH 57/69] Crystalize the decision tree --- Lib/functools.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 0ed2d5fc13dfa3..7c28bf8e118d07 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -888,24 +888,27 @@ def _find_impl(cls, registry): match = t return registry.get(match) -def _get_dispatch_param(func, *, _dispatchmethod=False): +def _get_dispatch_param(func, *, _inside_dispatchmethod=False): """Finds the first positional and user-specified parameter in a callable or descriptor. Used by singledispatch for registration by type annotation of the parameter. """ # Fast path for typical callables and descriptors. - # idx is 0 when singledispatch() and 1 when singledispatchmethod(). + + # For staticmethods always pick the first parameter. if isinstance(func, staticmethod): idx = 0 func = func.__func__ - elif isinstance(func, classmethod): + # For classmethods and bound methods always pick the second parameter. + elif isinstance(func, (classmethod, MethodType)): idx = 1 func = func.__func__ - elif _dispatchmethod and not isinstance(func, MethodType): - idx = 1 + # For unbound methods and functions, pick: + # - the first parameter if calling from singledispatch() + # - the second parameter if calling from singledispatchmethod() else: - idx = 0 + idx = _inside_dispatchmethod if isinstance(func, FunctionType) and not hasattr(func, "__wrapped__"): # Method from inspect._signature_from_function. @@ -971,7 +974,7 @@ def _is_valid_dispatch_type(cls): return (isinstance(cls, UnionType) and all(isinstance(arg, type) for arg in cls.__args__)) - def register(cls, func=None, _dispatchmethod=False): + def register(cls, func=None, _inside_dispatchmethod=False): """generic_func.register(cls, func) -> func Registers a new implementation for the given *cls* on a *generic_func*. @@ -996,7 +999,8 @@ def register(cls, func=None, _dispatchmethod=False): ) func = cls - argname = _get_dispatch_param(func, _dispatchmethod=_dispatchmethod) + argname = _get_dispatch_param( + func, _inside_dispatchmethod=_inside_dispatchmethod) if argname is None: raise TypeError( f"Invalid first argument to `register()`: {func!r} " @@ -1075,12 +1079,12 @@ def __init__(self, func): self.dispatcher = singledispatch(func) self.func = func - def register(self, cls, method=None, _dispatchmethod=True): + def register(self, cls, method=None): """generic_method.register(cls, func) -> func Registers a new implementation for the given *cls* on a *generic_method*. """ - return self.dispatcher.register(cls, func=method, _dispatchmethod=_dispatchmethod) + return self.dispatcher.register(cls, func=method, _inside_dispatchmethod=True) def __get__(self, obj, cls=None): return _singledispatchmethod_get(self, obj, cls) From 50c0e648c3ab54784fee7447f8998933cee0d380 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Thu, 8 Jan 2026 02:04:49 +0100 Subject: [PATCH 58/69] Fix comment --- Lib/functools.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/functools.py b/Lib/functools.py index 7c28bf8e118d07..d81f623a455ac4 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -911,12 +911,13 @@ def _get_dispatch_param(func, *, _inside_dispatchmethod=False): idx = _inside_dispatchmethod if isinstance(func, FunctionType) and not hasattr(func, "__wrapped__"): - # Method from inspect._signature_from_function. + # Emulate inspect._signature_from_function to get the desired parameter. func_code = func.__code__ try: return func_code.co_varnames[:func_code.co_argcount][idx] except IndexError: pass + # Fallback path for more nuanced inspection of ambiguous callables. import inspect try: From 052c2fda40497e2950a644582818bf276ebb24e9 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Thu, 8 Jan 2026 02:17:37 +0100 Subject: [PATCH 59/69] Better comments --- Lib/functools.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index d81f623a455ac4..0f1b5cd48a0ea4 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -894,22 +894,21 @@ def _get_dispatch_param(func, *, _inside_dispatchmethod=False): Used by singledispatch for registration by type annotation of the parameter. """ - # Fast path for typical callables and descriptors. - - # For staticmethods always pick the first parameter. + # Pick the first parameter if function had @staticmethod. if isinstance(func, staticmethod): idx = 0 func = func.__func__ - # For classmethods and bound methods always pick the second parameter. + # Pick the second parameter if function had @classmethod or is any bound method. elif isinstance(func, (classmethod, MethodType)): idx = 1 func = func.__func__ - # For unbound methods and functions, pick: - # - the first parameter if calling from singledispatch() - # - the second parameter if calling from singledispatchmethod() + # If it is likely a regular function: + # Pick the first parameter if calling from singledispatch(). + # Pick the second parameter if calling from singledispatchmethod. else: idx = _inside_dispatchmethod + # If it is a simple function, try to fast read from the code object. if isinstance(func, FunctionType) and not hasattr(func, "__wrapped__"): # Emulate inspect._signature_from_function to get the desired parameter. func_code = func.__code__ @@ -918,7 +917,7 @@ def _get_dispatch_param(func, *, _inside_dispatchmethod=False): except IndexError: pass - # Fallback path for more nuanced inspection of ambiguous callables. + # Otherwise delegate wrapped or ambiguous callables to inspect.signature (slower). import inspect try: param = list(inspect.signature(func).parameters.values())[idx] From 30994eba1c5eb36d187947fede4fd9997860f271 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Thu, 8 Jan 2026 02:25:43 +0100 Subject: [PATCH 60/69] Fiat lux --- Lib/functools.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 0f1b5cd48a0ea4..807ac1d6920ce1 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -898,17 +898,17 @@ def _get_dispatch_param(func, *, _inside_dispatchmethod=False): if isinstance(func, staticmethod): idx = 0 func = func.__func__ - # Pick the second parameter if function had @classmethod or is any bound method. + # Pick the second parameter if function had @classmethod or is a bound method. elif isinstance(func, (classmethod, MethodType)): idx = 1 func = func.__func__ - # If it is likely a regular function: - # Pick the first parameter if calling from singledispatch(). - # Pick the second parameter if calling from singledispatchmethod. + # If it is a regular function: + # Pick the first parameter if registering from singledispatch. + # Pick the second parameter if registering from singledispatchmethod. else: idx = _inside_dispatchmethod - # If it is a simple function, try to fast read from the code object. + # If it is a simple function, try to read from the code object fast. if isinstance(func, FunctionType) and not hasattr(func, "__wrapped__"): # Emulate inspect._signature_from_function to get the desired parameter. func_code = func.__code__ From 3edad444a6622e11a8c2b44999c7bac076960ef8 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Thu, 8 Jan 2026 02:32:26 +0100 Subject: [PATCH 61/69] Rename function to `_get_singledispatch_annotated_param` --- Lib/functools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 807ac1d6920ce1..0010841a2e1f26 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -888,7 +888,7 @@ def _find_impl(cls, registry): match = t return registry.get(match) -def _get_dispatch_param(func, *, _inside_dispatchmethod=False): +def _get_singledispatch_annotated_param(func, *, _inside_dispatchmethod=False): """Finds the first positional and user-specified parameter in a callable or descriptor. @@ -999,7 +999,7 @@ def register(cls, func=None, _inside_dispatchmethod=False): ) func = cls - argname = _get_dispatch_param( + argname = _get_singledispatch_annotated_param( func, _inside_dispatchmethod=_inside_dispatchmethod) if argname is None: raise TypeError( From fbc205e7feae2b550507ccd9bac053c30175c2d4 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Thu, 8 Jan 2026 02:33:22 +0100 Subject: [PATCH 62/69] Disambiguate comment --- Lib/functools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/functools.py b/Lib/functools.py index 0010841a2e1f26..37b955730734a0 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -921,7 +921,7 @@ def _get_singledispatch_annotated_param(func, *, _inside_dispatchmethod=False): import inspect try: param = list(inspect.signature(func).parameters.values())[idx] - if param.kind < 3: # Discard (*, arg) and (**args) + if param.kind < 3: # False for (*, arg) and (**args) parameters. return param.name except IndexError: pass From b691969f091db7a20dd842dba9051f489bc79192 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Thu, 8 Jan 2026 02:34:55 +0100 Subject: [PATCH 63/69] More comments --- Lib/functools.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 37b955730734a0..426fe4a699cbbc 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -903,8 +903,8 @@ def _get_singledispatch_annotated_param(func, *, _inside_dispatchmethod=False): idx = 1 func = func.__func__ # If it is a regular function: - # Pick the first parameter if registering from singledispatch. - # Pick the second parameter if registering from singledispatchmethod. + # Pick the first parameter if registering via singledispatch. + # Pick the second parameter if registering via singledispatchmethod. else: idx = _inside_dispatchmethod @@ -917,11 +917,14 @@ def _get_singledispatch_annotated_param(func, *, _inside_dispatchmethod=False): except IndexError: pass - # Otherwise delegate wrapped or ambiguous callables to inspect.signature (slower). + # Fall back to inspect.signature (slower, but complete). import inspect try: param = list(inspect.signature(func).parameters.values())[idx] - if param.kind < 3: # False for (*, arg) and (**args) parameters. + # True for positional "(arg)" and positional-only "(arg, /)" parameters. + # True for variadic positional "(*args)" parameters for backward compatibility. + # False for keyword-only "(*, arg)" and keyword variadic "(**args)" parameters. + if param.kind < 3: return param.name except IndexError: pass From 0859bc099a36a7eedffdd4fbf5ef1e84efeb0806 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Thu, 8 Jan 2026 04:00:02 +0100 Subject: [PATCH 64/69] Add more missing tests --- Lib/test/test_functools.py | 78 +++++++++++++++++++++++--------------- 1 file changed, 48 insertions(+), 30 deletions(-) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 1145207e587105..e27181a88cfa1a 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -2910,42 +2910,29 @@ def _(arg: float, /): @t.register def _(a1: list, a2: None, /, a3: None, *, a4: None): return "list" - def _(arg: bytes): + + def wrapped1(arg: bytes) -> str: return "bytes" @t.register - @functools.wraps(_) - def wrapper(*args, **kwargs): - return _(*args, **kwargs) - - class SomeClass: - def for_dict(this, arg: dict): - self.assertIs(this, inst1) - return "dict" - - def for_set(this, arg: set, arg2: None): - self.assertIs(this, inst1) - return "set" - - def for_complex(this: object, arg: complex, arg2: None): - self.assertIs(this, inst2) - return "complex" + @functools.wraps(wrapped1) + def wrapper1(*args, **kwargs): + return wrapped1(*args, **kwargs) - inst1 = SomeClass() - t.register(inst1.for_dict) - t.register(inst1.for_set) - - inst2 = SomeClass() - t.register(inst2.for_complex) + def wrapped2(arg: bytearray) -> str: + return "bytearray" + @t.register + @functools.wraps(wrapped2) + def wrapper2(*args: typing.Any, **kwargs: typing.Any): + return wrapped2(*args, **kwargs) + # Check if the dispatch works. self.assertEqual(t(0), "int") self.assertEqual(t(''), "str") self.assertEqual(t(0.0), "float") self.assertEqual(t([], None, None, a4=None), "list") self.assertEqual(t(NotImplemented), "base") self.assertEqual(t(b''), "bytes") - self.assertEqual(t({}), "dict") - self.assertEqual(t(set(), None), "set") - self.assertEqual(t(0j, None), "complex") + self.assertEqual(t(bytearray()), "bytearray") def test_method_type_ann_register(self): @@ -2957,22 +2944,34 @@ def t(self, arg): def _(self, arg: int): return "int" @t.register + def _(self, arg: complex, /): + return "complex" + @t.register def _(self, /, arg: str): return "str" # See GH-130827. - def _(self: typing.Self, arg: bytes): + def wrapped1(self: typing.Self, arg: bytes): return "bytes" @t.register - @functools.wraps(_) - def wrapper(self, *args, **kwargs): - return self._(*args, **kwargs) + @functools.wraps(wrapped1) + def wrapper1(self, *args, **kwargs): + return self.wrapped1(*args, **kwargs) + + def wrapped2(self, arg: bytearray) -> str: + return "bytearray" + @t.register + @functools.wraps(wrapped2) + def wrapper2(self, *args: typing.Any, **kwargs: typing.Any): + return self.wrapped2(*args, **kwargs) a = A() self.assertEqual(a.t(0), "int") + self.assertEqual(a.t(0j), "complex") self.assertEqual(a.t(''), "str") self.assertEqual(a.t(0.0), "base") self.assertEqual(a.t(b''), "bytes") + self.assertEqual(a.t(bytearray()), "bytearray") def test_staticmethod_type_ann_register(self): def wrapper_decorator(func): @@ -3083,6 +3082,25 @@ def outer3(cls, arg: bytearray): self.assertEqual(a.t(0j).arg, "complex") self.assertEqual(A.t(bytearray()).arg, "bytearray") + def test_boundmethod_type_ann_register(self): + class C: + @functools.singledispatchmethod + def sdm(self, x: object) -> str: + return "C.sdm" + + def method(self, x: int) -> str: + return "C.method" + + sd = functools.singledispatch(lambda x: "sd") + + sd.register(C().method) + self.assertEqual(sd(0j), "sd") + self.assertEqual(sd(1), "C.method") + + C.sdm.register(C().method) + self.assertEqual(C().sdm(0j), "C.sdm") + self.assertEqual(C().sdm(1), "C.method") + def test_method_wrapping_attributes(self): class A: @functools.singledispatchmethod From 7ac8275f4ad4c47351fe14fddc489fd13c4f0c68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20S=C5=82awecki?= Date: Thu, 8 Jan 2026 04:50:59 +0100 Subject: [PATCH 65/69] Cast the `idx` to an integer explicitly Co-authored-by: Jelle Zijlstra --- Lib/functools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/functools.py b/Lib/functools.py index 426fe4a699cbbc..7aa6295232d537 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -906,7 +906,7 @@ def _get_singledispatch_annotated_param(func, *, _inside_dispatchmethod=False): # Pick the first parameter if registering via singledispatch. # Pick the second parameter if registering via singledispatchmethod. else: - idx = _inside_dispatchmethod + idx = int(_inside_dispatchmethod) # If it is a simple function, try to read from the code object fast. if isinstance(func, FunctionType) and not hasattr(func, "__wrapped__"): From fbe00f8cc058b2afa77410a6fb4588c86bf1fbb1 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Thu, 8 Jan 2026 04:58:03 +0100 Subject: [PATCH 66/69] Check param kinds by name (code review) --- Lib/functools.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 7aa6295232d537..d2a92ce8affda6 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -921,10 +921,8 @@ def _get_singledispatch_annotated_param(func, *, _inside_dispatchmethod=False): import inspect try: param = list(inspect.signature(func).parameters.values())[idx] - # True for positional "(arg)" and positional-only "(arg, /)" parameters. - # True for variadic positional "(*args)" parameters for backward compatibility. - # False for keyword-only "(*, arg)" and keyword variadic "(**args)" parameters. - if param.kind < 3: + # Allow variadic positional "(*args)" parameters for backward compatibility. + if param.kind not in (inspect.Parameter.KEYWORD_ONLY, inspect.Parameter.VAR_KEYWORD): return param.name except IndexError: pass From 7ada2b08fa784efe84b5f40c02fd577aad800f87 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Thu, 8 Jan 2026 04:59:01 +0100 Subject: [PATCH 67/69] Minime the try-except --- Lib/functools.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index d2a92ce8affda6..7ed3d67f3cf79c 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -919,13 +919,15 @@ def _get_singledispatch_annotated_param(func, *, _inside_dispatchmethod=False): # Fall back to inspect.signature (slower, but complete). import inspect + params = list(inspect.signature(func).parameters.values()) try: - param = list(inspect.signature(func).parameters.values())[idx] + param = params[idx] + except IndexError: + pass + else: # Allow variadic positional "(*args)" parameters for backward compatibility. if param.kind not in (inspect.Parameter.KEYWORD_ONLY, inspect.Parameter.VAR_KEYWORD): return param.name - except IndexError: - pass return None def singledispatch(func): From ac2f5a2962ae395f8cad7e5890bd92ae03f1d0d0 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Thu, 8 Jan 2026 05:53:39 +0100 Subject: [PATCH 68/69] Remove all new tests --- Lib/test/test_functools.py | 220 +++---------------------------------- 1 file changed, 15 insertions(+), 205 deletions(-) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index e27181a88cfa1a..090926fd8d8b61 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -2895,47 +2895,6 @@ def add(self, x, y): Abstract() def test_type_ann_register(self): - @functools.singledispatch - def t(arg): - return "base" - @t.register - def _(arg: int): - return "int" - @t.register - def _(arg: str): - return "str" - @t.register - def _(arg: float, /): - return "float" - @t.register - def _(a1: list, a2: None, /, a3: None, *, a4: None): - return "list" - - def wrapped1(arg: bytes) -> str: - return "bytes" - @t.register - @functools.wraps(wrapped1) - def wrapper1(*args, **kwargs): - return wrapped1(*args, **kwargs) - - def wrapped2(arg: bytearray) -> str: - return "bytearray" - @t.register - @functools.wraps(wrapped2) - def wrapper2(*args: typing.Any, **kwargs: typing.Any): - return wrapped2(*args, **kwargs) - - # Check if the dispatch works. - self.assertEqual(t(0), "int") - self.assertEqual(t(''), "str") - self.assertEqual(t(0.0), "float") - self.assertEqual(t([], None, None, a4=None), "list") - self.assertEqual(t(NotImplemented), "base") - self.assertEqual(t(b''), "bytes") - self.assertEqual(t(bytearray()), "bytearray") - - def test_method_type_ann_register(self): - class A: @functools.singledispatchmethod def t(self, arg): @@ -2944,43 +2903,15 @@ def t(self, arg): def _(self, arg: int): return "int" @t.register - def _(self, arg: complex, /): - return "complex" - @t.register - def _(self, /, arg: str): + def _(self, arg: str): return "str" - # See GH-130827. - def wrapped1(self: typing.Self, arg: bytes): - return "bytes" - @t.register - @functools.wraps(wrapped1) - def wrapper1(self, *args, **kwargs): - return self.wrapped1(*args, **kwargs) - - def wrapped2(self, arg: bytearray) -> str: - return "bytearray" - @t.register - @functools.wraps(wrapped2) - def wrapper2(self, *args: typing.Any, **kwargs: typing.Any): - return self.wrapped2(*args, **kwargs) - a = A() self.assertEqual(a.t(0), "int") - self.assertEqual(a.t(0j), "complex") self.assertEqual(a.t(''), "str") self.assertEqual(a.t(0.0), "base") - self.assertEqual(a.t(b''), "bytes") - self.assertEqual(a.t(bytearray()), "bytearray") def test_staticmethod_type_ann_register(self): - def wrapper_decorator(func): - wrapped = func.__func__ - @staticmethod - @functools.wraps(wrapped) - def wrapper(*args, **kwargs): - return wrapped(*args, **kwargs) - return wrapper class A: @functools.singledispatchmethod @staticmethod @@ -2992,49 +2923,15 @@ def _(arg: int): return isinstance(arg, int) @t.register @staticmethod - def _(arg: str, /): + def _(arg: str): return isinstance(arg, str) - @t.register - @wrapper_decorator - @staticmethod - def _(arg: bytes) -> bool: - return isinstance(arg, bytes) - @wrapper_decorator - @staticmethod - def outer1(arg: complex): - return isinstance(arg, complex) - @wrapper_decorator - @staticmethod - def outer2(arg: bool): - return isinstance(arg, bool) - @wrapper_decorator - @staticmethod - def outer3(arg: bytearray): - return isinstance(arg, bytearray) - - A.t.register(staticmethod(A.outer1)) - A.t.register(staticmethod(A.__dict__['outer2'])) a = A() - a.t.register(staticmethod(a.outer3)) self.assertTrue(A.t(0)) self.assertTrue(A.t('')) self.assertEqual(A.t(0.0), 0.0) - self.assertTrue(A.t(0j)) - self.assertTrue(a.t(42j)) - self.assertTrue(A.t(True)) - self.assertTrue(a.t(False)) - self.assertTrue(A.t(bytearray([1]))) - self.assertTrue(a.t(bytearray())) def test_classmethod_type_ann_register(self): - def wrapper_decorator(func): - wrapped = func.__func__ - @classmethod - @functools.wraps(wrapped) - def wrapper(*args, **kwargs): - return wrapped(*args, **kwargs) - return wrapper class A: def __init__(self, arg): self.arg = arg @@ -3051,55 +2948,10 @@ def _(cls, arg: int): @classmethod def _(cls, arg: str): return cls("str") - @t.register - @wrapper_decorator - @classmethod - def _(cls, arg: bytes): - return cls("bytes") - @wrapper_decorator - @classmethod - def outer1(cls, arg: list): - return cls("list") - @wrapper_decorator - @classmethod - def outer2(cls, arg: complex): - return cls("complex") - @wrapper_decorator - @classmethod - def outer3(cls, arg: bytearray): - return cls("bytearray") - - A.t.register(A.outer1) - A.t.register(A.__dict__['outer2']) - a = A(None) - a.t.register(a.outer3) self.assertEqual(A.t(0).arg, "int") - self.assertEqual(a.t('').arg, "str") + self.assertEqual(A.t('').arg, "str") self.assertEqual(A.t(0.0).arg, "base") - self.assertEqual(a.t(b'').arg, "bytes") - self.assertEqual(A.t([]).arg, "list") - self.assertEqual(a.t(0j).arg, "complex") - self.assertEqual(A.t(bytearray()).arg, "bytearray") - - def test_boundmethod_type_ann_register(self): - class C: - @functools.singledispatchmethod - def sdm(self, x: object) -> str: - return "C.sdm" - - def method(self, x: int) -> str: - return "C.method" - - sd = functools.singledispatch(lambda x: "sd") - - sd.register(C().method) - self.assertEqual(sd(0j), "sd") - self.assertEqual(sd(1), "C.method") - - C.sdm.register(C().method) - self.assertEqual(C().sdm(0j), "C.sdm") - self.assertEqual(C().sdm(1), "C.method") def test_method_wrapping_attributes(self): class A: @@ -3318,27 +3170,12 @@ def test_invalid_registrations(self): @functools.singledispatch def i(arg): return "base" - with self.assertRaises(TypeError) as exc: - @i.register - def _() -> None: - return "My function doesn't take arguments" - self.assertStartsWith(str(exc.exception), msg_prefix) - self.assertEndsWith(str(exc.exception), "does not accept positional arguments.") - - with self.assertRaises(TypeError) as exc: - @i.register - def _(*, foo: str) -> None: - return "My function takes keyword-only arguments" - self.assertStartsWith(str(exc.exception), msg_prefix) - self.assertEndsWith(str(exc.exception), "does not accept positional arguments.") - with self.assertRaises(TypeError) as exc: @i.register(42) def _(arg): return "I annotated with a non-type" self.assertStartsWith(str(exc.exception), msg_prefix + "42") self.assertEndsWith(str(exc.exception), msg_suffix) - with self.assertRaises(TypeError) as exc: @i.register def _(arg): @@ -3348,33 +3185,6 @@ def _(arg): ) self.assertEndsWith(str(exc.exception), msg_suffix) - with self.assertRaises(TypeError) as exc: - @i.register - def _(arg, extra: int): - return "I did not annotate the right param" - self.assertStartsWith(str(exc.exception), msg_prefix + - "._" - ) - self.assertEndsWith(str(exc.exception), - "Use either `@register(some_class)` or add a type annotation " - f"to parameter 'arg' of your callable.") - - with self.assertRaises(TypeError) as exc: - # See GH-84644. - - @functools.singledispatch - def func(arg):... - - @func.register - def _int(arg) -> int:... - - self.assertStartsWith(str(exc.exception), msg_prefix + - "._int" - ) - self.assertEndsWith(str(exc.exception), - "Use either `@register(some_class)` or add a type annotation " - f"to parameter 'arg' of your callable.") - with self.assertRaises(TypeError) as exc: @i.register def _(arg: typing.Iterable[str]): @@ -3638,44 +3448,44 @@ def _(item: int, arg: bytes) -> str: def test_method_signatures(self): class A: - def m(self, item: int, arg) -> str: + def m(self, item, arg: int) -> str: return str(item) @classmethod - def cm(cls, item: int, arg) -> str: + def cm(cls, item, arg: int) -> str: return str(item) @functools.singledispatchmethod - def func(self, item: int, arg) -> str: + def func(self, item, arg: int) -> str: return str(item) @func.register - def _(self, item: bytes, arg) -> str: + def _(self, item, arg: bytes) -> str: return str(item) @functools.singledispatchmethod @classmethod - def cls_func(cls, item: int, arg) -> str: + def cls_func(cls, item, arg: int) -> str: return str(arg) @func.register @classmethod - def _(cls, item: bytes, arg) -> str: + def _(cls, item, arg: bytes) -> str: return str(item) @functools.singledispatchmethod @staticmethod - def static_func(item: int, arg) -> str: + def static_func(item, arg: int) -> str: return str(arg) @func.register @staticmethod - def _(item: bytes, arg) -> str: + def _(item, arg: bytes) -> str: return str(item) self.assertEqual(str(Signature.from_callable(A.func)), - '(self, item: int, arg) -> str') + '(self, item, arg: int) -> str') self.assertEqual(str(Signature.from_callable(A().func)), - '(self, item: int, arg) -> str') + '(self, item, arg: int) -> str') self.assertEqual(str(Signature.from_callable(A.cls_func)), - '(cls, item: int, arg) -> str') + '(cls, item, arg: int) -> str') self.assertEqual(str(Signature.from_callable(A.static_func)), - '(item: int, arg) -> str') + '(item, arg: int) -> str') def test_method_non_descriptor(self): class Callable: From ba46e43692c728b5402ecf02425b1b010cfd384c Mon Sep 17 00:00:00 2001 From: johnslavik Date: Thu, 8 Jan 2026 05:58:44 +0100 Subject: [PATCH 69/69] Add previously failing tests only --- Lib/test/test_functools.py | 89 ++++++++++++++++++++++++++++++++------ 1 file changed, 76 insertions(+), 13 deletions(-) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 090926fd8d8b61..4c1b70dafb6e2e 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -2903,13 +2903,34 @@ def t(self, arg): def _(self, arg: int): return "int" @t.register - def _(self, arg: str): + def _(self, arg: complex, /): + return "complex" + @t.register + def _(self, /, arg: str): return "str" + # See GH-130827. + def wrapped1(self: typing.Self, arg: bytes): + return "bytes" + @t.register + @functools.wraps(wrapped1) + def wrapper1(self, *args, **kwargs): + return self.wrapped1(*args, **kwargs) + + def wrapped2(self, arg: bytearray) -> str: + return "bytearray" + @t.register + @functools.wraps(wrapped2) + def wrapper2(self, *args: typing.Any, **kwargs: typing.Any): + return self.wrapped2(*args, **kwargs) + a = A() self.assertEqual(a.t(0), "int") + self.assertEqual(a.t(0j), "complex") self.assertEqual(a.t(''), "str") self.assertEqual(a.t(0.0), "base") + self.assertEqual(a.t(b''), "bytes") + self.assertEqual(a.t(bytearray()), "bytearray") def test_staticmethod_type_ann_register(self): class A: @@ -3170,12 +3191,27 @@ def test_invalid_registrations(self): @functools.singledispatch def i(arg): return "base" + with self.assertRaises(TypeError) as exc: + @i.register + def _() -> None: + return "My function doesn't take arguments" + self.assertStartsWith(str(exc.exception), msg_prefix) + self.assertEndsWith(str(exc.exception), "does not accept positional arguments.") + + with self.assertRaises(TypeError) as exc: + @i.register + def _(*, foo: str) -> None: + return "My function takes keyword-only arguments" + self.assertStartsWith(str(exc.exception), msg_prefix) + self.assertEndsWith(str(exc.exception), "does not accept positional arguments.") + with self.assertRaises(TypeError) as exc: @i.register(42) def _(arg): return "I annotated with a non-type" self.assertStartsWith(str(exc.exception), msg_prefix + "42") self.assertEndsWith(str(exc.exception), msg_suffix) + with self.assertRaises(TypeError) as exc: @i.register def _(arg): @@ -3185,6 +3221,33 @@ def _(arg): ) self.assertEndsWith(str(exc.exception), msg_suffix) + with self.assertRaises(TypeError) as exc: + @i.register + def _(arg, extra: int): + return "I did not annotate the right param" + self.assertStartsWith(str(exc.exception), msg_prefix + + "._" + ) + self.assertEndsWith(str(exc.exception), + "Use either `@register(some_class)` or add a type annotation " + f"to parameter 'arg' of your callable.") + + with self.assertRaises(TypeError) as exc: + # See GH-84644. + + @functools.singledispatch + def func(arg):... + + @func.register + def _int(arg) -> int:... + + self.assertStartsWith(str(exc.exception), msg_prefix + + "._int" + ) + self.assertEndsWith(str(exc.exception), + "Use either `@register(some_class)` or add a type annotation " + f"to parameter 'arg' of your callable.") + with self.assertRaises(TypeError) as exc: @i.register def _(arg: typing.Iterable[str]): @@ -3448,44 +3511,44 @@ def _(item: int, arg: bytes) -> str: def test_method_signatures(self): class A: - def m(self, item, arg: int) -> str: + def m(self, item: int, arg) -> str: return str(item) @classmethod - def cm(cls, item, arg: int) -> str: + def cm(cls, item: int, arg) -> str: return str(item) @functools.singledispatchmethod - def func(self, item, arg: int) -> str: + def func(self, item: int, arg) -> str: return str(item) @func.register - def _(self, item, arg: bytes) -> str: + def _(self, item: bytes, arg) -> str: return str(item) @functools.singledispatchmethod @classmethod - def cls_func(cls, item, arg: int) -> str: + def cls_func(cls, item: int, arg) -> str: return str(arg) @func.register @classmethod - def _(cls, item, arg: bytes) -> str: + def _(cls, item: bytes, arg) -> str: return str(item) @functools.singledispatchmethod @staticmethod - def static_func(item, arg: int) -> str: + def static_func(item: int, arg) -> str: return str(arg) @func.register @staticmethod - def _(item, arg: bytes) -> str: + def _(item: bytes, arg) -> str: return str(item) self.assertEqual(str(Signature.from_callable(A.func)), - '(self, item, arg: int) -> str') + '(self, item: int, arg) -> str') self.assertEqual(str(Signature.from_callable(A().func)), - '(self, item, arg: int) -> str') + '(self, item: int, arg) -> str') self.assertEqual(str(Signature.from_callable(A.cls_func)), - '(cls, item, arg: int) -> str') + '(cls, item: int, arg) -> str') self.assertEqual(str(Signature.from_callable(A.static_func)), - '(item, arg: int) -> str') + '(item: int, arg) -> str') def test_method_non_descriptor(self): class Callable: