deprecate and warn about __multicall__ usage in hooks, refine docs about hook ordering,

make hookwrappers respect tryfirst/trylast

--HG--
branch : more_plugin
This commit is contained in:
holger krekel 2015-04-27 12:50:34 +02:00
parent dea1c96031
commit c54afbe42e
7 changed files with 122 additions and 47 deletions

View File

@ -39,6 +39,10 @@
- fix issue732: properly unregister plugins from any hook calling
sites allowing to have temporary plugins during test execution.
- deprecate and warn about ``__multicall__`` argument in hook
implementations. Use the ``hookwrapper`` mechanism instead already
introduced with pytest-2.7.
2.7.1.dev (compared to 2.7.0)
-----------------------------

View File

@ -9,7 +9,7 @@ import py
# DON't import pytest here because it causes import cycle troubles
import sys, os
from _pytest import hookspec # the extension point definitions
from _pytest.core import PluginManager, hookimpl_opts
from _pytest.core import PluginManager, hookimpl_opts, varnames
# pytest startup
#
@ -117,6 +117,18 @@ class PytestPluginManager(PluginManager):
self.trace.root.setwriter(err.write)
self.enable_tracing()
def _verify_hook(self, hook, plugin):
super(PytestPluginManager, self)._verify_hook(hook, plugin)
method = getattr(plugin, hook.name)
if "__multicall__" in varnames(method):
fslineno = py.code.getfslineno(method)
warning = dict(code="I1",
fslocation=fslineno,
message="%r hook uses deprecated __multicall__ "
"argument" % (hook.name))
self._warnings.append(warning)
def register(self, plugin, name=None):
ret = super(PytestPluginManager, self).register(plugin, name)
if ret:
@ -138,6 +150,9 @@ class PytestPluginManager(PluginManager):
"trylast: mark a hook implementation function such that the "
"plugin machinery will try to call it last/as late as possible.")
for warning in self._warnings:
if isinstance(warning, dict):
config.warn(**warning)
else:
config.warn(code="I1", message=warning)
#
@ -712,10 +727,10 @@ class Config(object):
fin = self._cleanup.pop()
fin()
def warn(self, code, message):
def warn(self, code, message, fslocation=None):
""" generate a warning for this test session. """
self.hook.pytest_logwarning(code=code, message=message,
fslocation=None, nodeid=None)
fslocation=fslocation, nodeid=None)
def get_terminal_writer(self):
return self.pluginmanager.get_plugin("terminalreporter")._tw

View File

@ -408,6 +408,12 @@ class PluginManager(object):
class MultiCall:
""" execute a call into multiple python functions/methods. """
# XXX note that the __multicall__ argument is supported only
# for pytest compatibility reasons. It was never officially
# supported there and is explicitely deprecated since 2.8
# so we can remove it soon, allowing to avoid the below recursion
# in execute() and simplify/speed up the execute loop.
def __init__(self, methods, kwargs, firstresult=False):
self.methods = methods
self.kwargs = kwargs
@ -527,20 +533,20 @@ class HookCaller(object):
def _add_method(self, meth):
if hasattr(meth, 'hookwrapper'):
self._wrappers.append(meth)
elif hasattr(meth, 'trylast'):
self._nonwrappers.insert(0, meth)
elif hasattr(meth, 'tryfirst'):
self._nonwrappers.append(meth)
methods = self._wrappers
else:
# find the last nonwrapper which is not tryfirst marked
nonwrappers = self._nonwrappers
i = len(nonwrappers) - 1
while i >= 0 and hasattr(nonwrappers[i], "tryfirst"):
i -= 1
methods = self._nonwrappers
# and insert right in front of the tryfirst ones
nonwrappers.insert(i+1, meth)
if hasattr(meth, 'trylast'):
methods.insert(0, meth)
elif hasattr(meth, 'tryfirst'):
methods.append(meth)
else:
# find last non-tryfirst method
i = len(methods) - 1
while i >= 0 and hasattr(methods[i], "tryfirst"):
i -= 1
methods.insert(i + 1, meth)
def __repr__(self):
return "<HookCaller %r>" %(self.name,)

View File

@ -164,6 +164,8 @@ class TerminalReporter:
def pytest_logwarning(self, code, fslocation, message, nodeid):
warnings = self.stats.setdefault("warnings", [])
if isinstance(fslocation, tuple):
fslocation = "%s:%d" % fslocation
warning = WarningReport(code=code, fslocation=fslocation,
message=message, nodeid=nodeid)
warnings.append(warning)

View File

@ -221,36 +221,21 @@ be "future-compatible": we can introduce new hook named parameters without
breaking the signatures of existing hook implementations. It is one of
the reasons for the general long-lived compatibility of pytest plugins.
Hook function results
---------------------
Note that hook functions other than ``pytest_runtest_*`` are not
allowed to raise exceptions. Doing so will break the pytest run.
firstresult: stop at first non-None result
-------------------------------------------
Most calls to ``pytest`` hooks result in a **list of results** which contains
all non-None results of the called hook functions.
Some hooks are specified so that the hook call only executes until the
first function returned a non-None value which is then also the
result of the overall hook call. The remaining hook functions will
not be called in this case.
Note that hook functions other than ``pytest_runtest_*`` are not
allowed to raise exceptions. Doing so will break the pytest run.
Hook function ordering
----------------------
For any given hook there may be more than one implementation and we thus
generally view ``hook`` execution as a ``1:N`` function call where ``N``
is the number of registered functions. There are ways to
influence if a hook implementation comes before or after others, i.e.
the position in the ``N``-sized list of functions::
@pytest.hookimpl_spec(tryfirst=True)
def pytest_collection_modifyitems(items):
# will execute as early as possible
@pytest.hookimpl_spec(trylast=True)
def pytest_collection_modifyitems(items):
# will execute as late as possible
Some hook specifications use the ``firstresult=True`` option so that the hook
call only executes until the first of N registered functions returns a
non-None result which is then taken as result of the overall hook call.
The remaining hook functions will not be called in this case.
hookwrapper: executing around other hooks
@ -290,6 +275,47 @@ perform tracing or other side effects around the actual hook implementations.
If the result of the underlying hook is a mutable object, they may modify
that result, however.
Hook function ordering / call example
-------------------------------------
For any given hook specification there may be more than one
implementation and we thus generally view ``hook`` execution as a
``1:N`` function call where ``N`` is the number of registered functions.
There are ways to influence if a hook implementation comes before or
after others, i.e. the position in the ``N``-sized list of functions::
# Plugin 1
@pytest.hookimpl_spec(tryfirst=True)
def pytest_collection_modifyitems(items):
# will execute as early as possible
# Plugin 2
@pytest.hookimpl_spec(trylast=True)
def pytest_collection_modifyitems(items):
# will execute as late as possible
# Plugin 3
@pytest.hookimpl_spec(hookwrapper=True)
def pytest_collection_modifyitems(items):
# will execute even before the tryfirst one above!
outcome = yield
# will execute after all non-hookwrappers executed
Here is the order of execution:
1. Plugin3's pytest_collection_modifyitems called until the yield point
2. Plugin1's pytest_collection_modifyitems is called
3. Plugin2's pytest_collection_modifyitems is called
4. Plugin3's pytest_collection_modifyitems called for executing after the yield
The yield receives a :py:class:`CallOutcome` instance which encapsulates
the result from calling the non-wrappers. Wrappers cannot modify the result.
It's possible to use ``tryfirst`` and ``trylast`` also in conjunction with
``hookwrapper=True`` in which case it will influence the ordering of hookwrappers
among each other.
Declaring new hooks
------------------------

View File

@ -66,13 +66,12 @@ def check_open_files(config):
error.append(error[0])
raise AssertionError("\n".join(error))
@pytest.hookimpl_opts(trylast=True)
def pytest_runtest_teardown(item, __multicall__):
@pytest.hookimpl_opts(hookwrapper=True, trylast=True)
def pytest_runtest_teardown(item):
yield
item.config._basedir.chdir()
if hasattr(item.config, '_openfiles'):
x = __multicall__.execute()
check_open_files(item.config)
return x
# XXX copied from execnet's conftest.py - needs to be merged
winpymap = {

View File

@ -336,6 +336,19 @@ class TestAddMethodOrdering:
assert hc._nonwrappers == [he_method1_middle]
assert hc._wrappers == [he_method1, he_method3]
def test_adding_wrappers_ordering_tryfirst(self, hc, addmeth):
@addmeth(hookwrapper=True, tryfirst=True)
def he_method1():
pass
@addmeth(hookwrapper=True)
def he_method2():
pass
assert hc._nonwrappers == []
assert hc._wrappers == [he_method2, he_method1]
def test_hookspec_opts(self, pm):
class HookSpec:
@hookspec_opts()
@ -530,6 +543,16 @@ class TestPytestPluginInteractions:
finally:
undo()
def test_warn_on_deprecated_multicall(self, pytestpm):
class Plugin:
def pytest_configure(self, __multicall__):
pass
before = list(pytestpm._warnings)
pytestpm.register(Plugin())
assert len(pytestpm._warnings) == len(before) + 1
assert "deprecated" in pytestpm._warnings[-1]["message"]
def test_namespace_has_default_and_env_plugins(testdir):
p = testdir.makepyfile("""
@ -969,7 +992,7 @@ class TestPytestPluginManager:
monkeypatch.setenv('PYTEST_PLUGINS', 'pytest_x500', prepend=",")
result = testdir.runpytest(p)
assert result.ret == 0
result.stdout.fnmatch_lines(["*1 passed in*"])
result.stdout.fnmatch_lines(["*1 passed*"])
def test_import_plugin_importname(self, testdir, pytestpm):
pytest.raises(ImportError, 'pytestpm.import_plugin("qweqwex.y")')