Skip to content

Commit

Permalink
Merge pull request #1711 from nicoddemus/invocation-scoped-fixtures
Browse files Browse the repository at this point in the history
Invocation scoped fixtures
  • Loading branch information
nicoddemus committed Jul 21, 2016
2 parents 832ada1 + 05f3422 commit ae07985
Show file tree
Hide file tree
Showing 11 changed files with 475 additions and 62 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,17 @@ time or change existing behaviors in order to make them less surprising/more use
never fail because tuples are always truthy and are usually a mistake
(see `#1562`_). Thanks `@kvas-it`_, for the PR.

* Experimentally introduce new ``"invocation"`` fixture scope. At invocation scope a
fixture function is cached in the same way as the fixture or test function that requests it.
You can now use the builtin ``monkeypatch`` fixture from ``session``-scoped fixtures
where previously you would get an error that you can not use a ``function``-scoped fixture from a
``session``-scoped one.*
Thanks `@nicoddemus`_ for the PR.

* Allow passing a custom debugger class (e.g. ``--pdbcls=IPython.core.debugger:Pdb``).
Thanks to `@anntzer`_ for the PR.


*

*
Expand Down
4 changes: 2 additions & 2 deletions _pytest/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ def capsys(request):
captured output available via ``capsys.readouterr()`` method calls
which return a ``(out, err)`` tuple.
"""
if "capfd" in request._funcargs:
if "capfd" in request.fixturenames:
raise request.raiseerror(error_capsysfderror)
request.node._capfuncarg = c = CaptureFixture(SysCapture, request)
return c
Expand All @@ -172,7 +172,7 @@ def capfd(request):
captured output available via ``capfd.readouterr()`` method calls
which return a ``(out, err)`` tuple.
"""
if "capsys" in request._funcargs:
if "capsys" in request.fixturenames:
request.raiseerror(error_capsysfderror)
if not hasattr(os, 'dup'):
pytest.skip("capfd funcarg needs os.dup")
Expand Down
138 changes: 99 additions & 39 deletions _pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,6 @@ def __init__(self, argnames, names_closure, name2fixturedefs):
self.name2fixturedefs = name2fixturedefs




class FixtureRequest(FuncargnamesCompatAttr):
""" A request for a fixture from a test or fixture function.
Expand All @@ -276,34 +274,51 @@ def __init__(self, pyfuncitem):
self.fixturename = None
#: Scope string, one of "function", "class", "module", "session"
self.scope = "function"
self._funcargs = {}
self._fixturedefs = {}
# rename both attributes below because their key has changed; better an attribute error
# than subtle key misses; also backward incompatibility
self._fixture_values = {} # (argname, scope) -> fixture value
self._fixture_defs = {} # (argname, scope) -> FixtureDef
fixtureinfo = pyfuncitem._fixtureinfo
self._arg2fixturedefs = fixtureinfo.name2fixturedefs.copy()
self._arg2index = {}
self.fixturenames = fixtureinfo.names_closure
self._fixturemanager = pyfuncitem.session._fixturemanager

@property
def fixturenames(self):
# backward incompatible note: now a readonly property
return list(self._pyfuncitem._fixtureinfo.names_closure)

@property
def node(self):
""" underlying collection node (depends on current request scope)"""
return self._getscopeitem(self.scope)


def _getnextfixturedef(self, argname):
fixturedefs = self._arg2fixturedefs.get(argname, None)
def _getnextfixturedef(self, argname, scope):
def trygetfixturedefs(argname):
fixturedefs = self._arg2fixturedefs.get(argname, None)
if fixturedefs is None:
fixturedefs = self._arg2fixturedefs.get(argname + ':' + scope, None)
return fixturedefs

fixturedefs = trygetfixturedefs(argname)
if fixturedefs is None:
# we arrive here because of a a dynamic call to
# getfixturevalue(argname) usage which was naturally
# not known at parsing/collection time
fixturedefs = self._fixturemanager.getfixturedefs(
argname, self._pyfuncitem.parent.nodeid)
self._arg2fixturedefs[argname] = fixturedefs
parentid = self._pyfuncitem.parent.nodeid
fixturedefs = self._fixturemanager.getfixturedefs(argname, parentid)
if fixturedefs:
self._arg2fixturedefs[argname] = fixturedefs
fixturedefs_by_argname = self._fixturemanager.getfixturedefs_multiple_scopes(argname, parentid)
if fixturedefs_by_argname:
self._arg2fixturedefs.update(fixturedefs_by_argname)
fixturedefs = trygetfixturedefs(argname)
# fixturedefs list is immutable so we maintain a decreasing index
index = self._arg2index.get(argname, 0) - 1
index = self._arg2index.get((argname, scope), 0) - 1
if fixturedefs is None or (-index > len(fixturedefs)):
raise FixtureLookupError(argname, self)
self._arg2index[argname] = index
self._arg2index[(argname, scope)] = index
return fixturedefs[index]

@property
Expand Down Expand Up @@ -442,10 +457,10 @@ def getfuncargvalue(self, argname):

def _get_active_fixturedef(self, argname):
try:
return self._fixturedefs[argname]
return self._fixture_defs[(argname, self.scope)]
except KeyError:
try:
fixturedef = self._getnextfixturedef(argname)
fixturedef = self._getnextfixturedef(argname, self.scope)
except FixtureLookupError:
if argname == "request":
class PseudoFixtureDef:
Expand All @@ -456,8 +471,8 @@ class PseudoFixtureDef:
# remove indent to prevent the python3 exception
# from leaking into the call
result = self._getfixturevalue(fixturedef)
self._funcargs[argname] = result
self._fixturedefs[argname] = fixturedef
self._fixture_values[(argname, self.scope)] = result
self._fixture_defs[(argname, self.scope)] = fixturedef
return fixturedef

def _get_fixturestack(self):
Expand Down Expand Up @@ -578,11 +593,10 @@ def __init__(self, request, scope, param, param_index, fixturedef):
self._fixturedef = fixturedef
self.addfinalizer = fixturedef.addfinalizer
self._pyfuncitem = request._pyfuncitem
self._funcargs = request._funcargs
self._fixturedefs = request._fixturedefs
self._fixture_values = request._fixture_values
self._fixture_defs = request._fixture_defs
self._arg2fixturedefs = request._arg2fixturedefs
self._arg2index = request._arg2index
self.fixturenames = request.fixturenames
self._fixturemanager = request._fixturemanager

def __repr__(self):
Expand Down Expand Up @@ -622,7 +636,7 @@ def formatrepr(self):
fspath, lineno = getfslineno(function)
try:
lines, _ = inspect.getsourcelines(get_real_func(function))
except (IOError, IndexError):
except (IOError, IndexError, TypeError):
error_msg = "file %s, line %s: source code not available"
addline(error_msg % (fspath, lineno+1))
else:
Expand All @@ -636,9 +650,9 @@ def formatrepr(self):
if msg is None:
fm = self.request._fixturemanager
available = []
for name, fixturedef in fm._arg2fixturedefs.items():
parentid = self.request._pyfuncitem.parent.nodeid
faclist = list(fm._matchfactories(fixturedef, parentid))
parentid = self.request._pyfuncitem.parent.nodeid
for name, fixturedefs in fm._arg2fixturedefs.items():
faclist = list(fm._matchfactories(fixturedefs, parentid))
if faclist:
available.append(name)
msg = "fixture %r not found" % (self.argname,)
Expand Down Expand Up @@ -749,7 +763,7 @@ def execute(self, request):
assert not hasattr(self, "cached_result")

ihook = self._fixturemanager.session.ihook
ihook.pytest_fixture_setup(fixturedef=self, request=request)
return ihook.pytest_fixture_setup(fixturedef=self, request=request)

def __repr__(self):
return ("<FixtureDef name=%r scope=%r baseid=%r >" %
Expand Down Expand Up @@ -984,10 +998,12 @@ def getfixtureclosure(self, fixturenames, parentnode):

parentid = parentnode.nodeid
fixturenames_closure = self._getautousenames(parentid)

def merge(otherlist):
for arg in otherlist:
if arg not in fixturenames_closure:
fixturenames_closure.append(arg)

merge(fixturenames)
arg2fixturedefs = {}
lastlen = -1
Expand All @@ -1000,6 +1016,11 @@ def merge(otherlist):
if fixturedefs:
arg2fixturedefs[argname] = fixturedefs
merge(fixturedefs[-1].argnames)
fixturedefs_by_argname = self.getfixturedefs_multiple_scopes(argname, parentid)
if fixturedefs_by_argname:
arg2fixturedefs.update(fixturedefs_by_argname)
for fixturedefs in fixturedefs_by_argname.values():
merge(fixturedefs[-1].argnames)
return fixturenames_closure, arg2fixturedefs

def pytest_generate_tests(self, metafunc):
Expand All @@ -1018,7 +1039,7 @@ def pytest_generate_tests(self, metafunc):
indirect=True, scope=fixturedef.scope,
ids=fixturedef.ids)
else:
continue # will raise FixtureLookupError at setup time
continue # will raise FixtureLookupError at setup time

def pytest_collection_modifyitems(self, items):
# separate parametrized setups
Expand Down Expand Up @@ -1057,25 +1078,43 @@ def parsefactories(self, node_or_obj, nodeid=NOTSET, unittest=False):
msg = 'fixtures cannot have "pytest_funcarg__" prefix ' \
'and be decorated with @pytest.fixture:\n%s' % name
assert not name.startswith(self._argprefix), msg
fixturedef = FixtureDef(self, nodeid, name, obj,
marker.scope, marker.params,
unittest=unittest, ids=marker.ids)
faclist = self._arg2fixturedefs.setdefault(name, [])
if fixturedef.has_location:
faclist.append(fixturedef)

def new_fixture_def(name, scope):
"""Create and registers a new FixtureDef with given name and scope."""
fixture_def = FixtureDef(self, nodeid, name, obj,
scope, marker.params,
unittest=unittest, ids=marker.ids)

faclist = self._arg2fixturedefs.setdefault(name, [])
if fixture_def.has_location:
faclist.append(fixture_def)
else:
# fixturedefs with no location are at the front
# so this inserts the current fixturedef after the
# existing fixturedefs from external plugins but
# before the fixturedefs provided in conftests.
i = len([f for f in faclist if not f.has_location])
faclist.insert(i, fixture_def)
if marker.autouse:
autousenames.append(name)

if marker.scope == 'invocation':
for new_scope in scopes:
new_fixture_def(name + ':{0}'.format(new_scope), new_scope)
else:
# fixturedefs with no location are at the front
# so this inserts the current fixturedef after the
# existing fixturedefs from external plugins but
# before the fixturedefs provided in conftests.
i = len([f for f in faclist if not f.has_location])
faclist.insert(i, fixturedef)
if marker.autouse:
autousenames.append(name)
new_fixture_def(name, marker.scope)

if autousenames:
self._nodeid_and_autousenames.append((nodeid or '', autousenames))

def getfixturedefs(self, argname, nodeid):
"""
Gets a list of fixtures which are applicable to the given node id.
:param str argname: name of the fixture to search for
:param str nodeid: full node id of the requesting test.
:return: list[FixtureDef]
"""
try:
fixturedefs = self._arg2fixturedefs[argname]
except KeyError:
Expand All @@ -1087,3 +1126,24 @@ def _matchfactories(self, fixturedefs, nodeid):
for fixturedef in fixturedefs:
if nodeid.startswith(fixturedef.baseid):
yield fixturedef

def getfixturedefs_multiple_scopes(self, argname, nodeid):
"""
Gets multiple scoped fixtures which are applicable to the given nodeid. Multiple scoped
fixtures are created by "invocation" scoped fixtures and have argnames in
the form: "<argname>:<scope>" (for example "tmpdir:session").
:return: dict of "argname" -> [FixtureDef].
Arguments similar to ``getfixturedefs``.
"""
prefix = argname + ':'
fixturedefs_by_argname = dict((k, v) for k, v in self._arg2fixturedefs.items()
if k.startswith(prefix))
if fixturedefs_by_argname:
result = {}
for argname, fixturedefs in fixturedefs_by_argname.items():
result[argname] = tuple(self._matchfactories(fixturedefs, nodeid))
return result
else:
return None
9 changes: 6 additions & 3 deletions _pytest/monkeypatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
RE_IMPORT_ERROR_NAME = re.compile("^No module named (.*)$")


@pytest.fixture
@pytest.fixture(scope='invocation')
def monkeypatch(request):
"""The returned ``monkeypatch`` fixture provides these
helper methods to modify objects, dictionaries or os.environ::
Expand All @@ -25,9 +25,11 @@ def monkeypatch(request):
monkeypatch.chdir(path)
All modifications will be undone after the requesting
test function has finished. The ``raising``
test function or fixture has finished. The ``raising``
parameter determines if a KeyError or AttributeError
will be raised if the set/deletion operation has no target.
This fixture is ``invocation``-scoped.
"""
mpatch = MonkeyPatch()
request.addfinalizer(mpatch.undo)
Expand Down Expand Up @@ -97,7 +99,8 @@ def __repr__(self):


class MonkeyPatch:
""" Object keeping a record of setattr/item/env/syspath changes. """
""" Object returned by the ``monkeypatch`` fixture keeping a record of setattr/item/env/syspath changes.
"""

def __init__(self):
self._setattr = []
Expand Down
1 change: 0 additions & 1 deletion _pytest/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -1506,4 +1506,3 @@ def setup(self):
super(Function, self).setup()
fixtures.fillfixtures(self)


10 changes: 9 additions & 1 deletion doc/en/fixture.rst
Original file line number Diff line number Diff line change
Expand Up @@ -603,7 +603,7 @@ first execute with one instance and then finalizers are called
before the next fixture instance is created. Among other things,
this eases testing of applications which create and use global state.

The following example uses two parametrized funcargs, one of which is
The following example uses two parametrized fixture, one of which is
scoped on a per-module basis, and all the functions perform ``print`` calls
to show the setup/teardown flow::

Expand Down Expand Up @@ -863,6 +863,14 @@ All test methods in this TestClass will use the transaction fixture while
other test classes or functions in the module will not use it unless
they also add a ``transact`` reference.

invocation-scoped fixtures
--------------------------

pytest 3.0 introduced a new advanced scope for fixtures: ``"invocation"``. Fixtures marked with
this scope can be requested from any other scope, providing a version of the fixture for that scope.

See more in :ref:`invocation_scoped_fixture`.

Shifting (visibility of) fixture functions
----------------------------------------------------

Expand Down
Loading

0 comments on commit ae07985

Please sign in to comment.