From 2b1410895e9ac30c4f08e9a701017cce65a9717b Mon Sep 17 00:00:00 2001 From: turturica Date: Wed, 11 Apr 2018 15:39:42 -0700 Subject: [PATCH 01/14] Add package scoped fixtures #2283 --- AUTHORS | 1 + _pytest/fixtures.py | 10 +++++--- _pytest/main.py | 31 +++++++++++++++++-------- _pytest/python.py | 47 +++++++++++++++++++++++++++++++++++++- changelog/2283.feature | 1 + testing/python/fixture.py | 33 ++++++++++++++++++++++++++ testing/test_collection.py | 2 +- testing/test_session.py | 2 +- 8 files changed, 112 insertions(+), 15 deletions(-) create mode 100644 changelog/2283.feature diff --git a/AUTHORS b/AUTHORS index 8376c3870..d15411476 100644 --- a/AUTHORS +++ b/AUTHORS @@ -87,6 +87,7 @@ Hugo van Kemenade Hui Wang (coldnight) Ian Bicking Ian Lesperance +Ionuț Turturică Jaap Broekhuizen Jan Balster Janne Vanhala diff --git a/_pytest/fixtures.py b/_pytest/fixtures.py index 6190dea01..a71cf12bf 100644 --- a/_pytest/fixtures.py +++ b/_pytest/fixtures.py @@ -36,6 +36,7 @@ def pytest_sessionstart(session): import _pytest.nodes scopename2class.update({ + 'package': _pytest.python.Package, 'class': _pytest.python.Class, 'module': _pytest.python.Module, 'function': _pytest.nodes.Item, @@ -48,6 +49,7 @@ scopename2class = {} scope2props = dict(session=()) +scope2props["package"] = ("fspath",) scope2props["module"] = ("fspath", "module") scope2props["class"] = scope2props["module"] + ("cls",) scope2props["instance"] = scope2props["class"] + ("instance", ) @@ -156,9 +158,11 @@ def get_parametrized_fixture_keys(item, scopenum): continue if scopenum == 0: # session key = (argname, param_index) - elif scopenum == 1: # module + elif scopenum == 1: # package key = (argname, param_index, item.fspath) - elif scopenum == 2: # class + elif scopenum == 2: # module + key = (argname, param_index, item.fspath) + elif scopenum == 3: # class key = (argname, param_index, item.fspath, item.cls) yield key @@ -596,7 +600,7 @@ class ScopeMismatchError(Exception): """ -scopes = "session module class function".split() +scopes = "session package module class function".split() scopenum_function = scopes.index("function") diff --git a/_pytest/main.py b/_pytest/main.py index 9b59e03a2..a18328867 100644 --- a/_pytest/main.py +++ b/_pytest/main.py @@ -405,17 +405,30 @@ class Session(nodes.FSCollector): def _collect(self, arg): names = self._parsearg(arg) - path = names.pop(0) - if path.check(dir=1): + argpath = names.pop(0) + paths = [] + if argpath.check(dir=1): assert not names, "invalid arg %r" % (arg,) - for path in path.visit(fil=lambda x: x.check(file=1), - rec=self._recurse, bf=True, sort=True): - for x in self._collectfile(path): - yield x + for path in argpath.visit(fil=lambda x: x.check(file=1), + rec=self._recurse, bf=True, sort=True): + pkginit = path.dirpath().join('__init__.py') + if pkginit.exists() and not any(x in pkginit.parts() for x in paths): + for x in self._collectfile(pkginit): + yield x + paths.append(x.fspath.dirpath()) + + if not any(x in path.parts() for x in paths): + for x in self._collectfile(path): + yield x else: - assert path.check(file=1) - for x in self.matchnodes(self._collectfile(path), names): - yield x + assert argpath.check(file=1) + pkginit = argpath.dirpath().join('__init__.py') + if not self.isinitpath(argpath) and pkginit.exists(): + for x in self._collectfile(pkginit): + yield x + else: + for x in self.matchnodes(self._collectfile(argpath), names): + yield x def _collectfile(self, path): ihook = self.gethookproxy(path) diff --git a/_pytest/python.py b/_pytest/python.py index 94f83a37d..8110b7a72 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -17,6 +17,7 @@ from _pytest.mark import MarkerError from _pytest.config import hookimpl import _pytest +from _pytest.main import Session import pluggy from _pytest import fixtures from _pytest import nodes @@ -157,7 +158,7 @@ def pytest_collect_file(path, parent): ext = path.ext if ext == ".py": if not parent.session.isinitpath(path): - for pat in parent.config.getini('python_files'): + for pat in parent.config.getini('python_files') + ['__init__.py']: if path.fnmatch(pat): break else: @@ -167,9 +168,23 @@ def pytest_collect_file(path, parent): def pytest_pycollect_makemodule(path, parent): + if path.basename == '__init__.py': + return Package(path, parent) return Module(path, parent) +def pytest_ignore_collect(path, config): + # Skip duplicate packages. + keepduplicates = config.getoption("keepduplicates") + if keepduplicates: + duplicate_paths = config.pluginmanager._duplicatepaths + if path.basename == '__init__.py': + if path in duplicate_paths: + return True + else: + duplicate_paths.add(path) + + @hookimpl(hookwrapper=True) def pytest_pycollect_makeitem(collector, name, obj): outcome = yield @@ -475,6 +490,36 @@ class Module(nodes.File, PyCollector): self.addfinalizer(teardown_module) +class Package(Session, Module): + + def __init__(self, fspath, parent=None, config=None, session=None, nodeid=None): + session = parent.session + nodes.FSCollector.__init__( + self, fspath, parent=parent, + config=config, session=session, nodeid=nodeid) + self.name = fspath.pyimport().__name__ + self.trace = session.trace + self._norecursepatterns = session._norecursepatterns + for path in list(session.config.pluginmanager._duplicatepaths): + if path.dirname == fspath.dirname and path != fspath: + session.config.pluginmanager._duplicatepaths.remove(path) + pass + + def isinitpath(self, path): + return path in self.session._initialpaths + + def collect(self): + path = self.fspath.dirpath() + pkg_prefix = None + for path in path.visit(fil=lambda x: 1, + rec=self._recurse, bf=True, sort=True): + if pkg_prefix and pkg_prefix in path.parts(): + continue + for x in self._collectfile(path): + yield x + if isinstance(x, Package): + pkg_prefix = path.dirpath() + def _get_xunit_setup_teardown(holder, attr_name, param_obj=None): """ Return a callable to perform xunit-style setup or teardown if diff --git a/changelog/2283.feature b/changelog/2283.feature new file mode 100644 index 000000000..6f8019000 --- /dev/null +++ b/changelog/2283.feature @@ -0,0 +1 @@ +Pytest now supports package-level fixtures. diff --git a/testing/python/fixture.py b/testing/python/fixture.py index c558ea3cf..e7593368e 100644 --- a/testing/python/fixture.py +++ b/testing/python/fixture.py @@ -1448,6 +1448,39 @@ class TestFixtureManagerParseFactories(object): reprec = testdir.inline_run("..") reprec.assertoutcome(passed=2) + def test_package_xunit_fixture(self, testdir): + testdir.makepyfile(__init__="""\ + values = [] + """) + package = testdir.mkdir("package") + package.join("__init__.py").write(dedent("""\ + from .. import values + def setup_module(): + values.append("package") + def teardown_module(): + values[:] = [] + """)) + package.join("test_x.py").write(dedent("""\ + from .. import values + def test_x(): + assert values == ["package"] + """)) + package = testdir.mkdir("package2") + package.join("__init__.py").write(dedent("""\ + from .. import values + def setup_module(): + values.append("package2") + def teardown_module(): + values[:] = [] + """)) + package.join("test_x.py").write(dedent("""\ + from .. import values + def test_x(): + assert values == ["package2"] + """)) + reprec = testdir.inline_run() + reprec.assertoutcome(passed=2) + class TestAutouseDiscovery(object): diff --git a/testing/test_collection.py b/testing/test_collection.py index f2d542c62..b9d0a9470 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -835,7 +835,7 @@ def test_continue_on_collection_errors_maxfail(testdir): def test_fixture_scope_sibling_conftests(testdir): """Regression test case for https://github.com/pytest-dev/pytest/issues/2836""" - foo_path = testdir.mkpydir("foo") + foo_path = testdir.mkdir("foo") foo_path.join("conftest.py").write(_pytest._code.Source(""" import pytest @pytest.fixture diff --git a/testing/test_session.py b/testing/test_session.py index 32d8ce689..a7dad6a19 100644 --- a/testing/test_session.py +++ b/testing/test_session.py @@ -192,7 +192,7 @@ class TestNewSession(SessionTests): started = reprec.getcalls("pytest_collectstart") finished = reprec.getreports("pytest_collectreport") assert len(started) == len(finished) - assert len(started) == 7 # XXX extra TopCollector + assert len(started) == 8 # XXX extra TopCollector colfail = [x for x in finished if x.failed] assert len(colfail) == 1 From 35df2cdbee3e626abbecfc14e394f4c8b6e252f3 Mon Sep 17 00:00:00 2001 From: turturica Date: Wed, 11 Apr 2018 15:45:43 -0700 Subject: [PATCH 02/14] Fix linting error. --- _pytest/python.py | 1 + 1 file changed, 1 insertion(+) diff --git a/_pytest/python.py b/_pytest/python.py index 8110b7a72..de659e517 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -520,6 +520,7 @@ class Package(Session, Module): if isinstance(x, Package): pkg_prefix = path.dirpath() + def _get_xunit_setup_teardown(holder, attr_name, param_obj=None): """ Return a callable to perform xunit-style setup or teardown if From c02e8d8b0d4a50cb802b901f78412448612d03ed Mon Sep 17 00:00:00 2001 From: turturica Date: Mon, 16 Apr 2018 11:44:05 -0700 Subject: [PATCH 03/14] Fix test collection when tests are passed as IDs at the command line. Note this is still broken due to #3358. --- _pytest/main.py | 7 +++++-- testing/python/collect.py | 2 +- testing/test_collection.py | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/_pytest/main.py b/_pytest/main.py index a18328867..5cad8bae3 100644 --- a/_pytest/main.py +++ b/_pytest/main.py @@ -423,9 +423,12 @@ class Session(nodes.FSCollector): else: assert argpath.check(file=1) pkginit = argpath.dirpath().join('__init__.py') - if not self.isinitpath(argpath) and pkginit.exists(): + if not self.isinitpath(pkginit): + self._initialpaths.add(pkginit) + if pkginit.exists(): for x in self._collectfile(pkginit): - yield x + for y in self.matchnodes(x._collectfile(argpath), names): + yield y else: for x in self.matchnodes(self._collectfile(argpath), names): yield x diff --git a/testing/python/collect.py b/testing/python/collect.py index de40486a8..96928569f 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -927,7 +927,7 @@ def test_setup_only_available_in_subdir(testdir): def test_modulecol_roundtrip(testdir): - modcol = testdir.getmodulecol("pass", withinit=True) + modcol = testdir.getmodulecol("pass", withinit=False) trail = modcol.nodeid newcol = modcol.session.perform_collect([trail], genitems=0)[0] assert modcol.name == newcol.name diff --git a/testing/test_collection.py b/testing/test_collection.py index b9d0a9470..3efef08b2 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -587,7 +587,7 @@ class Test_getinitialnodes(object): col = testdir.getnode(config, x) assert isinstance(col, pytest.Module) assert col.name == 'x.py' - assert col.parent.parent is None + assert col.parent.parent.parent is None for col in col.listchain(): assert col.config is config From c416b1d9358e457cbd0d8323ffa3ceeb08d27516 Mon Sep 17 00:00:00 2001 From: turturica Date: Fri, 20 Apr 2018 15:04:58 -0700 Subject: [PATCH 04/14] Don't stop at the first package when looking up package-scoped fixtures. Example: package1.subpackage1 package1.subpackage2 package1's setup/teardown were executed again when exiting subpackage1 and entering subpackage2. --- _pytest/fixtures.py | 17 ++++++++++++++++- _pytest/nodes.py | 1 + _pytest/python.py | 37 ++++++++++++++++++++++++++++++++++--- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/_pytest/fixtures.py b/_pytest/fixtures.py index a71cf12bf..e4bb91514 100644 --- a/_pytest/fixtures.py +++ b/_pytest/fixtures.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, division, print_function import functools import inspect +import os import sys import warnings from collections import OrderedDict, deque, defaultdict @@ -70,6 +71,17 @@ def scopeproperty(name=None, doc=None): return decoratescope +def get_scope_package(node, fixturedef): + cls = node.Package + current = node + fixture_package_name = os.path.join(fixturedef.baseid, '__init__.py') + while current and type(current) is not cls or \ + fixture_package_name != current.nodeid: + current = current.parent + assert current + return current + + def get_scope_node(node, scope): cls = scopename2class.get(scope) if cls is None: @@ -558,7 +570,10 @@ class FixtureRequest(FuncargnamesCompatAttr): if scope == "function": # this might also be a non-function Item despite its attribute name return self._pyfuncitem - node = get_scope_node(self._pyfuncitem, scope) + if scope == 'package': + node = get_scope_package(self._pyfuncitem, self._fixturedef) + else: + node = get_scope_node(self._pyfuncitem, scope) if node is None and scope == "class": # fallback to function item itself node = self._pyfuncitem diff --git a/_pytest/nodes.py b/_pytest/nodes.py index 799ee078a..dfe63b220 100644 --- a/_pytest/nodes.py +++ b/_pytest/nodes.py @@ -116,6 +116,7 @@ class Node(object): Function = _CompatProperty("Function") File = _CompatProperty("File") Item = _CompatProperty("Item") + Package = _CompatProperty("Package") def _getcustomclass(self, name): maybe_compatprop = getattr(type(self), name) diff --git a/_pytest/python.py b/_pytest/python.py index de659e517..fa5de427e 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -13,11 +13,11 @@ from itertools import count import py import six +from _pytest.main import FSHookProxy from _pytest.mark import MarkerError from _pytest.config import hookimpl import _pytest -from _pytest.main import Session import pluggy from _pytest import fixtures from _pytest import nodes @@ -490,7 +490,7 @@ class Module(nodes.File, PyCollector): self.addfinalizer(teardown_module) -class Package(Session, Module): +class Package(Module): def __init__(self, fspath, parent=None, config=None, session=None, nodeid=None): session = parent.session @@ -503,7 +503,38 @@ class Package(Session, Module): for path in list(session.config.pluginmanager._duplicatepaths): if path.dirname == fspath.dirname and path != fspath: session.config.pluginmanager._duplicatepaths.remove(path) - pass + + def _recurse(self, path): + ihook = self.gethookproxy(path.dirpath()) + if ihook.pytest_ignore_collect(path=path, config=self.config): + return + for pat in self._norecursepatterns: + if path.check(fnmatch=pat): + return False + ihook = self.gethookproxy(path) + ihook.pytest_collect_directory(path=path, parent=self) + return True + + def gethookproxy(self, fspath): + # check if we have the common case of running + # hooks with all conftest.py filesall conftest.py + pm = self.config.pluginmanager + my_conftestmodules = pm._getconftestmodules(fspath) + remove_mods = pm._conftest_plugins.difference(my_conftestmodules) + if remove_mods: + # one or more conftests are not in use at this fspath + proxy = FSHookProxy(fspath, pm, remove_mods) + else: + # all plugis are active for this fspath + proxy = self.config.hook + return proxy + + def _collectfile(self, path): + ihook = self.gethookproxy(path) + if not self.isinitpath(path): + if ihook.pytest_ignore_collect(path=path, config=self.config): + return () + return ihook.pytest_collect_file(path=path, parent=self) def isinitpath(self, path): return path in self.session._initialpaths From 69031d00337dc61ed1bd41465dd80d5cc2cb793f Mon Sep 17 00:00:00 2001 From: turturica Date: Fri, 20 Apr 2018 15:54:21 -0700 Subject: [PATCH 05/14] Forgot one file from previous commit. --- pytest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pytest.py b/pytest.py index d3aebbff9..f33b72532 100644 --- a/pytest.py +++ b/pytest.py @@ -22,7 +22,7 @@ from _pytest.main import Session from _pytest.nodes import Item, Collector, File from _pytest.fixtures import fillfixtures as _fillfuncargs from _pytest.python import ( - Module, Class, Instance, Function, Generator, + Package, Module, Class, Instance, Function, Generator, ) from _pytest.python_api import approx, raises @@ -56,6 +56,7 @@ __all__ = [ 'Item', 'File', 'Collector', + 'Package', 'Session', 'Module', 'Class', From dc90c9108fd8cc65bc76da7a6d71b6ed66c40256 Mon Sep 17 00:00:00 2001 From: turturica Date: Fri, 20 Apr 2018 17:15:09 -0700 Subject: [PATCH 06/14] Collapse all parent nested package fixtures when pointing to a sub-node. Example: Given this hierarchy: p1.s1.s2.s3 I want to run pytest p1/s1/s2/s3/foo.py If there are any package fixtures defined at p1..s2 levels, they should also be executed. --- _pytest/fixtures.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/_pytest/fixtures.py b/_pytest/fixtures.py index e4bb91514..e9d032f92 100644 --- a/_pytest/fixtures.py +++ b/_pytest/fixtures.py @@ -75,10 +75,11 @@ def get_scope_package(node, fixturedef): cls = node.Package current = node fixture_package_name = os.path.join(fixturedef.baseid, '__init__.py') - while current and type(current) is not cls or \ - fixture_package_name != current.nodeid: + while current and (type(current) is not cls or \ + fixture_package_name != current.nodeid): current = current.parent - assert current + if current is None: + return node.session return current From b0474398ec13057675cbf9ceace3826bf4e32db9 Mon Sep 17 00:00:00 2001 From: turturica Date: Fri, 20 Apr 2018 18:18:44 -0700 Subject: [PATCH 07/14] Fix a formatting error. --- _pytest/fixtures.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/_pytest/fixtures.py b/_pytest/fixtures.py index e9d032f92..b3e46355e 100644 --- a/_pytest/fixtures.py +++ b/_pytest/fixtures.py @@ -75,8 +75,8 @@ def get_scope_package(node, fixturedef): cls = node.Package current = node fixture_package_name = os.path.join(fixturedef.baseid, '__init__.py') - while current and (type(current) is not cls or \ - fixture_package_name != current.nodeid): + while current and (type(current) is not cls or + fixture_package_name != current.nodeid): current = current.parent if current is None: return node.session From fedc78522bd43e82d8b6c4f89df2b3a6c8e898ea Mon Sep 17 00:00:00 2001 From: turturica Date: Sat, 21 Apr 2018 18:39:42 -0700 Subject: [PATCH 08/14] Build a stack of all previous packages instead of just the one closest to the initial argument(s). Address #3358 by caching nodes in a session dict. --- _pytest/main.py | 59 ++++++++++++++++++++++++++++++++--------- testing/test_session.py | 2 +- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/_pytest/main.py b/_pytest/main.py index 5cad8bae3..9ca20a73c 100644 --- a/_pytest/main.py +++ b/_pytest/main.py @@ -308,6 +308,8 @@ class Session(nodes.FSCollector): self.trace = config.trace.root.get("collection") self._norecursepatterns = config.getini("norecursedirs") self.startdir = py.path.local() + # Keep track of any collected nodes in here, so we don't duplicate fixtures + self._node_cache = {} self.config.pluginmanager.register(self, name="session") @@ -407,31 +409,58 @@ class Session(nodes.FSCollector): names = self._parsearg(arg) argpath = names.pop(0) paths = [] + + root = self + # Start with a Session root, and delve to argpath item (dir or file) + # and stack all Packages found on the way. + # No point in finding packages when collecting doctests + if not self.config.option.doctestmodules: + for parent in argpath.parts(): + pm = self.config.pluginmanager + if pm._confcutdir and pm._confcutdir.relto(parent): + continue + + if parent.isdir(): + pkginit = parent.join("__init__.py") + if pkginit.isfile(): + if pkginit in self._node_cache: + root = self._node_cache[pkginit] + else: + col = root._collectfile(pkginit) + if col: + root = col[0] + self._node_cache[root.fspath] = root + + # If it's a directory argument, recurse and look for any Subpackages. + # Let the Package collector deal with subnodes, don't collect here. if argpath.check(dir=1): assert not names, "invalid arg %r" % (arg,) for path in argpath.visit(fil=lambda x: x.check(file=1), rec=self._recurse, bf=True, sort=True): pkginit = path.dirpath().join('__init__.py') if pkginit.exists() and not any(x in pkginit.parts() for x in paths): - for x in self._collectfile(pkginit): + for x in root._collectfile(pkginit): yield x paths.append(x.fspath.dirpath()) if not any(x in path.parts() for x in paths): - for x in self._collectfile(path): - yield x + for x in root._collectfile(path): + if (type(x), x.fspath) in self._node_cache: + yield self._node_cache[(type(x), x.fspath)] + else: + yield x + self._node_cache[(type(x), x.fspath)] = x else: assert argpath.check(file=1) - pkginit = argpath.dirpath().join('__init__.py') - if not self.isinitpath(pkginit): - self._initialpaths.add(pkginit) - if pkginit.exists(): - for x in self._collectfile(pkginit): - for y in self.matchnodes(x._collectfile(argpath), names): - yield y + + if argpath in self._node_cache: + col = self._node_cache[argpath] else: - for x in self.matchnodes(self._collectfile(argpath), names): - yield x + col = root._collectfile(argpath) + if col: + self._node_cache[argpath] = col + for y in self.matchnodes(col, names): + yield y def _collectfile(self, path): ihook = self.gethookproxy(path) @@ -516,7 +545,11 @@ class Session(nodes.FSCollector): resultnodes.append(node) continue assert isinstance(node, nodes.Collector) - rep = collect_one_node(node) + if node.nodeid in self._node_cache: + rep = self._node_cache[node.nodeid] + else: + rep = collect_one_node(node) + self._node_cache[node.nodeid] = rep if rep.passed: has_matched = False for x in rep.result: diff --git a/testing/test_session.py b/testing/test_session.py index a7dad6a19..32d8ce689 100644 --- a/testing/test_session.py +++ b/testing/test_session.py @@ -192,7 +192,7 @@ class TestNewSession(SessionTests): started = reprec.getcalls("pytest_collectstart") finished = reprec.getreports("pytest_collectreport") assert len(started) == len(finished) - assert len(started) == 8 # XXX extra TopCollector + assert len(started) == 7 # XXX extra TopCollector colfail = [x for x in finished if x.failed] assert len(colfail) == 1 From f8350c63041c8ba042c105fcbf78c9207d3b7dc5 Mon Sep 17 00:00:00 2001 From: turturica Date: Sat, 21 Apr 2018 19:51:33 -0700 Subject: [PATCH 09/14] Fix an issue that popped up only on Windows. --- _pytest/nodes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_pytest/nodes.py b/_pytest/nodes.py index dfe63b220..33752064f 100644 --- a/_pytest/nodes.py +++ b/_pytest/nodes.py @@ -329,7 +329,7 @@ class FSCollector(Collector): if not nodeid: nodeid = _check_initialpaths_for_relpath(session, fspath) - if os.sep != SEP: + if nodeid and os.sep != SEP: nodeid = nodeid.replace(os.sep, SEP) super(FSCollector, self).__init__(name, parent, config, session, nodeid=nodeid, fspath=fspath) From acacf75f49ad79ea8fa5af0852976fa1a406b325 Mon Sep 17 00:00:00 2001 From: turturica Date: Tue, 24 Apr 2018 13:32:58 -0700 Subject: [PATCH 10/14] Added another package-scoped fixture test. Changed existing complex tests to use package fixtures. --- testing/python/fixture.py | 52 +++++++++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/testing/python/fixture.py b/testing/python/fixture.py index e7593368e..e77bccb73 100644 --- a/testing/python/fixture.py +++ b/testing/python/fixture.py @@ -1481,6 +1481,36 @@ class TestFixtureManagerParseFactories(object): reprec = testdir.inline_run() reprec.assertoutcome(passed=2) + def test_package_fixture_complex(self, testdir): + testdir.makepyfile(__init__="""\ + values = [] + """) + package = testdir.mkdir("package") + package.join("__init__.py").write("") + package.join("conftest.py").write(dedent("""\ + import pytest + from .. import values + @pytest.fixture(scope="package") + def one(): + values.append("package") + yield values + values.pop() + @pytest.fixture(scope="package", autouse=True) + def two(): + values.append("package-auto") + yield values + values.pop() + """)) + package.join("test_x.py").write(dedent("""\ + from .. import values + def test_package_autouse(): + assert values == ["package-auto"] + def test_package(one): + assert values == ["package-auto", "package"] + """)) + reprec = testdir.inline_run() + reprec.assertoutcome(passed=2) + class TestAutouseDiscovery(object): @@ -3321,6 +3351,10 @@ class TestScopeOrdering(object): def s1(): FIXTURE_ORDER.append('s1') + @pytest.fixture(scope="package") + def p1(): + FIXTURE_ORDER.append('p1') + @pytest.fixture(scope="module") def m1(): FIXTURE_ORDER.append('m1') @@ -3341,15 +3375,15 @@ class TestScopeOrdering(object): def f2(): FIXTURE_ORDER.append('f2') - def test_foo(f1, m1, f2, s1): pass + def test_foo(f1, p1, m1, f2, s1): pass """) items, _ = testdir.inline_genitems() request = FixtureRequest(items[0]) # order of fixtures based on their scope and position in the parameter list - assert request.fixturenames == 's1 my_tmpdir_factory m1 f1 f2 my_tmpdir'.split() + assert request.fixturenames == 's1 my_tmpdir_factory p1 m1 f1 f2 my_tmpdir'.split() testdir.runpytest() # actual fixture execution differs: dependent fixtures must be created first ("my_tmpdir") - assert pytest.FIXTURE_ORDER == 's1 my_tmpdir_factory m1 my_tmpdir f1 f2'.split() + assert pytest.FIXTURE_ORDER == 's1 my_tmpdir_factory p1 m1 my_tmpdir f1 f2'.split() def test_func_closure_module(self, testdir): testdir.makepyfile(""" @@ -3411,9 +3445,13 @@ class TestScopeOrdering(object): 'sub/conftest.py': """ import pytest + @pytest.fixture(scope='package', autouse=True) + def p_sub(): pass + @pytest.fixture(scope='module', autouse=True) def m_sub(): pass """, + 'sub/__init__.py': "", 'sub/test_func.py': """ import pytest @@ -3428,7 +3466,7 @@ class TestScopeOrdering(object): """}) items, _ = testdir.inline_genitems() request = FixtureRequest(items[0]) - assert request.fixturenames == 'm_conf m_sub m_test f1'.split() + assert request.fixturenames == 'p_sub m_conf m_sub m_test f1'.split() def test_func_closure_all_scopes_complex(self, testdir): """Complex test involving all scopes and mixing autouse with normal fixtures""" @@ -3437,7 +3475,11 @@ class TestScopeOrdering(object): @pytest.fixture(scope='session') def s1(): pass + + @pytest.fixture(scope='package', autouse=True) + def p1(): pass """) + testdir.makepyfile(**{"__init__.py": ""}) testdir.makepyfile(""" import pytest @@ -3464,4 +3506,4 @@ class TestScopeOrdering(object): """) items, _ = testdir.inline_genitems() request = FixtureRequest(items[0]) - assert request.fixturenames == 's1 m1 m2 c1 f2 f1'.split() + assert request.fixturenames == 's1 p1 m1 m2 c1 f2 f1'.split() From 229c8e551d09ae7ed894a2d7d106fefa1307c5a2 Mon Sep 17 00:00:00 2001 From: turturica Date: Wed, 25 Apr 2018 18:44:54 -0700 Subject: [PATCH 11/14] Fix parametrized fixtures reordering. --- _pytest/fixtures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_pytest/fixtures.py b/_pytest/fixtures.py index 04c76e7fb..1512c9cf0 100644 --- a/_pytest/fixtures.py +++ b/_pytest/fixtures.py @@ -172,7 +172,7 @@ def get_parametrized_fixture_keys(item, scopenum): if scopenum == 0: # session key = (argname, param_index) elif scopenum == 1: # package - key = (argname, param_index, item.fspath) + key = (argname, param_index, item.fspath.dirpath()) elif scopenum == 2: # module key = (argname, param_index, item.fspath) elif scopenum == 3: # class From 6fc7f07a8058d579cae73cc3ae792cd216f90304 Mon Sep 17 00:00:00 2001 From: turturica Date: Thu, 26 Apr 2018 23:05:03 -0700 Subject: [PATCH 12/14] Workaround for py36-xdist failure. --- _pytest/python.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_pytest/python.py b/_pytest/python.py index 901892c73..0a3d98ea7 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -498,7 +498,7 @@ class Package(Module): nodes.FSCollector.__init__( self, fspath, parent=parent, config=config, session=session, nodeid=nodeid) - self.name = fspath.pyimport().__name__ + self.name = fspath.dirname self.trace = session.trace self._norecursepatterns = session._norecursepatterns for path in list(session.config.pluginmanager._duplicatepaths): From 7d0dba18de7dc63468ce09d198e7c53970b37de6 Mon Sep 17 00:00:00 2001 From: turturica Date: Fri, 27 Apr 2018 10:23:15 -0700 Subject: [PATCH 13/14] Removed _CompatProperty("Package") --- _pytest/fixtures.py | 3 ++- _pytest/nodes.py | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/_pytest/fixtures.py b/_pytest/fixtures.py index 1512c9cf0..52d7088e9 100644 --- a/_pytest/fixtures.py +++ b/_pytest/fixtures.py @@ -72,7 +72,8 @@ def scopeproperty(name=None, doc=None): def get_scope_package(node, fixturedef): - cls = node.Package + import pytest + cls = pytest.Package current = node fixture_package_name = os.path.join(fixturedef.baseid, '__init__.py') while current and (type(current) is not cls or diff --git a/_pytest/nodes.py b/_pytest/nodes.py index 33752064f..69ba9ac92 100644 --- a/_pytest/nodes.py +++ b/_pytest/nodes.py @@ -116,7 +116,6 @@ class Node(object): Function = _CompatProperty("Function") File = _CompatProperty("File") Item = _CompatProperty("Item") - Package = _CompatProperty("Package") def _getcustomclass(self, name): maybe_compatprop = getattr(type(self), name) From 027d2336b8d6de145e14a0eaaedacd04dca21c08 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 5 Jul 2018 21:54:02 -0300 Subject: [PATCH 14/14] Add minimal docs for package-scoped fixtures (experimental) --- changelog/2283.feature | 2 +- doc/en/fixture.rst | 16 ++++++++++++++++ src/_pytest/fixtures.py | 27 +++++++++++++++++---------- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/changelog/2283.feature b/changelog/2283.feature index 6f8019000..9a8f2c4c9 100644 --- a/changelog/2283.feature +++ b/changelog/2283.feature @@ -1 +1 @@ -Pytest now supports package-level fixtures. +New ``package`` fixture scope: fixtures are finalized when the last test of a *package* finishes. This feature is considered **experimental**, so use it sparingly. diff --git a/doc/en/fixture.rst b/doc/en/fixture.rst index e07d00eaa..aca0e456f 100644 --- a/doc/en/fixture.rst +++ b/doc/en/fixture.rst @@ -258,6 +258,22 @@ instance, you can simply declare it: Finally, the ``class`` scope will invoke the fixture once per test *class*. +``package`` scope (experimental) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. versionadded:: 3.7 + +In pytest 3.7 the ``package`` scope has been introduced. Package-scoped fixtures +are finalized when the last test of a *package* finishes. + +.. warning:: + This functionality is considered **experimental** and may be removed in future + versions if hidden corner-cases or serious problems with this functionality + are discovered after it gets more usage in the wild. + + Use this new feature sparingly and please make sure to report any issues you find. + + Higher-scoped fixtures are instantiated first --------------------------------------------- diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index a5bbdbec9..6f1a0880d 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -960,16 +960,27 @@ class FixtureFunctionMarker(object): def fixture(scope="function", params=None, autouse=False, ids=None, name=None): """Decorator to mark a fixture factory function. - This decorator can be used (with or without parameters) to define a - fixture function. The name of the fixture function can later be - referenced to cause its invocation ahead of running tests: test - modules or classes can use the pytest.mark.usefixtures(fixturename) - marker. Test functions can directly use fixture names as input + This decorator can be used, with or without parameters, to define a + fixture function. + + The name of the fixture function can later be referenced to cause its + invocation ahead of running tests: test + modules or classes can use the ``pytest.mark.usefixtures(fixturename)`` + marker. + + Test functions can directly use fixture names as input arguments in which case the fixture instance returned from the fixture function will be injected. + Fixtures can provide their values to test functions using ``return`` or ``yield`` + statements. When using ``yield`` the code block after the ``yield`` statement is executed + as teardown code regardless of the test outcome, and must yield exactly once. + :arg scope: the scope for which this fixture is shared, one of - "function" (default), "class", "module" or "session". + ``"function"`` (default), ``"class"``, ``"module"``, + ``"package"`` or ``"session"``. + + ``"package"`` is considered **experimental** at this time. :arg params: an optional list of parameters which will cause multiple invocations of the fixture function and all of the tests @@ -990,10 +1001,6 @@ def fixture(scope="function", params=None, autouse=False, ids=None, name=None): to resolve this is to name the decorated function ``fixture_`` and then use ``@pytest.fixture(name='')``. - - Fixtures can optionally provide their values to test functions using a ``yield`` statement, - instead of ``return``. In this case, the code block after the ``yield`` statement is executed - as teardown code regardless of the test outcome. A fixture function must yield exactly once. """ if callable(scope) and params is None and autouse is False: # direct decoration