[Python-checkins] [3.9] bpo-45678: Fix `singledispatchmethod` `classmethod`/`staticmethod` bug (GH-29394)

ambv webhook-mailer at python.org
Thu Nov 4 15:34:22 EDT 2021


https://github.com/python/cpython/commit/effb72fa0f6f8f8ec92687fc6a29d63bbdb7e98d
commit: effb72fa0f6f8f8ec92687fc6a29d63bbdb7e98d
branch: 3.9
author: Alex Waygood <Alex.Waygood at Gmail.com>
committer: ambv <lukasz at langa.pl>
date: 2021-11-04T20:34:14+01:00
summary:

[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 <lukasz at langa.pl>

files:
A Misc/NEWS.d/next/Library/2021-11-03-17-28-43.bpo-45678.Zj_O8j.rst
M Lib/functools.py
M Lib/test/test_functools.py

diff --git a/Lib/functools.py b/Lib/functools.py
index 5054e281ad281..1a290e1c02461 100644
--- a/Lib/functools.py
+++ b/Lib/functools.py
@@ -901,6 +901,13 @@ def __init__(self, func):
         self.dispatcher = singledispatch(func)
         self.func = func
 
+        # bpo-45678: special-casing for classmethod/staticmethod in Python <=3.9,
+        # as functools.update_wrapper doesn't work properly in singledispatchmethod.__get__
+        # if it is applied to an unbound classmethod/staticmethod
+        if isinstance(func, (staticmethod, classmethod)):
+            self._wrapped_func = func.__func__
+        else:
+            self._wrapped_func = func
     def register(self, cls, method=None):
         """generic_method.register(cls, func) -> func
 
@@ -921,7 +928,7 @@ def _method(*args, **kwargs):
 
         _method.__isabstractmethod__ = self.__isabstractmethod__
         _method.register = self.register
-        update_wrapper(_method, self.func)
+        update_wrapper(_method, self._wrapped_func)
         return _method
 
     @property
diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py
index 96e93ed8eab34..b2a7e5a88b422 100644
--- a/Lib/test/test_functools.py
+++ b/Lib/test/test_functools.py
@@ -2401,7 +2401,7 @@ def _(cls, arg):
         self.assertEqual(A.t(0.0).arg, "base")
 
     def test_abstractmethod_register(self):
-        class Abstract(abc.ABCMeta):
+        class Abstract(metaclass=abc.ABCMeta):
 
             @functools.singledispatchmethod
             @abc.abstractmethod
@@ -2409,6 +2409,10 @@ def add(self, x, y):
                 pass
 
         self.assertTrue(Abstract.add.__isabstractmethod__)
+        self.assertTrue(Abstract.__dict__['add'].__isabstractmethod__)
+
+        with self.assertRaises(TypeError):
+            Abstract()
 
     def test_type_ann_register(self):
         class A:
@@ -2469,6 +2473,141 @@ def _(cls, arg: str):
         self.assertEqual(A.t('').arg, "str")
         self.assertEqual(A.t(0.0).arg, "base")
 
+    def test_method_wrapping_attributes(self):
+        class A:
+            @functools.singledispatchmethod
+            def func(self, arg: int) -> str:
+                """My function docstring"""
+                return str(arg)
+            @functools.singledispatchmethod
+            @classmethod
+            def cls_func(cls, arg: int) -> str:
+                """My function docstring"""
+                return str(arg)
+            @functools.singledispatchmethod
+            @staticmethod
+            def static_func(arg: int) -> str:
+                """My function docstring"""
+                return str(arg)
+
+        for meth in (
+            A.func,
+            A().func,
+            A.cls_func,
+            A().cls_func,
+            A.static_func,
+            A().static_func
+        ):
+            with self.subTest(meth=meth):
+                self.assertEqual(meth.__doc__, 'My function docstring')
+                self.assertEqual(meth.__annotations__['arg'], int)
+
+        self.assertEqual(A.func.__name__, 'func')
+        self.assertEqual(A().func.__name__, 'func')
+        self.assertEqual(A.cls_func.__name__, 'cls_func')
+        self.assertEqual(A().cls_func.__name__, 'cls_func')
+        self.assertEqual(A.static_func.__name__, 'static_func')
+        self.assertEqual(A().static_func.__name__, 'static_func')
+
+    def test_double_wrapped_methods(self):
+        def classmethod_friendly_decorator(func):
+            wrapped = func.__func__
+            @classmethod
+            @functools.wraps(wrapped)
+            def wrapper(*args, **kwargs):
+                return wrapped(*args, **kwargs)
+            return wrapper
+
+        class WithoutSingleDispatch:
+            @classmethod
+            @contextlib.contextmanager
+            def cls_context_manager(cls, arg: int) -> str:
+                try:
+                    yield str(arg)
+                finally:
+                    return 'Done'
+
+            @classmethod_friendly_decorator
+            @classmethod
+            def decorated_classmethod(cls, arg: int) -> str:
+                return str(arg)
+
+        class WithSingleDispatch:
+            @functools.singledispatchmethod
+            @classmethod
+            @contextlib.contextmanager
+            def cls_context_manager(cls, arg: int) -> str:
+                """My function docstring"""
+                try:
+                    yield str(arg)
+                finally:
+                    return 'Done'
+
+            @functools.singledispatchmethod
+            @classmethod_friendly_decorator
+            @classmethod
+            def decorated_classmethod(cls, arg: int) -> str:
+                """My function docstring"""
+                return str(arg)
+
+        # These are sanity checks
+        # to test the test itself is working as expected
+        with WithoutSingleDispatch.cls_context_manager(5) as foo:
+            without_single_dispatch_foo = foo
+
+        with WithSingleDispatch.cls_context_manager(5) as foo:
+            single_dispatch_foo = foo
+
+        self.assertEqual(without_single_dispatch_foo, single_dispatch_foo)
+        self.assertEqual(single_dispatch_foo, '5')
+        
+        self.assertEqual(
+            WithoutSingleDispatch.decorated_classmethod(5),
+            WithSingleDispatch.decorated_classmethod(5)
+        )
+
+        self.assertEqual(WithSingleDispatch.decorated_classmethod(5), '5')
+
+        # Behavioural checks now follow
+        for method_name in ('cls_context_manager', 'decorated_classmethod'):
+            with self.subTest(method=method_name):
+                self.assertEqual(
+                    getattr(WithSingleDispatch, method_name).__name__,
+                    getattr(WithoutSingleDispatch, method_name).__name__
+                )
+
+                self.assertEqual(
+                    getattr(WithSingleDispatch(), method_name).__name__,
+                    getattr(WithoutSingleDispatch(), method_name).__name__
+                )
+
+        for meth in (
+            WithSingleDispatch.cls_context_manager,
+            WithSingleDispatch().cls_context_manager,
+            WithSingleDispatch.decorated_classmethod,
+            WithSingleDispatch().decorated_classmethod
+        ):
+            with self.subTest(meth=meth):
+                self.assertEqual(meth.__doc__, 'My function docstring')
+                self.assertEqual(meth.__annotations__['arg'], int)
+
+        self.assertEqual(
+            WithSingleDispatch.cls_context_manager.__name__,
+            'cls_context_manager'
+        )
+        self.assertEqual(
+            WithSingleDispatch().cls_context_manager.__name__,
+            'cls_context_manager'
+        )
+        self.assertEqual(
+            WithSingleDispatch.decorated_classmethod.__name__,
+            'decorated_classmethod'
+        )
+        self.assertEqual(
+            WithSingleDispatch().decorated_classmethod.__name__,
+            'decorated_classmethod'
+        )
+
     def test_invalid_registrations(self):
         msg_prefix = "Invalid first argument to `register()`: "
         msg_suffix = (
diff --git a/Misc/NEWS.d/next/Library/2021-11-03-17-28-43.bpo-45678.Zj_O8j.rst b/Misc/NEWS.d/next/Library/2021-11-03-17-28-43.bpo-45678.Zj_O8j.rst
new file mode 100644
index 0000000000000..f00707ca0099e
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2021-11-03-17-28-43.bpo-45678.Zj_O8j.rst
@@ -0,0 +1,2 @@
+Fix bug in Python 3.9 that meant ``functools.singledispatchmethod`` failed
+to properly wrap the attributes of the target method. Patch by Alex Waygood.



More information about the Python-checkins mailing list