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

Fix issue where fixtures would lose the decorated functionality #3780

Merged
merged 2 commits into from
Aug 9, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/_pytest/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,12 +228,31 @@ def ascii_escaped(val):
return val.encode("unicode-escape")


class _PytestWrapper(object):
"""Dummy wrapper around a function object for internal use only.

Used to correctly unwrap the underlying function object
when we are creating fixtures, because we wrap the function object ourselves with a decorator
to issue warnings when the fixture function is called directly.
"""

def __init__(self, obj):
self.obj = obj


def get_real_func(obj):
""" gets the real function object of the (possibly) wrapped object by
functools.wraps or functools.partial.
"""
start_obj = obj
for i in range(100):
# __pytest_wrapped__ is set by @pytest.fixture when wrapping the fixture function
# to trigger a warning if it gets called directly instead of by pytest: we don't
# want to unwrap further than this otherwise we lose useful wrappings like @mock.patch (#3774)
new_obj = getattr(obj, "__pytest_wrapped__", None)
if isinstance(new_obj, _PytestWrapper):
obj = new_obj.obj
break
new_obj = getattr(obj, "__wrapped__", None)
if new_obj is None:
break
Expand Down
8 changes: 5 additions & 3 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
safe_getattr,
FuncargnamesCompatAttr,
get_real_method,
_PytestWrapper,
)
from _pytest.deprecated import FIXTURE_FUNCTION_CALL, RemovedInPytest4Warning
from _pytest.outcomes import fail, TEST_OUTCOME
Expand Down Expand Up @@ -954,9 +955,6 @@ def _ensure_immutable_ids(ids):
def wrap_function_to_warning_if_called_directly(function, fixture_marker):
"""Wrap the given fixture function so we can issue warnings about it being called directly, instead of
used as an argument in a test function.

The warning is emitted only in Python 3, because I didn't find a reliable way to make the wrapper function
keep the original signature, and we probably will drop Python 2 in Pytest 4 anyway.
"""
is_yield_function = is_generator(function)
msg = FIXTURE_FUNCTION_CALL.format(name=fixture_marker.name or function.__name__)
Expand All @@ -982,6 +980,10 @@ def result(*args, **kwargs):
if six.PY2:
result.__wrapped__ = function

# keep reference to the original function in our own custom attribute so we don't unwrap
# further than this point and lose useful wrappings like @mock.patch (#3774)
result.__pytest_wrapped__ = _PytestWrapper(function)

return result


Expand Down
7 changes: 7 additions & 0 deletions testing/acceptance_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1044,3 +1044,10 @@ def test2():
)
result = testdir.runpytest_subprocess()
result.stdout.fnmatch_lines(["*1 failed, 1 passed in*"])


def test_fixture_mock_integration(testdir):
"""Test that decorators applied to fixture are left working (#3774)"""
p = testdir.copy_example("acceptance/fixture_mock_integration.py")
result = testdir.runpytest(p)
result.stdout.fnmatch_lines("*1 passed*")
17 changes: 17 additions & 0 deletions testing/example_scripts/acceptance/fixture_mock_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Reproduces issue #3774"""

import mock

import pytest

config = {"mykey": "ORIGINAL"}


@pytest.fixture(scope="function")
@mock.patch.dict(config, {"mykey": "MOCKED"})
def my_fixture():
return config["mykey"]


def test_foobar(my_fixture):
assert my_fixture == "MOCKED"
32 changes: 31 additions & 1 deletion testing/test_compat.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from __future__ import absolute_import, division, print_function
import sys
from functools import wraps

import six

import pytest
from _pytest.compat import is_generator, get_real_func, safe_getattr
from _pytest.compat import is_generator, get_real_func, safe_getattr, _PytestWrapper
from _pytest.outcomes import OutcomeException


Expand Down Expand Up @@ -38,6 +41,33 @@ def __getattr__(self, attr):
print(res)


def test_get_real_func():
"""Check that get_real_func correctly unwraps decorators until reaching the real function"""

def decorator(f):
@wraps(f)
def inner():
pass

if six.PY2:
inner.__wrapped__ = f
return inner

def func():
pass

wrapped_func = decorator(decorator(func))
assert get_real_func(wrapped_func) is func

wrapped_func2 = decorator(decorator(wrapped_func))
assert get_real_func(wrapped_func2) is func

# special case for __pytest_wrapped__ attribute: used to obtain the function up until the point
# a function was wrapped by pytest itself
wrapped_func2.__pytest_wrapped__ = _PytestWrapper(wrapped_func)
assert get_real_func(wrapped_func2) is wrapped_func


@pytest.mark.skipif(
sys.version_info < (3, 4), reason="asyncio available in Python 3.4+"
)
Expand Down