diff --git a/AUTHORS b/AUTHORS index 556156c9f..c260a9653 100644 --- a/AUTHORS +++ b/AUTHORS @@ -89,6 +89,7 @@ Hugo van Kemenade Hui Wang (coldnight) Ian Bicking Ian Lesperance +Ionuț Turturică Jaap Broekhuizen Jan Balster Janne Vanhala diff --git a/changelog/2283.feature b/changelog/2283.feature new file mode 100644 index 000000000..9a8f2c4c9 --- /dev/null +++ b/changelog/2283.feature @@ -0,0 +1 @@ +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 55df7e9d1..6f1a0880d 100644 --- a/src/_pytest/fixtures.py +++ b/src/_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 @@ -45,6 +46,7 @@ def pytest_sessionstart(session): scopename2class.update( { + "package": _pytest.python.Package, "class": _pytest.python.Class, "module": _pytest.python.Module, "function": _pytest.nodes.Item, @@ -58,6 +60,7 @@ scopename2class = {} scope2props = dict(session=()) +scope2props["package"] = ("fspath",) scope2props["module"] = ("fspath", "module") scope2props["class"] = scope2props["module"] + ("cls",) scope2props["instance"] = scope2props["class"] + ("instance",) @@ -80,6 +83,21 @@ def scopeproperty(name=None, doc=None): return decoratescope +def get_scope_package(node, fixturedef): + 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 fixture_package_name != current.nodeid + ): + current = current.parent + if current is None: + return node.session + return current + + def get_scope_node(node, scope): cls = scopename2class.get(scope) if cls is None: @@ -173,9 +191,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.dirpath()) + elif scopenum == 2: # module key = (argname, param_index, item.fspath) - elif scopenum == 2: # class + elif scopenum == 3: # class key = (argname, param_index, item.fspath, item.cls) yield key @@ -612,7 +632,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 @@ -656,7 +679,7 @@ class ScopeMismatchError(Exception): """ -scopes = "session module class function".split() +scopes = "session package module class function".split() scopenum_function = scopes.index("function") @@ -937,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 @@ -967,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 diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 7feeaf8cf..9599fa161 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -383,6 +383,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") @@ -481,18 +483,61 @@ 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 = [] + + 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 path.visit( + for path in argpath.visit( fil=lambda x: x.check(file=1), rec=self._recurse, bf=True, sort=True ): - for x in self._collectfile(path): - yield x + pkginit = path.dirpath().join("__init__.py") + if pkginit.exists() and not any(x in pkginit.parts() for x in paths): + 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 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 path.check(file=1) - for x in self.matchnodes(self._collectfile(path), names): - yield x + assert argpath.check(file=1) + + if argpath in self._node_cache: + col = self._node_cache[argpath] + else: + 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) @@ -577,7 +622,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/src/_pytest/nodes.py b/src/_pytest/nodes.py index 2ae4bd8c9..85f176e90 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -358,7 +358,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__( diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 19a6b1593..54fa56026 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -13,6 +13,7 @@ from itertools import count import py import six +from _pytest.main import FSHookProxy from _pytest.mark import MarkerError from _pytest.config import hookimpl @@ -201,7 +202,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: @@ -211,9 +212,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 @@ -531,6 +546,66 @@ class Module(nodes.File, PyCollector): self.addfinalizer(teardown_module) +class Package(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.dirname + 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) + + 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 + + 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/src/pytest.py b/src/pytest.py index f27f5a195..ae542b76d 100644 --- a/src/pytest.py +++ b/src/pytest.py @@ -18,7 +18,7 @@ from _pytest.mark import MARK_GEN as mark, param 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 +from _pytest.python import Package, Module, Class, Instance, Function, Generator from _pytest.python_api import approx, raises @@ -50,6 +50,7 @@ __all__ = [ "Item", "File", "Collector", + "Package", "Session", "Module", "Class", diff --git a/testing/python/collect.py b/testing/python/collect.py index c6afe7064..a76cecada 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -1078,7 +1078,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/python/fixture.py b/testing/python/fixture.py index 9cc3b6890..4dd8d1de8 100644 --- a/testing/python/fixture.py +++ b/testing/python/fixture.py @@ -1658,6 +1658,97 @@ 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) + + 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): @pytest.fixture @@ -3833,6 +3924,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') @@ -3853,16 +3948,20 @@ 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( @@ -3931,9 +4030,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 @@ -3950,7 +4053,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""" @@ -3960,8 +4063,12 @@ 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 @@ -3990,4 +4097,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() diff --git a/testing/test_collection.py b/testing/test_collection.py index e12e788b4..6480cc85d 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -647,7 +647,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 @@ -904,7 +904,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( """