Merge pull request #3389 from jonozzz/features

Add package scoped fixtures #2283
This commit is contained in:
Ronny Pfannschmidt 2018-07-06 11:42:36 +02:00 committed by GitHub
commit 1e94ac784f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 314 additions and 34 deletions

View File

@ -89,6 +89,7 @@ Hugo van Kemenade
Hui Wang (coldnight)
Ian Bicking
Ian Lesperance
Ionuț Turturică
Jaap Broekhuizen
Jan Balster
Janne Vanhala

1
changelog/2283.feature Normal file
View File

@ -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.

View File

@ -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
---------------------------------------------

View File

@ -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_<fixturename>`` and then use
``@pytest.fixture(name='<fixturename>')``.
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

View File

@ -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:

View File

@ -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__(

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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()

View File

@ -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(
"""