Skip to content

Commit c54afbe

Browse files
committed
deprecate and warn about __multicall__ usage in hooks, refine docs about hook ordering,
make hookwrappers respect tryfirst/trylast --HG-- branch : more_plugin
1 parent dea1c96 commit c54afbe

File tree

7 files changed

+119
-44
lines changed

7 files changed

+119
-44
lines changed

CHANGELOG

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@
3939
- fix issue732: properly unregister plugins from any hook calling
4040
sites allowing to have temporary plugins during test execution.
4141

42+
- deprecate and warn about ``__multicall__`` argument in hook
43+
implementations. Use the ``hookwrapper`` mechanism instead already
44+
introduced with pytest-2.7.
45+
4246

4347
2.7.1.dev (compared to 2.7.0)
4448
-----------------------------

_pytest/config.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
# DON't import pytest here because it causes import cycle troubles
1010
import sys, os
1111
from _pytest import hookspec # the extension point definitions
12-
from _pytest.core import PluginManager, hookimpl_opts
12+
from _pytest.core import PluginManager, hookimpl_opts, varnames
1313

1414
# pytest startup
1515
#
@@ -117,6 +117,18 @@ def __init__(self):
117117
self.trace.root.setwriter(err.write)
118118
self.enable_tracing()
119119

120+
121+
def _verify_hook(self, hook, plugin):
122+
super(PytestPluginManager, self)._verify_hook(hook, plugin)
123+
method = getattr(plugin, hook.name)
124+
if "__multicall__" in varnames(method):
125+
fslineno = py.code.getfslineno(method)
126+
warning = dict(code="I1",
127+
fslocation=fslineno,
128+
message="%r hook uses deprecated __multicall__ "
129+
"argument" % (hook.name))
130+
self._warnings.append(warning)
131+
120132
def register(self, plugin, name=None):
121133
ret = super(PytestPluginManager, self).register(plugin, name)
122134
if ret:
@@ -138,7 +150,10 @@ def pytest_configure(self, config):
138150
"trylast: mark a hook implementation function such that the "
139151
"plugin machinery will try to call it last/as late as possible.")
140152
for warning in self._warnings:
141-
config.warn(code="I1", message=warning)
153+
if isinstance(warning, dict):
154+
config.warn(**warning)
155+
else:
156+
config.warn(code="I1", message=warning)
142157

143158
#
144159
# internal API for local conftest plugin handling
@@ -712,10 +727,10 @@ def _ensure_unconfigure(self):
712727
fin = self._cleanup.pop()
713728
fin()
714729

715-
def warn(self, code, message):
730+
def warn(self, code, message, fslocation=None):
716731
""" generate a warning for this test session. """
717732
self.hook.pytest_logwarning(code=code, message=message,
718-
fslocation=None, nodeid=None)
733+
fslocation=fslocation, nodeid=None)
719734

720735
def get_terminal_writer(self):
721736
return self.pluginmanager.get_plugin("terminalreporter")._tw

_pytest/core.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,12 @@ def load_setuptools_entrypoints(self, entrypoint_name):
408408
class MultiCall:
409409
""" execute a call into multiple python functions/methods. """
410410

411+
# XXX note that the __multicall__ argument is supported only
412+
# for pytest compatibility reasons. It was never officially
413+
# supported there and is explicitely deprecated since 2.8
414+
# so we can remove it soon, allowing to avoid the below recursion
415+
# in execute() and simplify/speed up the execute loop.
416+
411417
def __init__(self, methods, kwargs, firstresult=False):
412418
self.methods = methods
413419
self.kwargs = kwargs
@@ -527,20 +533,20 @@ def _add_plugin(self, plugin):
527533

528534
def _add_method(self, meth):
529535
if hasattr(meth, 'hookwrapper'):
530-
self._wrappers.append(meth)
531-
elif hasattr(meth, 'trylast'):
532-
self._nonwrappers.insert(0, meth)
536+
methods = self._wrappers
537+
else:
538+
methods = self._nonwrappers
539+
540+
if hasattr(meth, 'trylast'):
541+
methods.insert(0, meth)
533542
elif hasattr(meth, 'tryfirst'):
534-
self._nonwrappers.append(meth)
543+
methods.append(meth)
535544
else:
536-
# find the last nonwrapper which is not tryfirst marked
537-
nonwrappers = self._nonwrappers
538-
i = len(nonwrappers) - 1
539-
while i >= 0 and hasattr(nonwrappers[i], "tryfirst"):
545+
# find last non-tryfirst method
546+
i = len(methods) - 1
547+
while i >= 0 and hasattr(methods[i], "tryfirst"):
540548
i -= 1
541-
542-
# and insert right in front of the tryfirst ones
543-
nonwrappers.insert(i+1, meth)
549+
methods.insert(i + 1, meth)
544550

545551
def __repr__(self):
546552
return "<HookCaller %r>" %(self.name,)

_pytest/terminal.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@ def pytest_internalerror(self, excrepr):
164164

165165
def pytest_logwarning(self, code, fslocation, message, nodeid):
166166
warnings = self.stats.setdefault("warnings", [])
167+
if isinstance(fslocation, tuple):
168+
fslocation = "%s:%d" % fslocation
167169
warning = WarningReport(code=code, fslocation=fslocation,
168170
message=message, nodeid=nodeid)
169171
warnings.append(warning)

doc/en/writing_plugins.txt

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -221,36 +221,21 @@ be "future-compatible": we can introduce new hook named parameters without
221221
breaking the signatures of existing hook implementations. It is one of
222222
the reasons for the general long-lived compatibility of pytest plugins.
223223

224-
Hook function results
225-
---------------------
226-
227-
Most calls to ``pytest`` hooks result in a **list of results** which contains
228-
all non-None results of the called hook functions.
229-
230-
Some hooks are specified so that the hook call only executes until the
231-
first function returned a non-None value which is then also the
232-
result of the overall hook call. The remaining hook functions will
233-
not be called in this case.
234-
235224
Note that hook functions other than ``pytest_runtest_*`` are not
236225
allowed to raise exceptions. Doing so will break the pytest run.
237226

238-
Hook function ordering
239-
----------------------
240227

241-
For any given hook there may be more than one implementation and we thus
242-
generally view ``hook`` execution as a ``1:N`` function call where ``N``
243-
is the number of registered functions. There are ways to
244-
influence if a hook implementation comes before or after others, i.e.
245-
the position in the ``N``-sized list of functions::
246228

247-
@pytest.hookimpl_spec(tryfirst=True)
248-
def pytest_collection_modifyitems(items):
249-
# will execute as early as possible
229+
firstresult: stop at first non-None result
230+
-------------------------------------------
250231

251-
@pytest.hookimpl_spec(trylast=True)
252-
def pytest_collection_modifyitems(items):
253-
# will execute as late as possible
232+
Most calls to ``pytest`` hooks result in a **list of results** which contains
233+
all non-None results of the called hook functions.
234+
235+
Some hook specifications use the ``firstresult=True`` option so that the hook
236+
call only executes until the first of N registered functions returns a
237+
non-None result which is then taken as result of the overall hook call.
238+
The remaining hook functions will not be called in this case.
254239

255240

256241
hookwrapper: executing around other hooks
@@ -290,6 +275,47 @@ perform tracing or other side effects around the actual hook implementations.
290275
If the result of the underlying hook is a mutable object, they may modify
291276
that result, however.
292277

278+
279+
280+
Hook function ordering / call example
281+
-------------------------------------
282+
283+
For any given hook specification there may be more than one
284+
implementation and we thus generally view ``hook`` execution as a
285+
``1:N`` function call where ``N`` is the number of registered functions.
286+
There are ways to influence if a hook implementation comes before or
287+
after others, i.e. the position in the ``N``-sized list of functions::
288+
289+
# Plugin 1
290+
@pytest.hookimpl_spec(tryfirst=True)
291+
def pytest_collection_modifyitems(items):
292+
# will execute as early as possible
293+
294+
# Plugin 2
295+
@pytest.hookimpl_spec(trylast=True)
296+
def pytest_collection_modifyitems(items):
297+
# will execute as late as possible
298+
299+
# Plugin 3
300+
@pytest.hookimpl_spec(hookwrapper=True)
301+
def pytest_collection_modifyitems(items):
302+
# will execute even before the tryfirst one above!
303+
outcome = yield
304+
# will execute after all non-hookwrappers executed
305+
306+
Here is the order of execution:
307+
308+
1. Plugin3's pytest_collection_modifyitems called until the yield point
309+
2. Plugin1's pytest_collection_modifyitems is called
310+
3. Plugin2's pytest_collection_modifyitems is called
311+
4. Plugin3's pytest_collection_modifyitems called for executing after the yield
312+
The yield receives a :py:class:`CallOutcome` instance which encapsulates
313+
the result from calling the non-wrappers. Wrappers cannot modify the result.
314+
315+
It's possible to use ``tryfirst`` and ``trylast`` also in conjunction with
316+
``hookwrapper=True`` in which case it will influence the ordering of hookwrappers
317+
among each other.
318+
293319
Declaring new hooks
294320
------------------------
295321

testing/conftest.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,12 @@ def check_open_files(config):
6666
error.append(error[0])
6767
raise AssertionError("\n".join(error))
6868

69-
@pytest.hookimpl_opts(trylast=True)
70-
def pytest_runtest_teardown(item, __multicall__):
69+
@pytest.hookimpl_opts(hookwrapper=True, trylast=True)
70+
def pytest_runtest_teardown(item):
71+
yield
7172
item.config._basedir.chdir()
7273
if hasattr(item.config, '_openfiles'):
73-
x = __multicall__.execute()
7474
check_open_files(item.config)
75-
return x
7675

7776
# XXX copied from execnet's conftest.py - needs to be merged
7877
winpymap = {

testing/test_core.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,19 @@ def he_method3():
336336
assert hc._nonwrappers == [he_method1_middle]
337337
assert hc._wrappers == [he_method1, he_method3]
338338

339+
def test_adding_wrappers_ordering_tryfirst(self, hc, addmeth):
340+
@addmeth(hookwrapper=True, tryfirst=True)
341+
def he_method1():
342+
pass
343+
344+
@addmeth(hookwrapper=True)
345+
def he_method2():
346+
pass
347+
348+
assert hc._nonwrappers == []
349+
assert hc._wrappers == [he_method2, he_method1]
350+
351+
339352
def test_hookspec_opts(self, pm):
340353
class HookSpec:
341354
@hookspec_opts()
@@ -530,6 +543,16 @@ def pytest_plugin_registered(self):
530543
finally:
531544
undo()
532545

546+
def test_warn_on_deprecated_multicall(self, pytestpm):
547+
class Plugin:
548+
def pytest_configure(self, __multicall__):
549+
pass
550+
551+
before = list(pytestpm._warnings)
552+
pytestpm.register(Plugin())
553+
assert len(pytestpm._warnings) == len(before) + 1
554+
assert "deprecated" in pytestpm._warnings[-1]["message"]
555+
533556

534557
def test_namespace_has_default_and_env_plugins(testdir):
535558
p = testdir.makepyfile("""
@@ -969,7 +992,7 @@ def test_hello(pytestconfig):
969992
monkeypatch.setenv('PYTEST_PLUGINS', 'pytest_x500', prepend=",")
970993
result = testdir.runpytest(p)
971994
assert result.ret == 0
972-
result.stdout.fnmatch_lines(["*1 passed in*"])
995+
result.stdout.fnmatch_lines(["*1 passed*"])
973996

974997
def test_import_plugin_importname(self, testdir, pytestpm):
975998
pytest.raises(ImportError, 'pytestpm.import_plugin("qweqwex.y")')

0 commit comments

Comments
 (0)