add documented hookimpl_opts and hookspec_opts decorators

so that one doesn't have to use pytest.mark or function-attribute setting anymore

--HG--
branch : more_plugin
This commit is contained in:
holger krekel 2015-04-25 11:29:11 +02:00
parent bbbb6dc2e3
commit d2a5c7f99b
21 changed files with 150 additions and 47 deletions

View File

@ -26,6 +26,13 @@
change but it might still break 3rd party plugins which relied on
details like especially the pluginmanager.add_shutdown() API.
Thanks Holger Krekel.
- pluginmanagement: introduce ``pytest.hookimpl_opts`` and
``pytest.hookspec_opts`` decorators for setting impl/spec
specific parameters. This substitutes the previous
now deprecated use of ``pytest.mark`` which is meant to
contain markers for test functions only.
2.7.1.dev (compared to 2.7.0)
-----------------------------

View File

@ -29,7 +29,7 @@ def pytest_addoption(parser):
help="shortcut for --capture=no.")
@pytest.mark.hookwrapper
@pytest.hookimpl_opts(hookwrapper=True)
def pytest_load_initial_conftests(early_config, parser, args):
ns = early_config.known_args_namespace
pluginmanager = early_config.pluginmanager
@ -101,7 +101,7 @@ class CaptureManager:
if capfuncarg is not None:
capfuncarg.close()
@pytest.mark.hookwrapper
@pytest.hookimpl_opts(hookwrapper=True)
def pytest_make_collect_report(self, collector):
if isinstance(collector, pytest.File):
self.resumecapture()
@ -115,13 +115,13 @@ class CaptureManager:
else:
yield
@pytest.mark.hookwrapper
@pytest.hookimpl_opts(hookwrapper=True)
def pytest_runtest_setup(self, item):
self.resumecapture()
yield
self.suspendcapture_item(item, "setup")
@pytest.mark.hookwrapper
@pytest.hookimpl_opts(hookwrapper=True)
def pytest_runtest_call(self, item):
self.resumecapture()
self.activate_funcargs(item)
@ -129,17 +129,17 @@ class CaptureManager:
#self.deactivate_funcargs() called from suspendcapture()
self.suspendcapture_item(item, "call")
@pytest.mark.hookwrapper
@pytest.hookimpl_opts(hookwrapper=True)
def pytest_runtest_teardown(self, item):
self.resumecapture()
yield
self.suspendcapture_item(item, "teardown")
@pytest.mark.tryfirst
@pytest.hookimpl_opts(tryfirst=True)
def pytest_keyboard_interrupt(self, excinfo):
self.reset_capturings()
@pytest.mark.tryfirst
@pytest.hookimpl_opts(tryfirst=True)
def pytest_internalerror(self, excinfo):
self.reset_capturings()

View File

@ -7,6 +7,52 @@ import py
py3 = sys.version_info > (3,0)
def hookspec_opts(firstresult=False):
""" returns a decorator which will define a function as a hook specfication.
If firstresult is True the 1:N hook call (N being the number of registered
hook implementation functions) will stop at I<=N when the I'th function
returns a non-None result.
"""
def setattr_hookspec_opts(func):
if firstresult:
func.firstresult = firstresult
return func
return setattr_hookspec_opts
def hookimpl_opts(hookwrapper=False, optionalhook=False,
tryfirst=False, trylast=False):
""" Return a decorator which marks a function as a hook implementation.
If optionalhook is True a missing matching hook specification will not result
in an error (by default it is an error if no matching spec is found).
If tryfirst is True this hook implementation will run as early as possible
in the chain of N hook implementations for a specfication.
If trylast is True this hook implementation will run as late as possible
in the chain of N hook implementations.
If hookwrapper is True the hook implementations needs to execute exactly
one "yield". The code before the yield is run early before any non-hookwrapper
function is run. The code after the yield is run after all non-hookwrapper
function have run. The yield receives an ``CallOutcome`` object representing
the exception or result outcome of the inner calls (including other hookwrapper
calls).
"""
def setattr_hookimpl_opts(func):
if hookwrapper:
func.hookwrapper = True
if optionalhook:
func.optionalhook = True
if tryfirst:
func.tryfirst = True
if trylast:
func.trylast = True
return func
return setattr_hookimpl_opts
class TagTracer:
def __init__(self):
self._tag2proc = {}

View File

@ -22,7 +22,7 @@ def pytest_addoption(parser):
help="store internal tracing debug information in 'pytestdebug.log'.")
@pytest.mark.hookwrapper
@pytest.hookimpl_opts(hookwrapper=True)
def pytest_cmdline_parse():
outcome = yield
config = outcome.get_result()

View File

@ -1,5 +1,7 @@
""" hook specifications for pytest plugins, invoked from main.py and builtin plugins. """
from _pytest.core import hookspec_opts
# -------------------------------------------------------------------------
# Initialization
# -------------------------------------------------------------------------
@ -15,9 +17,9 @@ def pytest_namespace():
are parsed.
"""
@hookspec_opts(firstresult=True)
def pytest_cmdline_parse(pluginmanager, args):
"""return initialized config object, parsing the specified args. """
pytest_cmdline_parse.firstresult = True
def pytest_cmdline_preparse(config, args):
"""(deprecated) modify command line arguments before option parsing. """
@ -47,10 +49,10 @@ def pytest_addoption(parser):
via (deprecated) ``pytest.config``.
"""
@hookspec_opts(firstresult=True)
def pytest_cmdline_main(config):
""" called for performing the main command line action. The default
implementation will invoke the configure hooks and runtest_mainloop. """
pytest_cmdline_main.firstresult = True
def pytest_load_initial_conftests(args, early_config, parser):
""" implements the loading of initial conftest files ahead
@ -64,18 +66,18 @@ def pytest_configure(config):
def pytest_unconfigure(config):
""" called before test process is exited. """
@hookspec_opts(firstresult=True)
def pytest_runtestloop(session):
""" called for performing the main runtest loop
(after collection finished). """
pytest_runtestloop.firstresult = True
# -------------------------------------------------------------------------
# collection hooks
# -------------------------------------------------------------------------
@hookspec_opts(firstresult=True)
def pytest_collection(session):
""" perform the collection protocol for the given session. """
pytest_collection.firstresult = True
def pytest_collection_modifyitems(session, config, items):
""" called after collection has been performed, may filter or re-order
@ -84,16 +86,16 @@ def pytest_collection_modifyitems(session, config, items):
def pytest_collection_finish(session):
""" called after collection has been performed and modified. """
@hookspec_opts(firstresult=True)
def pytest_ignore_collect(path, config):
""" return True to prevent considering this path for collection.
This hook is consulted for all files and directories prior to calling
more specific hooks.
"""
pytest_ignore_collect.firstresult = True
@hookspec_opts(firstresult=True)
def pytest_collect_directory(path, parent):
""" called before traversing a directory for collection files. """
pytest_collect_directory.firstresult = True
def pytest_collect_file(path, parent):
""" return collection Node or None for the given path. Any new node
@ -112,29 +114,29 @@ def pytest_collectreport(report):
def pytest_deselected(items):
""" called for test items deselected by keyword. """
@hookspec_opts(firstresult=True)
def pytest_make_collect_report(collector):
""" perform ``collector.collect()`` and return a CollectReport. """
pytest_make_collect_report.firstresult = True
# -------------------------------------------------------------------------
# Python test function related hooks
# -------------------------------------------------------------------------
@hookspec_opts(firstresult=True)
def pytest_pycollect_makemodule(path, parent):
""" return a Module collector or None for the given path.
This hook will be called for each matching test module path.
The pytest_collect_file hook needs to be used if you want to
create test modules for files that do not match as a test module.
"""
pytest_pycollect_makemodule.firstresult = True
@hookspec_opts(firstresult=True)
def pytest_pycollect_makeitem(collector, name, obj):
""" return custom item/collector for a python object in a module, or None. """
pytest_pycollect_makeitem.firstresult = True
@hookspec_opts(firstresult=True)
def pytest_pyfunc_call(pyfuncitem):
""" call underlying test function. """
pytest_pyfunc_call.firstresult = True
def pytest_generate_tests(metafunc):
""" generate (multiple) parametrized calls to a test function."""
@ -145,6 +147,7 @@ def pytest_generate_tests(metafunc):
def pytest_itemstart(item, node):
""" (deprecated, use pytest_runtest_logstart). """
@hookspec_opts(firstresult=True)
def pytest_runtest_protocol(item, nextitem):
""" implements the runtest_setup/call/teardown protocol for
the given test item, including capturing exceptions and calling
@ -158,7 +161,6 @@ def pytest_runtest_protocol(item, nextitem):
:return boolean: True if no further hook implementations should be invoked.
"""
pytest_runtest_protocol.firstresult = True
def pytest_runtest_logstart(nodeid, location):
""" signal the start of running a single test item. """
@ -178,12 +180,12 @@ def pytest_runtest_teardown(item, nextitem):
so that nextitem only needs to call setup-functions.
"""
@hookspec_opts(firstresult=True)
def pytest_runtest_makereport(item, call):
""" return a :py:class:`_pytest.runner.TestReport` object
for the given :py:class:`pytest.Item` and
:py:class:`_pytest.runner.CallInfo`.
"""
pytest_runtest_makereport.firstresult = True
def pytest_runtest_logreport(report):
""" process a test setup/call/teardown report relating to
@ -220,9 +222,9 @@ def pytest_assertrepr_compare(config, op, left, right):
def pytest_report_header(config, startdir):
""" return a string to be displayed as header info for terminal reporting."""
@hookspec_opts(firstresult=True)
def pytest_report_teststatus(report):
""" return result-category, shortletter and verbose word for reporting."""
pytest_report_teststatus.firstresult = True
def pytest_terminal_summary(terminalreporter):
""" add additional section in terminal summary reporting. """
@ -236,9 +238,9 @@ def pytest_logwarning(message, code, nodeid, fslocation):
# doctest hooks
# -------------------------------------------------------------------------
@hookspec_opts(firstresult=True)
def pytest_doctest_prepare_content(content):
""" return processed content for a given doctest"""
pytest_doctest_prepare_content.firstresult = True
# -------------------------------------------------------------------------
# error handling and internal debugging hooks

View File

@ -519,12 +519,12 @@ class Session(FSCollector):
def _makeid(self):
return ""
@pytest.mark.tryfirst
@pytest.hookimpl_opts(tryfirst=True)
def pytest_collectstart(self):
if self.shouldstop:
raise self.Interrupted(self.shouldstop)
@pytest.mark.tryfirst
@pytest.hookimpl_opts(tryfirst=True)
def pytest_runtest_logreport(self, report):
if report.failed and not hasattr(report, 'wasxfail'):
self._testsfailed += 1

View File

@ -24,7 +24,7 @@ def pytest_runtest_makereport(item, call):
call.excinfo = call2.excinfo
@pytest.mark.trylast
@pytest.hookimpl_opts(trylast=True)
def pytest_runtest_setup(item):
if is_potential_nosetest(item):
if isinstance(item.parent, pytest.Generator):

View File

@ -11,7 +11,7 @@ def pytest_addoption(parser):
choices=['failed', 'all'],
help="send failed|all info to bpaste.net pastebin service.")
@pytest.mark.trylast
@pytest.hookimpl_opts(trylast=True)
def pytest_configure(config):
if config.option.pastebin == "all":
tr = config.pluginmanager.getplugin('terminalreporter')

View File

@ -172,7 +172,7 @@ def pytest_configure(config):
def pytest_sessionstart(session):
session._fixturemanager = FixtureManager(session)
@pytest.mark.trylast
@pytest.hookimpl_opts(trylast=True)
def pytest_namespace():
raises.Exception = pytest.fail.Exception
return {
@ -191,7 +191,7 @@ def pytestconfig(request):
return request.config
@pytest.mark.trylast
@pytest.hookimpl_opts(trylast=True)
def pytest_pyfunc_call(pyfuncitem):
testfunction = pyfuncitem.obj
if pyfuncitem._isyieldedfunction():
@ -219,7 +219,7 @@ def pytest_collect_file(path, parent):
def pytest_pycollect_makemodule(path, parent):
return Module(path, parent)
@pytest.mark.hookwrapper
@pytest.hookimpl_opts(hookwrapper=True)
def pytest_pycollect_makeitem(collector, name, obj):
outcome = yield
res = outcome.get_result()
@ -1667,7 +1667,7 @@ class FixtureManager:
self.parsefactories(plugin, nodeid)
self._seenplugins.add(plugin)
@pytest.mark.tryfirst
@pytest.hookimpl_opts(tryfirst=True)
def pytest_configure(self, config):
plugins = config.pluginmanager.getplugins()
for plugin in plugins:

View File

@ -133,7 +133,7 @@ class MarkEvaluator:
return expl
@pytest.mark.tryfirst
@pytest.hookimpl_opts(tryfirst=True)
def pytest_runtest_setup(item):
evalskip = MarkEvaluator(item, 'skipif')
if evalskip.istrue():
@ -151,7 +151,7 @@ def check_xfail_no_run(item):
if not evalxfail.get('run', True):
pytest.xfail("[NOTRUN] " + evalxfail.getexplanation())
@pytest.mark.hookwrapper
@pytest.hookimpl_opts(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()

View File

@ -265,7 +265,7 @@ class TerminalReporter:
def pytest_collection_modifyitems(self):
self.report_collect(True)
@pytest.mark.trylast
@pytest.hookimpl_opts(trylast=True)
def pytest_sessionstart(self, session):
self._sessionstarttime = time.time()
if not self.showheader:
@ -350,7 +350,7 @@ class TerminalReporter:
indent = (len(stack) - 1) * " "
self._tw.line("%s%s" % (indent, col))
@pytest.mark.hookwrapper
@pytest.hookimpl_opts(hookwrapper=True)
def pytest_sessionfinish(self, exitstatus):
outcome = yield
outcome.get_result()

View File

@ -140,7 +140,7 @@ class TestCaseFunction(pytest.Function):
if traceback:
excinfo.traceback = traceback
@pytest.mark.tryfirst
@pytest.hookimpl_opts(tryfirst=True)
def pytest_runtest_makereport(item, call):
if isinstance(item, TestCaseFunction):
if item._excinfo:
@ -152,7 +152,7 @@ def pytest_runtest_makereport(item, call):
# twisted trial support
@pytest.mark.hookwrapper
@pytest.hookimpl_opts(hookwrapper=True)
def pytest_runtest_protocol(item):
if isinstance(item, TestCaseFunction) and \
'twisted.trial.unittest' in sys.modules:

View File

@ -201,9 +201,9 @@ You can ask which markers exist for your test suite - the list includes our just
@pytest.mark.usefixtures(fixturename1, fixturename2, ...): mark tests as needing all of the specified fixtures. see http://pytest.org/latest/fixture.html#usefixtures
@pytest.mark.tryfirst: mark a hook implementation function such that the plugin machinery will try to call it first/as early as possible.
@pytest.hookimpl_opts(tryfirst=True): mark a hook implementation function such that the plugin machinery will try to call it first/as early as possible.
@pytest.mark.trylast: mark a hook implementation function such that the plugin machinery will try to call it last/as late as possible.
@pytest.hookimpl_opts(trylast=True): mark a hook implementation function such that the plugin machinery will try to call it last/as late as possible.
For an example on how to add and work with markers from a plugin, see
@ -375,9 +375,9 @@ The ``--markers`` option always gives you a list of available markers::
@pytest.mark.usefixtures(fixturename1, fixturename2, ...): mark tests as needing all of the specified fixtures. see http://pytest.org/latest/fixture.html#usefixtures
@pytest.mark.tryfirst: mark a hook implementation function such that the plugin machinery will try to call it first/as early as possible.
@pytest.hookimpl_opts(tryfirst=True): mark a hook implementation function such that the plugin machinery will try to call it first/as early as possible.
@pytest.mark.trylast: mark a hook implementation function such that the plugin machinery will try to call it last/as late as possible.
@pytest.hookimpl_opts(trylast=True): mark a hook implementation function such that the plugin machinery will try to call it last/as late as possible.
Reading markers which were set from multiple places

View File

@ -534,7 +534,7 @@ case we just write some informations out to a ``failures`` file::
import pytest
import os.path
@pytest.mark.tryfirst
@pytest.hookimpl_opts(tryfirst=True)
def pytest_runtest_makereport(item, call, __multicall__):
# execute all other hooks to obtain the report object
rep = __multicall__.execute()
@ -607,7 +607,7 @@ here is a little example implemented via a local plugin::
import pytest
@pytest.mark.tryfirst
@pytest.hookimpl_opts(tryfirst=True)
def pytest_runtest_makereport(item, call, __multicall__):
# execute all other hooks to obtain the report object
rep = __multicall__.execute()

View File

@ -458,7 +458,7 @@ Here is an example definition of a hook wrapper::
import pytest
@pytest.mark.hookwrapper
@pytest.hookimpl_opts(hookwrapper=True)
def pytest_pyfunc_call(pyfuncitem):
# do whatever you want before the next hook executes
outcome = yield

View File

@ -12,6 +12,7 @@ if __name__ == '__main__': # if run as a script or by 'python -m pytest'
# else we are imported
from _pytest.config import main, UsageError, _preloadplugins, cmdline
from _pytest.core import hookspec_opts, hookimpl_opts
from _pytest import __version__
_preloadplugins() # to populate pytest.* namespace so help(pytest) works

View File

@ -66,7 +66,7 @@ def check_open_files(config):
error.append(error[0])
raise AssertionError("\n".join(error))
@pytest.mark.trylast
@pytest.hookimpl_opts(trylast=True)
def pytest_runtest_teardown(item, __multicall__):
item.config._basedir.chdir()
if hasattr(item.config, '_openfiles'):

View File

@ -563,7 +563,7 @@ class TestConftestCustomization:
b = testdir.mkdir("a").mkdir("b")
b.join("conftest.py").write(py.code.Source("""
import pytest
@pytest.mark.hookwrapper
@pytest.hookimpl_opts(hookwrapper=True)
def pytest_pycollect_makeitem():
outcome = yield
if outcome.excinfo is None:

View File

@ -192,6 +192,53 @@ class TestAddMethodOrdering:
assert hc.nonwrappers == [he_method1_middle]
assert hc.wrappers == [he_method1, he_method3]
def test_hookspec_opts(self, pm):
class HookSpec:
@hookspec_opts()
def he_myhook1(self, arg1):
pass
@hookspec_opts(firstresult=True)
def he_myhook2(self, arg1):
pass
@hookspec_opts(firstresult=False)
def he_myhook3(self, arg1):
pass
pm.addhooks(HookSpec)
assert not pm.hook.he_myhook1.firstresult
assert pm.hook.he_myhook2.firstresult
assert not pm.hook.he_myhook3.firstresult
def test_hookimpl_opts(self):
for name in ["hookwrapper", "optionalhook", "tryfirst", "trylast"]:
for val in [True, False]:
@hookimpl_opts(**{name: val})
def he_myhook1(self, arg1):
pass
if val:
assert getattr(he_myhook1, name)
else:
assert not hasattr(he_myhook1, name)
def test_decorator_functional(self, pm):
class HookSpec:
@hookspec_opts(firstresult=True)
def he_myhook(self, arg1):
""" add to arg1 """
pm.addhooks(HookSpec)
class Plugin:
@hookimpl_opts()
def he_myhook(self, arg1):
return arg1 + 1
pm.register(Plugin())
results = pm.hook.he_myhook(arg1=17)
assert results == 18
class TestPytestPluginInteractions:

View File

@ -38,7 +38,7 @@ def test_hookvalidation_unknown(testdir):
def test_hookvalidation_optional(testdir):
testdir.makeconftest("""
import pytest
@pytest.mark.optionalhook
@pytest.hookimpl_opts(optionalhook=True)
def pytest_hello(xyz):
pass
""")

View File

@ -510,7 +510,7 @@ class TestKeywordSelection:
""")
testdir.makepyfile(conftest="""
import pytest
@pytest.mark.hookwrapper
@pytest.hookimpl_opts(hookwrapper=True)
def pytest_pycollect_makeitem(name):
outcome = yield
if name == "TestClass":