Skip to content
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
2 changes: 2 additions & 0 deletions changelog/1682.deprecation.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Passing arguments to pytest.fixture() as positional arguments is deprecated - pass them
as a keyword argument instead.
3 changes: 3 additions & 0 deletions changelog/1682.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
The ``scope`` parameter of ``@pytest.fixture`` can now be a callable that receives
the fixture name and the ``config`` object as keyword-only parameters.
See `the docs <https://docs.pytest.org/en/fixture.html#dynamic-scope>`__ for more information.
2 changes: 1 addition & 1 deletion doc/en/example/costlysetup/conftest.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import pytest


@pytest.fixture("session")
@pytest.fixture(scope="session")
def setup(request):
setup = CostlySetup()
yield setup
Expand Down
26 changes: 26 additions & 0 deletions doc/en/fixture.rst
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,32 @@ are finalized when the last test of a *package* finishes.
Use this new feature sparingly and please make sure to report any issues you find.


Dynamic scope
^^^^^^^^^^^^^

In some cases, you might want to change the scope of the fixture without changing the code.
To do that, pass a callable to ``scope``. The callable must return a string with a valid scope
and will be executed only once - during the fixture definition. It will be called with two
keyword arguments - ``fixture_name`` as a string and ``config`` with a configuration object.

This can be especially useful when dealing with fixtures that need time for setup, like spawning
a docker container. You can use the command-line argument to control the scope of the spawned
containers for different environments. See the example below.

.. code-block:: python

def determine_scope(fixture_name, config):
if config.getoption("--keep-containers"):
return "session"
return "function"


@pytest.fixture(scope=determine_scope)
def docker_container():
yield spawn_container()



Order: Higher-scoped fixtures are instantiated first
----------------------------------------------------

Expand Down
5 changes: 5 additions & 0 deletions src/_pytest/deprecated.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,8 @@
"--result-log is deprecated and scheduled for removal in pytest 6.0.\n"
"See https://docs.pytest.org/en/latest/deprecations.html#result-log-result-log for more information."
)

FIXTURE_POSITIONAL_ARGUMENTS = PytestDeprecationWarning(
"Passing arguments to pytest.fixture() as positional arguments is deprecated - pass them "
"as a keyword argument instead."
)
118 changes: 112 additions & 6 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import inspect
import itertools
import sys
import warnings
from collections import defaultdict
from collections import deque
from collections import OrderedDict
Expand All @@ -27,6 +28,7 @@
from _pytest.compat import is_generator
from _pytest.compat import NOTSET
from _pytest.compat import safe_getattr
from _pytest.deprecated import FIXTURE_POSITIONAL_ARGUMENTS
from _pytest.outcomes import fail
from _pytest.outcomes import TEST_OUTCOME

Expand Down Expand Up @@ -58,7 +60,6 @@ def pytest_sessionstart(session):

scopename2class = {} # type: Dict[str, Type[nodes.Node]]


scope2props = dict(session=()) # type: Dict[str, Tuple[str, ...]]
scope2props["package"] = ("fspath",)
scope2props["module"] = ("fspath", "module")
Expand Down Expand Up @@ -792,6 +793,25 @@ def _teardown_yield_fixture(fixturefunc, it):
)


def _eval_scope_callable(scope_callable, fixture_name, config):
try:
result = scope_callable(fixture_name=fixture_name, config=config)
except Exception:
raise TypeError(
"Error evaluating {} while defining fixture '{}'.\n"
"Expected a function with the signature (*, fixture_name, config)".format(
scope_callable, fixture_name
)
)
if not isinstance(result, str):
fail(
"Expected {} to return a 'str' while defining fixture '{}', but it returned:\n"
"{!r}".format(scope_callable, fixture_name, result),
pytrace=False,
)
return result


class FixtureDef:
""" A container for a factory definition. """

Expand All @@ -811,6 +831,8 @@ def __init__(
self.has_location = baseid is not None
self.func = func
self.argname = argname
if callable(scope):
scope = _eval_scope_callable(scope, argname, fixturemanager.config)
self.scope = scope
self.scopenum = scope2index(
scope or "function",
Expand Down Expand Up @@ -995,7 +1017,57 @@ def __call__(self, function):
return function


def fixture(scope="function", params=None, autouse=False, ids=None, name=None):
FIXTURE_ARGS_ORDER = ("scope", "params", "autouse", "ids", "name")


def _parse_fixture_args(callable_or_scope, *args, **kwargs):
arguments = {
"scope": "function",
"params": None,
"autouse": False,
"ids": None,
"name": None,
}
kwargs = {
key: value for key, value in kwargs.items() if arguments.get(key) != value
}

fixture_function = None
if isinstance(callable_or_scope, str):
args = list(args)
args.insert(0, callable_or_scope)
else:
fixture_function = callable_or_scope

positionals = set()
for positional, argument_name in zip(args, FIXTURE_ARGS_ORDER):
arguments[argument_name] = positional
positionals.add(argument_name)

duplicated_kwargs = {kwarg for kwarg in kwargs.keys() if kwarg in positionals}
if duplicated_kwargs:
raise TypeError(
"The fixture arguments are defined as positional and keyword: {}. "
"Use only keyword arguments.".format(", ".join(duplicated_kwargs))
)

if positionals:
warnings.warn(FIXTURE_POSITIONAL_ARGUMENTS, stacklevel=2)

arguments.update(kwargs)

return fixture_function, arguments


def fixture(
callable_or_scope=None,
*args,
scope="function",
params=None,
autouse=False,
ids=None,
name=None
):
"""Decorator to mark a fixture factory function.

This decorator can be used, with or without parameters, to define a
Expand Down Expand Up @@ -1041,21 +1113,55 @@ def fixture(scope="function", params=None, autouse=False, ids=None, name=None):
``fixture_<fixturename>`` and then use
``@pytest.fixture(name='<fixturename>')``.
"""
if callable(scope) and params is None and autouse is False:
fixture_function, arguments = _parse_fixture_args(
callable_or_scope,
*args,
scope=scope,
params=params,
autouse=autouse,
ids=ids,
name=name
)
scope = arguments.get("scope")
params = arguments.get("params")
autouse = arguments.get("autouse")
ids = arguments.get("ids")
name = arguments.get("name")

if fixture_function and params is None and autouse is False:
# direct decoration
return FixtureFunctionMarker("function", params, autouse, name=name)(scope)
return FixtureFunctionMarker(scope, params, autouse, name=name)(
fixture_function
)

if params is not None and not isinstance(params, (list, tuple)):
params = list(params)
return FixtureFunctionMarker(scope, params, autouse, ids=ids, name=name)


def yield_fixture(scope="function", params=None, autouse=False, ids=None, name=None):
def yield_fixture(
callable_or_scope=None,
*args,
scope="function",
params=None,
autouse=False,
ids=None,
name=None
):
""" (return a) decorator to mark a yield-fixture factory function.

.. deprecated:: 3.0
Use :py:func:`pytest.fixture` directly instead.
"""
return fixture(scope=scope, params=params, autouse=autouse, ids=ids, name=name)
return fixture(
callable_or_scope,
*args,
scope=scope,
params=params,
autouse=autouse,
ids=ids,
name=name
)


defaultfuncargprefixmarker = fixture()
Expand Down
95 changes: 94 additions & 1 deletion testing/python/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -2217,6 +2217,68 @@ def test_1(arg):
["*ScopeMismatch*You tried*function*session*request*"]
)

def test_dynamic_scope(self, testdir):
testdir.makeconftest(
"""
import pytest


def pytest_addoption(parser):
parser.addoption("--extend-scope", action="store_true", default=False)


def dynamic_scope(fixture_name, config):
if config.getoption("--extend-scope"):
return "session"
return "function"


@pytest.fixture(scope=dynamic_scope)
def dynamic_fixture(calls=[]):
calls.append("call")
return len(calls)

"""
)

testdir.makepyfile(
"""
def test_first(dynamic_fixture):
assert dynamic_fixture == 1


def test_second(dynamic_fixture):
assert dynamic_fixture == 2

"""
)

reprec = testdir.inline_run()
reprec.assertoutcome(passed=2)

reprec = testdir.inline_run("--extend-scope")
reprec.assertoutcome(passed=1, failed=1)

def test_dynamic_scope_bad_return(self, testdir):
testdir.makepyfile(
"""
import pytest

def dynamic_scope(**_):
return "wrong-scope"

@pytest.fixture(scope=dynamic_scope)
def fixture():
pass

"""
)
result = testdir.runpytest()
result.stdout.fnmatch_lines(
"Fixture 'fixture' from test_dynamic_scope_bad_return.py "
"got an unexpected scope value 'wrong-scope'"
)

def test_register_only_with_mark(self, testdir):
testdir.makeconftest(
"""
Expand Down Expand Up @@ -4044,12 +4106,43 @@ def test_fixture_named_request(testdir):
)


def test_fixture_duplicated_arguments(testdir):
"""Raise error if there are positional and keyword arguments for the same parameter (#1682)."""
with pytest.raises(TypeError) as excinfo:

@pytest.fixture("session", scope="session")
def arg(arg):
pass

assert (
str(excinfo.value)
== "The fixture arguments are defined as positional and keyword: scope. "
"Use only keyword arguments."
)


def test_fixture_with_positionals(testdir):
"""Raise warning, but the positionals should still works (#1682)."""
from _pytest.deprecated import FIXTURE_POSITIONAL_ARGUMENTS

with pytest.warns(pytest.PytestDeprecationWarning) as warnings:

@pytest.fixture("function", [0], True)
def fixture_with_positionals():
pass

assert str(warnings[0].message) == str(FIXTURE_POSITIONAL_ARGUMENTS)

assert fixture_with_positionals._pytestfixturefunction.scope == "function"
assert fixture_with_positionals._pytestfixturefunction.params == (0,)
assert fixture_with_positionals._pytestfixturefunction.autouse


def test_indirect_fixture_does_not_break_scope(testdir):
"""Ensure that fixture scope is respected when using indirect fixtures (#570)"""
testdir.makepyfile(
"""
import pytest

instantiated = []

@pytest.fixture(scope="session")
Expand Down