Skip to content

Commit 9669413

Browse files
authored
Merge pull request #5776 from aklajnert/1682-dynamic-scope
Implemented the dynamic scope feature.
2 parents c997c32 + e2382e9 commit 9669413

File tree

7 files changed

+243
-8
lines changed

7 files changed

+243
-8
lines changed

changelog/1682.deprecation.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Passing arguments to pytest.fixture() as positional arguments is deprecated - pass them
2+
as a keyword argument instead.

changelog/1682.feature.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
The ``scope`` parameter of ``@pytest.fixture`` can now be a callable that receives
2+
the fixture name and the ``config`` object as keyword-only parameters.
3+
See `the docs <https://docs.pytest.org/en/fixture.html#dynamic-scope>`__ for more information.

doc/en/example/costlysetup/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import pytest
22

33

4-
@pytest.fixture("session")
4+
@pytest.fixture(scope="session")
55
def setup(request):
66
setup = CostlySetup()
77
yield setup

doc/en/fixture.rst

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,32 @@ are finalized when the last test of a *package* finishes.
301301
Use this new feature sparingly and please make sure to report any issues you find.
302302

303303

304+
Dynamic scope
305+
^^^^^^^^^^^^^
306+
307+
In some cases, you might want to change the scope of the fixture without changing the code.
308+
To do that, pass a callable to ``scope``. The callable must return a string with a valid scope
309+
and will be executed only once - during the fixture definition. It will be called with two
310+
keyword arguments - ``fixture_name`` as a string and ``config`` with a configuration object.
311+
312+
This can be especially useful when dealing with fixtures that need time for setup, like spawning
313+
a docker container. You can use the command-line argument to control the scope of the spawned
314+
containers for different environments. See the example below.
315+
316+
.. code-block:: python
317+
318+
def determine_scope(fixture_name, config):
319+
if config.getoption("--keep-containers"):
320+
return "session"
321+
return "function"
322+
323+
324+
@pytest.fixture(scope=determine_scope)
325+
def docker_container():
326+
yield spawn_container()
327+
328+
329+
304330
Order: Higher-scoped fixtures are instantiated first
305331
----------------------------------------------------
306332

src/_pytest/deprecated.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,8 @@
2929
"--result-log is deprecated and scheduled for removal in pytest 6.0.\n"
3030
"See https://docs.pytest.org/en/latest/deprecations.html#result-log-result-log for more information."
3131
)
32+
33+
FIXTURE_POSITIONAL_ARGUMENTS = PytestDeprecationWarning(
34+
"Passing arguments to pytest.fixture() as positional arguments is deprecated - pass them "
35+
"as a keyword argument instead."
36+
)

src/_pytest/fixtures.py

Lines changed: 112 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import inspect
33
import itertools
44
import sys
5+
import warnings
56
from collections import defaultdict
67
from collections import deque
78
from collections import OrderedDict
@@ -27,6 +28,7 @@
2728
from _pytest.compat import is_generator
2829
from _pytest.compat import NOTSET
2930
from _pytest.compat import safe_getattr
31+
from _pytest.deprecated import FIXTURE_POSITIONAL_ARGUMENTS
3032
from _pytest.outcomes import fail
3133
from _pytest.outcomes import TEST_OUTCOME
3234

@@ -58,7 +60,6 @@ def pytest_sessionstart(session):
5860

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

61-
6263
scope2props = dict(session=()) # type: Dict[str, Tuple[str, ...]]
6364
scope2props["package"] = ("fspath",)
6465
scope2props["module"] = ("fspath", "module")
@@ -792,6 +793,25 @@ def _teardown_yield_fixture(fixturefunc, it):
792793
)
793794

794795

796+
def _eval_scope_callable(scope_callable, fixture_name, config):
797+
try:
798+
result = scope_callable(fixture_name=fixture_name, config=config)
799+
except Exception:
800+
raise TypeError(
801+
"Error evaluating {} while defining fixture '{}'.\n"
802+
"Expected a function with the signature (*, fixture_name, config)".format(
803+
scope_callable, fixture_name
804+
)
805+
)
806+
if not isinstance(result, str):
807+
fail(
808+
"Expected {} to return a 'str' while defining fixture '{}', but it returned:\n"
809+
"{!r}".format(scope_callable, fixture_name, result),
810+
pytrace=False,
811+
)
812+
return result
813+
814+
795815
class FixtureDef:
796816
""" A container for a factory definition. """
797817

@@ -811,6 +831,8 @@ def __init__(
811831
self.has_location = baseid is not None
812832
self.func = func
813833
self.argname = argname
834+
if callable(scope):
835+
scope = _eval_scope_callable(scope, argname, fixturemanager.config)
814836
self.scope = scope
815837
self.scopenum = scope2index(
816838
scope or "function",
@@ -995,7 +1017,57 @@ def __call__(self, function):
9951017
return function
9961018

9971019

998-
def fixture(scope="function", params=None, autouse=False, ids=None, name=None):
1020+
FIXTURE_ARGS_ORDER = ("scope", "params", "autouse", "ids", "name")
1021+
1022+
1023+
def _parse_fixture_args(callable_or_scope, *args, **kwargs):
1024+
arguments = {
1025+
"scope": "function",
1026+
"params": None,
1027+
"autouse": False,
1028+
"ids": None,
1029+
"name": None,
1030+
}
1031+
kwargs = {
1032+
key: value for key, value in kwargs.items() if arguments.get(key) != value
1033+
}
1034+
1035+
fixture_function = None
1036+
if isinstance(callable_or_scope, str):
1037+
args = list(args)
1038+
args.insert(0, callable_or_scope)
1039+
else:
1040+
fixture_function = callable_or_scope
1041+
1042+
positionals = set()
1043+
for positional, argument_name in zip(args, FIXTURE_ARGS_ORDER):
1044+
arguments[argument_name] = positional
1045+
positionals.add(argument_name)
1046+
1047+
duplicated_kwargs = {kwarg for kwarg in kwargs.keys() if kwarg in positionals}
1048+
if duplicated_kwargs:
1049+
raise TypeError(
1050+
"The fixture arguments are defined as positional and keyword: {}. "
1051+
"Use only keyword arguments.".format(", ".join(duplicated_kwargs))
1052+
)
1053+
1054+
if positionals:
1055+
warnings.warn(FIXTURE_POSITIONAL_ARGUMENTS, stacklevel=2)
1056+
1057+
arguments.update(kwargs)
1058+
1059+
return fixture_function, arguments
1060+
1061+
1062+
def fixture(
1063+
callable_or_scope=None,
1064+
*args,
1065+
scope="function",
1066+
params=None,
1067+
autouse=False,
1068+
ids=None,
1069+
name=None
1070+
):
9991071
"""Decorator to mark a fixture factory function.
10001072
10011073
This decorator can be used, with or without parameters, to define a
@@ -1041,21 +1113,55 @@ def fixture(scope="function", params=None, autouse=False, ids=None, name=None):
10411113
``fixture_<fixturename>`` and then use
10421114
``@pytest.fixture(name='<fixturename>')``.
10431115
"""
1044-
if callable(scope) and params is None and autouse is False:
1116+
fixture_function, arguments = _parse_fixture_args(
1117+
callable_or_scope,
1118+
*args,
1119+
scope=scope,
1120+
params=params,
1121+
autouse=autouse,
1122+
ids=ids,
1123+
name=name
1124+
)
1125+
scope = arguments.get("scope")
1126+
params = arguments.get("params")
1127+
autouse = arguments.get("autouse")
1128+
ids = arguments.get("ids")
1129+
name = arguments.get("name")
1130+
1131+
if fixture_function and params is None and autouse is False:
10451132
# direct decoration
1046-
return FixtureFunctionMarker("function", params, autouse, name=name)(scope)
1133+
return FixtureFunctionMarker(scope, params, autouse, name=name)(
1134+
fixture_function
1135+
)
1136+
10471137
if params is not None and not isinstance(params, (list, tuple)):
10481138
params = list(params)
10491139
return FixtureFunctionMarker(scope, params, autouse, ids=ids, name=name)
10501140

10511141

1052-
def yield_fixture(scope="function", params=None, autouse=False, ids=None, name=None):
1142+
def yield_fixture(
1143+
callable_or_scope=None,
1144+
*args,
1145+
scope="function",
1146+
params=None,
1147+
autouse=False,
1148+
ids=None,
1149+
name=None
1150+
):
10531151
""" (return a) decorator to mark a yield-fixture factory function.
10541152
10551153
.. deprecated:: 3.0
10561154
Use :py:func:`pytest.fixture` directly instead.
10571155
"""
1058-
return fixture(scope=scope, params=params, autouse=autouse, ids=ids, name=name)
1156+
return fixture(
1157+
callable_or_scope,
1158+
*args,
1159+
scope=scope,
1160+
params=params,
1161+
autouse=autouse,
1162+
ids=ids,
1163+
name=name
1164+
)
10591165

10601166

10611167
defaultfuncargprefixmarker = fixture()

testing/python/fixtures.py

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2217,6 +2217,68 @@ def test_1(arg):
22172217
["*ScopeMismatch*You tried*function*session*request*"]
22182218
)
22192219

2220+
def test_dynamic_scope(self, testdir):
2221+
testdir.makeconftest(
2222+
"""
2223+
import pytest
2224+
2225+
2226+
def pytest_addoption(parser):
2227+
parser.addoption("--extend-scope", action="store_true", default=False)
2228+
2229+
2230+
def dynamic_scope(fixture_name, config):
2231+
if config.getoption("--extend-scope"):
2232+
return "session"
2233+
return "function"
2234+
2235+
2236+
@pytest.fixture(scope=dynamic_scope)
2237+
def dynamic_fixture(calls=[]):
2238+
calls.append("call")
2239+
return len(calls)
2240+
2241+
"""
2242+
)
2243+
2244+
testdir.makepyfile(
2245+
"""
2246+
def test_first(dynamic_fixture):
2247+
assert dynamic_fixture == 1
2248+
2249+
2250+
def test_second(dynamic_fixture):
2251+
assert dynamic_fixture == 2
2252+
2253+
"""
2254+
)
2255+
2256+
reprec = testdir.inline_run()
2257+
reprec.assertoutcome(passed=2)
2258+
2259+
reprec = testdir.inline_run("--extend-scope")
2260+
reprec.assertoutcome(passed=1, failed=1)
2261+
2262+
def test_dynamic_scope_bad_return(self, testdir):
2263+
testdir.makepyfile(
2264+
"""
2265+
import pytest
2266+
2267+
def dynamic_scope(**_):
2268+
return "wrong-scope"
2269+
2270+
@pytest.fixture(scope=dynamic_scope)
2271+
def fixture():
2272+
pass
2273+
2274+
"""
2275+
)
2276+
result = testdir.runpytest()
2277+
result.stdout.fnmatch_lines(
2278+
"Fixture 'fixture' from test_dynamic_scope_bad_return.py "
2279+
"got an unexpected scope value 'wrong-scope'"
2280+
)
2281+
22202282
def test_register_only_with_mark(self, testdir):
22212283
testdir.makeconftest(
22222284
"""
@@ -4044,12 +4106,43 @@ def test_fixture_named_request(testdir):
40444106
)
40454107

40464108

4109+
def test_fixture_duplicated_arguments(testdir):
4110+
"""Raise error if there are positional and keyword arguments for the same parameter (#1682)."""
4111+
with pytest.raises(TypeError) as excinfo:
4112+
4113+
@pytest.fixture("session", scope="session")
4114+
def arg(arg):
4115+
pass
4116+
4117+
assert (
4118+
str(excinfo.value)
4119+
== "The fixture arguments are defined as positional and keyword: scope. "
4120+
"Use only keyword arguments."
4121+
)
4122+
4123+
4124+
def test_fixture_with_positionals(testdir):
4125+
"""Raise warning, but the positionals should still works (#1682)."""
4126+
from _pytest.deprecated import FIXTURE_POSITIONAL_ARGUMENTS
4127+
4128+
with pytest.warns(pytest.PytestDeprecationWarning) as warnings:
4129+
4130+
@pytest.fixture("function", [0], True)
4131+
def fixture_with_positionals():
4132+
pass
4133+
4134+
assert str(warnings[0].message) == str(FIXTURE_POSITIONAL_ARGUMENTS)
4135+
4136+
assert fixture_with_positionals._pytestfixturefunction.scope == "function"
4137+
assert fixture_with_positionals._pytestfixturefunction.params == (0,)
4138+
assert fixture_with_positionals._pytestfixturefunction.autouse
4139+
4140+
40474141
def test_indirect_fixture_does_not_break_scope(testdir):
40484142
"""Ensure that fixture scope is respected when using indirect fixtures (#570)"""
40494143
testdir.makepyfile(
40504144
"""
40514145
import pytest
4052-
40534146
instantiated = []
40544147
40554148
@pytest.fixture(scope="session")

0 commit comments

Comments
 (0)