Skip to content

Add package scoped fixtures #2283 #3389

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
Jul 6, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ Hugo van Kemenade
Hui Wang (coldnight)
Ian Bicking
Ian Lesperance
Ionuț Turturică
Jaap Broekhuizen
Jan Balster
Janne Vanhala
Expand Down
1 change: 1 addition & 0 deletions changelog/2283.feature
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions doc/en/fixture.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
---------------------------------------------

Expand Down
58 changes: 44 additions & 14 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import functools
import inspect
import os
import sys
import warnings
from collections import OrderedDict, deque, defaultdict
Expand Down Expand Up @@ -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,
Expand All @@ -58,6 +60,7 @@ def pytest_sessionstart(session):


scope2props = dict(session=())
scope2props["package"] = ("fspath",)
scope2props["module"] = ("fspath", "module")
scope2props["class"] = scope2props["module"] + ("cls",)
scope2props["instance"] = scope2props["class"] + ("instance",)
Expand All @@ -80,6 +83,21 @@ def provide(self):
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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -612,7 +632,10 @@ def _getscopeitem(self, scope):
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
Expand Down Expand Up @@ -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")


Expand Down Expand Up @@ -937,16 +960,27 @@ def __call__(self, function):
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
Expand All @@ -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
Expand Down
67 changes: 58 additions & 9 deletions src/_pytest/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,8 @@ def __init__(self, config):
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")

Expand Down Expand Up @@ -481,18 +483,61 @@ def collect(self):

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)
Expand Down Expand Up @@ -577,7 +622,11 @@ def _matchnodes(self, matching, names):
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:
Expand Down
2 changes: 1 addition & 1 deletion src/_pytest/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ def __init__(self, fspath, parent=None, config=None, session=None, nodeid=None):

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__(
Expand Down
77 changes: 76 additions & 1 deletion src/_pytest/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import py
import six
from _pytest.main import FSHookProxy
from _pytest.mark import MarkerError
from _pytest.config import hookimpl

Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -531,6 +546,66 @@ def setup(self):
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
Expand Down
3 changes: 2 additions & 1 deletion src/pytest.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,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
from _pytest.python import Package, Module, Class, Instance, Function, Generator

from _pytest.python_api import approx, raises

Expand Down Expand Up @@ -50,6 +50,7 @@
"Item",
"File",
"Collector",
"Package",
"Session",
"Module",
"Class",
Expand Down
2 changes: 1 addition & 1 deletion testing/python/collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -1078,7 +1078,7 @@ def pytest_runtest_teardown(item):


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
Expand Down
Loading