From 59250bcde943b0fb3abe1c59ef7cf06912fef88a Mon Sep 17 00:00:00 2001 From: Claudiu Belu Date: Wed, 7 Jun 2017 04:34:52 -0700 Subject: [PATCH 1/3] bpo-30587: Adds autospec argument for Mock / MagicMock / AsyncMock Mock can accept a spec object / class as an argument, making sure that accessing attributes that do not exist in the spec will cause an AttributeError to be raised, but there is no guarantee that the spec's methods signatures are respected in any way. This creates the possibility to have faulty code with passing unittests and assertions. Example: from unittest import mock class Something(object): def foo(self, a, b, c, d): pass m = mock.Mock(spec=Something) m.foo() Adds the autospec argument to NonCallableMock and its mock_add_spec method. These changes are inherited by Mock, MagicMock, AsyncMock. Passes the spec's attribute with the same name to the child mock (spec-ing the child), if the mock's autospec is True. Sets _mock_check_sig only if the given spec is used via autospec, so that passing plain spec/spec_set does not change existing signature-checking behavior. Adds unit tests to validate the fact that the autospecced method signatures are enforced. Signed-off-by: Claudiu Belu --- Lib/test/test_unittest/testmock/testmock.py | 90 +++++++++++++++++++ Lib/unittest/mock.py | 42 +++++++-- .../2017-11-20-06-36-00.bpo-30587.1SsAy9.rst | 3 + 3 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2017-11-20-06-36-00.bpo-30587.1SsAy9.rst diff --git a/Lib/test/test_unittest/testmock/testmock.py b/Lib/test/test_unittest/testmock/testmock.py index 764585ec5d54688..11d0b7c776cd720 100644 --- a/Lib/test/test_unittest/testmock/testmock.py +++ b/Lib/test/test_unittest/testmock/testmock.py @@ -1,3 +1,4 @@ +import asyncio import copy import re import sys @@ -38,6 +39,16 @@ def cmeth(cls, a, b, c, d=None): pass def smeth(a, b, c, d=None): pass +class SomethingAsync(object): + async def meth(self, a, b, c, d=None): pass + + @classmethod + async def cmeth(cls, a, b, c, d=None): pass + + @staticmethod + async def smeth(a, b, c, d=None): pass + + class SomethingElse(object): def __init__(self): self._instance = None @@ -695,6 +706,71 @@ def test_attributes(mock): test_attributes(Mock(spec=Something())) + def _check_autospeced_something(self, something): + # assert that AttributeError is raised if the method does not exist. + self.assertRaises(AttributeError, getattr, something, 'foolish') + + self._check_autospeced_something_method(something.meth) + self._check_autospeced_something_method(something.cmeth) + self._check_autospeced_something_method(something.smeth) + + + def _check_autospeced_something_method(self, mock_method): + # check that the methods are callable with correct args. + mock_method(sentinel.a, sentinel.b, sentinel.c) + mock_method(sentinel.a, sentinel.b, sentinel.c, d=sentinel.d) + mock_method.assert_has_calls([ + call(sentinel.a, sentinel.b, sentinel.c), + call(sentinel.a, sentinel.b, sentinel.c, d=sentinel.d)]) + + # assert that TypeError is raised if the method signature is not + # respected. + self.assertRaises(TypeError, mock_method) + self.assertRaises(TypeError, mock_method, sentinel.a) + self.assertRaises(TypeError, mock_method, a=sentinel.a) + self.assertRaises(TypeError, mock_method, sentinel.a, sentinel.b, + sentinel.c, e=sentinel.e) + + + def test_mock_autospec_all_members(self): + for spec in [Something, Something()]: + mock_something = Mock(autospec=spec) + self._check_autospeced_something(mock_something) + + + def _check_autospeced_something_async(self, something): + # assert that AttributeError is raised if the method does not exist. + self.assertRaises(AttributeError, getattr, something, 'foolish') + + self._check_autospeced_something_method_async(something.meth) + self._check_autospeced_something_method_async(something.cmeth) + self._check_autospeced_something_method_async(something.smeth) + + + def _check_autospeced_something_method_async(self, mock_method): + # check that the methods are callable with correct args. + asyncio.run(mock_method(sentinel.a, sentinel.b, sentinel.c)) + asyncio.run(mock_method(sentinel.a, sentinel.b, sentinel.c, + d=sentinel.d)) + mock_method.assert_has_calls([ + call(sentinel.a, sentinel.b, sentinel.c), + call(sentinel.a, sentinel.b, sentinel.c, d=sentinel.d)]) + + # assert that TypeError is raised if the method signature is not + # respected. + self.assertRaises(TypeError, mock_method) + self.assertRaises(TypeError, mock_method, sentinel.a) + self.assertRaises(TypeError, mock_method, a=sentinel.a) + self.assertRaises(TypeError, mock_method, sentinel.a, sentinel.b, + sentinel.c, e=sentinel.e) + + + def test_mock_autospec_all_members_async(self): + for spec in [SomethingAsync, SomethingAsync()]: + mock_something = AsyncMock(autospec=spec) + self._check_autospeced_something_async(mock_something) + + def test_wraps_calls(self): real = Mock() @@ -1974,6 +2050,20 @@ def test_mock_add_spec_magic_methods(self): self.assertRaises(TypeError, lambda: mock['foo']) + def test_mock_add_spec_autospec_all_members(self): + for spec in [Something, Something()]: + mock_something = Mock() + mock_something.mock_add_spec(spec, autospec=True) + self._check_autospeced_something(mock_something) + + + def test_mock_add_spec_autospec_all_members_async(self): + for spec in [SomethingAsync, SomethingAsync()]: + mock_something = Mock() + mock_something.mock_add_spec(spec, autospec=True) + self._check_autospeced_something_async(mock_something) + + def test_adding_child_mock(self): for Klass in (NonCallableMock, Mock, MagicMock, NonCallableMagicMock, AsyncMock): diff --git a/Lib/unittest/mock.py b/Lib/unittest/mock.py index 49011faaa51eaed..ca86c9f885110a8 100644 --- a/Lib/unittest/mock.py +++ b/Lib/unittest/mock.py @@ -136,6 +136,8 @@ def checksig(self, /, *args, **kwargs): type(mock)._mock_check_sig = checksig type(mock).__signature__ = sig + return func, sig + def _copy_func_details(func, funcopy): # we explicitly don't copy func.__dict__ into this copy as it would @@ -463,7 +465,8 @@ def __new__( def __init__( self, spec=None, wraps=None, name=None, spec_set=None, parent=None, _spec_state=None, _new_name='', _new_parent=None, - _spec_as_instance=False, _eat_self=None, unsafe=False, **kwargs + _spec_as_instance=False, _eat_self=None, unsafe=False, + autospec=None, **kwargs ): if _new_parent is None: _new_parent = parent @@ -474,10 +477,15 @@ def __init__( __dict__['_mock_new_name'] = _new_name __dict__['_mock_new_parent'] = _new_parent __dict__['_mock_sealed'] = False + __dict__['_autospec'] = autospec if spec_set is not None: spec = spec_set spec_set = True + if autospec is not None: + # autospec is even stricter than spec_set. + spec = autospec + autospec = True if _eat_self is None: _eat_self = parent is not None @@ -520,12 +528,18 @@ def attach_mock(self, mock, attribute): setattr(self, attribute, mock) - def mock_add_spec(self, spec, spec_set=False): + def mock_add_spec(self, spec, spec_set=False, autospec=None): """Add a spec to a mock. `spec` can either be an object or a list of strings. Only attributes on the `spec` can be fetched as attributes from the mock. - If `spec_set` is True then only attributes on the spec can be set.""" + If `spec_set` is True then only attributes on the spec can be set. + If `autospec` is True then only attributes on the spec can be accessed + and set, and if a method in the `spec` is called, it's signature is + checked. + """ + if autospec is not None: + self.__dict__['_autospec'] = autospec self._mock_add_spec(spec, spec_set) @@ -543,8 +557,13 @@ def _mock_add_spec(self, spec, spec_set, _spec_as_instance=False, _spec_class = spec else: _spec_class = type(spec) - res = _get_signature_object(spec, - _spec_as_instance, _eat_self) + + if self.__dict__.get('_autospec') is None: + res = _get_signature_object(spec, _spec_as_instance, _eat_self) + else: + res = _check_signature(spec, self, _eat_self, + _spec_as_instance) + _spec_signature = res and res[1] spec_list = dir(spec) @@ -712,9 +731,20 @@ def __getattr__(self, name): # execution? wraps = getattr(self._mock_wraps, name) + kwargs = {} + if self.__dict__.get('_autospec') is not None: + # get the mock's spec attribute with the same name and + # pass it to the child. + spec_class = self.__dict__.get('_spec_class') + spec = getattr(spec_class, name, None) + is_type = isinstance(spec_class, type) + eat_self = _must_skip(spec_class, name, is_type) + kwargs['_eat_self'] = eat_self + kwargs['autospec'] = spec + result = self._get_child_mock( parent=self, name=name, wraps=wraps, _new_name=name, - _new_parent=self + _new_parent=self, **kwargs ) self._mock_children[name] = result diff --git a/Misc/NEWS.d/next/Library/2017-11-20-06-36-00.bpo-30587.1SsAy9.rst b/Misc/NEWS.d/next/Library/2017-11-20-06-36-00.bpo-30587.1SsAy9.rst new file mode 100644 index 000000000000000..105b8319e37f4bc --- /dev/null +++ b/Misc/NEWS.d/next/Library/2017-11-20-06-36-00.bpo-30587.1SsAy9.rst @@ -0,0 +1,3 @@ +mock: Added autospec argument to the constructors Mock constructors and mock_add_spec. +Passing the autospec argument will also check the method signatures of the mocked +methods. From aa5855638e4b746fbeffe7d53643c8af685558b9 Mon Sep 17 00:00:00 2001 From: Claudiu Belu Date: Tue, 21 Jul 2026 19:39:26 +0000 Subject: [PATCH 2/3] tests: Skip tests that require working socket --- Lib/test/test_unittest/testmock/testmock.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_unittest/testmock/testmock.py b/Lib/test/test_unittest/testmock/testmock.py index 11d0b7c776cd720..01a6f4983130cae 100644 --- a/Lib/test/test_unittest/testmock/testmock.py +++ b/Lib/test/test_unittest/testmock/testmock.py @@ -4,7 +4,7 @@ import sys import tempfile -from test.support import ALWAYS_EQ +from test.support import ALWAYS_EQ, requires_working_socket import unittest from test.test_unittest.testmock.support import is_instance from unittest import mock @@ -765,6 +765,7 @@ def _check_autospeced_something_method_async(self, mock_method): sentinel.c, e=sentinel.e) + @requires_working_socket() def test_mock_autospec_all_members_async(self): for spec in [SomethingAsync, SomethingAsync()]: mock_something = AsyncMock(autospec=spec) @@ -2057,6 +2058,7 @@ def test_mock_add_spec_autospec_all_members(self): self._check_autospeced_something(mock_something) + @requires_working_socket() def test_mock_add_spec_autospec_all_members_async(self): for spec in [SomethingAsync, SomethingAsync()]: mock_something = Mock() From 728efcef75342b15336059719d1254066ab1bef7 Mon Sep 17 00:00:00 2001 From: Claudiu Belu Date: Wed, 22 Jul 2026 17:45:36 +0000 Subject: [PATCH 3/3] bpo-30587: Extend autospec test coverage and fix partial / spec-list gaps Fix _get_signature_object to handle functools.partial specs via inspect.signature() directly, instead of going through partial.__call__. This was silently disabling signature checking for any partial function / method spec. Preserve an explicit spec given as a list of attribute names alongside autospec, extending the autospecced attribute set instead of letting autospec override it entirely. This allows whitelisting instance attributes that are only set in __init__, which are invisible to autospec. Add tests for: - autospec combined with wraps (sync and async). - autospec on plain functions, partial functions and partial methods. - a function side_effect on an autospec'd method. - a class's __init__ signature being enforced. - autospec + spec list attribute whitelist combination. - a real @property descriptor is not eagerly triggered by autospec=, mirroring the existing spec= test. - autospec propagates recursively through a class-level attribute that is itself a spec'd object, enforcing the inner object's signatures too. - reset_mock() does not clear _mock_check_sig. - attach_mock() with a Mock(autospec=...) child preserves both call recording and signature enforcement after reparenting. - magic / dunder methods on a MagicMock(autospec=...): existence is still gated by the spec, and __call__'s signature is enforced (against __init__ when autospeccing a class, against __call__ itself when autospeccing an instance). Signed-off-by: Claudiu Belu --- Lib/test/test_unittest/testmock/testmock.py | 191 +++++++++++++++++++- Lib/unittest/mock.py | 23 ++- 2 files changed, 208 insertions(+), 6 deletions(-) diff --git a/Lib/test/test_unittest/testmock/testmock.py b/Lib/test/test_unittest/testmock/testmock.py index 01a6f4983130cae..ab2bc5aa8b82e55 100644 --- a/Lib/test/test_unittest/testmock/testmock.py +++ b/Lib/test/test_unittest/testmock/testmock.py @@ -1,5 +1,6 @@ import asyncio import copy +import functools import re import sys import tempfile @@ -30,7 +31,8 @@ def next(self): class Something(object): - def meth(self, a, b, c, d=None): pass + def meth(self, a, b, c, d=None): + return a, b, c, d @classmethod def cmeth(cls, a, b, c, d=None): pass @@ -66,7 +68,21 @@ class Typos(): set_spec = None -def something(a): pass +def something(a): + return a + + +def something_two_args(a, b): + return a, b + + +class SomethingWithProps(object): + def __init__(self, a, b, c=None): + self.a = a + self.b = b + self.c = c + + def meth(self, x, y): pass class MockTest(unittest.TestCase): @@ -382,6 +398,17 @@ def test_reset_mock(self): "children incorrectly cleared") self.assertFalse(mock.something.called, "child not reset") + def test_mock_autospec_reset_mock(self): + mock_something = Mock(autospec=Something) + mock_something.meth(sentinel.a, sentinel.b, sentinel.c) + + mock_something.reset_mock() + + self.assertEqual(mock_something.meth.call_count, 0) + # the child mock is preserved by reset_mock(), so signature checking + # is still enforced afterwards. + self.assertRaises(TypeError, mock_something.meth) + mock_something.meth(sentinel.a, sentinel.b, sentinel.c) def test_reset_mock_recursion(self): mock = Mock() @@ -738,6 +765,12 @@ def test_mock_autospec_all_members(self): self._check_autospeced_something(mock_something) + def test_mock_autospec_all_members_wraps(self): + something = Something() + mock_something = Mock(autospec=something, wraps=something) + self._check_autospeced_something(mock_something) + + def _check_autospeced_something_async(self, something): # assert that AttributeError is raised if the method does not exist. self.assertRaises(AttributeError, getattr, something, 'foolish') @@ -772,6 +805,140 @@ def test_mock_autospec_all_members_async(self): self._check_autospeced_something_async(mock_something) + @requires_working_socket() + def test_mock_autospec_all_members_wraps_async(self): + something = SomethingAsync() + mock_something = AsyncMock(autospec=something, wraps=something) + self._check_autospeced_something_async(mock_something) + + + def test_mock_autospec_function(self): + mock_func = Mock(autospec=something, wraps=something) + + result = mock_func(sentinel.a) + self.assertEqual(result, sentinel.a) + + self.assertRaises(TypeError, mock_func) + self.assertRaises(TypeError, mock_func, sentinel.a, sentinel.b) + + + def test_mock_autospec_partial_function(self): + partial_something = functools.partial(something_two_args, sentinel.a) + + mock_func = Mock(autospec=partial_something, wraps=partial_something) + + result = mock_func(sentinel.b) + self.assertEqual(result, (sentinel.a, sentinel.b)) + + self.assertRaises(TypeError, mock_func) + self.assertRaises(TypeError, mock_func, sentinel.b, sentinel.c) + + + def test_mock_autospec_partial_method(self): + obj = Something() + partial_meth = functools.partial(obj.meth, sentinel.a, sentinel.b) + + mock_meth = Mock(autospec=partial_meth, wraps=partial_meth) + + result = mock_meth(sentinel.c) + self.assertEqual(result, (sentinel.a, sentinel.b, sentinel.c, None)) + + self.assertRaises(TypeError, mock_meth) + self.assertRaises(TypeError, mock_meth, sentinel.d, e=sentinel.e) + + + def test_mock_autospec_side_effect(self): + def side_effect(a, b, c, d=None): + return (a, b, c, d) + + mock_something = Mock(autospec=Something) + mock_something.meth.side_effect = side_effect + + result = mock_something.meth(sentinel.a, sentinel.b, sentinel.c) + self.assertEqual(result, (sentinel.a, sentinel.b, sentinel.c, None)) + + # signature checking is enforced before the side_effect runs. + self.assertRaises(TypeError, mock_something.meth) + self.assertRaises(TypeError, mock_something.meth, sentinel.a) + + + def test_mock_autospec_class_init_signature(self): + mock_class = Mock(autospec=SomethingWithProps) + + mock_class(sentinel.a, sentinel.b) + mock_class(sentinel.a, sentinel.b, sentinel.c) + + self.assertRaises(TypeError, mock_class) + self.assertRaises(TypeError, mock_class, sentinel.a) + self.assertRaises(TypeError, mock_class, sentinel.a, sentinel.b, + sentinel.c, e=sentinel.e) + + + def test_mock_autospec_with_spec_list_for_init_only_attributes(self): + # SomethingWithProps only assigns 'a' and 'b' as instance attributes + # in __init__, so they are absent from dir(SomethingWithProps) and + # would normally be rejected by autospec. + mock_something = Mock( + autospec=SomethingWithProps, spec=['a', 'b', 'c']) + + # the extra attributes from `spec` are accessible. + mock_something.a + mock_something.b + + # autospec is still applied: methods are still signature-checked. + mock_something.meth(sentinel.x, sentinel.y) + self.assertRaises(TypeError, mock_something.meth) + + # attributes that are neither on the spec class nor in the extra + # `spec` list are still rejected. + self.assertRaises(AttributeError, getattr, mock_something, 'foolish') + + + def test_mock_autospec_nested(self): + class Inner(object): + def meth(self, a, b): pass + + class Outer(object): + inner = Inner() + + mock_outer = Mock(autospec=Outer) + self.assertIsInstance(mock_outer.inner, Mock) + + mock_outer.inner.meth(sentinel.a, sentinel.b) + self.assertRaises(TypeError, mock_outer.inner.meth) + self.assertRaises(TypeError, mock_outer.inner.meth, sentinel.a) + + + def test_mock_autospec_magic_methods(self): + for Klass in MagicMock, NonCallableMagicMock: + mock = Klass(autospec=int) + int(mock) + + mock.__int__.return_value = 4 + self.assertEqual(int(mock), 4) + + # attributes that don't exist on the spec are still rejected. + self.assertRaises(AttributeError, getattr, mock, 'foo') + + + def test_mock_autospec_call_signature(self): + class Caller(object): + def __init__(self, a): pass + def __call__(self, x): pass + + # autospeccing the class checks the constructor signature. + mock_cls = MagicMock(autospec=Caller) + mock_cls(a=sentinel.a) + self.assertRaises(TypeError, mock_cls) + self.assertRaises(TypeError, mock_cls, sentinel.a, sentinel.b) + + # autospeccing an instance checks the __call__ signature instead. + mock_instance = MagicMock(autospec=Caller(sentinel.a)) + mock_instance(x=sentinel.x) + self.assertRaises(TypeError, mock_instance) + self.assertRaises(TypeError, mock_instance, sentinel.x, sentinel.y) + + def test_wraps_calls(self): real = Mock() @@ -2262,6 +2429,19 @@ def test_attach_mock_return_value(self): self.assertEqual(m.mock_calls, call().foo().call_list()) + def test_mock_autospec_attach_mock(self): + m = Mock() + child = Mock(autospec=Something) + m.attach_mock(child, 'child') + + child.meth(sentinel.a, sentinel.b, sentinel.c) + m.assert_has_calls( + [call.child.meth(sentinel.a, sentinel.b, sentinel.c)]) + + # signature checking survives being attached to another mock. + self.assertRaises(TypeError, child.meth) + + def test_attach_mock_patch_autospec(self): parent = Mock() @@ -2511,6 +2691,13 @@ def test_property_not_called_with_spec_mock(self): self.assertIsNone(obj._instance, msg='after mock') self.assertEqual('object', obj.instance) + def test_mock_autospec_property_not_called(self): + obj = SomethingElse() + self.assertIsNone(obj._instance, msg='before mock') + mock = Mock(autospec=obj) + self.assertIsNone(obj._instance) + self.assertEqual('object', obj.instance) + def test_decorated_async_methods_with_spec_mock(self): class Foo(): @classmethod diff --git a/Lib/unittest/mock.py b/Lib/unittest/mock.py index ca86c9f885110a8..a95d23989e64a97 100644 --- a/Lib/unittest/mock.py +++ b/Lib/unittest/mock.py @@ -107,6 +107,11 @@ def _get_signature_object(func, as_instance, eat_self): eat_self = True # Use the original decorated method to extract the correct function signature func = func.__func__ + elif isinstance(func, partial): + # inspect.signature() already accounts for a partial's bound + # arguments; going through func.__call__ (a builtin method-wrapper) + # would lose that and fail to produce a signature at all. + pass elif not isinstance(func, FunctionTypes): # If we really want to model an instance of the passed type, # __call__ should be looked up, not __init__. @@ -477,19 +482,29 @@ def __init__( __dict__['_mock_new_name'] = _new_name __dict__['_mock_new_parent'] = _new_parent __dict__['_mock_sealed'] = False - __dict__['_autospec'] = autospec + __dict__['_mock_autospec'] = autospec if spec_set is not None: spec = spec_set spec_set = True + + extra_spec_props = None if autospec is not None: # autospec is even stricter than spec_set. + # an explicit spec given as a list of attribute names is preserved + # as additional allowed attributes (e.g.: instance attributes set + # in __init__, which won't show up via autospec). + if spec is not None and _is_list(spec): + extra_spec_props = spec spec = autospec autospec = True + if _eat_self is None: _eat_self = parent is not None self._mock_add_spec(spec, spec_set, _spec_as_instance, _eat_self) + if extra_spec_props is not None: + self._mock_extend_spec_methods(extra_spec_props) __dict__['_mock_children'] = {} __dict__['_mock_wraps'] = wraps @@ -539,7 +554,7 @@ def mock_add_spec(self, spec, spec_set=False, autospec=None): checked. """ if autospec is not None: - self.__dict__['_autospec'] = autospec + self.__dict__['_mock_autospec'] = autospec self._mock_add_spec(spec, spec_set) @@ -558,7 +573,7 @@ def _mock_add_spec(self, spec, spec_set, _spec_as_instance=False, else: _spec_class = type(spec) - if self.__dict__.get('_autospec') is None: + if self.__dict__.get('_mock_autospec') is None: res = _get_signature_object(spec, _spec_as_instance, _eat_self) else: res = _check_signature(spec, self, _eat_self, @@ -732,7 +747,7 @@ def __getattr__(self, name): wraps = getattr(self._mock_wraps, name) kwargs = {} - if self.__dict__.get('_autospec') is not None: + if self.__dict__.get('_mock_autospec') is not None: # get the mock's spec attribute with the same name and # pass it to the child. spec_class = self.__dict__.get('_spec_class')