diff --git a/AUTHORS b/AUTHORS index 6d1a2a816..1ceb66dde 100644 --- a/AUTHORS +++ b/AUTHORS @@ -23,6 +23,7 @@ Andras Tim Andrea Cimatoribus Andreas Zeidler Andrey Paramonov +Andrzej Klajnert Andrzej Ostrowski Andy Freeland Anthon van der Neut diff --git a/changelog/1682.deprecation.rst b/changelog/1682.deprecation.rst new file mode 100644 index 000000000..741164eb6 --- /dev/null +++ b/changelog/1682.deprecation.rst @@ -0,0 +1,2 @@ +Passing arguments to pytest.fixture() as positional arguments is deprecated - pass them +as a keyword argument instead. diff --git a/changelog/1682.feature.rst b/changelog/1682.feature.rst new file mode 100644 index 000000000..392de6363 --- /dev/null +++ b/changelog/1682.feature.rst @@ -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 `__ for more information. diff --git a/doc/en/example/costlysetup/conftest.py b/doc/en/example/costlysetup/conftest.py index 8fa9c9fe4..803559834 100644 --- a/doc/en/example/costlysetup/conftest.py +++ b/doc/en/example/costlysetup/conftest.py @@ -1,7 +1,7 @@ import pytest -@pytest.fixture("session") +@pytest.fixture(scope="session") def setup(request): setup = CostlySetup() yield setup diff --git a/doc/en/fixture.rst b/doc/en/fixture.rst index 91b5aca85..1d6370bb5 100644 --- a/doc/en/fixture.rst +++ b/doc/en/fixture.rst @@ -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 ---------------------------------------------------- diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index c06908932..5186067ef 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -29,3 +29,8 @@ RESULT_LOG = PytestDeprecationWarning( "--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." +) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index d5f9ad2d3..156d55dc7 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -2,6 +2,7 @@ import functools import inspect import itertools import sys +import warnings from collections import defaultdict from collections import deque from collections import OrderedDict @@ -27,6 +28,7 @@ from _pytest.compat import getlocation 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 @@ -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") @@ -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. """ @@ -811,6 +831,8 @@ class FixtureDef: 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", @@ -986,7 +1008,40 @@ class FixtureFunctionMarker: 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. 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_`` and then use ``@pytest.fixture(name='')``. """ - 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 - 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, **kwargs): """ (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=callable_or_scope, *args, **kwargs) defaultfuncargprefixmarker = fixture() diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 1f383e752..762237bde 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -2216,6 +2216,68 @@ class TestFixtureMarker: ["*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( """ @@ -4009,3 +4071,54 @@ def test_fixture_named_request(testdir): " *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)