From 5479e0f0a4470518811463a99b726abb25fa53e9 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Mon, 9 Feb 2026 00:37:30 +0100 Subject: [PATCH 01/15] Dispatch on the second argument if generic method is instance-bindable --- Lib/functools.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 59fc2a8fbf6219..7123fe71819c63 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,12 @@ 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. + 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,7 +1094,12 @@ 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__) + if not isinstance(method, FunctionType): + args = args[1:] + else: + method = self._dispatch(args[0].__class__) if hasattr(method, "__get__"): method = method.__get__(self._obj, self._cls) return method(*args, **kwargs) From 3455cd9f6997242838a22133329f2b4994a22b34 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Mon, 9 Feb 2026 01:09:00 +0100 Subject: [PATCH 02/15] Only cut off first argument if method becomes bound --- Lib/functools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/functools.py b/Lib/functools.py index 7123fe71819c63..835c0fcec994dd 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -1096,7 +1096,7 @@ def __call__(self, /, *args, **kwargs): '1 positional argument') if self._skip_bound_arg: method = self._dispatch(args[1].__class__) - if not isinstance(method, FunctionType): + if isinstance(method, MethodType): args = args[1:] else: method = self._dispatch(args[0].__class__) From 1142577429589eb0a1b2cb341c4b8cda715995ce Mon Sep 17 00:00:00 2001 From: johnslavik Date: Mon, 9 Feb 2026 01:19:31 +0100 Subject: [PATCH 03/15] Cutoff on function, not method! --- Lib/functools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/functools.py b/Lib/functools.py index 835c0fcec994dd..b0b6772d0c2b5f 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -1096,7 +1096,7 @@ def __call__(self, /, *args, **kwargs): '1 positional argument') if self._skip_bound_arg: method = self._dispatch(args[1].__class__) - if isinstance(method, MethodType): + if isinstance(method, FunctionType): args = args[1:] else: method = self._dispatch(args[0].__class__) From d05750f5d7ba8f3b54e7d17ab95b5504f0565dca Mon Sep 17 00:00:00 2001 From: johnslavik Date: Mon, 9 Feb 2026 01:22:14 +0100 Subject: [PATCH 04/15] Cut first argument after the fact! --- Lib/functools.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index b0b6772d0c2b5f..0090f2defc1491 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 FunctionType, GenericAlias, MethodType, MappingProxyType, UnionType +from types import GenericAlias, MethodType, MappingProxyType, UnionType from _thread import RLock ################################################################################ @@ -1096,12 +1096,15 @@ def __call__(self, /, *args, **kwargs): '1 positional argument') if self._skip_bound_arg: method = self._dispatch(args[1].__class__) - if isinstance(method, FunctionType): - args = args[1:] 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) + and method.__self__ is self): + args = args[1:] return method(*args, **kwargs) def __getattr__(self, name): From b81977bb33a33617960975d6ca453fad880c6247 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Mon, 9 Feb 2026 01:22:41 +0100 Subject: [PATCH 05/15] Fix missing import --- Lib/functools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/functools.py b/Lib/functools.py index 0090f2defc1491..940beaced5adad 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 ################################################################################ From f6afaec4fff8d411e7b0438db757ffedaa440e39 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Mon, 9 Feb 2026 01:23:56 +0100 Subject: [PATCH 06/15] Fix formatting --- Lib/functools.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 940beaced5adad..64c43633ee09ce 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -1100,10 +1100,9 @@ def __call__(self, /, *args, **kwargs): 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) - and method.__self__ is self): + if (self._skip_bound_arg + and isinstance(method, MethodType) + and method.__self__ is self): args = args[1:] return method(*args, **kwargs) From 9dac0970087f1b1cea0a70db735f7a89603f7978 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Mon, 9 Feb 2026 01:33:01 +0100 Subject: [PATCH 07/15] Intentionally don't handle an edge case --- Lib/functools.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 64c43633ee09ce..86929484f1b2df 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -1101,8 +1101,7 @@ def __call__(self, /, *args, **kwargs): if hasattr(method, "__get__"): method = method.__get__(self._obj, self._cls) if (self._skip_bound_arg - and isinstance(method, MethodType) - and method.__self__ is self): + and isinstance(method, MethodType)): args = args[1:] return method(*args, **kwargs) From 354721b74ead710377a47e57176712a60edde537 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Mon, 9 Feb 2026 02:13:27 +0100 Subject: [PATCH 08/15] Add tests --- Lib/test/test_functools.py | 50 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 3801a82a610891..9de090116d1bc5 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -3005,6 +3005,56 @@ 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-144615.""" + 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. Return a partial() to workaround. + 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): From d80e5d1188e789d5afa634f5c4611e248067deb0 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Mon, 9 Feb 2026 02:13:39 +0100 Subject: [PATCH 09/15] Fix formatting --- Lib/test/test_functools.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 9de090116d1bc5..7ce2c437250ff2 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -3047,7 +3047,6 @@ def __get__(self, inst, owner): 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") From 97f23cdf66a2cfadb57981f215f4572261e2cfee Mon Sep 17 00:00:00 2001 From: johnslavik Date: Mon, 9 Feb 2026 02:16:43 +0100 Subject: [PATCH 10/15] Add news entry --- .../Library/2026-02-09-02-16-36.gh-issue-144615.s04x4n.rst | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-02-09-02-16-36.gh-issue-144615.s04x4n.rst 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. From 12cd97a100626a7130532fa586e79ebf0a4d09df Mon Sep 17 00:00:00 2001 From: johnslavik Date: Mon, 9 Feb 2026 02:22:43 +0100 Subject: [PATCH 11/15] Rewrite comments --- Lib/test/test_functools.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 7ce2c437250ff2..564319a0ce218e 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -3042,7 +3042,9 @@ 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. Return a partial() to workaround. + # as a separate argument. + # To work around this, wrap the returned bound method + # with `functools.partial`. return C().special5 generic.register(D2, D2()) From 1a24e431d785afbb2d14c98a067dc8560ea3f2bb Mon Sep 17 00:00:00 2001 From: johnslavik Date: Mon, 9 Feb 2026 02:42:13 +0100 Subject: [PATCH 12/15] Inline argument cutoff condition --- Lib/functools.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 86929484f1b2df..a5779ebf40ecc7 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -1100,8 +1100,7 @@ def __call__(self, /, *args, **kwargs): 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)): + if self._skip_bound_arg and isinstance(method, MethodType): args = args[1:] return method(*args, **kwargs) From 615c284e64174ce1699ad35b9553c73b5485c12c Mon Sep 17 00:00:00 2001 From: johnslavik Date: Mon, 9 Feb 2026 02:44:53 +0100 Subject: [PATCH 13/15] Empty commit to unstuck GitHub processing recent push From ab7d78cee78c52827b841c62c030bbbfb0f217ac Mon Sep 17 00:00:00 2001 From: johnslavik Date: Mon, 9 Feb 2026 02:50:33 +0100 Subject: [PATCH 14/15] Add correct GitHub references --- Lib/functools.py | 4 +++- Lib/test/test_functools.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index a5779ebf40ecc7..d0e6e52a2eb3c1 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -1060,12 +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. + # 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: diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 564319a0ce218e..6626abac20c09b 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -3006,7 +3006,7 @@ def static_func(arg: int) -> str: self.assertEqual(A().static_func.__name__, 'static_func') def test_method_classlevel_calls(self): - """Regression test for GH-144615.""" + """Regression test for GH-143535.""" class C: @functools.singledispatchmethod def generic(self, x: object): From dd804d2ebe47cb7a39907fd1e6b66241f628e859 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 10 Feb 2026 09:16:16 +0100 Subject: [PATCH 15/15] Make the implementation more debuggable --- Lib/functools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/functools.py b/Lib/functools.py index d0e6e52a2eb3c1..297204ce7709ae 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -1103,7 +1103,7 @@ def __call__(self, /, *args, **kwargs): if hasattr(method, "__get__"): method = method.__get__(self._obj, self._cls) if self._skip_bound_arg and isinstance(method, MethodType): - args = args[1:] + return method(*args[1:], **kwargs) return method(*args, **kwargs) def __getattr__(self, name):