diff --git a/_pytest/core.py b/_pytest/core.py index 9273564ba..6822e22fd 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -89,30 +89,34 @@ def add_method_wrapper(cls, wrapper_func): setattr(cls, name, wrap_exec) return lambda: setattr(cls, name, oldcall) +def raise_wrapfail(wrap_controller, msg): + co = wrap_controller.gi_code + raise RuntimeError("wrap_controller at %r %s:%d %s" % + (co.co_name, co.co_filename, co.co_firstlineno, msg)) def wrapped_call(wrap_controller, func): - """ Wrap calling to a function with a generator. The first yield - will trigger calling the function and receive an according CallOutcome - object representing an exception or a result. The generator then - needs to finish (raise StopIteration) in order for the wrapped call - to complete. + """ Wrap calling to a function with a generator which needs to yield + exactly once. The yield point will trigger calling the wrapped function + and return its CallOutcome to the yield point. The generator then needs + to finish (raise StopIteration) in order for the wrapped call to complete. """ try: next(wrap_controller) # first yield except StopIteration: - return + raise_wrapfail(wrap_controller, "did not yield") call_outcome = CallOutcome(func) try: wrap_controller.send(call_outcome) - co = wrap_controller.gi_frame.f_code - raise RuntimeError("wrap_controller for %r %s:%d has second yield" % - (co.co_name, co.co_filename, co.co_firstlineno)) + raise_wrapfail(wrap_controller, "has second yield") except StopIteration: pass return call_outcome.get_result() class CallOutcome: + """ Outcome of a function call, either an exception or a proper result. + Calling the ``get_result`` method will return the result or reraise + the exception raised when the function was called. """ excinfo = None def __init__(self, func): try: diff --git a/doc/en/plugins.txt b/doc/en/plugins.txt index d5a2af95e..3ef24e019 100644 --- a/doc/en/plugins.txt +++ b/doc/en/plugins.txt @@ -187,8 +187,8 @@ Plugin discovery order at tool startup invocation: - if no test paths are specified use current dir as a test path - - if exists, load ``conftest.py`` and ``test*/conftest.py`` relative - to the directory part of the first test path. + - if exists, load ``conftest.py`` and ``test*/conftest.py`` relative + to the directory part of the first test path. Note that pytest does not find ``conftest.py`` files in deeper nested sub directories at tool startup. It is usually a good idea to keep @@ -431,6 +431,41 @@ declaring the hook functions directly in your plugin module, for example:: This has the added benefit of allowing you to conditionally install hooks depending on which plugins are installed. +hookwrapper: executing around other hooks +------------------------------------------------- + +.. currentmodule:: _pytest.core + +.. versionadded:: 2.7 (experimental) + +pytest plugins can implement hook wrappers which which wrap the execution +of other hook implementations. A hook wrapper is a generator function +which yields exactly once. When pytest invokes hooks it first executes +hook wrappers and passes the same arguments as to the regular hooks. + +At the yield point of the hook wrapper pytest will execute the next hook +implementations and return their result to the yield point in the form of +a :py:class:`CallOutcome` instance which encapsulates a result or +exception info. The yield point itself will thus typically not raise +exceptions (unless there are bugs). + +Here is an example definition of a hook wrapper:: + + import pytest + + @pytest.mark.hookwrapper + def pytest_pyfunc_call(pyfuncitem): + # do whatever you want before the next hook executes + outcome = yield + # outcome.excinfo may be None or a (cls, val, tb) tuple + res = outcome.get_result() # will raise if outcome was exception + # postprocess result + +Note that hook wrappers don't return results themselves, they merely +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. + Reference of objects involved in hooks =========================================================== @@ -470,3 +505,6 @@ Reference of objects involved in hooks .. autoclass:: _pytest.runner.TestReport() :members: +.. autoclass:: _pytest.core.CallOutcome() + :members: + diff --git a/testing/test_core.py b/testing/test_core.py index 1a6d20f06..1ab39f665 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -783,6 +783,22 @@ class TestWrapMethod: assert A().f() == "A.f" assert l == [] + def test_no_yield(self): + class A: + def method(self): + return + + def method(self): + if 0: + yield + + add_method_wrapper(A, method) + with pytest.raises(RuntimeError) as excinfo: + A().method() + + assert "method" in str(excinfo.value) + assert "did not yield" in str(excinfo.value) + def test_method_raises(self): class A: def error(self, val):