Skip to content

Commit 1e94ac7

Browse files
Merge pull request #3389 from jonozzz/features
Add package scoped fixtures #2283
2 parents 3f5e06e + 027d233 commit 1e94ac7

File tree

11 files changed

+314
-34
lines changed

11 files changed

+314
-34
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ Hugo van Kemenade
8989
Hui Wang (coldnight)
9090
Ian Bicking
9191
Ian Lesperance
92+
Ionuț Turturică
9293
Jaap Broekhuizen
9394
Jan Balster
9495
Janne Vanhala

changelog/2283.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

doc/en/fixture.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,22 @@ instance, you can simply declare it:
258258
Finally, the ``class`` scope will invoke the fixture once per test *class*.
259259

260260

261+
``package`` scope (experimental)
262+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
263+
264+
.. versionadded:: 3.7
265+
266+
In pytest 3.7 the ``package`` scope has been introduced. Package-scoped fixtures
267+
are finalized when the last test of a *package* finishes.
268+
269+
.. warning::
270+
This functionality is considered **experimental** and may be removed in future
271+
versions if hidden corner-cases or serious problems with this functionality
272+
are discovered after it gets more usage in the wild.
273+
274+
Use this new feature sparingly and please make sure to report any issues you find.
275+
276+
261277
Higher-scoped fixtures are instantiated first
262278
---------------------------------------------
263279

src/_pytest/fixtures.py

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import functools
44
import inspect
5+
import os
56
import sys
67
import warnings
78
from collections import OrderedDict, deque, defaultdict
@@ -45,6 +46,7 @@ def pytest_sessionstart(session):
4546

4647
scopename2class.update(
4748
{
49+
"package": _pytest.python.Package,
4850
"class": _pytest.python.Class,
4951
"module": _pytest.python.Module,
5052
"function": _pytest.nodes.Item,
@@ -58,6 +60,7 @@ def pytest_sessionstart(session):
5860

5961

6062
scope2props = dict(session=())
63+
scope2props["package"] = ("fspath",)
6164
scope2props["module"] = ("fspath", "module")
6265
scope2props["class"] = scope2props["module"] + ("cls",)
6366
scope2props["instance"] = scope2props["class"] + ("instance",)
@@ -80,6 +83,21 @@ def provide(self):
8083
return decoratescope
8184

8285

86+
def get_scope_package(node, fixturedef):
87+
import pytest
88+
89+
cls = pytest.Package
90+
current = node
91+
fixture_package_name = os.path.join(fixturedef.baseid, "__init__.py")
92+
while current and (
93+
type(current) is not cls or fixture_package_name != current.nodeid
94+
):
95+
current = current.parent
96+
if current is None:
97+
return node.session
98+
return current
99+
100+
83101
def get_scope_node(node, scope):
84102
cls = scopename2class.get(scope)
85103
if cls is None:
@@ -173,9 +191,11 @@ def get_parametrized_fixture_keys(item, scopenum):
173191
continue
174192
if scopenum == 0: # session
175193
key = (argname, param_index)
176-
elif scopenum == 1: # module
194+
elif scopenum == 1: # package
195+
key = (argname, param_index, item.fspath.dirpath())
196+
elif scopenum == 2: # module
177197
key = (argname, param_index, item.fspath)
178-
elif scopenum == 2: # class
198+
elif scopenum == 3: # class
179199
key = (argname, param_index, item.fspath, item.cls)
180200
yield key
181201

@@ -612,7 +632,10 @@ def _getscopeitem(self, scope):
612632
if scope == "function":
613633
# this might also be a non-function Item despite its attribute name
614634
return self._pyfuncitem
615-
node = get_scope_node(self._pyfuncitem, scope)
635+
if scope == "package":
636+
node = get_scope_package(self._pyfuncitem, self._fixturedef)
637+
else:
638+
node = get_scope_node(self._pyfuncitem, scope)
616639
if node is None and scope == "class":
617640
# fallback to function item itself
618641
node = self._pyfuncitem
@@ -656,7 +679,7 @@ class ScopeMismatchError(Exception):
656679
"""
657680

658681

659-
scopes = "session module class function".split()
682+
scopes = "session package module class function".split()
660683
scopenum_function = scopes.index("function")
661684

662685

@@ -937,16 +960,27 @@ def __call__(self, function):
937960
def fixture(scope="function", params=None, autouse=False, ids=None, name=None):
938961
"""Decorator to mark a fixture factory function.
939962
940-
This decorator can be used (with or without parameters) to define a
941-
fixture function. The name of the fixture function can later be
942-
referenced to cause its invocation ahead of running tests: test
943-
modules or classes can use the pytest.mark.usefixtures(fixturename)
944-
marker. Test functions can directly use fixture names as input
963+
This decorator can be used, with or without parameters, to define a
964+
fixture function.
965+
966+
The name of the fixture function can later be referenced to cause its
967+
invocation ahead of running tests: test
968+
modules or classes can use the ``pytest.mark.usefixtures(fixturename)``
969+
marker.
970+
971+
Test functions can directly use fixture names as input
945972
arguments in which case the fixture instance returned from the fixture
946973
function will be injected.
947974
975+
Fixtures can provide their values to test functions using ``return`` or ``yield``
976+
statements. When using ``yield`` the code block after the ``yield`` statement is executed
977+
as teardown code regardless of the test outcome, and must yield exactly once.
978+
948979
:arg scope: the scope for which this fixture is shared, one of
949-
"function" (default), "class", "module" or "session".
980+
``"function"`` (default), ``"class"``, ``"module"``,
981+
``"package"`` or ``"session"``.
982+
983+
``"package"`` is considered **experimental** at this time.
950984
951985
:arg params: an optional list of parameters which will cause multiple
952986
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):
9671001
to resolve this is to name the decorated function
9681002
``fixture_<fixturename>`` and then use
9691003
``@pytest.fixture(name='<fixturename>')``.
970-
971-
Fixtures can optionally provide their values to test functions using a ``yield`` statement,
972-
instead of ``return``. In this case, the code block after the ``yield`` statement is executed
973-
as teardown code regardless of the test outcome. A fixture function must yield exactly once.
9741004
"""
9751005
if callable(scope) and params is None and autouse is False:
9761006
# direct decoration

src/_pytest/main.py

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,8 @@ def __init__(self, config):
383383
self.trace = config.trace.root.get("collection")
384384
self._norecursepatterns = config.getini("norecursedirs")
385385
self.startdir = py.path.local()
386+
# Keep track of any collected nodes in here, so we don't duplicate fixtures
387+
self._node_cache = {}
386388

387389
self.config.pluginmanager.register(self, name="session")
388390

@@ -481,18 +483,61 @@ def collect(self):
481483

482484
def _collect(self, arg):
483485
names = self._parsearg(arg)
484-
path = names.pop(0)
485-
if path.check(dir=1):
486+
argpath = names.pop(0)
487+
paths = []
488+
489+
root = self
490+
# Start with a Session root, and delve to argpath item (dir or file)
491+
# and stack all Packages found on the way.
492+
# No point in finding packages when collecting doctests
493+
if not self.config.option.doctestmodules:
494+
for parent in argpath.parts():
495+
pm = self.config.pluginmanager
496+
if pm._confcutdir and pm._confcutdir.relto(parent):
497+
continue
498+
499+
if parent.isdir():
500+
pkginit = parent.join("__init__.py")
501+
if pkginit.isfile():
502+
if pkginit in self._node_cache:
503+
root = self._node_cache[pkginit]
504+
else:
505+
col = root._collectfile(pkginit)
506+
if col:
507+
root = col[0]
508+
self._node_cache[root.fspath] = root
509+
510+
# If it's a directory argument, recurse and look for any Subpackages.
511+
# Let the Package collector deal with subnodes, don't collect here.
512+
if argpath.check(dir=1):
486513
assert not names, "invalid arg %r" % (arg,)
487-
for path in path.visit(
514+
for path in argpath.visit(
488515
fil=lambda x: x.check(file=1), rec=self._recurse, bf=True, sort=True
489516
):
490-
for x in self._collectfile(path):
491-
yield x
517+
pkginit = path.dirpath().join("__init__.py")
518+
if pkginit.exists() and not any(x in pkginit.parts() for x in paths):
519+
for x in root._collectfile(pkginit):
520+
yield x
521+
paths.append(x.fspath.dirpath())
522+
523+
if not any(x in path.parts() for x in paths):
524+
for x in root._collectfile(path):
525+
if (type(x), x.fspath) in self._node_cache:
526+
yield self._node_cache[(type(x), x.fspath)]
527+
else:
528+
yield x
529+
self._node_cache[(type(x), x.fspath)] = x
492530
else:
493-
assert path.check(file=1)
494-
for x in self.matchnodes(self._collectfile(path), names):
495-
yield x
531+
assert argpath.check(file=1)
532+
533+
if argpath in self._node_cache:
534+
col = self._node_cache[argpath]
535+
else:
536+
col = root._collectfile(argpath)
537+
if col:
538+
self._node_cache[argpath] = col
539+
for y in self.matchnodes(col, names):
540+
yield y
496541

497542
def _collectfile(self, path):
498543
ihook = self.gethookproxy(path)
@@ -577,7 +622,11 @@ def _matchnodes(self, matching, names):
577622
resultnodes.append(node)
578623
continue
579624
assert isinstance(node, nodes.Collector)
580-
rep = collect_one_node(node)
625+
if node.nodeid in self._node_cache:
626+
rep = self._node_cache[node.nodeid]
627+
else:
628+
rep = collect_one_node(node)
629+
self._node_cache[node.nodeid] = rep
581630
if rep.passed:
582631
has_matched = False
583632
for x in rep.result:

src/_pytest/nodes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ def __init__(self, fspath, parent=None, config=None, session=None, nodeid=None):
358358

359359
if not nodeid:
360360
nodeid = _check_initialpaths_for_relpath(session, fspath)
361-
if os.sep != SEP:
361+
if nodeid and os.sep != SEP:
362362
nodeid = nodeid.replace(os.sep, SEP)
363363

364364
super(FSCollector, self).__init__(

src/_pytest/python.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import py
1515
import six
16+
from _pytest.main import FSHookProxy
1617
from _pytest.mark import MarkerError
1718
from _pytest.config import hookimpl
1819

@@ -201,7 +202,7 @@ def pytest_collect_file(path, parent):
201202
ext = path.ext
202203
if ext == ".py":
203204
if not parent.session.isinitpath(path):
204-
for pat in parent.config.getini("python_files"):
205+
for pat in parent.config.getini("python_files") + ["__init__.py"]:
205206
if path.fnmatch(pat):
206207
break
207208
else:
@@ -211,9 +212,23 @@ def pytest_collect_file(path, parent):
211212

212213

213214
def pytest_pycollect_makemodule(path, parent):
215+
if path.basename == "__init__.py":
216+
return Package(path, parent)
214217
return Module(path, parent)
215218

216219

220+
def pytest_ignore_collect(path, config):
221+
# Skip duplicate packages.
222+
keepduplicates = config.getoption("keepduplicates")
223+
if keepduplicates:
224+
duplicate_paths = config.pluginmanager._duplicatepaths
225+
if path.basename == "__init__.py":
226+
if path in duplicate_paths:
227+
return True
228+
else:
229+
duplicate_paths.add(path)
230+
231+
217232
@hookimpl(hookwrapper=True)
218233
def pytest_pycollect_makeitem(collector, name, obj):
219234
outcome = yield
@@ -531,6 +546,66 @@ def setup(self):
531546
self.addfinalizer(teardown_module)
532547

533548

549+
class Package(Module):
550+
def __init__(self, fspath, parent=None, config=None, session=None, nodeid=None):
551+
session = parent.session
552+
nodes.FSCollector.__init__(
553+
self, fspath, parent=parent, config=config, session=session, nodeid=nodeid
554+
)
555+
self.name = fspath.dirname
556+
self.trace = session.trace
557+
self._norecursepatterns = session._norecursepatterns
558+
for path in list(session.config.pluginmanager._duplicatepaths):
559+
if path.dirname == fspath.dirname and path != fspath:
560+
session.config.pluginmanager._duplicatepaths.remove(path)
561+
562+
def _recurse(self, path):
563+
ihook = self.gethookproxy(path.dirpath())
564+
if ihook.pytest_ignore_collect(path=path, config=self.config):
565+
return
566+
for pat in self._norecursepatterns:
567+
if path.check(fnmatch=pat):
568+
return False
569+
ihook = self.gethookproxy(path)
570+
ihook.pytest_collect_directory(path=path, parent=self)
571+
return True
572+
573+
def gethookproxy(self, fspath):
574+
# check if we have the common case of running
575+
# hooks with all conftest.py filesall conftest.py
576+
pm = self.config.pluginmanager
577+
my_conftestmodules = pm._getconftestmodules(fspath)
578+
remove_mods = pm._conftest_plugins.difference(my_conftestmodules)
579+
if remove_mods:
580+
# one or more conftests are not in use at this fspath
581+
proxy = FSHookProxy(fspath, pm, remove_mods)
582+
else:
583+
# all plugis are active for this fspath
584+
proxy = self.config.hook
585+
return proxy
586+
587+
def _collectfile(self, path):
588+
ihook = self.gethookproxy(path)
589+
if not self.isinitpath(path):
590+
if ihook.pytest_ignore_collect(path=path, config=self.config):
591+
return ()
592+
return ihook.pytest_collect_file(path=path, parent=self)
593+
594+
def isinitpath(self, path):
595+
return path in self.session._initialpaths
596+
597+
def collect(self):
598+
path = self.fspath.dirpath()
599+
pkg_prefix = None
600+
for path in path.visit(fil=lambda x: 1, rec=self._recurse, bf=True, sort=True):
601+
if pkg_prefix and pkg_prefix in path.parts():
602+
continue
603+
for x in self._collectfile(path):
604+
yield x
605+
if isinstance(x, Package):
606+
pkg_prefix = path.dirpath()
607+
608+
534609
def _get_xunit_setup_teardown(holder, attr_name, param_obj=None):
535610
"""
536611
Return a callable to perform xunit-style setup or teardown if

src/pytest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from _pytest.main import Session
1919
from _pytest.nodes import Item, Collector, File
2020
from _pytest.fixtures import fillfixtures as _fillfuncargs
21-
from _pytest.python import Module, Class, Instance, Function, Generator
21+
from _pytest.python import Package, Module, Class, Instance, Function, Generator
2222

2323
from _pytest.python_api import approx, raises
2424

@@ -50,6 +50,7 @@
5050
"Item",
5151
"File",
5252
"Collector",
53+
"Package",
5354
"Session",
5455
"Module",
5556
"Class",

testing/python/collect.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1078,7 +1078,7 @@ def pytest_runtest_teardown(item):
10781078

10791079

10801080
def test_modulecol_roundtrip(testdir):
1081-
modcol = testdir.getmodulecol("pass", withinit=True)
1081+
modcol = testdir.getmodulecol("pass", withinit=False)
10821082
trail = modcol.nodeid
10831083
newcol = modcol.session.perform_collect([trail], genitems=0)[0]
10841084
assert modcol.name == newcol.name

0 commit comments

Comments
 (0)