diff --git a/CHANGELOG b/CHANGELOG index 75357d6d5..e1776db30 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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) ----------------------------- diff --git a/_pytest/capture.py b/_pytest/capture.py index 047e1ca7e..613289e2a 100644 --- a/_pytest/capture.py +++ b/_pytest/capture.py @@ -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() diff --git a/_pytest/core.py b/_pytest/core.py index 51fb55d2b..7f0bedc2c 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -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 = {} diff --git a/_pytest/helpconfig.py b/_pytest/helpconfig.py index 7976ae826..945206312 100644 --- a/_pytest/helpconfig.py +++ b/_pytest/helpconfig.py @@ -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() diff --git a/_pytest/hookspec.py b/_pytest/hookspec.py index d0bc33936..81280eb38 100644 --- a/_pytest/hookspec.py +++ b/_pytest/hookspec.py @@ -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 diff --git a/_pytest/main.py b/_pytest/main.py index ed7d6aad9..4545aa6f6 100644 --- a/_pytest/main.py +++ b/_pytest/main.py @@ -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 diff --git a/_pytest/nose.py b/_pytest/nose.py index 089807b66..feb6b8b90 100644 --- a/_pytest/nose.py +++ b/_pytest/nose.py @@ -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): diff --git a/_pytest/pastebin.py b/_pytest/pastebin.py index 4d0badbf2..b1d973c2e 100644 --- a/_pytest/pastebin.py +++ b/_pytest/pastebin.py @@ -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') diff --git a/_pytest/python.py b/_pytest/python.py index 9071d03de..74ba77068 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -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: diff --git a/_pytest/skipping.py b/_pytest/skipping.py index f95edf8bd..db320349c 100644 --- a/_pytest/skipping.py +++ b/_pytest/skipping.py @@ -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() diff --git a/_pytest/terminal.py b/_pytest/terminal.py index 538bf3d8e..a021f5345 100644 --- a/_pytest/terminal.py +++ b/_pytest/terminal.py @@ -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() diff --git a/_pytest/unittest.py b/_pytest/unittest.py index c035bdd1a..f082d7195 100644 --- a/_pytest/unittest.py +++ b/_pytest/unittest.py @@ -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: diff --git a/doc/en/example/markers.txt b/doc/en/example/markers.txt index fac0eeb99..8a216d1a7 100644 --- a/doc/en/example/markers.txt +++ b/doc/en/example/markers.txt @@ -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 diff --git a/doc/en/example/simple.txt b/doc/en/example/simple.txt index e772fca37..f7a3b7eab 100644 --- a/doc/en/example/simple.txt +++ b/doc/en/example/simple.txt @@ -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() diff --git a/doc/en/plugins.txt b/doc/en/plugins.txt index 2e10417fe..2abb24861 100644 --- a/doc/en/plugins.txt +++ b/doc/en/plugins.txt @@ -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 diff --git a/pytest.py b/pytest.py index 6c25c6195..5979d9f2e 100644 --- a/pytest.py +++ b/pytest.py @@ -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 diff --git a/testing/conftest.py b/testing/conftest.py index 08aefbbd5..cdf9e4bf3 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -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'): diff --git a/testing/python/collect.py b/testing/python/collect.py index c84c4c733..eb8fad1f9 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -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: diff --git a/testing/test_core.py b/testing/test_core.py index 4d03433a2..f4113f9a1 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -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: diff --git a/testing/test_helpconfig.py b/testing/test_helpconfig.py index 30ce9c9f2..fd1416035 100644 --- a/testing/test_helpconfig.py +++ b/testing/test_helpconfig.py @@ -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 """) diff --git a/testing/test_mark.py b/testing/test_mark.py index a7ee038ea..ed3bebcae 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -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":