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

Deprecate calling fixture functions directly #3705

Merged
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
3 changes: 3 additions & 0 deletions changelog/3661.removal.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Calling a fixture function directly, as opposed to request them in a test function, now issues a ``RemovedInPytest4Warning``. It will be changed into an error in pytest ``4.0``.

This is a great source of confusion to new users, which will often call the fixture functions and request them from test functions interchangeably, which breaks the fixture resolution model.
1 change: 1 addition & 0 deletions src/_pytest/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ def iscoroutinefunction(func):


def getlocation(function, curdir):
function = get_real_func(function)
fn = py.path.local(inspect.getfile(function))
lineno = function.__code__.co_firstlineno
if fn.relto(curdir):
Expand Down
6 changes: 6 additions & 0 deletions src/_pytest/deprecated.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ class RemovedInPytest4Warning(DeprecationWarning):
"Please remove the prefix and use the @pytest.fixture decorator instead."
)

FIXTURE_FUNCTION_CALL = (
"Fixture {name} called directly. Fixtures are not meant to be called directly, "
"are created automatically when test functions request them as parameters. "
"See https://docs.pytest.org/en/latest/fixture.html for more information."
)

CFG_PYTEST_SECTION = (
"[pytest] section in {filename} files is deprecated, use [tool:pytest] instead."
)
Expand Down
52 changes: 51 additions & 1 deletion src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import sys
import warnings
from collections import OrderedDict, deque, defaultdict

import six
from more_itertools import flatten

import attr
Expand All @@ -29,6 +31,7 @@
safe_getattr,
FuncargnamesCompatAttr,
)
from _pytest.deprecated import FIXTURE_FUNCTION_CALL, RemovedInPytest4Warning
from _pytest.outcomes import fail, TEST_OUTCOME

FIXTURE_MSG = 'fixtures cannot have "pytest_funcarg__" prefix and be decorated with @pytest.fixture:\n{}'
Expand Down Expand Up @@ -798,7 +801,7 @@ def call_fixture_func(fixturefunc, request, kwargs):

def _teardown_yield_fixture(fixturefunc, it):
"""Executes the teardown of a fixture function by advancing the iterator after the
yield and ensure the iteration ends (if not it means there is more than one yield in the function"""
yield and ensure the iteration ends (if not it means there is more than one yield in the function)"""
try:
next(it)
except StopIteration:
Expand Down Expand Up @@ -928,6 +931,13 @@ def pytest_fixture_setup(fixturedef, request):
request._check_scope(argname, request.scope, fixdef.scope)
kwargs[argname] = result

# if function has been defined with @pytest.fixture, we want to
# pass the special __being_called_by_pytest parameter so we don't raise a warning
# this is an ugly hack, see #3720 for an opportunity to improve this
defined_using_fixture_decorator = hasattr(fixturedef.func, "_pytestfixturefunction")
if defined_using_fixture_decorator:
kwargs["__being_called_by_pytest"] = True

fixturefunc = resolve_fixture_function(fixturedef, request)
my_cache_key = request.param_index
try:
Expand All @@ -947,6 +957,44 @@ def _ensure_immutable_ids(ids):
return tuple(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__)
warning = RemovedInPytest4Warning(msg)

if is_yield_function:

@functools.wraps(function)
Copy link
Member

Choose a reason for hiding this comment

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

functools.wraps is broken on python2 and needs to set __wrapped__ or so to get the correct metadata

Copy link
Member Author

Choose a reason for hiding this comment

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

Ahh thanks, will try that!

def result(*args, **kwargs):
__tracebackhide__ = True
__being_called_by_pytest = kwargs.pop("__being_called_by_pytest", False)
Copy link
Member

Choose a reason for hiding this comment

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

instead of introducing this kwarg, why not just unpack the real function from the definition object

Copy link
Member Author

Choose a reason for hiding this comment

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

That is a great idea, thanks! I wished I have thought about it myself. 😁

I've tried and it works well, except when we have test functions which are staticmethods.

Here's an illustrative example:

import pytest
class Test(object):
    @staticmethod
    def test_something():
        pass

    @pytest.fixture
    def fix(self):
        return 1

    @staticmethod
    def test_fix(fix):
        assert fix == 1

test_something passes, but test_fix fails because we can't create the fixture because request.instance is None at that point.

The place where I did the unwrapping was in resolve_fixture_function:

def resolve_fixture_function(fixturedef, request):
    """Gets the actual callable that can be called to obtain the fixture value, dealing with unittest-specific
    instances and bound methods.
    """
    fixturefunc = get_real_func(fixturedef.func)  # <- unwrapping!
    if fixturedef.unittest:
        if request.instance is not None:
            # bind the unbound method to the TestCase instance
            fixturefunc = fixturefunc.__get__(request.instance)
    else:
        # the fixture function needs to be bound to the actual
        # request.instance so that code working with "fixturedef" behaves
        # as expected.
        if request.instance is not None:
            #is_method = getimfunc(fixturedef.func) != fixturedef.func
            is_method = inspect.ismethod(fixturedef.func)
            if is_method:
                fixturefunc = fixturefunc.__get__(request.instance)
    return fixturefunc

This issue affects 6 of our tests, on py27 and py36.

Any suggestions on how to proceed? The unwrapping before calling is much cleaner, but I'm not sure how to deal with staticmethods in that case.

Copy link
Member

Choose a reason for hiding this comment

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

this seems to me like we should open a follow-up issue and go for the mechanism you currently have as enabling the cleaner way requires a re-factoring/bugfixing in fixture lookup/requesting

Copy link
Member Author

Choose a reason for hiding this comment

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

Just to make sure I follow, keep using the keyword argument approach, and warning only in Python 3? I can try to make it work with Python 2 if you think setting __wrapped__ will work.

Copy link
Member

Choose a reason for hiding this comment

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

please try setting wrapped - wrt the keyword argument approach - i dont like it but as far as i understood we need to sort out a structural thing to get real extraction working

Copy link
Member

Choose a reason for hiding this comment

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

so we keep kwargs, and we try to set up the warnings for py2 as well

Copy link
Member Author

Choose a reason for hiding this comment

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

Follow up in #3720

if not __being_called_by_pytest:
warnings.warn(warning, stacklevel=3)
for x in function(*args, **kwargs):
yield x

else:

@functools.wraps(function)
def result(*args, **kwargs):
__tracebackhide__ = True
__being_called_by_pytest = kwargs.pop("__being_called_by_pytest", False)
if not __being_called_by_pytest:
warnings.warn(warning, stacklevel=3)
return function(*args, **kwargs)

if six.PY2:
result.__wrapped__ = function

return result


@attr.s(frozen=True)
class FixtureFunctionMarker(object):
scope = attr.ib()
Expand All @@ -964,6 +1012,8 @@ def __call__(self, function):
"fixture is being applied more than once to the same function"
)

function = wrap_function_to_warning_if_called_directly(function, self)
Copy link
Member

Choose a reason for hiding this comment

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

(just double checked, good that this is going to features):

changing a decorator from returning the original function to returning a wrapper always seems to break things -- I'm hoping nobody was depending on decorator ordering for @fixture before :)

Copy link
Member Author

Choose a reason for hiding this comment

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

Sure! 😁


function._pytestfixturefunction = self
return function

Expand Down
14 changes: 14 additions & 0 deletions testing/deprecated_test.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from __future__ import absolute_import, division, print_function


import pytest


Expand Down Expand Up @@ -263,3 +265,15 @@ def test_func():
str(PYTEST_PLUGINS_FROM_NON_TOP_LEVEL_CONFTEST).splitlines()[0]
not in res.stderr.str()
)


# @pytest.mark.skipif(six.PY2, reason="We issue the warning in Python 3 only")
def test_call_fixture_function_deprecated():
"""Check if a warning is raised if a fixture function is called directly (#3661)"""

@pytest.fixture
def fix():
return 1

with pytest.deprecated_call():
assert fix() == 1
7 changes: 7 additions & 0 deletions testing/example_scripts/tmpdir/tmpdir_fixture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import pytest


@pytest.mark.parametrize("a", [r"qwe/\abc"])
def test_fixture(tmpdir, a):
tmpdir.check(dir=1)
assert tmpdir.listdir() == []
9 changes: 6 additions & 3 deletions testing/python/fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -1456,6 +1456,7 @@ def test_parsefactories_conftest_and_module_and_class(self, testdir):
testdir.makepyfile(
"""
import pytest
import six

@pytest.fixture
def hello(request):
Expand All @@ -1468,9 +1469,11 @@ def test_hello(self, item, fm):
faclist = fm.getfixturedefs("hello", item.nodeid)
print (faclist)
assert len(faclist) == 3
assert faclist[0].func(item._request) == "conftest"
assert faclist[1].func(item._request) == "module"
assert faclist[2].func(item._request) == "class"

kwargs = {'__being_called_by_pytest': True}
assert faclist[0].func(item._request, **kwargs) == "conftest"
assert faclist[1].func(item._request, **kwargs) == "module"
assert faclist[2].func(item._request, **kwargs) == "class"
"""
)
reprec = testdir.inline_run("-s")
Expand Down
3 changes: 3 additions & 0 deletions testing/python/metafunc.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,9 @@ class A(object):
@hypothesis.settings(
deadline=400.0
) # very close to std deadline and CI boxes are not reliable in CPU power
@pytest.mark.xfail(
sys.platform.startswith("win32"), reason="flaky #3707", strict=False
)
def test_idval_hypothesis(self, value):
from _pytest.python import _idval

Expand Down
13 changes: 6 additions & 7 deletions testing/test_assertion.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
PY3 = sys.version_info >= (3, 0)


@pytest.fixture
def mock_config():
class Config(object):
verbose = False
Expand Down Expand Up @@ -768,15 +767,15 @@ def test_rewritten():
assert testdir.runpytest().ret == 0


def test_reprcompare_notin(mock_config):
detail = plugin.pytest_assertrepr_compare(
mock_config, "not in", "foo", "aaafoobbb"
)[1:]
def test_reprcompare_notin():
config = mock_config()
detail = plugin.pytest_assertrepr_compare(config, "not in", "foo", "aaafoobbb")[1:]
assert detail == ["'foo' is contained here:", " aaafoobbb", "? +++"]


def test_reprcompare_whitespaces(mock_config):
detail = plugin.pytest_assertrepr_compare(mock_config, "==", "\r\n", "\n")
def test_reprcompare_whitespaces():
config = mock_config()
detail = plugin.pytest_assertrepr_compare(config, "==", "\r\n", "\n")
assert detail == [
r"'\r\n' == '\n'",
r"Strings contain only whitespace, escaping them using repr()",
Expand Down
4 changes: 1 addition & 3 deletions testing/test_conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,7 @@

@pytest.fixture(scope="module", params=["global", "inpackage"])
def basedir(request, tmpdir_factory):
from _pytest.tmpdir import tmpdir

tmpdir = tmpdir(request, tmpdir_factory)
tmpdir = tmpdir_factory.mktemp("basedir", numbered=True)
Copy link
Member

Choose a reason for hiding this comment

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

could this also just take the tmpdir fixture as a positional?

Copy link
Member Author

@nicoddemus nicoddemus Jul 22, 2018

Choose a reason for hiding this comment

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

No because this basedir is module-scoped, and tmpdir is function-scoped. 😉

Copy link
Member

Choose a reason for hiding this comment

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

oh right of course!

tmpdir.ensure("adir/conftest.py").write("a=1 ; Directory = 3")
tmpdir.ensure("adir/b/conftest.py").write("b=2 ; a = 1.5")
if request.param == "inpackage":
Expand Down
32 changes: 4 additions & 28 deletions testing/test_tmpdir.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,11 @@
import py
import pytest

from _pytest.tmpdir import tmpdir


def test_funcarg(testdir):
testdir.makepyfile(
"""
def pytest_generate_tests(metafunc):
metafunc.addcall(id='a')
metafunc.addcall(id='b')
def test_func(tmpdir): pass
"""
)
from _pytest.tmpdir import TempdirFactory

reprec = testdir.inline_run()
calls = reprec.getcalls("pytest_runtest_setup")
item = calls[0].item
config = item.config
tmpdirhandler = TempdirFactory(config)
item._initrequest()
p = tmpdir(item._request, tmpdirhandler)
assert p.check()
bn = p.basename.strip("0123456789")
assert bn.endswith("test_func_a_")
item.name = "qwe/\\abc"
p = tmpdir(item._request, tmpdirhandler)
assert p.check()
bn = p.basename.strip("0123456789")
assert bn == "qwe__abc"
def test_tmpdir_fixture(testdir):
p = testdir.copy_example("tmpdir/tmpdir_fixture.py")
results = testdir.runpytest(p)
results.stdout.fnmatch_lines("*1 passed*")


def test_ensuretemp(recwarn):
Expand Down