Skip to content

Commit fdff8bf

Browse files
committed
[3.9] bpo-45678: Fix singledispatchmethod classmethod/staticmethod bug
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 #python#29328 and python#29390.
1 parent 3a93c66 commit fdff8bf

File tree

3 files changed

+148
-2
lines changed

3 files changed

+148
-2
lines changed

Lib/functools.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,15 @@ 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+
self._wrapped_func = (
908+
func.__func__
909+
if isinstance(func, (staticmethod, classmethod))
910+
else func
911+
)
912+
904913
def register(self, cls, method=None):
905914
"""generic_method.register(cls, func) -> func
906915
@@ -921,7 +930,7 @@ def _method(*args, **kwargs):
921930

922931
_method.__isabstractmethod__ = self.__isabstractmethod__
923932
_method.register = self.register
924-
update_wrapper(_method, self.func)
933+
update_wrapper(_method, self._wrapped_func)
925934
return _method
926935

927936
@property

Lib/test/test_functools.py

Lines changed: 136 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,137 @@ 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+
try:
2536+
with WithoutSingleDispatch.cls_context_manager(3) as foo:
2537+
assert foo == '3', (
2538+
"Classmethod contextmanager called from class not working"
2539+
)
2540+
with WithoutSingleDispatch().cls_context_manager(3) as bar:
2541+
assert bar == '3', (
2542+
"Classmethod contextmanager called from instance not working"
2543+
)
2544+
assert WithoutSingleDispatch.decorated_classmethod(666) == '666', (
2545+
"Wrapped classmethod called from class not working"
2546+
)
2547+
assert WithoutSingleDispatch().decorated_classmethod(666) == '666', (
2548+
"Wrapped classmethod called from instance not working"
2549+
)
2550+
except AssertionError as e:
2551+
self.fail(f"There's a bug in this test: '{e}'")
2552+
2553+
class A:
2554+
@functools.singledispatchmethod
2555+
@classmethod
2556+
@contextlib.contextmanager
2557+
def cls_context_manager(cls, arg: int) -> str:
2558+
"""My function docstring"""
2559+
try:
2560+
yield str(arg)
2561+
finally:
2562+
return 'Done'
2563+
2564+
@functools.singledispatchmethod
2565+
@classmethod_friendly_decorator
2566+
@classmethod
2567+
def decorated_classmethod(cls, arg: int) -> str:
2568+
"""My function docstring"""
2569+
return str(arg)
2570+
2571+
with WithoutSingleDispatch.cls_context_manager(5) as foo:
2572+
without_single_dispatch_foo = foo
2573+
2574+
with A.cls_context_manager(5) as foo:
2575+
single_dispatch_foo = foo
2576+
2577+
self.assertEqual(without_single_dispatch_foo, single_dispatch_foo)
2578+
self.assertEqual(single_dispatch_foo, '5')
2579+
2580+
for method_name in ('cls_context_manager', 'decorated_classmethod'):
2581+
with self.subTest(method=method_name):
2582+
self.assertEqual(
2583+
getattr(A, method_name).__name__,
2584+
getattr(WithoutSingleDispatch, method_name).__name__
2585+
)
2586+
2587+
self.assertEqual(
2588+
getattr(A(), method_name).__name__,
2589+
getattr(WithoutSingleDispatch(), method_name).__name__
2590+
)
2591+
2592+
for meth in (
2593+
A.cls_context_manager,
2594+
A().cls_context_manager,
2595+
A.decorated_classmethod,
2596+
A().decorated_classmethod
2597+
):
2598+
with self.subTest(meth=meth):
2599+
self.assertEqual(meth.__doc__, 'My function docstring')
2600+
self.assertEqual(meth.__annotations__['arg'], int)
2601+
2602+
self.assertEqual(A.cls_context_manager.__name__, 'cls_context_manager')
2603+
self.assertEqual(A().cls_context_manager.__name__, 'cls_context_manager')
2604+
self.assertEqual(A.decorated_classmethod.__name__, 'decorated_classmethod')
2605+
self.assertEqual(A().decorated_classmethod.__name__, 'decorated_classmethod')
2606+
24722607
def test_invalid_registrations(self):
24732608
msg_prefix = "Invalid first argument to `register()`: "
24742609
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)