Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

bpo-37047: Refactor AsyncMock setup logic for autospeccing #13574

Merged
merged 11 commits into from
May 27, 2019
6 changes: 5 additions & 1 deletion Doc/library/unittest.mock.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1945,7 +1945,7 @@ The full list of supported magic methods is:
* Container methods: ``__getitem__``, ``__setitem__``, ``__delitem__``,
``__contains__``, ``__len__``, ``__iter__``, ``__reversed__``
and ``__missing__``
* Context manager: ``__enter__`` and ``__exit__``
* Context manager: ``__enter__``, ``__exit__``, ``__aenter`` and ``__aexit__``
* Unary numeric methods: ``__neg__``, ``__pos__`` and ``__invert__``
* The numeric methods (including right hand and in-place variants):
``__add__``, ``__sub__``, ``__mul__``, ``__matmul__``, ``__div__``, ``__truediv__``,
Expand All @@ -1957,10 +1957,14 @@ The full list of supported magic methods is:
* Pickling: ``__reduce__``, ``__reduce_ex__``, ``__getinitargs__``,
``__getnewargs__``, ``__getstate__`` and ``__setstate__``
* File system path representation: ``__fspath__``
* Asynchronous iteration methods: ``__aiter__`` and ``__anext__``

.. versionchanged:: 3.8
Added support for :func:`os.PathLike.__fspath__`.

.. versionchanged:: 3.8
Added support for ``__aenter__``, ``__aexit__``, ``__aiter__`` and ``__anext__``.


The following methods exist but are *not* supported as they are either in use
by mock, can't be set dynamically, or can cause problems:
Expand Down
59 changes: 38 additions & 21 deletions Lib/unittest/mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ def _is_async_obj(obj):
return False


def _is_async_func(func):
if getattr(func, '__code__', None):
Copy link
Contributor

@mariocj89 mariocj89 May 25, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to have an async callable class? If so this won’t work.

Would a check for ‘callable’ work here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand "Asunción callable class" Do you mean something like async class Foo: pass which I think is not supported. Please add an example of the same.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no async version of callable() builtin like acallable().
asyncio.iscoroutinefunction() works for functions only

Copy link
Contributor

@mariocj89 mariocj89 May 26, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 note that the following will probably produce a standard mock rather than an async mock:

class A:
   async def __call__(self):
       await asyncio.sleep(1)

a = A()
# Creating a spec for a will not quite likely not create the right kind of mock, as a does not have __code__.

We can cover this in a different PR if possible I guess.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it produces a MagicMock but there could be a class with a mix of sync and async dunder methods too so not sure of the behavior and how to detect classes like this. I would like to cover it as part of different PR since this contain fixes for create_autospec. Thanks for catching this.

from unittest.mock import create_autospec

class A:

    async def __call__(self):
        await asyncio.sleep(1)

a = create_autospec(A)
b = a()
print(a)
print(b())
<MagicMock spec='A' id='4410501232'>
<MagicMock name='mock()()' id='4416843600'>

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally fair indeed.

return asyncio.iscoroutinefunction(func)
else:
return False


def _is_instance_mock(obj):
# can't use isinstance on Mock objects because they override __class__
# The base class for all mocks is NonCallableMock
Expand Down Expand Up @@ -225,6 +232,34 @@ def reset_mock():
mock._mock_delegate = funcopy


def _setup_async_mock(mock):
mock._is_coroutine = asyncio.coroutines._is_coroutine
mock.await_count = 0
mock.await_args = None
mock.await_args_list = _CallList()
mock.awaited = _AwaitEvent(mock)

# Mock is not configured yet so the attributes are set
# to a function and then the corresponding mock helper function
# is called when the helper is accessed similar to _setup_func.
def wrapper(attr, *args, **kwargs):
return getattr(mock.mock, attr)(*args, **kwargs)

for attribute in ('assert_awaited',
'assert_awaited_once',
'assert_awaited_with',
'assert_awaited_once_with',
'assert_any_await',
'assert_has_awaits',
'assert_not_awaited'):

# setattr(mock, attribute, wrapper) causes late binding
# hence attribute will always be the last value in the loop
# Use partial(wrapper, attribute) to ensure the attribute is bound
# correctly.
setattr(mock, attribute, partial(wrapper, attribute))


def _is_magic(name):
return '__%s__' % name[2:-2] == name

Expand Down Expand Up @@ -2151,7 +2186,7 @@ def assert_not_awaited(_mock_self):
"""
self = _mock_self
if self.await_count != 0:
msg = (f"Expected {self._mock_name or 'mock'} to have been awaited once."
msg = (f"Expected {self._mock_name or 'mock'} to not have been awaited."
f" Awaited {self.await_count} times.")
raise AssertionError(msg)

Expand Down Expand Up @@ -2457,10 +2492,7 @@ def create_autospec(spec, spec_set=False, instance=False, _parent=None,
spec = type(spec)

is_type = isinstance(spec, type)
if getattr(spec, '__code__', None):
is_async_func = asyncio.iscoroutinefunction(spec)
else:
is_async_func = False
is_async_func = _is_async_func(spec)
_kwargs = {'spec': spec}
if spec_set:
_kwargs = {'spec_set': spec}
Expand Down Expand Up @@ -2498,26 +2530,11 @@ def create_autospec(spec, spec_set=False, instance=False, _parent=None,
name=_name, **_kwargs)

if isinstance(spec, FunctionTypes):
wrapped_mock = mock
# should only happen at the top level because we don't
# recurse for functions
mock = _set_signature(mock, spec)
if is_async_func:
mock._is_coroutine = asyncio.coroutines._is_coroutine
mock.await_count = 0
mock.await_args = None
mock.await_args_list = _CallList()

for a in ('assert_awaited',
'assert_awaited_once',
'assert_awaited_with',
'assert_awaited_once_with',
'assert_any_await',
'assert_has_awaits',
'assert_not_awaited'):
def f(*args, **kwargs):
return getattr(wrapped_mock, a)(*args, **kwargs)
setattr(mock, a, f)
_setup_async_mock(mock)
else:
_check_signature(spec, mock, is_type, instance)

Expand Down
63 changes: 61 additions & 2 deletions Lib/unittest/test/testmock/testasync.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
import inspect
import unittest

from unittest.mock import call, AsyncMock, patch, MagicMock, create_autospec
from unittest.mock import (call, AsyncMock, patch, MagicMock, create_autospec,
_AwaitEvent)


def tearDownModule():
Expand All @@ -20,6 +21,9 @@ def normal_method(self):
async def async_func():
pass

async def async_func_args(a, b, *, c):
pass

def normal_func():
pass

Expand Down Expand Up @@ -141,8 +145,63 @@ def test_create_autospec_instance(self):
create_autospec(async_func, instance=True)

def test_create_autospec(self):
spec = create_autospec(async_func)
spec = create_autospec(async_func_args)
awaitable = spec(1, 2, c=3)
async def main():
await awaitable

self.assertEqual(spec.await_count, 0)
self.assertIsNone(spec.await_args)
self.assertEqual(spec.await_args_list, [])
self.assertIsInstance(spec.awaited, _AwaitEvent)
spec.assert_not_awaited()

asyncio.run(main())

self.assertTrue(asyncio.iscoroutinefunction(spec))
self.assertTrue(asyncio.iscoroutine(awaitable))
self.assertEqual(spec.await_count, 1)
self.assertEqual(spec.await_args, call(1, 2, c=3))
self.assertEqual(spec.await_args_list, [call(1, 2, c=3)])
spec.assert_awaited_once()
spec.assert_awaited_once_with(1, 2, c=3)
spec.assert_awaited_with(1, 2, c=3)
spec.assert_awaited()

def test_patch_with_autospec(self):

async def test_async():
with patch(f"{__name__}.async_func_args", autospec=True) as mock_method:
awaitable = mock_method(1, 2, c=3)
self.assertIsInstance(mock_method.mock, AsyncMock)

self.assertTrue(asyncio.iscoroutinefunction(mock_method))
self.assertTrue(asyncio.iscoroutine(awaitable))
self.assertTrue(inspect.isawaitable(awaitable))

# Verify the default values during mock setup
self.assertEqual(mock_method.await_count, 0)
self.assertEqual(mock_method.await_args_list, [])
self.assertIsNone(mock_method.await_args)
self.assertIsInstance(mock_method.awaited, _AwaitEvent)
mock_method.assert_not_awaited()

await awaitable

self.assertEqual(mock_method.await_count, 1)
self.assertEqual(mock_method.await_args, call(1, 2, c=3))
self.assertEqual(mock_method.await_args_list, [call(1, 2, c=3)])
mock_method.assert_awaited_once()
mock_method.assert_awaited_once_with(1, 2, c=3)
mock_method.assert_awaited_with(1, 2, c=3)
mock_method.assert_awaited()

mock_method.reset_mock()
self.assertEqual(mock_method.await_count, 0)
self.assertIsNone(mock_method.await_args)
self.assertEqual(mock_method.await_args_list, [])

asyncio.run(test_async())


class AsyncSpecTest(unittest.TestCase):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Handle late binding and attribute access in :class:`unittest.mock.AsyncMock`
setup for autospeccing. Document newly implemented async methods in
:class:`unittest.mock.MagicMock`.