From 29289b472f6532805f3329121fcb657bbca12ad1 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 27 Jun 2016 18:09:18 +0200 Subject: [PATCH 1/9] Add documentation for "invocation" scoped fixture --- doc/en/fixture.rst | 10 +++++- doc/en/invocation-fixture.rst | 61 +++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 doc/en/invocation-fixture.rst diff --git a/doc/en/fixture.rst b/doc/en/fixture.rst index 2077f2db0..daf4f278c 100644 --- a/doc/en/fixture.rst +++ b/doc/en/fixture.rst @@ -603,7 +603,7 @@ first execute with one instance and then finalizers are called before the next fixture instance is created. Among other things, this eases testing of applications which create and use global state. -The following example uses two parametrized funcargs, one of which is +The following example uses two parametrized fixture, one of which is scoped on a per-module basis, and all the functions perform ``print`` calls to show the setup/teardown flow:: @@ -863,6 +863,14 @@ All test methods in this TestClass will use the transaction fixture while other test classes or functions in the module will not use it unless they also add a ``transact`` reference. +invocation-scoped fixtures +-------------------------- + +pytest 3.0 introduced a new advanced scope for fixtures: ``"invocation"``. Fixtures marked with +this scope can be requested from any other scope, providing a version of the fixture for that scope. + +See more in :ref:`invocation_scoped_fixture`. + Shifting (visibility of) fixture functions ---------------------------------------------------- diff --git a/doc/en/invocation-fixture.rst b/doc/en/invocation-fixture.rst new file mode 100644 index 000000000..b096fd4b8 --- /dev/null +++ b/doc/en/invocation-fixture.rst @@ -0,0 +1,61 @@ +.. _invocation_scoped_fixture: + +Invocation-scoped fixtures +========================== + +.. versionadded:: 3.0 + +.. note:: + This feature is experimental, so if decided that it brings too much problems + or considered too complicated it might be removed in pytest ``3.1``. + + ``tmpdir`` and ``monkeypatch`` might become ``invocation`` scoped + fixtures in the future if decided to keep invocation-scoped fixtures. + +Fixtures can be defined with ``invocation`` scope, meaning that the fixture +can be requested by fixtures from any scope, but when they do they assume +the same scope as the fixture requesting it. + +An ``invocation``-scoped fixture can be requested from different scopes +in the same test session, in which case each scope will have its own copy. + +Example +------- + +Consider a fixture which manages external process execution: +this fixture provides auxiliary methods for tests and fixtures to start external +processes while making sure the +processes terminate at the appropriate time. Because it makes sense +to start a webserver for the entire session and also to execute a numerical +simulation for a single test function, the ``process_manager`` +fixture can be declared as ``invocation``, so each scope gets its own +value and can manage processes which will live for the duration of the scope. + +.. code-block:: python + + import pytest + + @pytest.fixture(scope='invocation') + def process_manager(): + m = ProcessManager() + yield m + m.shutdown_all() + + + @pytest.fixture(scope='session') + def server(process_manager): + process_manager.start(sys.executable, 'server.py') + + + @pytest.fixture(scope='function') + def start_simulation(process_manager): + import functools + return functools.partial(process_manager.start, + sys.executable, 'simulator.py') + + +In the above code, simulators started using ``start_simulation`` will be +terminated when the test function exits, while the server will be kept +active for the entire simulation run, being terminated when the test session +finishes. + From 775100881a547fe1a03299a3e4754662dd02b9fc Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 6 Jul 2016 21:35:32 -0300 Subject: [PATCH 2/9] Implement invocation-scoped fixtures --- _pytest/capture.py | 4 +- _pytest/python.py | 117 +++++++++++++++++++++++++++++++-------------- 2 files changed, 82 insertions(+), 39 deletions(-) diff --git a/_pytest/capture.py b/_pytest/capture.py index 48c14fbc8..f97cf6256 100644 --- a/_pytest/capture.py +++ b/_pytest/capture.py @@ -161,7 +161,7 @@ def capsys(request): captured output available via ``capsys.readouterr()`` method calls which return a ``(out, err)`` tuple. """ - if "capfd" in request._funcargs: + if "capfd" in request.fixturenames: raise request.raiseerror(error_capsysfderror) request.node._capfuncarg = c = CaptureFixture(SysCapture, request) return c @@ -172,7 +172,7 @@ def capfd(request): captured output available via ``capfd.readouterr()`` method calls which return a ``(out, err)`` tuple. """ - if "capsys" in request._funcargs: + if "capsys" in request.fixturenames: request.raiseerror(error_capsysfderror) if not hasattr(os, 'dup'): pytest.skip("capfd funcarg needs os.dup") diff --git a/_pytest/python.py b/_pytest/python.py index c959b21d8..60e100094 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -1838,34 +1838,51 @@ class FixtureRequest(FuncargnamesCompatAttr): self.fixturename = None #: Scope string, one of "function", "class", "module", "session" self.scope = "function" - self._funcargs = {} - self._fixturedefs = {} + # rename both attributes below because their key has changed; better an attribute error + # than subtle key misses; also backward incompatibility + self._fixture_values = {} # (argname, scope) -> fixture value + self._fixture_defs = {} # (argname, scope) -> FixtureDef fixtureinfo = pyfuncitem._fixtureinfo self._arg2fixturedefs = fixtureinfo.name2fixturedefs.copy() self._arg2index = {} - self.fixturenames = fixtureinfo.names_closure self._fixturemanager = pyfuncitem.session._fixturemanager + @property + def fixturenames(self): + # backward incompatible note: now a readonly property + return list(self._pyfuncitem._fixtureinfo.names_closure) + @property def node(self): """ underlying collection node (depends on current request scope)""" return self._getscopeitem(self.scope) - def _getnextfixturedef(self, argname): - fixturedefs = self._arg2fixturedefs.get(argname, None) + def _getnextfixturedef(self, argname, scope): + def trygetfixturedefs(argname): + fixturedefs = self._arg2fixturedefs.get(argname, None) + if fixturedefs is None: + fixturedefs = self._arg2fixturedefs.get(argname + ':' + scope, None) + return fixturedefs + + fixturedefs = trygetfixturedefs(argname) if fixturedefs is None: # we arrive here because of a a dynamic call to # getfixturevalue(argname) usage which was naturally # not known at parsing/collection time - fixturedefs = self._fixturemanager.getfixturedefs( - argname, self._pyfuncitem.parent.nodeid) - self._arg2fixturedefs[argname] = fixturedefs + parentid = self._pyfuncitem.parent.nodeid + fixturedefs = self._fixturemanager.getfixturedefs(argname, parentid) + if fixturedefs: + self._arg2fixturedefs[argname] = fixturedefs + fixturedefs_by_argname = self._fixturemanager.getfixturedefs_multiple_scopes(argname, parentid) + if fixturedefs_by_argname: + self._arg2fixturedefs.update(fixturedefs_by_argname) + fixturedefs = trygetfixturedefs(argname) # fixturedefs list is immutable so we maintain a decreasing index - index = self._arg2index.get(argname, 0) - 1 + index = self._arg2index.get((argname, scope), 0) - 1 if fixturedefs is None or (-index > len(fixturedefs)): raise FixtureLookupError(argname, self) - self._arg2index[argname] = index + self._arg2index[(argname, scope)] = index return fixturedefs[index] @property @@ -2004,10 +2021,10 @@ class FixtureRequest(FuncargnamesCompatAttr): def _get_active_fixturedef(self, argname): try: - return self._fixturedefs[argname] + return self._fixture_defs[(argname, self.scope)] except KeyError: try: - fixturedef = self._getnextfixturedef(argname) + fixturedef = self._getnextfixturedef(argname, self.scope) except FixtureLookupError: if argname == "request": class PseudoFixtureDef: @@ -2018,8 +2035,8 @@ class FixtureRequest(FuncargnamesCompatAttr): # remove indent to prevent the python3 exception # from leaking into the call result = self._getfixturevalue(fixturedef) - self._funcargs[argname] = result - self._fixturedefs[argname] = fixturedef + self._fixture_values[(argname, self.scope)] = result + self._fixture_defs[(argname, self.scope)] = fixturedef return fixturedef def _get_fixturestack(self): @@ -2140,11 +2157,10 @@ class SubRequest(FixtureRequest): self._fixturedef = fixturedef self.addfinalizer = fixturedef.addfinalizer self._pyfuncitem = request._pyfuncitem - self._funcargs = request._funcargs - self._fixturedefs = request._fixturedefs + self._fixture_values = request._fixture_values + self._fixture_defs = request._fixture_defs self._arg2fixturedefs = request._arg2fixturedefs self._arg2index = request._arg2index - self.fixturenames = request.fixturenames self._fixturemanager = request._fixturemanager def __repr__(self): @@ -2184,7 +2200,7 @@ class FixtureLookupError(LookupError): fspath, lineno = getfslineno(function) try: lines, _ = inspect.getsourcelines(get_real_func(function)) - except (IOError, IndexError): + except (IOError, IndexError, TypeError): error_msg = "file %s, line %s: source code not available" addline(error_msg % (fspath, lineno+1)) else: @@ -2198,9 +2214,9 @@ class FixtureLookupError(LookupError): if msg is None: fm = self.request._fixturemanager available = [] - for name, fixturedef in fm._arg2fixturedefs.items(): - parentid = self.request._pyfuncitem.parent.nodeid - faclist = list(fm._matchfactories(fixturedef, parentid)) + parentid = self.request._pyfuncitem.parent.nodeid + for name, fixturedefs in fm._arg2fixturedefs.items(): + faclist = list(fm._matchfactories(fixturedefs, parentid)) if faclist: available.append(name) msg = "fixture %r not found" % (self.argname,) @@ -2348,6 +2364,11 @@ class FixtureManager: if fixturedefs: arg2fixturedefs[argname] = fixturedefs merge(fixturedefs[-1].argnames) + fixturedefs_by_argname = self.getfixturedefs_multiple_scopes(argname, parentid) + if fixturedefs_by_argname: + arg2fixturedefs.update(fixturedefs_by_argname) + for fixturedefs in fixturedefs_by_argname.values(): + merge(fixturedefs[-1].argnames) return fixturenames_closure, arg2fixturedefs def pytest_generate_tests(self, metafunc): @@ -2366,7 +2387,7 @@ class FixtureManager: indirect=True, scope=fixturedef.scope, ids=fixturedef.ids) else: - continue # will raise FixtureLookupError at setup time + continue # will raise FixtureLookupError at setup time def pytest_collection_modifyitems(self, items): # separate parametrized setups @@ -2402,21 +2423,31 @@ class FixtureManager: if marker.name: name = marker.name assert not name.startswith(self._argprefix), name - fixturedef = FixtureDef(self, nodeid, name, obj, - marker.scope, marker.params, - unittest=unittest, ids=marker.ids) - faclist = self._arg2fixturedefs.setdefault(name, []) - if fixturedef.has_location: - faclist.append(fixturedef) + + def new_fixture_def(name, scope): + fixture_def = FixtureDef(self, nodeid, name, obj, + scope, marker.params, + unittest=unittest, ids=marker.ids) + + faclist = self._arg2fixturedefs.setdefault(name, []) + if fixture_def.has_location: + faclist.append(fixture_def) + else: + # fixturedefs with no location are at the front + # so this inserts the current fixturedef after the + # existing fixturedefs from external plugins but + # before the fixturedefs provided in conftests. + i = len([f for f in faclist if not f.has_location]) + faclist.insert(i, fixture_def) + if marker.autouse: + autousenames.append(name) + + if marker.scope == 'invocation': + for new_scope in scopes: + new_fixture_def(name + ':{0}'.format(new_scope), new_scope) else: - # fixturedefs with no location are at the front - # so this inserts the current fixturedef after the - # existing fixturedefs from external plugins but - # before the fixturedefs provided in conftests. - i = len([f for f in faclist if not f.has_location]) - faclist.insert(i, fixturedef) - if marker.autouse: - autousenames.append(name) + new_fixture_def(name, marker.scope) + if autousenames: self._nodeid_and_autousenames.append((nodeid or '', autousenames)) @@ -2433,6 +2464,18 @@ class FixtureManager: if nodeid.startswith(fixturedef.baseid): yield fixturedef + def getfixturedefs_multiple_scopes(self, argname, nodeid): + prefix = argname + ':' + fixturedefs_by_argname = dict((k, v) for k, v in self._arg2fixturedefs.items() + if k.startswith(prefix)) + if fixturedefs_by_argname: + result = {} + for argname, fixturedefs in fixturedefs_by_argname.items(): + result[argname] = tuple(self._matchfactories(fixturedefs, nodeid)) + return result + else: + return None + def fail_fixturefunc(fixturefunc, msg): fs, lineno = getfslineno(fixturefunc) @@ -2518,7 +2561,7 @@ class FixtureDef: assert not hasattr(self, "cached_result") ihook = self._fixturemanager.session.ihook - ihook.pytest_fixture_setup(fixturedef=self, request=request) + return ihook.pytest_fixture_setup(fixturedef=self, request=request) def __repr__(self): return ("" % From 8ed055efd8a4c8c86d458e2ff3205363b809545a Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sat, 9 Jul 2016 16:46:54 -0300 Subject: [PATCH 3/9] Add acceptance test for invocation-scoped fixtures --- testing/python/invocation_scope.py | 138 +++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 testing/python/invocation_scope.py diff --git a/testing/python/invocation_scope.py b/testing/python/invocation_scope.py new file mode 100644 index 000000000..ef0b6ef4d --- /dev/null +++ b/testing/python/invocation_scope.py @@ -0,0 +1,138 @@ + + +class TestAcceptance: + """ + Complete acceptance test for a invocation-scoped fixture. + """ + + def test_acceptance(self, testdir): + """ + Tests a "stack" fixture which provides a separate list to each scope which uses it. + + Some notes: + + - For each scope, define 2 fixtures of the same scope which use the "stack" fixture, + to ensure they get the same "stack" instance for its scope. + - Creates multiple test files, which tests on each modifying and checking fixtures to + ensure things are working properly. + """ + testdir.makeconftest(""" + import pytest + + @pytest.fixture(scope='invocation') + def stack(): + return [] + + @pytest.fixture(scope='session') + def session1_fix(stack): + stack.append('session1_fix') + return stack + + @pytest.fixture(scope='session') + def session2_fix(stack): + stack.append('session2_fix') + return stack + + @pytest.fixture(scope='module') + def module1_fix(stack): + stack.append('module1_fix') + return stack + + @pytest.fixture(scope='module') + def module2_fix(stack): + stack.append('module2_fix') + return stack + + @pytest.fixture(scope='class') + def class1_fix(stack): + stack.append('class1_fix') + return stack + + @pytest.fixture(scope='class') + def class2_fix(stack): + stack.append('class2_fix') + return stack + """) + testdir.makepyfile(test_0=""" + import pytest + + @pytest.fixture + def func_stack(stack): + return stack + + def test_scoped_instances(session1_fix, session2_fix, module1_fix, module2_fix, + class1_fix, class2_fix, stack, func_stack): + assert session1_fix is session2_fix + assert module1_fix is module2_fix + assert class1_fix is class2_fix + assert stack is func_stack + + assert session1_fix is not module2_fix + assert module2_fix is not class1_fix + assert class1_fix is not stack + """) + testdir.makepyfile(test_1=""" + def test_func_1(request, session1_fix, session2_fix, module1_fix, module2_fix, stack): + assert stack == [] + + assert session1_fix == ['session1_fix', 'session2_fix'] + session1_fix.append('test_1::test_func_1') + + assert module1_fix == ['module1_fix', 'module2_fix'] + module1_fix.append('test_1::test_func_1') + + + class Test: + + def test_1(self, request, session1_fix, module1_fix, class1_fix, class2_fix, stack): + assert stack == [] + + assert session1_fix == ['session1_fix', 'session2_fix', 'test_1::test_func_1'] + session1_fix.append('test_1::Test::test_1') + + assert module1_fix == ['module1_fix', 'module2_fix', 'test_1::test_func_1'] + module1_fix.append('test_1::test_func_1') + + assert class1_fix == ['class1_fix', 'class2_fix'] + class1_fix.append('test_1::Test::test_1') + + def test_2(self, request, class1_fix, class2_fix): + assert class1_fix == ['class1_fix', 'class2_fix', 'test_1::Test::test_1'] + class1_fix.append('Test.test_2') + + + def test_func_2(request, session1_fix, session2_fix, module1_fix, class1_fix, class2_fix, stack): + assert stack == [] + assert session1_fix == ['session1_fix', 'session2_fix', 'test_1::test_func_1', + 'test_1::Test::test_1'] + session1_fix.append('test_1::test_func_2') + + assert module1_fix == ['module1_fix', 'module2_fix', 'test_1::test_func_1', 'test_1::test_func_1'] + + assert class1_fix == ['class1_fix', 'class2_fix'] + """) + testdir.makepyfile(test_2=""" + import pytest + + @pytest.fixture(scope='session') + def another_session_stack(stack): + stack.append('other_session_stack') + return stack + + def test_func_2(request, another_session_stack, module1_fix, stack): + assert stack == [] + assert another_session_stack == [ + 'session1_fix', + 'session2_fix', + 'test_1::test_func_1', + 'test_1::Test::test_1', + 'test_1::test_func_2', + 'other_session_stack', + ] + assert module1_fix == ['module1_fix'] + """) + result = testdir.runpytest() + assert result.ret == 0 + result.stdout.fnmatch_lines('* 6 passed in *') + + From 2ffe354f21f831d14e7d806f07ed908913408561 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 11 Jul 2016 20:32:59 -0300 Subject: [PATCH 4/9] Add CHANGELOG for invocation-scoped fixtures --- CHANGELOG.rst | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 593b81fa6..0f8824321 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -116,7 +116,11 @@ Example '-o xfail_strict=True'. A complete ini-options can be viewed by py.test --help. Thanks `@blueyed`_ and `@fengxx`_ for the PR -* +* New scope for fixtures: ``"invocation"``. This fixtures may be requested by fixtures from + any scope, when they assume the same scope as the fixture requesting it. An ``invocation``-scoped + fixture can be requested from different scopes in the same test session, + in which case each scope will have its own copy. This feature is considered experimental. + Thanks `@nicoddemus`_ for the PR. * From 0ca06962e9a7972c5f553487390c79272c9d4e53 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 11 Jul 2016 20:33:16 -0300 Subject: [PATCH 5/9] Improve docs --- _pytest/fixtures.py | 19 +++++++++++++++++++ testing/python/invocation_scope.py | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/_pytest/fixtures.py b/_pytest/fixtures.py index 1698a1a6d..78de548cf 100644 --- a/_pytest/fixtures.py +++ b/_pytest/fixtures.py @@ -1074,6 +1074,9 @@ class FixtureManager: assert not name.startswith(self._argprefix), name def new_fixture_def(name, scope): + """ + Creates and registers a new FixtureDef with given name and scope. + """ fixture_def = FixtureDef(self, nodeid, name, obj, scope, marker.params, unittest=unittest, ids=marker.ids) @@ -1101,6 +1104,13 @@ class FixtureManager: self._nodeid_and_autousenames.append((nodeid or '', autousenames)) def getfixturedefs(self, argname, nodeid): + """ + Gets a list of fixtures which are applicable to the given node id. + + :param str argname: name of the fixture to search for + :param str nodeid: full node id of the requesting test. + :return: list[FixtureDef] + """ try: fixturedefs = self._arg2fixturedefs[argname] except KeyError: @@ -1114,6 +1124,15 @@ class FixtureManager: yield fixturedef def getfixturedefs_multiple_scopes(self, argname, nodeid): + """ + Gets multiple scoped fixtures which are applicable to the given nodeid. Multiple scoped + fixtures are usually created by "invocation" scoped fixtures and have argnames in + the form: ":" (for example "tmpdir:session"). + + :return: dict of "argname" -> [FixtureDef]. + + Arguments similar to ``getfixturedefs``. + """ prefix = argname + ':' fixturedefs_by_argname = dict((k, v) for k, v in self._arg2fixturedefs.items() if k.startswith(prefix)) diff --git a/testing/python/invocation_scope.py b/testing/python/invocation_scope.py index ef0b6ef4d..681863183 100644 --- a/testing/python/invocation_scope.py +++ b/testing/python/invocation_scope.py @@ -12,7 +12,7 @@ class TestAcceptance: Some notes: - For each scope, define 2 fixtures of the same scope which use the "stack" fixture, - to ensure they get the same "stack" instance for its scope. + to ensure they get the same "stack" instance for that scope. - Creates multiple test files, which tests on each modifying and checking fixtures to ensure things are working properly. """ From 0dd1c8bf14d25521d7a0af1bb637fa0699dca57f Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 11 Jul 2016 20:44:46 -0300 Subject: [PATCH 6/9] Add test to ensure capsys and capfd error out when using "getfixturevalue" inside a test --- testing/test_capture.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/testing/test_capture.py b/testing/test_capture.py index 2e69cfc85..c197c85e7 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -421,6 +421,25 @@ class TestCaptureFixture: "*capsys*capfd*same*time*", "*2 error*"]) + def test_capturing_getfixturevalue(self, testdir): + """Test that asking for "capfd" and "capsys" using request.getfixturevalue + in the same test is an error. + """ + testdir.makepyfile(""" + def test_one(capsys, request): + request.getfixturevalue("capfd") + def test_two(capfd, request): + request.getfixturevalue("capsys") + """) + result = testdir.runpytest() + result.stdout.fnmatch_lines([ + "*test_one*", + "*capsys*capfd*same*time*", + "*test_two*", + "*capsys*capfd*same*time*", + "*2 failed in*", + ]) + @pytest.mark.parametrize("method", ["sys", "fd"]) def test_capture_is_represented_on_failure_issue128(self, testdir, method): p = testdir.makepyfile(""" From 20f97c3041bca913a12ec5c06293856cd9284fd0 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 20 Jul 2016 20:20:19 -0300 Subject: [PATCH 7/9] Small documentation improvements --- CHANGELOG.rst | 9 +++++---- _pytest/fixtures.py | 6 ++---- doc/en/invocation-fixture.rst | 5 +++++ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0f8824321..af6ea69f3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -116,10 +116,11 @@ Example '-o xfail_strict=True'. A complete ini-options can be viewed by py.test --help. Thanks `@blueyed`_ and `@fengxx`_ for the PR -* New scope for fixtures: ``"invocation"``. This fixtures may be requested by fixtures from - any scope, when they assume the same scope as the fixture requesting it. An ``invocation``-scoped - fixture can be requested from different scopes in the same test session, - in which case each scope will have its own copy. This feature is considered experimental. +* Experimentally introduce new ``"invocation"`` fixture scope. At invocation scope a + fixture function is cached in the same way as the fixture or test function that requests it. + You can now use the builtin ``monkeypatch`` fixture from ``session``-scoped fixtures + where previously you would get an error that you can not use a ``function``-scoped fixture from a + ``session``-scoped one.* Thanks `@nicoddemus`_ for the PR. * diff --git a/_pytest/fixtures.py b/_pytest/fixtures.py index 78de548cf..ad9cfe5fe 100644 --- a/_pytest/fixtures.py +++ b/_pytest/fixtures.py @@ -1074,9 +1074,7 @@ class FixtureManager: assert not name.startswith(self._argprefix), name def new_fixture_def(name, scope): - """ - Creates and registers a new FixtureDef with given name and scope. - """ + """Create and registers a new FixtureDef with given name and scope.""" fixture_def = FixtureDef(self, nodeid, name, obj, scope, marker.params, unittest=unittest, ids=marker.ids) @@ -1126,7 +1124,7 @@ class FixtureManager: def getfixturedefs_multiple_scopes(self, argname, nodeid): """ Gets multiple scoped fixtures which are applicable to the given nodeid. Multiple scoped - fixtures are usually created by "invocation" scoped fixtures and have argnames in + fixtures are created by "invocation" scoped fixtures and have argnames in the form: ":" (for example "tmpdir:session"). :return: dict of "argname" -> [FixtureDef]. diff --git a/doc/en/invocation-fixture.rst b/doc/en/invocation-fixture.rst index b096fd4b8..4e49323c4 100644 --- a/doc/en/invocation-fixture.rst +++ b/doc/en/invocation-fixture.rst @@ -37,6 +37,11 @@ value and can manage processes which will live for the duration of the scope. @pytest.fixture(scope='invocation') def process_manager(): + """ + Return a ProcessManager instance which can be used to start + long-lived processes and ensures they are terminated at the + appropriate scope. + """ m = ProcessManager() yield m m.shutdown_all() From 6aea164b6dd3553dea89739bb29b383e285ae8db Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 20 Jul 2016 21:02:36 -0300 Subject: [PATCH 8/9] Add more tests for invocation scoped fixtures --- testing/python/invocation_scope.py | 76 ++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/testing/python/invocation_scope.py b/testing/python/invocation_scope.py index 681863183..81801067d 100644 --- a/testing/python/invocation_scope.py +++ b/testing/python/invocation_scope.py @@ -1,4 +1,80 @@ +def test_invocation_request(testdir): + """ + Simple test case with session and module scopes requesting an + invocation-scoped fixture. + """ + testdir.makeconftest(""" + import pytest + + @pytest.fixture(scope='invocation') + def my_name(request): + if request.scope == 'function': + return request.function.__name__ + elif request.scope == 'module': + return request.module.__name__ + elif request.scope == 'session': + return '' + + @pytest.fixture(scope='session') + def session_name(my_name): + return my_name + + @pytest.fixture(scope='module') + def module_name(my_name): + return my_name + """) + testdir.makepyfile(test_module_foo=""" + def test_foo(my_name, module_name, session_name): + assert my_name == 'test_foo' + assert module_name == 'test_module_foo' + assert session_name == '' + """) + testdir.makepyfile(test_module_bar=""" + def test_bar(my_name, module_name, session_name): + assert my_name == 'test_bar' + assert module_name == 'test_module_bar' + assert session_name == '' + """) + result = testdir.runpytest() + result.stdout.fnmatch_lines(['*2 passed*']) + + +def test_override_invocation_scoped(testdir): + """Test that it's possible to override invocation-scoped fixtures.""" + testdir.makeconftest(""" + import pytest + + @pytest.fixture(scope='invocation') + def magic_value(request): + if request.scope == 'function': + return 1 + elif request.scope == 'module': + return 100 + + @pytest.fixture(scope='module') + def module_magic_value(magic_value): + return magic_value * 2 + """) + testdir.makepyfile(test_module_override=""" + import pytest + + @pytest.fixture(scope='module') + def magic_value(): + return 42 + + def test_override(magic_value, module_magic_value): + assert magic_value == 42 + assert module_magic_value == 42 * 2 + """) + testdir.makepyfile(test_normal=""" + def test_normal(magic_value, module_magic_value): + assert magic_value == 1 + assert module_magic_value == 200 + """) + result = testdir.runpytest() + result.stdout.fnmatch_lines(['*2 passed*']) + class TestAcceptance: """ From 05f3422d7ca2ea75b5a5a1565a64642c847f6782 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 20 Jul 2016 22:05:49 -0300 Subject: [PATCH 9/9] Make monkeypatch invocation-scoped --- _pytest/monkeypatch.py | 9 ++++++--- doc/en/invocation-fixture.rst | 3 --- doc/en/monkeypatch.rst | 37 +++++++++++++++++++++-------------- testing/test_monkeypatch.py | 34 +++++++++++++++++++++++++++++++- 4 files changed, 61 insertions(+), 22 deletions(-) diff --git a/_pytest/monkeypatch.py b/_pytest/monkeypatch.py index d1de4a679..258de385f 100644 --- a/_pytest/monkeypatch.py +++ b/_pytest/monkeypatch.py @@ -10,7 +10,7 @@ import pytest RE_IMPORT_ERROR_NAME = re.compile("^No module named (.*)$") -@pytest.fixture +@pytest.fixture(scope='invocation') def monkeypatch(request): """The returned ``monkeypatch`` fixture provides these helper methods to modify objects, dictionaries or os.environ:: @@ -25,9 +25,11 @@ def monkeypatch(request): monkeypatch.chdir(path) All modifications will be undone after the requesting - test function has finished. The ``raising`` + test function or fixture has finished. The ``raising`` parameter determines if a KeyError or AttributeError will be raised if the set/deletion operation has no target. + + This fixture is ``invocation``-scoped. """ mpatch = MonkeyPatch() request.addfinalizer(mpatch.undo) @@ -97,7 +99,8 @@ notset = Notset() class MonkeyPatch: - """ Object keeping a record of setattr/item/env/syspath changes. """ + """ Object returned by the ``monkeypatch`` fixture keeping a record of setattr/item/env/syspath changes. + """ def __init__(self): self._setattr = [] diff --git a/doc/en/invocation-fixture.rst b/doc/en/invocation-fixture.rst index 4e49323c4..80f00adf7 100644 --- a/doc/en/invocation-fixture.rst +++ b/doc/en/invocation-fixture.rst @@ -9,9 +9,6 @@ Invocation-scoped fixtures This feature is experimental, so if decided that it brings too much problems or considered too complicated it might be removed in pytest ``3.1``. - ``tmpdir`` and ``monkeypatch`` might become ``invocation`` scoped - fixtures in the future if decided to keep invocation-scoped fixtures. - Fixtures can be defined with ``invocation`` scope, meaning that the fixture can be requested by fixtures from any scope, but when they do they assume the same scope as the fixture requesting it. diff --git a/doc/en/monkeypatch.rst b/doc/en/monkeypatch.rst index 4155a3a34..cc6b94958 100644 --- a/doc/en/monkeypatch.rst +++ b/doc/en/monkeypatch.rst @@ -6,7 +6,7 @@ Monkeypatching/mocking modules and environments Sometimes tests need to invoke functionality which depends on global settings or which invokes code which cannot be easily -tested such as network access. The ``monkeypatch`` function argument +tested such as network access. The ``monkeypatch`` fixture helps you to safely set/delete an attribute, dictionary item or environment variable or to modify ``sys.path`` for importing. See the `monkeypatch blog post`_ for some introduction material @@ -14,6 +14,9 @@ and a discussion of its motivation. .. _`monkeypatch blog post`: http://tetamap.wordpress.com/2009/03/03/monkeypatching-in-unit-tests-done-right/ +As of pytest-3.0, the ``monkeypatch`` fixture is :ref:`invocation-scoped ` +meaning it can be requested from fixtures of any scope. + Simple example: monkeypatching functions --------------------------------------------------- @@ -53,27 +56,31 @@ This autouse fixture will be executed for each test function and it will delete the method ``request.session.Session.request`` so that any attempts within tests to create http requests will fail. -example: setting an attribute on some class ------------------------------------------------------- +example: setting an environment variable for the test session +------------------------------------------------------------- -If you need to patch out ``os.getcwd()`` to return an artificial -value:: +If you would like for an environment variable to be +configured for the entire test session, you can add this to your +top-level ``conftest.py`` file: - def test_some_interaction(monkeypatch): - monkeypatch.setattr("os.getcwd", lambda: "/") +.. code-block:: python -which is equivalent to the long form:: + # content of conftest.py + @pytest.fixture(scope='session', autouse=True) + def enable_debugging(monkeypatch): + monkeypatch.setenv("DEBUGGING_VERBOSITY", "4") - def test_some_interaction(monkeypatch): - import os - monkeypatch.setattr(os, "getcwd", lambda: "/") - +This auto-use fixture will set the ``DEBUGGING_VERBOSITY`` environment variable for +the entire test session. + +Note that the ability to use a ``monkeypatch`` fixture from a ``session``-scoped +fixture was added in pytest-3.0. -Method reference of the monkeypatch function argument ------------------------------------------------------ +Method reference of the monkeypatch fixture +------------------------------------------- -.. autoclass:: monkeypatch +.. autoclass:: MonkeyPatch :members: setattr, replace, delattr, setitem, delitem, setenv, delenv, syspath_prepend, chdir, undo ``monkeypatch.setattr/delattr/delitem/delenv()`` all diff --git a/testing/test_monkeypatch.py b/testing/test_monkeypatch.py index 7599be47f..0c152a8b6 100644 --- a/testing/test_monkeypatch.py +++ b/testing/test_monkeypatch.py @@ -328,4 +328,36 @@ def test_issue1338_name_resolving(): try: monkeypatch.delattr('requests.sessions.Session.request') finally: - monkeypatch.undo() \ No newline at end of file + monkeypatch.undo() + + +def test_invocation_scoped_monkeypatch(testdir): + testdir.makeconftest(""" + import pytest + import sys + + @pytest.fixture(scope='module') + def stamp_sys(monkeypatch): + monkeypatch.setattr(sys, 'module_stamped', True, raising=False) + """) + testdir.makepyfile(test_inv_mokeypatch_1=""" + import sys + + def test_stamp_1(monkeypatch, stamp_sys): + assert sys.module_stamped + monkeypatch.setattr(sys, 'function_stamped', True, raising=False) + assert sys.function_stamped + + def test_stamp_2(monkeypatch): + assert sys.module_stamped + assert not hasattr(sys, 'function_stamped') + """) + testdir.makepyfile(test_inv_mokeypatch_2=""" + import sys + + def test_no_stamps(): + assert not hasattr(sys, 'module_stamped') + assert not hasattr(sys, 'function_stamped') + """) + result = testdir.runpytest() + result.stdout.fnmatch_lines(['*3 passed*'])