Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
87 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
3995e79
Merge branch 'main' into fix-singledispatch-annotation-parsing
johnslavik Jan 12, 2026
48d1bde
Use imperative form in the docstring
johnslavik Jan 19, 2026
fc5df46
Raise in `_get_singledispatch_annotated_param`
johnslavik Jan 20, 2026
7fcf4d5
Rename the ugly `_inside_dispatchmethod` to `__role__`
johnslavik Jan 20, 2026
f7ec61d
Hide private params from `.register` using `__text_signature__`
johnslavik Jan 20, 2026
946ccb8
Remove comments that are sorta obvious
johnslavik Jan 20, 2026
2d1180a
Make the private helper tighter
johnslavik Jan 20, 2026
cd10e91
Condense fast path comment
johnslavik Jan 20, 2026
0a622fb
Bring back comments but make them actually helpful
johnslavik Jan 20, 2026
9b2d20d
Another comment improvement
johnslavik Jan 20, 2026
9191a22
Fix incorrect comment
johnslavik Jan 20, 2026
ef74667
Restore old tests
johnslavik Jan 20, 2026
b55de76
Rework registration tests
johnslavik Jan 20, 2026
9977251
Use stars for referring to param names
johnslavik Jan 20, 2026
d282c9a
Use `__role__` name only in the `register()` signature
johnslavik Jan 20, 2026
4c43a9c
Denote the issues covered
johnslavik Jan 20, 2026
3b5a410
Add regrtest from @pR0Ps
pR0Ps Jan 20, 2026
f6ce233
Somewhat better comment
johnslavik Jan 20, 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
59 changes: 55 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,44 @@ def _find_impl(cls, registry):
match = t
return registry.get(match)

def _get_singledispatch_annotated_param(func, *, role):
"""Find the first positional and user-specified parameter in a callable
or descriptor.

Used by singledispatch for registration by type annotation of the parameter.
"""
if isinstance(func, staticmethod):
idx = 0 # Take the very first parameter.
func = func.__func__
elif isinstance(func, (classmethod, MethodType)):
idx = 1 # Skip *cls* or *self*.
func = func.__func__
else:
# Skip *self* when called from `singledispatchmethod.register`.
idx = 0 if role == "function" else 1
# Fast path: emulate `inspect._signature_from_function` if possible.
if isinstance(func, FunctionType) and not hasattr(func, "__wrapped__"):
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
raise TypeError(
f"Invalid first argument to `register()`: {func!r} "
f"does not accept positional arguments."
)

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

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

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

argname = _get_singledispatch_annotated_param(func, role=__role__)

# 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 @@ -1000,6 +1050,7 @@ def wrapper(*args, **kw):
funcname = getattr(func, '__name__', 'singledispatch function')
registry[object] = func
wrapper.register = register
wrapper.register.__text_signature__ = "(cls, func)" # Hide private parameters from help().
wrapper.dispatch = dispatch
wrapper.registry = MappingProxyType(registry)
wrapper._clear_cache = dispatch_cache.clear
Expand Down Expand Up @@ -1027,7 +1078,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, __role__="method")

def __get__(self, obj, cls=None):
return _singledispatchmethod_get(self, obj, cls)
Expand Down
154 changes: 153 additions & 1 deletion Lib/test/test_functools.py
Original file line number Diff line number Diff line change
Expand Up @@ -2721,6 +2721,22 @@ def __eq__(self, other):
return self.arg == other
self.assertEqual(i("str"), "str")

def test_annotations_positional_only(self):
"""Regression test for GH-143888."""
@functools.singledispatch
def f(arg, /, extra):
return "base"
@f.register
def f_int(arg: int, /, extra: str):
return "int"
@f.register
def f_str(arg: str, /, extra: int):
return "str"

self.assertEqual(f(None, "extra"), "base")
self.assertEqual(f(1, "extra"), "int")
self.assertEqual(f("s", "extra"), "str")

def test_method_register(self):
class A:
@functools.singledispatchmethod
Expand Down Expand Up @@ -2955,6 +2971,44 @@ def _(cls, arg: str):
self.assertEqual(A.t('').arg, "str")
self.assertEqual(A.t(0.0).arg, "base")

def test_method_type_ann_register_correct_param_skipped(self):
class C:
@functools.singledispatchmethod
def t(self, x):
return "base"

# This tests GH-130827.
@t.register
def _(self: typing.Self, x: int) -> str:
return "int"

@t.register
@classmethod
def _(self: type['C'], x: complex) -> str:
return "complex"

@t.register
@staticmethod # 'x' cannot be skipped.
def _(x: float) -> str:
return "float"

def _bytes(self: typing.Self, x: bytes) -> None:
return "bytes"

def _bytearray(self: typing.Self, x: bytearray) -> None:
return "bytearray"

c = C()
C.t.register(c._bytes)
c.t.register(C._bytearray)

self.assertEqual(c.t(NotImplemented), "base")
self.assertEqual(c.t(42), "int")
self.assertEqual(c.t(1991j), "complex")
self.assertEqual(c.t(.572), "float")
self.assertEqual(c.t(b'ytes'), "bytes")
self.assertEqual(c.t(bytearray(3)), "bytearray")

def test_method_wrapping_attributes(self):
class A:
@functools.singledispatchmethod
Expand Down Expand Up @@ -3175,7 +3229,7 @@ def i(arg):
with self.assertRaises(TypeError) as exc:
@i.register(42)
def _(arg):
return "I annotated with a non-type"
return "I passed a non-type"
self.assertStartsWith(str(exc.exception), msg_prefix + "42")
self.assertEndsWith(str(exc.exception), msg_suffix)
with self.assertRaises(TypeError) as exc:
Expand All @@ -3187,6 +3241,10 @@ def _(arg):
)
self.assertEndsWith(str(exc.exception), msg_suffix)

def test_type_ann_register_invalid_types(self):
@functools.singledispatch
def i(arg):
return "base"
with self.assertRaises(TypeError) as exc:
@i.register
def _(arg: typing.Iterable[str]):
Expand All @@ -3213,6 +3271,100 @@ def _(arg: typing.Union[int, typing.Iterable[str]]):
'int | typing.Iterable[str] not all arguments are classes.'
)

def test_type_ann_register_missing_annotation(self):
add_missing_re = (
r"Invalid first argument to `register\(\)`: <function .+>. "
r"Use either `@register\(some_class\)` or add a type annotation "
r"to parameter 'arg' of your callable."
)
no_positional_re = (
r"Invalid first argument to `register\(\)`: <function .+> "
r"does not accept positional arguments."
)

@functools.singledispatch
def d(arg):
pass

with self.assertRaisesRegex(TypeError, add_missing_re):
# This tests GH-84644.
@d.register
def _(arg) -> int:
"""I only annotated the return type."""
return 42

with self.assertRaisesRegex(TypeError, add_missing_re):
@d.register
def _(arg, /, arg2) -> int:
"""I did not annotate the first param."""
return 42

with self.assertRaisesRegex(TypeError, no_positional_re):
@d.register
def _(*, arg: int = 13, arg2: int = 37) -> int:
"""I do not accept positional arguments."""
return 42

with self.assertRaisesRegex(TypeError, add_missing_re):
@d.register
def _(arg, **kwargs: int):
"""I only annotated keyword arguments type."""
return 42

def test_method_type_ann_register_missing_annotation(self):
add_missing_re = (
r"Invalid first argument to `register\(\)`: <%s.+>. "
r"Use either `@register\(some_class\)` or add a type annotation "
r"to parameter 'arg' of your callable."
)
no_positional_re = (
r"Invalid first argument to `register\(\)`: <%s.+> "
r"does not accept positional arguments."
)

class C:
@functools.singledispatchmethod
def d(self, arg):
return "base"

with self.assertRaisesRegex(TypeError, no_positional_re % "function"):
@d.register
def _() -> None:
"""I am not a incorrect method."""
return 42

with self.assertRaisesRegex(TypeError, no_positional_re % "function"):
@d.register
def _(self: typing.Self):
"""I only take self."""
return 42

with self.assertRaisesRegex(TypeError, no_positional_re % "function"):
@d.register
def _(self: typing.Self, *, arg):
"""I did not annotate the key parameter."""
return 42

with self.assertRaisesRegex(TypeError, add_missing_re % "classmethod"):
@d.register
@classmethod
def _(cls: type[typing.Self], arg) -> int:
"""I did not annotate the key parameter again."""
return 42

with self.assertRaisesRegex(TypeError, add_missing_re % "staticmethod"):
@d.register
@staticmethod
def _(arg, arg2: int, /, *, arg3: int = 1991):
"""I missed first arg again."""
return 42

def later(self, arg, **kwargs: int):
return 42

with self.assertRaisesRegex(TypeError, add_missing_re % "bound method"):
C.d.register(C().later)

def test_invalid_positional_argument(self):
@functools.singledispatch
def f(*args, **kwargs):
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