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 - fix issue732: properly unregister plugins from any hook calling
sites allowing to have temporary plugins during test execution. 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) 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 # DON't import pytest here because it causes import cycle troubles
import sys, os import sys, os
from _pytest import hookspec # the extension point definitions 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 # pytest startup
# #
@ -117,6 +117,18 @@ class PytestPluginManager(PluginManager):
self.trace.root.setwriter(err.write) self.trace.root.setwriter(err.write)
self.enable_tracing() 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): def register(self, plugin, name=None):
ret = super(PytestPluginManager, self).register(plugin, name) ret = super(PytestPluginManager, self).register(plugin, name)
if ret: if ret:
@ -138,7 +150,10 @@ class PytestPluginManager(PluginManager):
"trylast: mark a hook implementation function such that the " "trylast: mark a hook implementation function such that the "
"plugin machinery will try to call it last/as late as possible.") "plugin machinery will try to call it last/as late as possible.")
for warning in self._warnings: for warning in self._warnings:
config.warn(code="I1", message=warning) if isinstance(warning, dict):
config.warn(**warning)
else:
config.warn(code="I1", message=warning)
# #
# internal API for local conftest plugin handling # internal API for local conftest plugin handling
@ -712,10 +727,10 @@ class Config(object):
fin = self._cleanup.pop() fin = self._cleanup.pop()
fin() fin()
def warn(self, code, message): def warn(self, code, message, fslocation=None):
""" generate a warning for this test session. """ """ generate a warning for this test session. """
self.hook.pytest_logwarning(code=code, message=message, self.hook.pytest_logwarning(code=code, message=message,
fslocation=None, nodeid=None) fslocation=fslocation, nodeid=None)
def get_terminal_writer(self): def get_terminal_writer(self):
return self.pluginmanager.get_plugin("terminalreporter")._tw return self.pluginmanager.get_plugin("terminalreporter")._tw

View File

@ -408,6 +408,12 @@ class PluginManager(object):
class MultiCall: class MultiCall:
""" execute a call into multiple python functions/methods. """ """ 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): def __init__(self, methods, kwargs, firstresult=False):
self.methods = methods self.methods = methods
self.kwargs = kwargs self.kwargs = kwargs
@ -527,20 +533,20 @@ class HookCaller(object):
def _add_method(self, meth): def _add_method(self, meth):
if hasattr(meth, 'hookwrapper'): if hasattr(meth, 'hookwrapper'):
self._wrappers.append(meth) methods = self._wrappers
elif hasattr(meth, 'trylast'):
self._nonwrappers.insert(0, meth)
elif hasattr(meth, 'tryfirst'):
self._nonwrappers.append(meth)
else: else:
# find the last nonwrapper which is not tryfirst marked methods = self._nonwrappers
nonwrappers = self._nonwrappers
i = len(nonwrappers) - 1
while i >= 0 and hasattr(nonwrappers[i], "tryfirst"):
i -= 1
# and insert right in front of the tryfirst ones if hasattr(meth, 'trylast'):
nonwrappers.insert(i+1, meth) 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): def __repr__(self):
return "<HookCaller %r>" %(self.name,) return "<HookCaller %r>" %(self.name,)

View File

@ -164,6 +164,8 @@ class TerminalReporter:
def pytest_logwarning(self, code, fslocation, message, nodeid): def pytest_logwarning(self, code, fslocation, message, nodeid):
warnings = self.stats.setdefault("warnings", []) warnings = self.stats.setdefault("warnings", [])
if isinstance(fslocation, tuple):
fslocation = "%s:%d" % fslocation
warning = WarningReport(code=code, fslocation=fslocation, warning = WarningReport(code=code, fslocation=fslocation,
message=message, nodeid=nodeid) message=message, nodeid=nodeid)
warnings.append(warning) 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 breaking the signatures of existing hook implementations. It is one of
the reasons for the general long-lived compatibility of pytest plugins. 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 Most calls to ``pytest`` hooks result in a **list of results** which contains
all non-None results of the called hook functions. all non-None results of the called hook functions.
Some hooks are specified so that the hook call only executes until the Some hook specifications use the ``firstresult=True`` option so that the hook
first function returned a non-None value which is then also the call only executes until the first of N registered functions returns a
result of the overall hook call. The remaining hook functions will non-None result which is then taken as result of the overall hook call.
not be called in this case. 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
hookwrapper: executing around other hooks 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 If the result of the underlying hook is a mutable object, they may modify
that result, however. 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 Declaring new hooks
------------------------ ------------------------

View File

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

View File

@ -336,6 +336,19 @@ class TestAddMethodOrdering:
assert hc._nonwrappers == [he_method1_middle] assert hc._nonwrappers == [he_method1_middle]
assert hc._wrappers == [he_method1, he_method3] 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): def test_hookspec_opts(self, pm):
class HookSpec: class HookSpec:
@hookspec_opts() @hookspec_opts()
@ -530,6 +543,16 @@ class TestPytestPluginInteractions:
finally: finally:
undo() 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): def test_namespace_has_default_and_env_plugins(testdir):
p = testdir.makepyfile(""" p = testdir.makepyfile("""
@ -969,7 +992,7 @@ class TestPytestPluginManager:
monkeypatch.setenv('PYTEST_PLUGINS', 'pytest_x500', prepend=",") monkeypatch.setenv('PYTEST_PLUGINS', 'pytest_x500', prepend=",")
result = testdir.runpytest(p) result = testdir.runpytest(p)
assert result.ret == 0 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): def test_import_plugin_importname(self, testdir, pytestpm):
pytest.raises(ImportError, 'pytestpm.import_plugin("qweqwex.y")') pytest.raises(ImportError, 'pytestpm.import_plugin("qweqwex.y")')