Implemented the dynamic scope feature.

This commit is contained in:
aklajnert 2019-08-21 14:41:37 +02:00 committed by Andrzej Klajnert
parent 404cf0c872
commit 10bf6aac76
8 changed files with 224 additions and 7 deletions

View File

@ -23,6 +23,7 @@ Andras Tim
Andrea Cimatoribus Andrea Cimatoribus
Andreas Zeidler Andreas Zeidler
Andrey Paramonov Andrey Paramonov
Andrzej Klajnert
Andrzej Ostrowski Andrzej Ostrowski
Andy Freeland Andy Freeland
Anthon van der Neut Anthon van der Neut

View File

@ -0,0 +1,2 @@
Passing arguments to pytest.fixture() as positional arguments is deprecated - pass them
as a keyword argument instead.

View File

@ -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.

View File

@ -1,7 +1,7 @@
import pytest import pytest
@pytest.fixture("session") @pytest.fixture(scope="session")
def setup(request): def setup(request):
setup = CostlySetup() setup = CostlySetup()
yield setup yield setup

View File

@ -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. 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 Order: Higher-scoped fixtures are instantiated first
---------------------------------------------------- ----------------------------------------------------

View File

@ -29,3 +29,8 @@ RESULT_LOG = PytestDeprecationWarning(
"--result-log is deprecated and scheduled for removal in pytest 6.0.\n" "--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." "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."
)

View File

@ -2,6 +2,7 @@ import functools
import inspect import inspect
import itertools import itertools
import sys import sys
import warnings
from collections import defaultdict from collections import defaultdict
from collections import deque from collections import deque
from collections import OrderedDict from collections import OrderedDict
@ -27,6 +28,7 @@ from _pytest.compat import getlocation
from _pytest.compat import is_generator from _pytest.compat import is_generator
from _pytest.compat import NOTSET from _pytest.compat import NOTSET
from _pytest.compat import safe_getattr from _pytest.compat import safe_getattr
from _pytest.deprecated import FIXTURE_POSITIONAL_ARGUMENTS
from _pytest.outcomes import fail from _pytest.outcomes import fail
from _pytest.outcomes import TEST_OUTCOME from _pytest.outcomes import TEST_OUTCOME
@ -58,7 +60,6 @@ def pytest_sessionstart(session):
scopename2class = {} # type: Dict[str, Type[nodes.Node]] scopename2class = {} # type: Dict[str, Type[nodes.Node]]
scope2props = dict(session=()) # type: Dict[str, Tuple[str, ...]] scope2props = dict(session=()) # type: Dict[str, Tuple[str, ...]]
scope2props["package"] = ("fspath",) scope2props["package"] = ("fspath",)
scope2props["module"] = ("fspath", "module") scope2props["module"] = ("fspath", "module")
@ -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: class FixtureDef:
""" A container for a factory definition. """ """ A container for a factory definition. """
@ -811,6 +831,8 @@ class FixtureDef:
self.has_location = baseid is not None self.has_location = baseid is not None
self.func = func self.func = func
self.argname = argname self.argname = argname
if callable(scope):
scope = _eval_scope_callable(scope, argname, fixturemanager.config)
self.scope = scope self.scope = scope
self.scopenum = scope2index( self.scopenum = scope2index(
scope or "function", scope or "function",
@ -986,7 +1008,40 @@ class FixtureFunctionMarker:
return 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 = dict(scope="function", params=None, autouse=False, ids=None, name=None)
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, **kwargs):
"""Decorator to mark a fixture factory function. """Decorator to mark a fixture factory function.
This decorator can be used, with or without parameters, to define a This decorator can be used, with or without parameters, to define a
@ -1032,21 +1087,33 @@ def fixture(scope="function", params=None, autouse=False, ids=None, name=None):
``fixture_<fixturename>`` and then use ``fixture_<fixturename>`` and then use
``@pytest.fixture(name='<fixturename>')``. ``@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, **kwargs
)
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 # 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)): if params is not None and not isinstance(params, (list, tuple)):
params = list(params) params = list(params)
return FixtureFunctionMarker(scope, params, autouse, ids=ids, name=name) 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, **kwargs):
""" (return a) decorator to mark a yield-fixture factory function. """ (return a) decorator to mark a yield-fixture factory function.
.. deprecated:: 3.0 .. deprecated:: 3.0
Use :py:func:`pytest.fixture` directly instead. Use :py:func:`pytest.fixture` directly instead.
""" """
return fixture(scope=scope, params=params, autouse=autouse, ids=ids, name=name) return fixture(callable_or_scope=callable_or_scope, *args, **kwargs)
defaultfuncargprefixmarker = fixture() defaultfuncargprefixmarker = fixture()

View File

@ -2216,6 +2216,68 @@ class TestFixtureMarker:
["*ScopeMismatch*You tried*function*session*request*"] ["*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): def test_register_only_with_mark(self, testdir):
testdir.makeconftest( testdir.makeconftest(
""" """
@ -4009,3 +4071,54 @@ def test_fixture_named_request(testdir):
" *test_fixture_named_request.py:5", " *test_fixture_named_request.py:5",
] ]
) )
def test_fixture_duplicated_arguments(testdir):
testdir.makepyfile(
"""
import pytest
with pytest.raises(TypeError) as excinfo:
@pytest.fixture("session", scope="session")
def arg(arg):
pass
def test_error():
assert (
str(excinfo.value)
== "The fixture arguments are defined as positional and keyword: scope. "
"Use only keyword arguments."
)
"""
)
reprec = testdir.inline_run()
reprec.assertoutcome(passed=1)
def test_fixture_with_positionals(testdir):
"""Raise warning, but the positionals should still works."""
testdir.makepyfile(
"""
import os
import pytest
from _pytest.deprecated import FIXTURE_POSITIONAL_ARGUMENTS
with pytest.warns(pytest.PytestDeprecationWarning) as warnings:
@pytest.fixture("function", [0], True)
def arg(monkeypatch):
monkeypatch.setenv("AUTOUSE_WORKS", "1")
def test_autouse():
assert os.environ.get("AUTOUSE_WORKS") == "1"
assert str(warnings[0].message) == str(FIXTURE_POSITIONAL_ARGUMENTS)
"""
)
reprec = testdir.inline_run()
reprec.assertoutcome(passed=1)