Skip to content

Commit 10bf6aa

Browse files
committed
Implemented the dynamic scope feature.
1 parent 404cf0c commit 10bf6aa

File tree

8 files changed

+224
-7
lines changed

8 files changed

+224
-7
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Andras Tim
2323
Andrea Cimatoribus
2424
Andreas Zeidler
2525
Andrey Paramonov
26+
Andrzej Klajnert
2627
Andrzej Ostrowski
2728
Andy Freeland
2829
Anthon van der Neut

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: 73 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",
@@ -986,7 +1008,40 @@ def __call__(self, function):
9861008
return function
9871009

9881010

989-
def fixture(scope="function", params=None, autouse=False, ids=None, name=None):
1011+
FIXTURE_ARGS_ORDER = ("scope", "params", "autouse", "ids", "name")
1012+
1013+
1014+
def _parse_fixture_args(callable_or_scope, *args, **kwargs):
1015+
arguments = dict(scope="function", params=None, autouse=False, ids=None, name=None)
1016+
1017+
fixture_function = None
1018+
if isinstance(callable_or_scope, str):
1019+
args = list(args)
1020+
args.insert(0, callable_or_scope)
1021+
else:
1022+
fixture_function = callable_or_scope
1023+
1024+
positionals = set()
1025+
for positional, argument_name in zip(args, FIXTURE_ARGS_ORDER):
1026+
arguments[argument_name] = positional
1027+
positionals.add(argument_name)
1028+
1029+
duplicated_kwargs = {kwarg for kwarg in kwargs.keys() if kwarg in positionals}
1030+
if duplicated_kwargs:
1031+
raise TypeError(
1032+
"The fixture arguments are defined as positional and keyword: {}. "
1033+
"Use only keyword arguments.".format(", ".join(duplicated_kwargs))
1034+
)
1035+
1036+
if positionals:
1037+
warnings.warn(FIXTURE_POSITIONAL_ARGUMENTS, stacklevel=2)
1038+
1039+
arguments.update(kwargs)
1040+
1041+
return fixture_function, arguments
1042+
1043+
1044+
def fixture(callable_or_scope=None, *args, **kwargs):
9901045
"""Decorator to mark a fixture factory function.
9911046
9921047
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):
10321087
``fixture_<fixturename>`` and then use
10331088
``@pytest.fixture(name='<fixturename>')``.
10341089
"""
1035-
if callable(scope) and params is None and autouse is False:
1090+
fixture_function, arguments = _parse_fixture_args(
1091+
callable_or_scope, *args, **kwargs
1092+
)
1093+
scope = arguments.get("scope")
1094+
params = arguments.get("params")
1095+
autouse = arguments.get("autouse")
1096+
ids = arguments.get("ids")
1097+
name = arguments.get("name")
1098+
1099+
if fixture_function and params is None and autouse is False:
10361100
# direct decoration
1037-
return FixtureFunctionMarker("function", params, autouse, name=name)(scope)
1101+
return FixtureFunctionMarker(scope, params, autouse, name=name)(
1102+
fixture_function
1103+
)
1104+
10381105
if params is not None and not isinstance(params, (list, tuple)):
10391106
params = list(params)
10401107
return FixtureFunctionMarker(scope, params, autouse, ids=ids, name=name)
10411108

10421109

1043-
def yield_fixture(scope="function", params=None, autouse=False, ids=None, name=None):
1110+
def yield_fixture(callable_or_scope=None, *args, **kwargs):
10441111
""" (return a) decorator to mark a yield-fixture factory function.
10451112
10461113
.. deprecated:: 3.0
10471114
Use :py:func:`pytest.fixture` directly instead.
10481115
"""
1049-
return fixture(scope=scope, params=params, autouse=autouse, ids=ids, name=name)
1116+
return fixture(callable_or_scope=callable_or_scope, *args, **kwargs)
10501117

10511118

10521119
defaultfuncargprefixmarker = fixture()

testing/python/fixtures.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2216,6 +2216,68 @@ def test_1(arg):
22162216
["*ScopeMismatch*You tried*function*session*request*"]
22172217
)
22182218

2219+
def test_dynamic_scope(self, testdir):
2220+
testdir.makeconftest(
2221+
"""
2222+
import pytest
2223+
2224+
2225+
def pytest_addoption(parser):
2226+
parser.addoption("--extend-scope", action="store_true", default=False)
2227+
2228+
2229+
def dynamic_scope(fixture_name, config):
2230+
if config.getoption("--extend-scope"):
2231+
return "session"
2232+
return "function"
2233+
2234+
2235+
@pytest.fixture(scope=dynamic_scope)
2236+
def dynamic_fixture(calls=[]):
2237+
calls.append("call")
2238+
return len(calls)
2239+
2240+
"""
2241+
)
2242+
2243+
testdir.makepyfile(
2244+
"""
2245+
def test_first(dynamic_fixture):
2246+
assert dynamic_fixture == 1
2247+
2248+
2249+
def test_second(dynamic_fixture):
2250+
assert dynamic_fixture == 2
2251+
2252+
"""
2253+
)
2254+
2255+
reprec = testdir.inline_run()
2256+
reprec.assertoutcome(passed=2)
2257+
2258+
reprec = testdir.inline_run("--extend-scope")
2259+
reprec.assertoutcome(passed=1, failed=1)
2260+
2261+
def test_dynamic_scope_bad_return(self, testdir):
2262+
testdir.makepyfile(
2263+
"""
2264+
import pytest
2265+
2266+
def dynamic_scope(**_):
2267+
return "wrong-scope"
2268+
2269+
@pytest.fixture(scope=dynamic_scope)
2270+
def fixture():
2271+
pass
2272+
2273+
"""
2274+
)
2275+
result = testdir.runpytest()
2276+
result.stdout.fnmatch_lines(
2277+
"Fixture 'fixture' from test_dynamic_scope_bad_return.py "
2278+
"got an unexpected scope value 'wrong-scope'"
2279+
)
2280+
22192281
def test_register_only_with_mark(self, testdir):
22202282
testdir.makeconftest(
22212283
"""
@@ -4009,3 +4071,54 @@ def test_fixture_named_request(testdir):
40094071
" *test_fixture_named_request.py:5",
40104072
]
40114073
)
4074+
4075+
4076+
def test_fixture_duplicated_arguments(testdir):
4077+
testdir.makepyfile(
4078+
"""
4079+
import pytest
4080+
4081+
with pytest.raises(TypeError) as excinfo:
4082+
4083+
@pytest.fixture("session", scope="session")
4084+
def arg(arg):
4085+
pass
4086+
4087+
def test_error():
4088+
assert (
4089+
str(excinfo.value)
4090+
== "The fixture arguments are defined as positional and keyword: scope. "
4091+
"Use only keyword arguments."
4092+
)
4093+
4094+
"""
4095+
)
4096+
4097+
reprec = testdir.inline_run()
4098+
reprec.assertoutcome(passed=1)
4099+
4100+
4101+
def test_fixture_with_positionals(testdir):
4102+
"""Raise warning, but the positionals should still works."""
4103+
testdir.makepyfile(
4104+
"""
4105+
import os
4106+
4107+
import pytest
4108+
from _pytest.deprecated import FIXTURE_POSITIONAL_ARGUMENTS
4109+
4110+
with pytest.warns(pytest.PytestDeprecationWarning) as warnings:
4111+
@pytest.fixture("function", [0], True)
4112+
def arg(monkeypatch):
4113+
monkeypatch.setenv("AUTOUSE_WORKS", "1")
4114+
4115+
4116+
def test_autouse():
4117+
assert os.environ.get("AUTOUSE_WORKS") == "1"
4118+
assert str(warnings[0].message) == str(FIXTURE_POSITIONAL_ARGUMENTS)
4119+
4120+
"""
4121+
)
4122+
4123+
reprec = testdir.inline_run()
4124+
reprec.assertoutcome(passed=1)

0 commit comments

Comments
 (0)