diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 68fa10b0c08d17..b385c4c75b635d 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -605,6 +605,10 @@ functools callables. (Contributed by Serhiy Storchaka in :gh:`140873`.) +* :func:`~functools.singledispatchmethod` now dispatches on the second argument + if it wraps a regular method and is called as a class attribute. + (Contributed by Bartosz Sławecki in :gh:`143535`.) + hashlib ------- diff --git a/Lib/functools.py b/Lib/functools.py index 59fc2a8fbf6219..9bc2ee7e8c894c 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 ################################################################################ @@ -1060,6 +1060,11 @@ def __init__(self, unbound, obj, cls): # Set instance attributes which cannot be handled in __getattr__() # because they conflict with type descriptors. func = unbound.func + + # Dispatch on the second argument if a generic method turns into + # a bound method on instance-level access. See GH-143535. + self._dispatch_arg_index = 1 if obj is None and isinstance(func, FunctionType) else 0 + try: self.__module__ = func.__module__ except AttributeError: @@ -1088,9 +1093,22 @@ def __call__(self, /, *args, **kwargs): 'singledispatchmethod method') raise TypeError(f'{funcname} requires at least ' '1 positional argument') - method = self._dispatch(args[0].__class__) + method = self._dispatch(args[self._dispatch_arg_index].__class__) + if hasattr(method, "__get__"): + # If the method is a descriptor, it might be necessary + # to drop the first argument before calling + # as it can be no longer expected after descriptor access. + skip_bound_arg = False + if isinstance(method, staticmethod): + skip_bound_arg = self._dispatch_arg_index == 1 + method = method.__get__(self._obj, self._cls) + if isinstance(method, MethodType): + skip_bound_arg = self._dispatch_arg_index == 1 + + if skip_bound_arg: + return method(*args[1:], **kwargs) return method(*args, **kwargs) def __getattr__(self, name): diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 3801a82a610891..86652b7fa4d6df 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -3005,6 +3005,57 @@ def static_func(arg: int) -> str: self.assertEqual(A.static_func.__name__, 'static_func') self.assertEqual(A().static_func.__name__, 'static_func') + def test_method_classlevel_calls(self): + """Regression test for GH-143535.""" + class C: + @functools.singledispatchmethod + def generic(self, x: object): + return "generic" + + @generic.register + def special1(self, x: int): + return "special1" + + @generic.register + @classmethod + def special2(self, x: float): + return "special2" + + @generic.register + @staticmethod + def special3(x: complex): + return "special3" + + def special4(self, x): + return "special4" + + class D1: + def __get__(self, _, owner): + return lambda inst, x: owner.special4(inst, x) + + generic.register(D1, D1()) + + def special5(self, x): + return "special5" + + class D2: + def __get__(self, inst, owner): + # Different instance bound to the returned method + # doesn't cause it to receive the original instance + # as a separate argument. + # To work around this, wrap the returned bound method + # with `functools.partial`. + return C().special5 + + generic.register(D2, D2()) + + self.assertEqual(C.generic(C(), "foo"), "generic") + self.assertEqual(C.generic(C(), 1), "special1") + self.assertEqual(C.generic(C(), 2.0), "special2") + self.assertEqual(C.generic(C(), 3j), "special3") + self.assertEqual(C.generic(C(), C.D1()), "special4") + self.assertEqual(C.generic(C(), C.D2()), "special5") + def test_method_repr(self): class Callable: def __call__(self, *args): diff --git a/Misc/NEWS.d/next/Library/2026-02-09-02-16-36.gh-issue-144615.s04x4n.rst b/Misc/NEWS.d/next/Library/2026-02-09-02-16-36.gh-issue-144615.s04x4n.rst new file mode 100644 index 00000000000000..1db257ae312e84 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-02-09-02-16-36.gh-issue-144615.s04x4n.rst @@ -0,0 +1,3 @@ +Methods directly decorated with :deco:`functools.singledispatchmethod` now +dispatch on the second argument when called after being accessed as class +attributes. Patch by Bartosz Sławecki.