Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
d3d6e64
Resolve first positional param, required to be annotated
johnslavik Jan 6, 2026
6ea2a4a
Special-case strings for forward refs similarly to typing
johnslavik Jan 6, 2026
0a39278
Rename `ref_or_type` to `ref_or_typeform`
johnslavik Jan 6, 2026
096fc3b
Add comment
johnslavik Jan 6, 2026
c8a5cdc
Shorten error message string line
johnslavik Jan 6, 2026
e1cde59
Adjust formatting to functools style
johnslavik Jan 6, 2026
6bc698b
Normalize `None` to a type, strip annotations
johnslavik Jan 6, 2026
4ef7c7c
Rename `ref_or_typeform` to `fwdref_or_typeform`
johnslavik Jan 6, 2026
004c852
Rename `skip_first` to `skip_first_param`
johnslavik Jan 6, 2026
9bc1436
Add news entry
johnslavik Jan 6, 2026
3115fd7
Add GH-130827 test
johnslavik Jan 3, 2026
f6c102f
Fix test
johnslavik Jan 6, 2026
7968570
Remove the `get_annotations` dance for now
johnslavik Jan 6, 2026
a808a1e
Fix incorrect `regster()` calls in `TestSingleDispatch.test_method_si…
johnslavik Jan 7, 2026
69b9978
Fix string signatures accordingly
johnslavik Jan 7, 2026
ebdb68d
Raise exception if positional argument not found
johnslavik Jan 7, 2026
8d86f9e
Break the exception chain
johnslavik Jan 7, 2026
82616f9
Support all callables
johnslavik Jan 7, 2026
c9a1f1a
Clarify comment
johnslavik Jan 7, 2026
e878207
Fiat lux, inline validation
johnslavik Jan 7, 2026
d3240a3
Add more test cases (mainly wrappers)
johnslavik Jan 7, 2026
9240b0d
More comments!
johnslavik Jan 7, 2026
f6ccb97
Less history pollution
johnslavik Jan 7, 2026
6642321
Document `_get_positional_param`
johnslavik Jan 7, 2026
0eaaa5b
Better comments!
johnslavik Jan 7, 2026
345b7e9
Shorten a comment
johnslavik Jan 7, 2026
8a46f3f
Rephrase the fallback path comment
johnslavik Jan 7, 2026
16f83ee
Improve the error message when missing an annotation
johnslavik Jan 7, 2026
552daaf
Correct the docstring
johnslavik Jan 7, 2026
113cc29
Rephrase the documentation again
johnslavik Jan 7, 2026
7c1bcea
Rename the function to `_get_dispatch_param`
johnslavik Jan 7, 2026
444425c
Rewrite the news entry using precise language
johnslavik Jan 7, 2026
9b26fb1
Add a test for positional-only parameter
johnslavik Jan 7, 2026
17dfb36
Add a mixed parameter types test case
johnslavik Jan 7, 2026
fbce76d
Do not break exception chain unnecessarily
johnslavik Jan 7, 2026
44b8bba
Improve the docstring
johnslavik Jan 7, 2026
ec01821
Add precedent case for GH-84644
johnslavik Jan 7, 2026
57faa34
Fix GH-84644 test
johnslavik Jan 7, 2026
e4fb514
Reword the documentation of `_get_dispatch_param`
johnslavik Jan 7, 2026
57965a9
Merge GH-130827 test into `test_method_type_ann_register`
johnslavik Jan 7, 2026
eadc38f
Add case this PR broke -- registering bound methods
johnslavik Jan 7, 2026
682c41e
Add bound methods to slow path
johnslavik Jan 7, 2026
c406755
Optimize instance checks in the fast path
johnslavik Jan 7, 2026
e238e6a
Use a match statement instead of a for loop
johnslavik Jan 7, 2026
1e61429
Rewrite to a try-except
johnslavik Jan 7, 2026
19458fc
Improve comment
johnslavik Jan 7, 2026
6390a82
Add more bound method tests
johnslavik Jan 7, 2026
3e33040
Reuse one instance of test class
johnslavik Jan 7, 2026
c50d344
Test instance validity in bound method tests
johnslavik Jan 7, 2026
32910f3
Tests and fixes for staticmethod
johnslavik Jan 7, 2026
4283fba
Add more tests for classmethod
johnslavik Jan 7, 2026
17b5088
Disambiguate a comment
johnslavik Jan 8, 2026
cdb7cca
Always respect descriptors, fallback to assumptions on function-like …
johnslavik Jan 8, 2026
62088c7
Add more comments
johnslavik Jan 8, 2026
c497857
Specialcase bound methods in singledispatchmethods
johnslavik Jan 8, 2026
0f75d98
Finalize the logic
johnslavik Jan 8, 2026
8350e71
Crystalize the decision tree
johnslavik Jan 8, 2026
50c0e64
Fix comment
johnslavik Jan 8, 2026
052c2fd
Better comments
johnslavik Jan 8, 2026
30994eb
Fiat lux
johnslavik Jan 8, 2026
3edad44
Rename function to `_get_singledispatch_annotated_param`
johnslavik Jan 8, 2026
fbc205e
Disambiguate comment
johnslavik Jan 8, 2026
b691969
More comments
johnslavik Jan 8, 2026
0859bc0
Add more missing tests
johnslavik Jan 8, 2026
7ac8275
Cast the `idx` to an integer explicitly
johnslavik Jan 8, 2026
fbe00f8
Check param kinds by name (code review)
johnslavik Jan 8, 2026
7ada2b0
Minime the try-except
johnslavik Jan 8, 2026
ac2f5a2
Remove all new tests
johnslavik Jan 8, 2026
ba46e43
Add previously failing tests only
johnslavik Jan 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 64 additions & 4 deletions Lib/functools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

################################################################################
Expand Down Expand Up @@ -888,6 +888,48 @@ def _find_impl(cls, registry):
match = t
return registry.get(match)

def _get_singledispatch_annotated_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.
"""
# Pick the first parameter if function had @staticmethod.
if isinstance(func, staticmethod):
idx = 0
func = func.__func__
# 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 a regular function:
# Pick the first parameter if registering via singledispatch.
# Pick the second parameter if registering via singledispatchmethod.
else:
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__"):
# 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

# Fall back to inspect.signature (slower, but complete).
import inspect
params = list(inspect.signature(func).parameters.values())
try:
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
return None

def singledispatch(func):
"""Single-dispatch generic function decorator.

Expand Down Expand Up @@ -935,7 +977,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, _inside_dispatchmethod=False):
"""generic_func.register(cls, func) -> func

Registers a new implementation for the given *cls* on a *generic_func*.
Expand All @@ -960,10 +1002,28 @@ def register(cls, func=None):
)
func = cls

argname = _get_singledispatch_annotated_param(
func, _inside_dispatchmethod=_inside_dispatchmethod)
if argname is None:
raise TypeError(
f"Invalid first argument to `register()`: {func!r} "
f"does not accept positional arguments."
)

# 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()))
annotations = get_type_hints(func, format=Format.FORWARDREF)

try:
cls = annotations[argname]
except KeyError:
raise TypeError(
f"Invalid first argument to `register()`: {func!r}. "
"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):
if isinstance(cls, UnionType):
raise TypeError(
Expand Down Expand Up @@ -1027,7 +1087,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, _inside_dispatchmethod=True)

def __get__(self, obj, cls=None):
return _singledispatchmethod_get(self, obj, cls)
Expand Down
89 changes: 76 additions & 13 deletions Lib/test/test_functools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand All @@ -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 +
"<function TestSingleDispatch.test_invalid_registrations.<locals>._"
)
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 +
"<function TestSingleDispatch.test_invalid_registrations.<locals>._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]):
Expand Down Expand Up @@ -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
Comment on lines -3451 to 3516
Copy link
Member Author

@johnslavik johnslavik Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test contained incorrect calls to singledispatchmethod.register. See #130309 (comment).

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:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
:func:`functools.singledispatch` and :func:`functools.singledispatchmethod`
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.
Loading