From a63585dcab2698ebbaa1c54a106c4c7a6df90d3e Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sat, 25 Apr 2015 11:29:11 +0200 Subject: [PATCH] introduce historic hook spec which will memorize calls to a hook in order to call them on later registered plugins --HG-- branch : more_plugin --- _pytest/config.py | 22 ++++++----------- _pytest/core.py | 48 ++++++++++++++++++++++++++--------- _pytest/hookspec.py | 59 +++++++++++++++++++++++++++----------------- testing/test_core.py | 59 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 138 insertions(+), 50 deletions(-) diff --git a/_pytest/config.py b/_pytest/config.py index 5156f3603..860f34e53 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -122,8 +122,8 @@ class PytestPluginManager(PluginManager): if ret: if not conftest: self._globalplugins.append(plugin) - if hasattr(self, "config"): - self.config._register_plugin(plugin, name) + self.hook.pytest_plugin_registered(plugin=plugin, + manager=self) return ret def unregister(self, plugin): @@ -704,19 +704,11 @@ class Config(object): self._cleanup = [] self.pluginmanager.register(self, "pytestconfig") self._configured = False - - def _register_plugin(self, plugin, name): - call_plugin = self.pluginmanager.call_plugin - call_plugin(plugin, "pytest_addhooks", {'pluginmanager': self.pluginmanager}) - self.hook.pytest_plugin_registered(plugin=plugin, - manager=self.pluginmanager) - dic = call_plugin(plugin, "pytest_namespace", {}) or {} - if dic: + def do_setns(dic): import pytest setns(pytest, dic) - call_plugin(plugin, "pytest_addoption", {'parser': self._parser}) - if self._configured: - call_plugin(plugin, "pytest_configure", {'config': self}) + self.hook.pytest_namespace.call_historic({}, proc=do_setns) + self.hook.pytest_addoption.call_historic(dict(parser=self._parser)) def add_cleanup(self, func): """ Add a function to be called when the config object gets out of @@ -726,12 +718,13 @@ class Config(object): def _do_configure(self): assert not self._configured self._configured = True - self.hook.pytest_configure(config=self) + self.hook.pytest_configure.call_historic(dict(config=self)) def _ensure_unconfigure(self): if self._configured: self._configured = False self.hook.pytest_unconfigure(config=self) + self.hook.pytest_configure._call_history = [] while self._cleanup: fin = self._cleanup.pop() fin() @@ -847,6 +840,7 @@ class Config(object): assert not hasattr(self, 'args'), ( "can only parse cmdline args at most once per Config object") self._origargs = args + self.hook.pytest_addhooks.call_historic(dict(pluginmanager=self.pluginmanager)) self._preparse(args) # XXX deprecated hook: self.hook.pytest_cmdline_preparse(config=self, args=args) diff --git a/_pytest/core.py b/_pytest/core.py index 7f0bedc2c..015999e08 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -7,16 +7,23 @@ import py py3 = sys.version_info > (3,0) -def hookspec_opts(firstresult=False): +def hookspec_opts(firstresult=False, historic=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. + + If historic is True calls to a hook will be memorized and replayed + on later registered plugins. """ def setattr_hookspec_opts(func): + if historic and firstresult: + raise ValueError("cannot have a historic firstresult hook") if firstresult: func.firstresult = firstresult + if historic: + func.historic = historic return func return setattr_hookspec_opts @@ -226,6 +233,7 @@ class PluginManager(object): def make_hook_caller(self, name, plugins): caller = getattr(self.hook, name) + assert not caller.historic hc = HookCaller(caller.name, plugins, firstresult=caller.firstresult, argnames=caller.argnames) for plugin in hc.plugins: @@ -272,15 +280,18 @@ class PluginManager(object): if name.startswith(self._prefix): specfunc = module_or_class.__dict__[name] firstresult = getattr(specfunc, 'firstresult', False) + historic = getattr(specfunc, 'historic', False) hc = getattr(self.hook, name, None) argnames = varnames(specfunc, startindex=isclass) if hc is None: hc = HookCaller(name, [], firstresult=firstresult, + historic=historic, argnames=argnames) setattr(self.hook, name, hc) else: # plugins registered this hook without knowing the spec - hc.setspec(firstresult=firstresult, argnames=argnames) + hc.setspec(firstresult=firstresult, argnames=argnames, + historic=historic) for plugin in hc.plugins: self._verify_hook(hc, specfunc, plugin) hc.add_method(getattr(plugin, name)) @@ -309,11 +320,6 @@ class PluginManager(object): """ Return a plugin or None for the given name. """ return self._name2plugin.get(name) - def call_plugin(self, plugin, methname, kwargs): - meth = getattr(plugin, methname, None) - if meth is not None: - return MultiCall(methods=[meth], kwargs=kwargs, firstresult=True).execute() - def _scan_plugin(self, plugin): hookcallers = [] for name in dir(plugin): @@ -334,6 +340,7 @@ class PluginManager(object): self._verify_hook(hook, method, plugin) hook.plugins.append(plugin) hook.add_method(method) + hook._apply_history(method) hookcallers.append(hook) return hookcallers @@ -441,25 +448,30 @@ class HookRelay: class HookCaller: - def __init__(self, name, plugins, argnames=None, firstresult=None): + def __init__(self, name, plugins, argnames=None, firstresult=None, + historic=False): self.name = name self.plugins = plugins if argnames is not None: argnames = ["__multicall__"] + list(argnames) + self.historic = historic self.argnames = argnames self.firstresult = firstresult self.wrappers = [] self.nonwrappers = [] + if self.historic: + self._call_history = [] @property def pre(self): return self.argnames is None - def setspec(self, argnames, firstresult): + def setspec(self, argnames, firstresult, historic): assert self.pre assert "self" not in argnames # sanity check self.argnames = ["__multicall__"] + list(argnames) self.firstresult = firstresult + self.historic = historic def remove_plugin(self, plugin): self.plugins.remove(plugin) @@ -472,6 +484,7 @@ class HookCaller: def add_method(self, meth): assert not self.pre if hasattr(meth, 'hookwrapper'): + assert not self.historic self.wrappers.append(meth) elif hasattr(meth, 'trylast'): self.nonwrappers.insert(0, meth) @@ -493,16 +506,27 @@ class HookCaller: return "" %(self.name,) def __call__(self, **kwargs): + assert not self.historic return self._docall(self.nonwrappers + self.wrappers, kwargs) def callextra(self, methods, **kwargs): + assert not self.historic return self._docall(self.nonwrappers + methods + self.wrappers, kwargs) def _docall(self, methods, kwargs): - assert not self.pre, self.name - return MultiCall(methods, kwargs, - firstresult=self.firstresult).execute() + return MultiCall(methods, kwargs, firstresult=self.firstresult).execute() + + def call_historic(self, kwargs, proc=None): + self._call_history.append((kwargs, proc)) + self._docall(self.nonwrappers + self.wrappers, kwargs) + + def _apply_history(self, meth): + if hasattr(self, "_call_history"): + for kwargs, proc in self._call_history: + res = MultiCall([meth], kwargs, firstresult=True).execute() + if proc is not None: + proc(res) class PluginValidationError(Exception): diff --git a/_pytest/hookspec.py b/_pytest/hookspec.py index 81280eb38..e230920dd 100644 --- a/_pytest/hookspec.py +++ b/_pytest/hookspec.py @@ -3,27 +3,23 @@ from _pytest.core import hookspec_opts # ------------------------------------------------------------------------- -# Initialization +# Initialization hooks called for every plugin # ------------------------------------------------------------------------- +@hookspec_opts(historic=True) def pytest_addhooks(pluginmanager): - """called at plugin load time to allow adding new hooks via a call to + """called at plugin registration time to allow adding new hooks via a call to pluginmanager.addhooks(module_or_class, prefix).""" +@hookspec_opts(historic=True) def pytest_namespace(): """return dict of name->object to be made globally available in - the pytest namespace. This hook is called before command line options - are parsed. + the pytest namespace. This hook is called at plugin registration + time. """ -@hookspec_opts(firstresult=True) -def pytest_cmdline_parse(pluginmanager, args): - """return initialized config object, parsing the specified args. """ - -def pytest_cmdline_preparse(config, args): - """(deprecated) modify command line arguments before option parsing. """ - +@hookspec_opts(historic=True) def pytest_addoption(parser): """register argparse-style options and ini-style config values. @@ -49,6 +45,26 @@ def pytest_addoption(parser): via (deprecated) ``pytest.config``. """ +@hookspec_opts(historic=True) +def pytest_configure(config): + """ called after command line options have been parsed + and all plugins and initial conftest files been loaded. + This hook is called for every plugin. + """ + +# ------------------------------------------------------------------------- +# Bootstrapping hooks called for plugins registered early enough: +# internal and 3rd party plugins as well as directly +# discoverable conftest.py local plugins. +# ------------------------------------------------------------------------- + +@hookspec_opts(firstresult=True) +def pytest_cmdline_parse(pluginmanager, args): + """return initialized config object, parsing the specified args. """ + +def pytest_cmdline_preparse(config, args): + """(deprecated) modify command line arguments before option parsing. """ + @hookspec_opts(firstresult=True) def pytest_cmdline_main(config): """ called for performing the main command line action. The default @@ -58,18 +74,6 @@ def pytest_load_initial_conftests(args, early_config, parser): """ implements the loading of initial conftest files ahead of command line option parsing. """ -def pytest_configure(config): - """ called after command line options have been parsed - and all plugins and initial conftest files been loaded. - """ - -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). """ # ------------------------------------------------------------------------- # collection hooks @@ -144,6 +148,12 @@ def pytest_generate_tests(metafunc): # ------------------------------------------------------------------------- # generic runtest related hooks # ------------------------------------------------------------------------- + +@hookspec_opts(firstresult=True) +def pytest_runtestloop(session): + """ called for performing the main runtest loop + (after collection finished). """ + def pytest_itemstart(item, node): """ (deprecated, use pytest_runtest_logstart). """ @@ -201,6 +211,9 @@ def pytest_sessionstart(session): def pytest_sessionfinish(session, exitstatus): """ whole test run finishes. """ +def pytest_unconfigure(config): + """ called before test process is exited. """ + # ------------------------------------------------------------------------- # hooks for customising the assert methods diff --git a/testing/test_core.py b/testing/test_core.py index f4113f9a1..2368be6d7 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -77,6 +77,61 @@ class TestPluginManager: #assert not pm._unverified_hooks assert pm.hook.he_method1(arg=1) == [2] + def test_register_unknown_hooks(self, pm): + class Plugin1: + def he_method1(self, arg): + return arg + 1 + + pm.register(Plugin1()) + class Hooks: + def he_method1(self, arg): + pass + pm.addhooks(Hooks) + #assert not pm._unverified_hooks + assert pm.hook.he_method1(arg=1) == [2] + + def test_register_historic(self, pm): + class Hooks: + @hookspec_opts(historic=True) + def he_method1(self, arg): + pass + pm.addhooks(Hooks) + + pm.hook.he_method1.call_historic(kwargs=dict(arg=1)) + l = [] + class Plugin: + def he_method1(self, arg): + l.append(arg) + + pm.register(Plugin()) + assert l == [1] + + class Plugin2: + def he_method1(self, arg): + l.append(arg*10) + pm.register(Plugin2()) + assert l == [1, 10] + pm.hook.he_method1.call_historic(dict(arg=12)) + assert l == [1, 10, 120, 12] + + def test_with_result_memorized(self, pm): + class Hooks: + @hookspec_opts(historic=True) + def he_method1(self, arg): + pass + pm.addhooks(Hooks) + + he_method1 = pm.hook.he_method1 + he_method1.call_historic(proc=lambda res: l.append(res), kwargs=dict(arg=1)) + l = [] + class Plugin: + def he_method1(self, arg): + return arg * 10 + + pm.register(Plugin()) + + assert l == [10] + class TestAddMethodOrdering: @pytest.fixture @@ -256,8 +311,10 @@ class TestPytestPluginInteractions: return xyz + 1 """) config = get_plugin_manager().config + pm = config.pluginmanager + pm.hook.pytest_addhooks.call_historic(dict(pluginmanager=config.pluginmanager)) config.pluginmanager._importconftest(conf) - print(config.pluginmanager.getplugins()) + #print(config.pluginmanager.getplugins()) res = config.hook.pytest_myhook(xyz=10) assert res == [11]