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) Hui Wang (coldnight)
Ian Bicking Ian Bicking
Ian Lesperance Ian Lesperance
Ionuț Turturică
Jaap Broekhuizen Jaap Broekhuizen
Jan Balster Jan Balster
Janne Vanhala 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*. 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 Higher-scoped fixtures are instantiated first
--------------------------------------------- ---------------------------------------------

View File

@ -2,6 +2,7 @@ from __future__ import absolute_import, division, print_function
import functools import functools
import inspect import inspect
import os
import sys import sys
import warnings import warnings
from collections import OrderedDict, deque, defaultdict from collections import OrderedDict, deque, defaultdict
@ -45,6 +46,7 @@ def pytest_sessionstart(session):
scopename2class.update( scopename2class.update(
{ {
"package": _pytest.python.Package,
"class": _pytest.python.Class, "class": _pytest.python.Class,
"module": _pytest.python.Module, "module": _pytest.python.Module,
"function": _pytest.nodes.Item, "function": _pytest.nodes.Item,
@ -58,6 +60,7 @@ scopename2class = {}
scope2props = dict(session=()) scope2props = dict(session=())
scope2props["package"] = ("fspath",)
scope2props["module"] = ("fspath", "module") scope2props["module"] = ("fspath", "module")
scope2props["class"] = scope2props["module"] + ("cls",) scope2props["class"] = scope2props["module"] + ("cls",)
scope2props["instance"] = scope2props["class"] + ("instance",) scope2props["instance"] = scope2props["class"] + ("instance",)
@ -80,6 +83,21 @@ def scopeproperty(name=None, doc=None):
return decoratescope 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): def get_scope_node(node, scope):
cls = scopename2class.get(scope) cls = scopename2class.get(scope)
if cls is None: if cls is None:
@ -173,9 +191,11 @@ def get_parametrized_fixture_keys(item, scopenum):
continue continue
if scopenum == 0: # session if scopenum == 0: # session
key = (argname, param_index) 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) key = (argname, param_index, item.fspath)
elif scopenum == 2: # class elif scopenum == 3: # class
key = (argname, param_index, item.fspath, item.cls) key = (argname, param_index, item.fspath, item.cls)
yield key yield key
@ -612,7 +632,10 @@ class FixtureRequest(FuncargnamesCompatAttr):
if scope == "function": if scope == "function":
# this might also be a non-function Item despite its attribute name # this might also be a non-function Item despite its attribute name
return self._pyfuncitem 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": if node is None and scope == "class":
# fallback to function item itself # fallback to function item itself
node = self._pyfuncitem 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") scopenum_function = scopes.index("function")
@ -937,16 +960,27 @@ class FixtureFunctionMarker(object):
def fixture(scope="function", params=None, autouse=False, ids=None, name=None): def fixture(scope="function", params=None, autouse=False, ids=None, name=None):
"""Decorator to mark a fixture factory function. """Decorator to mark a fixture factory function.
This decorator can be used (with or without parameters) to define a This decorator can be used, with or without parameters, to define a
fixture function. The name of the fixture function can later be fixture function.
referenced to cause its invocation ahead of running tests: test
modules or classes can use the pytest.mark.usefixtures(fixturename) The name of the fixture function can later be referenced to cause its
marker. Test functions can directly use fixture names as input 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 arguments in which case the fixture instance returned from the fixture
function will be injected. 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 :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 :arg params: an optional list of parameters which will cause multiple
invocations of the fixture function and all of the tests 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 to resolve this is to name the decorated function
``fixture_<fixturename>`` and then use ``fixture_<fixturename>`` and then use
``@pytest.fixture(name='<fixturename>')``. ``@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: if callable(scope) and params is None and autouse is False:
# direct decoration # direct decoration

View File

@ -383,6 +383,8 @@ class Session(nodes.FSCollector):
self.trace = config.trace.root.get("collection") self.trace = config.trace.root.get("collection")
self._norecursepatterns = config.getini("norecursedirs") self._norecursepatterns = config.getini("norecursedirs")
self.startdir = py.path.local() 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") self.config.pluginmanager.register(self, name="session")
@ -481,18 +483,61 @@ class Session(nodes.FSCollector):
def _collect(self, arg): def _collect(self, arg):
names = self._parsearg(arg) names = self._parsearg(arg)
path = names.pop(0) argpath = names.pop(0)
if path.check(dir=1): 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,) 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 fil=lambda x: x.check(file=1), rec=self._recurse, bf=True, sort=True
): ):
for x in self._collectfile(path): pkginit = path.dirpath().join("__init__.py")
yield x 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: else:
assert path.check(file=1) assert argpath.check(file=1)
for x in self.matchnodes(self._collectfile(path), names):
yield x 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): def _collectfile(self, path):
ihook = self.gethookproxy(path) ihook = self.gethookproxy(path)
@ -577,7 +622,11 @@ class Session(nodes.FSCollector):
resultnodes.append(node) resultnodes.append(node)
continue continue
assert isinstance(node, nodes.Collector) 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: if rep.passed:
has_matched = False has_matched = False
for x in rep.result: for x in rep.result:

View File

@ -358,7 +358,7 @@ class FSCollector(Collector):
if not nodeid: if not nodeid:
nodeid = _check_initialpaths_for_relpath(session, fspath) nodeid = _check_initialpaths_for_relpath(session, fspath)
if os.sep != SEP: if nodeid and os.sep != SEP:
nodeid = nodeid.replace(os.sep, SEP) nodeid = nodeid.replace(os.sep, SEP)
super(FSCollector, self).__init__( super(FSCollector, self).__init__(

View File

@ -13,6 +13,7 @@ from itertools import count
import py import py
import six import six
from _pytest.main import FSHookProxy
from _pytest.mark import MarkerError from _pytest.mark import MarkerError
from _pytest.config import hookimpl from _pytest.config import hookimpl
@ -201,7 +202,7 @@ def pytest_collect_file(path, parent):
ext = path.ext ext = path.ext
if ext == ".py": if ext == ".py":
if not parent.session.isinitpath(path): 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): if path.fnmatch(pat):
break break
else: else:
@ -211,9 +212,23 @@ def pytest_collect_file(path, parent):
def pytest_pycollect_makemodule(path, parent): def pytest_pycollect_makemodule(path, parent):
if path.basename == "__init__.py":
return Package(path, parent)
return Module(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) @hookimpl(hookwrapper=True)
def pytest_pycollect_makeitem(collector, name, obj): def pytest_pycollect_makeitem(collector, name, obj):
outcome = yield outcome = yield
@ -531,6 +546,66 @@ class Module(nodes.File, PyCollector):
self.addfinalizer(teardown_module) 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): def _get_xunit_setup_teardown(holder, attr_name, param_obj=None):
""" """
Return a callable to perform xunit-style setup or teardown if 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.main import Session
from _pytest.nodes import Item, Collector, File from _pytest.nodes import Item, Collector, File
from _pytest.fixtures import fillfixtures as _fillfuncargs 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 from _pytest.python_api import approx, raises
@ -50,6 +50,7 @@ __all__ = [
"Item", "Item",
"File", "File",
"Collector", "Collector",
"Package",
"Session", "Session",
"Module", "Module",
"Class", "Class",

View File

@ -1078,7 +1078,7 @@ def test_setup_only_available_in_subdir(testdir):
def test_modulecol_roundtrip(testdir): def test_modulecol_roundtrip(testdir):
modcol = testdir.getmodulecol("pass", withinit=True) modcol = testdir.getmodulecol("pass", withinit=False)
trail = modcol.nodeid trail = modcol.nodeid
newcol = modcol.session.perform_collect([trail], genitems=0)[0] newcol = modcol.session.perform_collect([trail], genitems=0)[0]
assert modcol.name == newcol.name assert modcol.name == newcol.name

View File

@ -1658,6 +1658,97 @@ class TestFixtureManagerParseFactories(object):
reprec = testdir.inline_run("..") reprec = testdir.inline_run("..")
reprec.assertoutcome(passed=2) 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): class TestAutouseDiscovery(object):
@pytest.fixture @pytest.fixture
@ -3833,6 +3924,10 @@ class TestScopeOrdering(object):
def s1(): def s1():
FIXTURE_ORDER.append('s1') FIXTURE_ORDER.append('s1')
@pytest.fixture(scope="package")
def p1():
FIXTURE_ORDER.append('p1')
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def m1(): def m1():
FIXTURE_ORDER.append('m1') FIXTURE_ORDER.append('m1')
@ -3853,16 +3948,20 @@ class TestScopeOrdering(object):
def f2(): def f2():
FIXTURE_ORDER.append('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() items, _ = testdir.inline_genitems()
request = FixtureRequest(items[0]) request = FixtureRequest(items[0])
# order of fixtures based on their scope and position in the parameter list # 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() testdir.runpytest()
# actual fixture execution differs: dependent fixtures must be created first ("my_tmpdir") # 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): def test_func_closure_module(self, testdir):
testdir.makepyfile( testdir.makepyfile(
@ -3931,9 +4030,13 @@ class TestScopeOrdering(object):
"sub/conftest.py": """ "sub/conftest.py": """
import pytest import pytest
@pytest.fixture(scope='package', autouse=True)
def p_sub(): pass
@pytest.fixture(scope='module', autouse=True) @pytest.fixture(scope='module', autouse=True)
def m_sub(): pass def m_sub(): pass
""", """,
"sub/__init__.py": "",
"sub/test_func.py": """ "sub/test_func.py": """
import pytest import pytest
@ -3950,7 +4053,7 @@ class TestScopeOrdering(object):
) )
items, _ = testdir.inline_genitems() items, _ = testdir.inline_genitems()
request = FixtureRequest(items[0]) 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): def test_func_closure_all_scopes_complex(self, testdir):
"""Complex test involving all scopes and mixing autouse with normal fixtures""" """Complex test involving all scopes and mixing autouse with normal fixtures"""
@ -3960,8 +4063,12 @@ class TestScopeOrdering(object):
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
def s1(): pass def s1(): pass
@pytest.fixture(scope='package', autouse=True)
def p1(): pass
""" """
) )
testdir.makepyfile(**{"__init__.py": ""})
testdir.makepyfile( testdir.makepyfile(
""" """
import pytest import pytest
@ -3990,4 +4097,4 @@ class TestScopeOrdering(object):
) )
items, _ = testdir.inline_genitems() items, _ = testdir.inline_genitems()
request = FixtureRequest(items[0]) 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) col = testdir.getnode(config, x)
assert isinstance(col, pytest.Module) assert isinstance(col, pytest.Module)
assert col.name == "x.py" assert col.name == "x.py"
assert col.parent.parent is None assert col.parent.parent.parent is None
for col in col.listchain(): for col in col.listchain():
assert col.config is config assert col.config is config
@ -904,7 +904,7 @@ def test_continue_on_collection_errors_maxfail(testdir):
def test_fixture_scope_sibling_conftests(testdir): def test_fixture_scope_sibling_conftests(testdir):
"""Regression test case for https://github.com/pytest-dev/pytest/issues/2836""" """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( foo_path.join("conftest.py").write(
_pytest._code.Source( _pytest._code.Source(
""" """