Skip to content

Commit effb72f

Browse files
AlexWaygoodambv
andauthored
[3.9] bpo-45678: Fix singledispatchmethod classmethod/staticmethod bug (GH-29394)
This PR fixes a bug in the 3.9 branch where ``functools.singledispatchmethod`` did not properly wrap attributes such as ``__name__``, ``__doc__`` and ``__module__`` of the target method. It also backports tests already merged into the 3.11 and 3.10 branches in #29328 and #29390. Co-authored-by: Łukasz Langa <[email protected]>
1 parent 9a4604b commit effb72f

File tree

3 files changed

+150
-2
lines changed

3 files changed

+150
-2
lines changed

Lib/functools.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,13 @@ def __init__(self, func):
901901
self.dispatcher = singledispatch(func)
902902
self.func = func
903903

904+
# bpo-45678: special-casing for classmethod/staticmethod in Python <=3.9,
905+
# as functools.update_wrapper doesn't work properly in singledispatchmethod.__get__
906+
# if it is applied to an unbound classmethod/staticmethod
907+
if isinstance(func, (staticmethod, classmethod)):
908+
self._wrapped_func = func.__func__
909+
else:
910+
self._wrapped_func = func
904911
def register(self, cls, method=None):
905912
"""generic_method.register(cls, func) -> func
906913
@@ -921,7 +928,7 @@ def _method(*args, **kwargs):
921928

922929
_method.__isabstractmethod__ = self.__isabstractmethod__
923930
_method.register = self.register
924-
update_wrapper(_method, self.func)
931+
update_wrapper(_method, self._wrapped_func)
925932
return _method
926933

927934
@property

Lib/test/test_functools.py

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2401,14 +2401,18 @@ def _(cls, arg):
24012401
self.assertEqual(A.t(0.0).arg, "base")
24022402

24032403
def test_abstractmethod_register(self):
2404-
class Abstract(abc.ABCMeta):
2404+
class Abstract(metaclass=abc.ABCMeta):
24052405

24062406
@functools.singledispatchmethod
24072407
@abc.abstractmethod
24082408
def add(self, x, y):
24092409
pass
24102410

24112411
self.assertTrue(Abstract.add.__isabstractmethod__)
2412+
self.assertTrue(Abstract.__dict__['add'].__isabstractmethod__)
2413+
2414+
with self.assertRaises(TypeError):
2415+
Abstract()
24122416

24132417
def test_type_ann_register(self):
24142418
class A:
@@ -2469,6 +2473,141 @@ def _(cls, arg: str):
24692473
self.assertEqual(A.t('').arg, "str")
24702474
self.assertEqual(A.t(0.0).arg, "base")
24712475

2476+
def test_method_wrapping_attributes(self):
2477+
class A:
2478+
@functools.singledispatchmethod
2479+
def func(self, arg: int) -> str:
2480+
"""My function docstring"""
2481+
return str(arg)
2482+
@functools.singledispatchmethod
2483+
@classmethod
2484+
def cls_func(cls, arg: int) -> str:
2485+
"""My function docstring"""
2486+
return str(arg)
2487+
@functools.singledispatchmethod
2488+
@staticmethod
2489+
def static_func(arg: int) -> str:
2490+
"""My function docstring"""
2491+
return str(arg)
2492+
2493+
for meth in (
2494+
A.func,
2495+
A().func,
2496+
A.cls_func,
2497+
A().cls_func,
2498+
A.static_func,
2499+
A().static_func
2500+
):
2501+
with self.subTest(meth=meth):
2502+
self.assertEqual(meth.__doc__, 'My function docstring')
2503+
self.assertEqual(meth.__annotations__['arg'], int)
2504+
2505+
self.assertEqual(A.func.__name__, 'func')
2506+
self.assertEqual(A().func.__name__, 'func')
2507+
self.assertEqual(A.cls_func.__name__, 'cls_func')
2508+
self.assertEqual(A().cls_func.__name__, 'cls_func')
2509+
self.assertEqual(A.static_func.__name__, 'static_func')
2510+
self.assertEqual(A().static_func.__name__, 'static_func')
2511+
2512+
def test_double_wrapped_methods(self):
2513+
def classmethod_friendly_decorator(func):
2514+
wrapped = func.__func__
2515+
@classmethod
2516+
@functools.wraps(wrapped)
2517+
def wrapper(*args, **kwargs):
2518+
return wrapped(*args, **kwargs)
2519+
return wrapper
2520+
2521+
class WithoutSingleDispatch:
2522+
@classmethod
2523+
@contextlib.contextmanager
2524+
def cls_context_manager(cls, arg: int) -> str:
2525+
try:
2526+
yield str(arg)
2527+
finally:
2528+
return 'Done'
2529+
2530+
@classmethod_friendly_decorator
2531+
@classmethod
2532+
def decorated_classmethod(cls, arg: int) -> str:
2533+
return str(arg)
2534+
2535+
class WithSingleDispatch:
2536+
@functools.singledispatchmethod
2537+
@classmethod
2538+
@contextlib.contextmanager
2539+
def cls_context_manager(cls, arg: int) -> str:
2540+
"""My function docstring"""
2541+
try:
2542+
yield str(arg)
2543+
finally:
2544+
return 'Done'
2545+
2546+
@functools.singledispatchmethod
2547+
@classmethod_friendly_decorator
2548+
@classmethod
2549+
def decorated_classmethod(cls, arg: int) -> str:
2550+
"""My function docstring"""
2551+
return str(arg)
2552+
2553+
# These are sanity checks
2554+
# to test the test itself is working as expected
2555+
with WithoutSingleDispatch.cls_context_manager(5) as foo:
2556+
without_single_dispatch_foo = foo
2557+
2558+
with WithSingleDispatch.cls_context_manager(5) as foo:
2559+
single_dispatch_foo = foo
2560+
2561+
self.assertEqual(without_single_dispatch_foo, single_dispatch_foo)
2562+
self.assertEqual(single_dispatch_foo, '5')
2563+
2564+
self.assertEqual(
2565+
WithoutSingleDispatch.decorated_classmethod(5),
2566+
WithSingleDispatch.decorated_classmethod(5)
2567+
)
2568+
2569+
self.assertEqual(WithSingleDispatch.decorated_classmethod(5), '5')
2570+
2571+
# Behavioural checks now follow
2572+
for method_name in ('cls_context_manager', 'decorated_classmethod'):
2573+
with self.subTest(method=method_name):
2574+
self.assertEqual(
2575+
getattr(WithSingleDispatch, method_name).__name__,
2576+
getattr(WithoutSingleDispatch, method_name).__name__
2577+
)
2578+
2579+
self.assertEqual(
2580+
getattr(WithSingleDispatch(), method_name).__name__,
2581+
getattr(WithoutSingleDispatch(), method_name).__name__
2582+
)
2583+
2584+
for meth in (
2585+
WithSingleDispatch.cls_context_manager,
2586+
WithSingleDispatch().cls_context_manager,
2587+
WithSingleDispatch.decorated_classmethod,
2588+
WithSingleDispatch().decorated_classmethod
2589+
):
2590+
with self.subTest(meth=meth):
2591+
self.assertEqual(meth.__doc__, 'My function docstring')
2592+
self.assertEqual(meth.__annotations__['arg'], int)
2593+
2594+
self.assertEqual(
2595+
WithSingleDispatch.cls_context_manager.__name__,
2596+
'cls_context_manager'
2597+
)
2598+
self.assertEqual(
2599+
WithSingleDispatch().cls_context_manager.__name__,
2600+
'cls_context_manager'
2601+
)
2602+
self.assertEqual(
2603+
WithSingleDispatch.decorated_classmethod.__name__,
2604+
'decorated_classmethod'
2605+
)
2606+
self.assertEqual(
2607+
WithSingleDispatch().decorated_classmethod.__name__,
2608+
'decorated_classmethod'
2609+
)
2610+
24722611
def test_invalid_registrations(self):
24732612
msg_prefix = "Invalid first argument to `register()`: "
24742613
msg_suffix = (
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix bug in Python 3.9 that meant ``functools.singledispatchmethod`` failed
2+
to properly wrap the attributes of the target method. Patch by Alex Waygood.

0 commit comments

Comments
 (0)