Skip to content

Commit 4eab6df

Browse files
committed
gh-79644: Fix create_autospec signature handling for functools partial and partialmethod
Fixes both cases reported in gh-79644: 1. functools.partial: _get_signature_object() fell through to inspecting func.__call__ for anything that wasn't a plain function / method. For a functools.partial, that resolves to the C slot wrapper's own generic "(*args, **kwargs)" signature rather than the partial's effective, post-partial-application one. inspect.signature() already has first-class support for computing a functools.partial's effective signature directly; skip the func.__call__ detour and let it do so. 2. functools.partialmethod: _must_skip() special-cases staticmethod / classmethod / plain functions to decide whether `self` should be dropped from an autospecced method's signature, but had no branch for functools.partialmethod. The raw partialmethod object stored in klass.__dict__ matched none of those cases and fell through to the "not a method" branch, always returning False regardless of `is_type`. The resolved attribute used to build the child mock's signature (obtained via getattr(), which goes through the descriptor protocol) is a plain function whose signature still includes `self` when accessed unbound at the class level, so without this fix self's position was checked against the partialmethod's own pre-bound argument instead of being skipped. Signed-off-by: Claudiu Belu <cbelu@cloudbasesolutions.com>
1 parent c98e984 commit 4eab6df

3 files changed

Lines changed: 62 additions & 2 deletions

File tree

Lib/test/test_unittest/testmock/testhelpers.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import functools
12
import inspect
23
import time
34
import types
@@ -954,6 +955,45 @@ def __getattr__(self, attribute):
954955
self.assertNotHasAttr(autospec, '__name__')
955956

956957

958+
def test_autospec_partial_function_signature_enforced(self):
959+
# A mock created from a functools.partial object enforces the
960+
# partial's effective, post-partial-application signature: "method"
961+
# is already bound to "POST", so only "url" and "payload" remain.
962+
def send_request(method, url, payload=None):
963+
return method, url, payload
964+
965+
send_post_request = partial(send_request, "POST")
966+
send_post_request("https://example.com")
967+
send_post_request("https://example.com", payload="data")
968+
969+
mocked = create_autospec(send_post_request)
970+
mocked("https://example.com")
971+
mocked("https://example.com", payload="data")
972+
self.assertRaises(TypeError, mocked)
973+
self.assertRaises(TypeError, mocked, "a", "b", "c")
974+
self.assertRaises(TypeError, mocked, "https://example.com", foo="lish")
975+
976+
977+
def test_autospec_partialmethod_self_skipped(self):
978+
# _must_skip() didn't recognize functools.partialmethod class
979+
# attributes, so `self` was never dropped from the enforced
980+
# signature when autospeccing a whole class.
981+
class Foo:
982+
def method(self, a, b, c=3):
983+
return a, b, c
984+
partial_method = functools.partialmethod(method, 1)
985+
986+
real = Foo()
987+
real.partial_method(2, 3)
988+
989+
mocked = create_autospec(Foo)
990+
instance = mocked()
991+
instance.partial_method(2)
992+
instance.partial_method(2, c=4)
993+
self.assertRaises(TypeError, instance.partial_method)
994+
self.assertRaises(TypeError, instance.partial_method, 2, 3, 4)
995+
996+
957997
def test_autospec_signature_staticmethod(self):
958998
class Foo:
959999
@staticmethod

Lib/unittest/mock.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
from dataclasses import fields, is_dataclass
3838
from types import CodeType, ModuleType, MethodType
3939
from unittest.util import safe_repr
40-
from functools import wraps, partial
40+
from functools import partialmethod, wraps, partial
4141
from threading import RLock
4242

4343

@@ -107,6 +107,14 @@ def _get_signature_object(func, as_instance, eat_self):
107107
eat_self = True
108108
# Use the original decorated method to extract the correct function signature
109109
func = func.__func__
110+
elif isinstance(func, partial):
111+
# inspect.signature() already knows how to compute the effective,
112+
# post-partial-application signature of a functools.partial object
113+
# directly. Don't fall through to the generic func.__call__ lookup
114+
# below: partial.__call__ is a C slot wrapper whose own signature is
115+
# just the unenforceable "(*args, **kwargs)" of the functools.partial
116+
# constructor, not of the wrapped callable.
117+
pass
110118
elif not isinstance(func, FunctionTypes):
111119
# If we really want to model an instance of the passed type,
112120
# __call__ should be looked up, not __init__.
@@ -2917,9 +2925,12 @@ def _must_skip(spec, entry, is_type):
29172925
continue
29182926
if isinstance(result, (staticmethod, classmethod)):
29192927
return False
2920-
elif isinstance(result, FunctionTypes):
2928+
elif isinstance(result, (FunctionTypes, partialmethod)):
29212929
# Normal method => skip if looked up on type
29222930
# (if looked up on instance, self is already skipped)
2931+
# functools.partialmethod resolves to a plain function carrying
2932+
# `self` when looked up via getattr() on the type, see
2933+
# functools.partialmethod.__get__.
29232934
return is_type
29242935
else:
29252936
return False
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Fix :func:`unittest.mock.create_autospec` (and ``mock.patch(...,
2+
autospec=True)``) computing the wrong signature for :class:`functools.partial`
3+
and :class:`functools.partialmethod` targets. A ``functools.partial`` mock
4+
previously accepted calls with any number of arguments, since its
5+
effective, post-partial-application signature was never consulted. A
6+
``functools.partialmethod`` class attribute, when autospecced as part of a
7+
whole class, previously had its ``self`` parameter checked against the
8+
partialmethod's own pre-bound argument instead of being skipped, so calls
9+
were validated against the wrong signature shape.

0 commit comments

Comments
 (0)