Skip to content

Commit 97181bb

Browse files
johnslavikjaraco
andauthored
gh-143535: Dispatch on the second argument if generic method is instance-bindable (GH-144615)
Co-authored-by: Jason R. Coombs <jaraco@jaraco.com>
1 parent 0f759f1 commit 97181bb

File tree

4 files changed

+78
-2
lines changed

4 files changed

+78
-2
lines changed

Doc/whatsnew/3.15.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,10 @@ functools
744744
callables.
745745
(Contributed by Serhiy Storchaka in :gh:`140873`.)
746746

747+
* :func:`~functools.singledispatchmethod` now dispatches on the second argument
748+
if it wraps a regular method and is called as a class attribute.
749+
(Contributed by Bartosz Sławecki in :gh:`143535`.)
750+
747751

748752
hashlib
749753
-------

Lib/functools.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
# import weakref # Deferred to single_dispatch()
2020
from operator import itemgetter
2121
from reprlib import recursive_repr
22-
from types import GenericAlias, MethodType, MappingProxyType, UnionType
22+
from types import FunctionType, GenericAlias, MethodType, MappingProxyType, UnionType
2323
from _thread import RLock
2424

2525
################################################################################
@@ -1060,6 +1060,11 @@ def __init__(self, unbound, obj, cls):
10601060
# Set instance attributes which cannot be handled in __getattr__()
10611061
# because they conflict with type descriptors.
10621062
func = unbound.func
1063+
1064+
# Dispatch on the second argument if a generic method turns into
1065+
# a bound method on instance-level access. See GH-143535.
1066+
self._dispatch_arg_index = 1 if obj is None and isinstance(func, FunctionType) else 0
1067+
10631068
try:
10641069
self.__module__ = func.__module__
10651070
except AttributeError:
@@ -1088,9 +1093,22 @@ def __call__(self, /, *args, **kwargs):
10881093
'singledispatchmethod method')
10891094
raise TypeError(f'{funcname} requires at least '
10901095
'1 positional argument')
1091-
method = self._dispatch(args[0].__class__)
1096+
method = self._dispatch(args[self._dispatch_arg_index].__class__)
1097+
10921098
if hasattr(method, "__get__"):
1099+
# If the method is a descriptor, it might be necessary
1100+
# to drop the first argument before calling
1101+
# as it can be no longer expected after descriptor access.
1102+
skip_bound_arg = False
1103+
if isinstance(method, staticmethod):
1104+
skip_bound_arg = self._dispatch_arg_index == 1
1105+
10931106
method = method.__get__(self._obj, self._cls)
1107+
if isinstance(method, MethodType):
1108+
skip_bound_arg = self._dispatch_arg_index == 1
1109+
1110+
if skip_bound_arg:
1111+
return method(*args[1:], **kwargs)
10941112
return method(*args, **kwargs)
10951113

10961114
def __getattr__(self, name):

Lib/test/test_functools.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3005,6 +3005,57 @@ def static_func(arg: int) -> str:
30053005
self.assertEqual(A.static_func.__name__, 'static_func')
30063006
self.assertEqual(A().static_func.__name__, 'static_func')
30073007

3008+
def test_method_classlevel_calls(self):
3009+
"""Regression test for GH-143535."""
3010+
class C:
3011+
@functools.singledispatchmethod
3012+
def generic(self, x: object):
3013+
return "generic"
3014+
3015+
@generic.register
3016+
def special1(self, x: int):
3017+
return "special1"
3018+
3019+
@generic.register
3020+
@classmethod
3021+
def special2(self, x: float):
3022+
return "special2"
3023+
3024+
@generic.register
3025+
@staticmethod
3026+
def special3(x: complex):
3027+
return "special3"
3028+
3029+
def special4(self, x):
3030+
return "special4"
3031+
3032+
class D1:
3033+
def __get__(self, _, owner):
3034+
return lambda inst, x: owner.special4(inst, x)
3035+
3036+
generic.register(D1, D1())
3037+
3038+
def special5(self, x):
3039+
return "special5"
3040+
3041+
class D2:
3042+
def __get__(self, inst, owner):
3043+
# Different instance bound to the returned method
3044+
# doesn't cause it to receive the original instance
3045+
# as a separate argument.
3046+
# To work around this, wrap the returned bound method
3047+
# with `functools.partial`.
3048+
return C().special5
3049+
3050+
generic.register(D2, D2())
3051+
3052+
self.assertEqual(C.generic(C(), "foo"), "generic")
3053+
self.assertEqual(C.generic(C(), 1), "special1")
3054+
self.assertEqual(C.generic(C(), 2.0), "special2")
3055+
self.assertEqual(C.generic(C(), 3j), "special3")
3056+
self.assertEqual(C.generic(C(), C.D1()), "special4")
3057+
self.assertEqual(C.generic(C(), C.D2()), "special5")
3058+
30083059
def test_method_repr(self):
30093060
class Callable:
30103061
def __call__(self, *args):
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Methods directly decorated with :deco:`functools.singledispatchmethod` now
2+
dispatch on the second argument when called after being accessed as class
3+
attributes. Patch by Bartosz Sławecki.

0 commit comments

Comments
 (0)