Skip to content

Commit 8c1db00

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 c71bf4d commit 8c1db00

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 types
34
import unittest
@@ -957,6 +958,45 @@ def __getattr__(self, attribute):
957958
self.assertNotHasAttr(autospec, '__name__')
958959

959960

961+
def test_autospec_partial_function_signature_enforced(self):
962+
# A mock created from a functools.partial object enforces the
963+
# partial's effective, post-partial-application signature: "method"
964+
# is already bound to "POST", so only "url" and "payload" remain.
965+
def send_request(method, url, payload=None):
966+
return method, url, payload
967+
968+
send_post_request = partial(send_request, "POST")
969+
send_post_request("https://example.com")
970+
send_post_request("https://example.com", payload="data")
971+
972+
mocked = create_autospec(send_post_request)
973+
mocked("https://example.com")
974+
mocked("https://example.com", payload="data")
975+
self.assertRaises(TypeError, mocked)
976+
self.assertRaises(TypeError, mocked, "a", "b", "c")
977+
self.assertRaises(TypeError, mocked, "https://example.com", foo="lish")
978+
979+
980+
def test_autospec_partialmethod_self_skipped(self):
981+
# _must_skip() didn't recognize functools.partialmethod class
982+
# attributes, so `self` was never dropped from the enforced
983+
# signature when autospeccing a whole class.
984+
class Foo:
985+
def method(self, a, b, c=3):
986+
return a, b, c
987+
partial_method = functools.partialmethod(method, 1)
988+
989+
real = Foo()
990+
real.partial_method(2, 3)
991+
992+
mocked = create_autospec(Foo)
993+
instance = mocked()
994+
instance.partial_method(2)
995+
instance.partial_method(2, c=4)
996+
self.assertRaises(TypeError, instance.partial_method)
997+
self.assertRaises(TypeError, instance.partial_method, 2, 3, 4)
998+
999+
9601000
def test_autospec_signature_staticmethod(self):
9611001
class Foo:
9621002
@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, LazyImportType, 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__.
@@ -2921,9 +2929,12 @@ def _must_skip(spec, entry, is_type):
29212929
continue
29222930
if isinstance(result, (staticmethod, classmethod)):
29232931
return False
2924-
elif isinstance(result, FunctionTypes):
2932+
elif isinstance(result, (FunctionTypes, partialmethod)):
29252933
# Normal method => skip if looked up on type
29262934
# (if looked up on instance, self is already skipped)
2935+
# functools.partialmethod resolves to a plain function carrying
2936+
# `self` when looked up via getattr() on the type, see
2937+
# functools.partialmethod.__get__.
29272938
return is_type
29282939
else:
29292940
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)