diff --git a/Lib/functools.py b/Lib/functools.py index 59fc2a8fbf6219..297204ce7709ae 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,14 @@ 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. + if obj is None and isinstance(func, FunctionType): + self._skip_bound_arg = True + else: + self._skip_bound_arg = False + try: self.__module__ = func.__module__ except AttributeError: @@ -1088,9 +1096,14 @@ def __call__(self, /, *args, **kwargs): 'singledispatchmethod method') raise TypeError(f'{funcname} requires at least ' '1 positional argument') - method = self._dispatch(args[0].__class__) + if self._skip_bound_arg: + method = self._dispatch(args[1].__class__) + else: + method = self._dispatch(args[0].__class__) if hasattr(method, "__get__"): method = method.__get__(self._obj, self._cls) + if self._skip_bound_arg and isinstance(method, MethodType): + 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..6626abac20c09b 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(self, 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.