Repository: python/cpython
Pull request: gh-102978: Fix mock.patch function signatures for class and staticmethod decorators
Closes #102978 

As reported in the original [issue](https://github.com/python/cpython/issues/102978), `unittest.mock.patch` does not correctly extract function signatures from methods decorated with `@classmethod` and `@staticmethod` when `patch` is called with `autospec=True`.

This fix adds a special case to the extraction logic which uses the original decorated function via `__func__` to get the correct signature.

I also updated the relevant tests.


<!-- gh-issue-number: gh-102978 -->
* Issue: gh-102978
<!-- /gh-issue-number -->


Problem statement:
unittest.mock.patch with autospec doesn't hold for classmethods nor staticmethods
# Bug report

Using `patch` with `autospec=True` does not work when a method is decorated with `@classmethod` or `@staticmethod`. The resulting mock can be called with any arguments without raising a `TypeError`.

Example:
```python
from unittest.mock import patch
import pytest

class Foo:
    def foo(self):
        pass
    @staticmethod
    def bar():
        pass
    @classmethod
    def baz(cls):
        pass

@pytest.mark.parametrize("method", ["foo", "bar", "baz"])
def test_foo(method: str):
    with patch.object(Foo, method, autospec=True):
        getattr(Foo(), method)(5)
```

The only subtest that fails is `foo`. The other two pass, even though they're clearly being called incorrectly.

If you prefer not to use `pytest` to demo/repro this:

```python
with patch.object(Foo, "foo", autospec=True):
    try:
        Foo().foo(5)
    except TypeError:
        print("Correctly raises on foo")
    else:
        print("Incorrectly does not raise with foo")

with patch.object(Foo, "bar", autospec=True):
    try:
        Foo().bar(5)
    except TypeError:
        print("Correctly raises on bar")
    else:
        print("Incorrectly does not raise with bar")

with patch.object(Foo, "baz", autospec=True):
    try:
        Foo().baz(5)
    except TypeError:
        print("Correctly raises on baz")
    else:
        print("Incorrectly does not raise with baz")
```

This has output:
```
Correctly raises on foo
Incorrectly does not raise with bar
Incorrectly does not raise with baz
```

# Your environment

- CPython versions tested on: 3.10, 3.11
- Operating system and architecture: macOS 12.6 with Apple M1 chip

<!-- gh-linked-prs -->
### Linked PRs
* gh-103228
* gh-103499
<!-- /gh-linked-prs -->

Hints from issue discussion:
Looks like the signature is not set for `@classmethod` and `@staticmethod`. Adding a special case for them and using `__func__` to get the correct signature I was able to get it to work. I'll see if I can contribute a PR that fixes this.

Commit message: Fix mock.patch function signatures for class and staticmethod decorators

Fixes unittest.mock.patch not enforcing function signatures for methods
decorated with @classmethod or @staticmethod when patch is called with
autospec=True.

Patch to review:
diff --git a/Lib/test/test_unittest/testmock/testhelpers.py b/Lib/test/test_unittest/testmock/testhelpers.py
index 9e7ec5d62d5da2..74c1df20a3947b 100644
--- a/Lib/test/test_unittest/testmock/testhelpers.py
+++ b/Lib/test/test_unittest/testmock/testhelpers.py
@@ -952,6 +952,26 @@ def __getattr__(self, attribute):
         self.assertFalse(hasattr(autospec, '__name__'))
 
 
+    def test_autospec_signature_staticmethod(self):
+        # Regression test for gh-102978
+        class Foo:
+            @staticmethod
+            def static_method(a, b=10, *, c): pass
+
+        mock = create_autospec(Foo.__dict__['static_method'])
+        self.assertEqual(inspect.signature(Foo.static_method), inspect.signature(mock))
+
+
+    def test_autospec_signature_classmethod(self):
+        # Regression test for gh-102978
+        class Foo:
+            @classmethod
+            def class_method(cls, a, b=10, *, c): pass
+
+        mock = create_autospec(Foo.__dict__['class_method'])
+        self.assertEqual(inspect.signature(Foo.class_method), inspect.signature(mock))
+
+
     def test_spec_inspect_signature(self):
 
         def myfunc(x, y): pass
diff --git a/Lib/test/test_unittest/testmock/testpatch.py b/Lib/test/test_unittest/testmock/testpatch.py
index 8ceb5d973e1aaf..4f9a1ee7495902 100644
--- a/Lib/test/test_unittest/testmock/testpatch.py
+++ b/Lib/test/test_unittest/testmock/testpatch.py
@@ -996,6 +996,38 @@ def test_autospec_classmethod(self):
             method.assert_called_once_with()
 
 
+    def test_autospec_staticmethod_signature(self):
+        # Regression test for gh-102978
+        # Patched methods which are decorated with @staticmethod should have the same signature
+        class Foo:
+            @staticmethod
+            def static_method(a, b=10, *, c): pass
+
+        Foo.static_method(1, 2, c=3)
+
+        with patch.object(Foo, 'static_method', autospec=True) as method:
+            method(1, 2, c=3)
+            self.assertRaises(TypeError, method)
+            self.assertRaises(TypeError, method, 1)
+            self.assertRaises(TypeError, method, 1, 2, 3, c=4)
+
+
+    def test_autospec_classmethod_signature(self):
+        # Regression test for gh-102978
+        # Patched methods which are decorated with @classmethod should have the same signature
+        class Foo:
+            @classmethod
+            def class_method(cls, a, b=10, *, c): pass
+
+        Foo.class_method(1, 2, c=3)
+
+        with patch.object(Foo, 'class_method', autospec=True) as method:
+            method(1, 2, c=3)
+            self.assertRaises(TypeError, method)
+            self.assertRaises(TypeError, method, 1)
+            self.assertRaises(TypeError, method, 1, 2, 3, c=4)
+
+
     def test_autospec_with_new(self):
         patcher = patch('%s.function' % __name__, new=3, autospec=True)
         self.assertRaises(TypeError, patcher.start)
diff --git a/Lib/unittest/mock.py b/Lib/unittest/mock.py
index 0f93cb53c3d5ce..7ca085760650af 100644
--- a/Lib/unittest/mock.py
+++ b/Lib/unittest/mock.py
@@ -98,6 +98,12 @@ def _get_signature_object(func, as_instance, eat_self):
         func = func.__init__
         # Skip the `self` argument in __init__
         eat_self = True
+    elif isinstance(func, (classmethod, staticmethod)):
+        if isinstance(func, classmethod):
+            # Skip the `cls` argument of a class method
+            eat_self = True
+        # Use the original decorated method to extract the correct function signature
+        func = func.__func__
     elif not isinstance(func, FunctionTypes):
         # If we really want to model an instance of the passed type,
         # __call__ should be looked up, not __init__.
diff --git a/Misc/ACKS b/Misc/ACKS
index 929e06a87cb794..d37f6d346e58eb 100644
--- a/Misc/ACKS
+++ b/Misc/ACKS
@@ -1548,6 +1548,7 @@ Hugo van Rossum
 Saskia van Rossum
 Robin Roth
 Clement Rouault
+Tomas Roun
 Donald Wallace Rouse II
 Liam Routt
 Todd Rovito
diff --git a/Misc/NEWS.d/next/Library/2023-04-03-23-44-34.gh-issue-102978.gy9eVk.rst b/Misc/NEWS.d/next/Library/2023-04-03-23-44-34.gh-issue-102978.gy9eVk.rst
new file mode 100644
index 00000000000000..df63af10a385eb
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2023-04-03-23-44-34.gh-issue-102978.gy9eVk.rst
@@ -0,0 +1,3 @@
+Fixes :func:`unittest.mock.patch` not enforcing function signatures for methods
+decorated with ``@classmethod`` or ``@staticmethod`` when patch is called with
+``autospec=True``.


Code review comments:
diff_hunk: @@ -952,6 +952,26 @@ def __getattr__(self, attribute):
         self.assertFalse(hasattr(autospec, '__name__'))
 
 
+    def test_autospec_signature_staticmethod(self):
+        # Regression test for gh-102978
path: Lib/test/test_unittest/testmock/testhelpers.py
text: @user1:
It's a small thing, but these comments don't add much and will bitrot.
```suggestion
```

diff_hunk: @@ -952,6 +952,26 @@ def __getattr__(self, attribute):
         self.assertFalse(hasattr(autospec, '__name__'))
 
 
+    def test_autospec_signature_staticmethod(self):
+        # Regression test for gh-102978
+        class Foo:
+            @staticmethod
+            def static_method(a, b=10, *, c): pass
+
+        mock = create_autospec(Foo.__dict__['static_method'])
+        self.assertEqual(inspect.signature(Foo.static_method), inspect.signature(mock))
+
+
+    def test_autospec_signature_classmethod(self):
+        # Regression test for gh-102978
path: Lib/test/test_unittest/testmock/testhelpers.py
text: @user1:
```suggestion
```