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