From b03c1342ac5ec544d2eebe95f0b70800b569da98 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sat, 25 Apr 2015 11:29:11 +0200 Subject: [PATCH 01/24] allow to register plugins with hooks that are only added later --HG-- branch : more_plugin --- _pytest/config.py | 1 + _pytest/core.py | 103 +++++++++++++++++++++++++++++-------------- testing/test_core.py | 19 ++++++-- 3 files changed, 87 insertions(+), 36 deletions(-) diff --git a/_pytest/config.py b/_pytest/config.py index 7948a7c39..d6837f0fa 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -38,6 +38,7 @@ def main(args=None, plugins=None): tw.line("ERROR: could not load %s\n" % (e.path), red=True) return 4 else: + config.pluginmanager.check_pending() return config.hook.pytest_cmdline_main(config=config) class cmdline: # compatibility namespace diff --git a/_pytest/core.py b/_pytest/core.py index ae3da5381..91353801b 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -181,7 +181,7 @@ class PluginManager(object): def make_hook_caller(self, name, plugins): caller = getattr(self.hook, name) methods = self.listattr(name, plugins=plugins) - return HookCaller(caller.name, caller.firstresult, + return HookCaller(caller.name, [plugins], firstresult=caller.firstresult, argnames=caller.argnames, methods=methods) def register(self, plugin, name=None): @@ -201,13 +201,9 @@ class PluginManager(object): return self._do_register(plugin, name) def _do_register(self, plugin, name): - hookcallers = list(self._scan_plugin(plugin)) - self._plugin2hookcallers[plugin] = hookcallers + self._plugin2hookcallers[plugin] = self._scan_plugin(plugin) self._name2plugin[name] = plugin self._plugins.append(plugin) - # rescan all methods for the hookcallers we found - for hookcaller in hookcallers: - self._scan_methods(hookcaller) return True def unregister(self, plugin): @@ -219,6 +215,7 @@ class PluginManager(object): del self._name2plugin[name] hookcallers = self._plugin2hookcallers.pop(plugin) for hookcaller in hookcallers: + hookcaller.plugins.remove(plugin) self._scan_methods(hookcaller) def addhooks(self, module_or_class): @@ -228,11 +225,20 @@ class PluginManager(object): names = [] for name in dir(module_or_class): if name.startswith(self._prefix): - method = module_or_class.__dict__[name] - firstresult = getattr(method, 'firstresult', False) - hc = HookCaller(name, firstresult=firstresult, - argnames=varnames(method, startindex=isclass)) - setattr(self.hook, name, hc) + specfunc = module_or_class.__dict__[name] + firstresult = getattr(specfunc, 'firstresult', False) + hc = getattr(self.hook, name, None) + argnames = varnames(specfunc, startindex=isclass) + if hc is None: + hc = HookCaller(name, [], firstresult=firstresult, + argnames=argnames, methods=[]) + setattr(self.hook, name, hc) + else: + # plugins registered this hook without knowing the spec + hc.setspec(firstresult=firstresult, argnames=argnames) + self._scan_methods(hc) + for plugin in hc.plugins: + self._verify_hook(hc, specfunc, plugin) names.append(name) if not names: raise ValueError("did not find new %r hooks in %r" @@ -282,18 +288,14 @@ class PluginManager(object): return l def _scan_methods(self, hookcaller): - hookcaller.methods = self.listattr(hookcaller.name) + hookcaller.methods = self.listattr(hookcaller.name, hookcaller.plugins) def call_plugin(self, plugin, methname, kwargs): return MultiCall(methods=self.listattr(methname, plugins=[plugin]), kwargs=kwargs, firstresult=True).execute() - def _scan_plugin(self, plugin): - def fail(msg, *args): - name = getattr(plugin, '__name__', plugin) - raise PluginValidationError("plugin %r\n%s" %(name, msg % args)) - + hookcallers = [] for name in dir(plugin): if name[0] == "_" or not name.startswith(self._prefix): continue @@ -302,17 +304,40 @@ class PluginManager(object): if hook is None: if self._excludefunc is not None and self._excludefunc(name): continue - if getattr(method, 'optionalhook', False): - continue - fail("found unknown hook: %r", name) - for arg in varnames(method): - if arg not in hook.argnames: - fail("argument %r not available\n" - "actual definition: %s\n" - "available hookargs: %s", - arg, formatdef(method), - ", ".join(hook.argnames)) - yield hook + hook = HookCaller(name, [plugin]) + setattr(self.hook, name, hook) + elif hook.pre: + # there is only a pre non-specced stub + hook.plugins.append(plugin) + else: + # we have a hook spec, can verify early + self._verify_hook(hook, method, plugin) + hook.plugins.append(plugin) + self._scan_methods(hook) + hookcallers.append(hook) + return hookcallers + + def _verify_hook(self, hook, method, plugin): + for arg in varnames(method): + if arg not in hook.argnames: + pluginname = self._get_canonical_name(plugin) + raise PluginValidationError( + "Plugin %r\nhook %r\nargument %r not available\n" + "plugin definition: %s\n" + "available hookargs: %s" %( + pluginname, hook.name, arg, formatdef(method), + ", ".join(hook.argnames))) + + def check_pending(self): + for name in self.hook.__dict__: + if name.startswith(self._prefix): + hook = getattr(self.hook, name) + if hook.pre: + for plugin in hook.plugins: + method = getattr(plugin, hook.name) + if not getattr(method, "optionalhook", False): + raise PluginValidationError( + "unknown hook %r in plugin %r" %(name, plugin)) def _get_canonical_name(self, plugin): return getattr(plugin, "__name__", None) or str(id(plugin)) @@ -396,13 +421,24 @@ class HookRelay: class HookCaller: - def __init__(self, name, firstresult, argnames, methods=()): + def __init__(self, name, plugins, argnames=None, firstresult=None, methods=None): self.name = name - self.firstresult = firstresult - self.argnames = ["__multicall__"] - self.argnames.extend(argnames) - assert "self" not in argnames # sanity check + self.plugins = plugins self.methods = methods + if argnames is not None: + argnames = ["__multicall__"] + list(argnames) + self.argnames = argnames + self.firstresult = firstresult + + @property + def pre(self): + return self.argnames is None + + def setspec(self, argnames, firstresult): + assert self.pre + assert "self" not in argnames # sanity check + self.argnames = ["__multicall__"] + list(argnames) + self.firstresult = firstresult def __repr__(self): return "" %(self.name,) @@ -414,6 +450,7 @@ class HookCaller: return self._docall(self.methods + methods, kwargs) def _docall(self, methods, kwargs): + assert not self.pre, self.name return MultiCall(methods, kwargs, firstresult=self.firstresult).execute() diff --git a/testing/test_core.py b/testing/test_core.py index bc4546cd6..dd5f0a833 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -32,12 +32,13 @@ class TestPluginManager: pm.unregister(a1) assert not pm.isregistered(a1) - def test_register_mismatch_method(self): - pm = get_plugin_manager() + def test_register_mismatch_method(self, pytestpm): class hello: def pytest_gurgel(self): pass - pytest.raises(Exception, lambda: pm.register(hello())) + pytestpm.register(hello()) + with pytest.raises(PluginValidationError): + pytestpm.check_pending() def test_register_mismatch_arg(self): pm = get_plugin_manager() @@ -77,6 +78,18 @@ class TestPluginManager: l = list(plugins.listattr('x')) assert l == [41, 42, 43] + 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] class TestPytestPluginInteractions: From 02a4042dca240f5011f08daffe0d85223520622c Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sat, 25 Apr 2015 11:29:11 +0200 Subject: [PATCH 02/24] incrementally update hook call lists instead of regenerating the whole list on each registered plugin --HG-- branch : more_plugin --- _pytest/core.py | 87 +++++++++--------- testing/test_config.py | 3 +- testing/test_core.py | 195 ++++++++++++++++++++++++----------------- 3 files changed, 164 insertions(+), 121 deletions(-) diff --git a/_pytest/core.py b/_pytest/core.py index 91353801b..3bdb41b14 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -180,9 +180,13 @@ class PluginManager(object): def make_hook_caller(self, name, plugins): caller = getattr(self.hook, name) - methods = self.listattr(name, plugins=plugins) - return HookCaller(caller.name, [plugins], firstresult=caller.firstresult, - argnames=caller.argnames, methods=methods) + hc = HookCaller(caller.name, plugins, firstresult=caller.firstresult, + argnames=caller.argnames) + for plugin in hc.plugins: + meth = getattr(plugin, name, None) + if meth is not None: + hc._add_method(meth) + return hc def register(self, plugin, name=None): """ Register a plugin with the given name and ensure that all its @@ -216,7 +220,7 @@ class PluginManager(object): hookcallers = self._plugin2hookcallers.pop(plugin) for hookcaller in hookcallers: hookcaller.plugins.remove(plugin) - self._scan_methods(hookcaller) + hookcaller._scan_methods() def addhooks(self, module_or_class): """ add new hook definitions from the given module_or_class using @@ -231,14 +235,14 @@ class PluginManager(object): argnames = varnames(specfunc, startindex=isclass) if hc is None: hc = HookCaller(name, [], firstresult=firstresult, - argnames=argnames, methods=[]) + argnames=argnames) setattr(self.hook, name, hc) else: # plugins registered this hook without knowing the spec hc.setspec(firstresult=firstresult, argnames=argnames) - self._scan_methods(hc) for plugin in hc.plugins: self._verify_hook(hc, specfunc, plugin) + hc._add_method(getattr(plugin, name)) names.append(name) if not names: raise ValueError("did not find new %r hooks in %r" @@ -264,35 +268,10 @@ class PluginManager(object): """ Return a plugin or None for the given name. """ return self._name2plugin.get(name) - def listattr(self, attrname, plugins=None): - if plugins is None: - plugins = self._plugins - l = [] - last = [] - wrappers = [] - for plugin in plugins: - try: - meth = getattr(plugin, attrname) - except AttributeError: - continue - if hasattr(meth, 'hookwrapper'): - wrappers.append(meth) - elif hasattr(meth, 'tryfirst'): - last.append(meth) - elif hasattr(meth, 'trylast'): - l.insert(0, meth) - else: - l.append(meth) - l.extend(last) - l.extend(wrappers) - return l - - def _scan_methods(self, hookcaller): - hookcaller.methods = self.listattr(hookcaller.name, hookcaller.plugins) - def call_plugin(self, plugin, methname, kwargs): - return MultiCall(methods=self.listattr(methname, plugins=[plugin]), - kwargs=kwargs, firstresult=True).execute() + 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 = [] @@ -313,7 +292,7 @@ class PluginManager(object): # we have a hook spec, can verify early self._verify_hook(hook, method, plugin) hook.plugins.append(plugin) - self._scan_methods(hook) + hook._add_method(method) hookcallers.append(hook) return hookcallers @@ -348,7 +327,7 @@ class MultiCall: """ execute a call into multiple python functions/methods. """ def __init__(self, methods, kwargs, firstresult=False): - self.methods = list(methods) + self.methods = methods self.kwargs = kwargs self.kwargs["__multicall__"] = self self.results = [] @@ -421,14 +400,15 @@ class HookRelay: class HookCaller: - def __init__(self, name, plugins, argnames=None, firstresult=None, methods=None): + def __init__(self, name, plugins, argnames=None, firstresult=None): self.name = name self.plugins = plugins - self.methods = methods if argnames is not None: argnames = ["__multicall__"] + list(argnames) self.argnames = argnames self.firstresult = firstresult + self.wrappers = [] + self.nonwrappers = [] @property def pre(self): @@ -440,14 +420,41 @@ class HookCaller: self.argnames = ["__multicall__"] + list(argnames) self.firstresult = firstresult + def _scan_methods(self): + self.wrappers[:] = [] + self.nonwrappers[:] = [] + for plugin in self.plugins: + self._add_method(getattr(plugin, self.name)) + + def _add_method(self, meth): + assert not self.pre + if hasattr(meth, 'hookwrapper'): + self.wrappers.append(meth) + elif hasattr(meth, 'trylast'): + self.nonwrappers.insert(0, meth) + elif hasattr(meth, 'tryfirst'): + self.nonwrappers.append(meth) + else: + if not self.nonwrappers or not hasattr(self.nonwrappers[-1], "tryfirst"): + self.nonwrappers.append(meth) + else: + for i in reversed(range(len(self.nonwrappers)-1)): + if hasattr(self.nonwrappers[i], "tryfirst"): + continue + self.nonwrappers.insert(i+1, meth) + break + else: + self.nonwrappers.insert(0, meth) + def __repr__(self): return "" %(self.name,) def __call__(self, **kwargs): - return self._docall(self.methods, kwargs) + return self._docall(self.nonwrappers + self.wrappers, kwargs) def callextra(self, methods, **kwargs): - return self._docall(self.methods + methods, kwargs) + return self._docall(self.nonwrappers + methods + self.wrappers, + kwargs) def _docall(self, methods, kwargs): assert not self.pre, self.name diff --git a/testing/test_config.py b/testing/test_config.py index f1e3c5eb9..cf8a70b6f 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -355,7 +355,8 @@ def test_load_initial_conftest_last_ordering(testdir): pass m = My() pm.register(m) - l = pm.listattr("pytest_load_initial_conftests") + hc = pm.hook.pytest_load_initial_conftests + l = hc.nonwrappers + hc.wrappers assert l[-1].__module__ == "_pytest.capture" assert l[-2] == m.pytest_load_initial_conftests assert l[-3].__module__ == "_pytest.config" diff --git a/testing/test_core.py b/testing/test_core.py index dd5f0a833..c413385ed 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -64,20 +64,6 @@ class TestPluginManager: assert not pm.isregistered(my) assert pm.getplugins()[-1:] == [my2] - def test_listattr(self): - plugins = PluginManager("xyz") - class api1: - x = 41 - class api2: - x = 42 - class api3: - x = 43 - plugins.register(api1()) - plugins.register(api2()) - plugins.register(api3()) - l = list(plugins.listattr('x')) - assert l == [41, 42, 43] - def test_register_unknown_hooks(self, pm): class Plugin1: def he_method1(self, arg): @@ -91,6 +77,121 @@ class TestPluginManager: #assert not pm._unverified_hooks assert pm.hook.he_method1(arg=1) == [2] +class TestAddMethodOrdering: + @pytest.fixture + def hc(self, pm): + class Hooks: + def he_method1(self, arg): + pass + pm.addhooks(Hooks) + return pm.hook.he_method1 + + @pytest.fixture + def addmeth(self, hc): + def addmeth(tryfirst=False, trylast=False, hookwrapper=False): + def wrap(func): + if tryfirst: + func.tryfirst = True + if trylast: + func.trylast = True + if hookwrapper: + func.hookwrapper = True + hc._add_method(func) + return func + return wrap + return addmeth + + def test_adding_nonwrappers(self, hc, addmeth): + @addmeth() + def he_method1(): + pass + + @addmeth() + def he_method2(): + pass + + @addmeth() + def he_method3(): + pass + assert hc.nonwrappers == [he_method1, he_method2, he_method3] + + def test_adding_nonwrappers_trylast(self, hc, addmeth): + @addmeth() + def he_method1_middle(): + pass + + @addmeth(trylast=True) + def he_method1(): + pass + + @addmeth() + def he_method1_b(): + pass + assert hc.nonwrappers == [he_method1, he_method1_middle, he_method1_b] + + def test_adding_nonwrappers_trylast2(self, hc, addmeth): + @addmeth() + def he_method1_middle(): + pass + + @addmeth() + def he_method1_b(): + pass + + @addmeth(trylast=True) + def he_method1(): + pass + assert hc.nonwrappers == [he_method1, he_method1_middle, he_method1_b] + + def test_adding_nonwrappers_tryfirst(self, hc, addmeth): + @addmeth(tryfirst=True) + def he_method1(): + pass + + @addmeth() + def he_method1_middle(): + pass + + @addmeth() + def he_method1_b(): + pass + assert hc.nonwrappers == [he_method1_middle, he_method1_b, he_method1] + + def test_adding_nonwrappers_trylast(self, hc, addmeth): + @addmeth() + def he_method1_a(): + pass + + @addmeth(trylast=True) + def he_method1_b(): + pass + + @addmeth() + def he_method1_c(): + pass + + @addmeth(trylast=True) + def he_method1_d(): + pass + assert hc.nonwrappers == [he_method1_d, he_method1_b, he_method1_a, he_method1_c] + + def test_adding_wrappers_ordering(self, hc, addmeth): + @addmeth(hookwrapper=True) + def he_method1(): + pass + + @addmeth() + def he_method1_middle(): + pass + + @addmeth(hookwrapper=True) + def he_method3(): + pass + + assert hc.nonwrappers == [he_method1_middle] + assert hc.wrappers == [he_method1, he_method3] + + class TestPytestPluginInteractions: def test_addhooks_conftestplugin(self, testdir): @@ -201,43 +302,6 @@ class TestPytestPluginInteractions: assert pytestpm.trace.root.indent == indent assert saveindent[0] > indent - # lower level API - - def test_listattr(self): - pluginmanager = PluginManager("xyz") - class My2: - x = 42 - pluginmanager.register(My2()) - assert not pluginmanager.listattr("hello") - assert pluginmanager.listattr("x") == [42] - - def test_listattr_tryfirst(self): - class P1: - @pytest.mark.tryfirst - def m(self): - return 17 - - class P2: - def m(self): - return 23 - class P3: - def m(self): - return 19 - - pluginmanager = PluginManager("xyz") - p1 = P1() - p2 = P2() - p3 = P3() - pluginmanager.register(p1) - pluginmanager.register(p2) - pluginmanager.register(p3) - methods = pluginmanager.listattr('m') - assert methods == [p2.m, p3.m, p1.m] - del P1.m.__dict__['tryfirst'] - pytest.mark.trylast(getattr(P2.m, 'im_func', P2.m)) - methods = pluginmanager.listattr('m') - assert methods == [p2.m, p1.m, p3.m] - def test_namespace_has_default_and_env_plugins(testdir): p = testdir.makepyfile(""" @@ -386,35 +450,6 @@ class TestMultiCall: assert res == [] assert l == ["m1 init", "m2 init", "m2 finish", "m1 finish"] - def test_listattr_hookwrapper_ordering(self): - class P1: - @pytest.mark.hookwrapper - def m(self): - return 17 - - class P2: - def m(self): - return 23 - - class P3: - @pytest.mark.tryfirst - def m(self): - return 19 - - pluginmanager = PluginManager("xyz") - p1 = P1() - p2 = P2() - p3 = P3() - pluginmanager.register(p1) - pluginmanager.register(p2) - pluginmanager.register(p3) - methods = pluginmanager.listattr('m') - assert methods == [p2.m, p3.m, p1.m] - ## listattr keeps a cache and deleting - ## a function attribute requires clearing it - #pluginmanager._listattrcache.clear() - #del P1.m.__dict__['tryfirst'] - def test_hookwrapper_not_yield(self): def m1(): pass From f41528433b13838b31ba1e49a25632c322e9592d Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sat, 25 Apr 2015 11:29:11 +0200 Subject: [PATCH 03/24] also incrementally remove plugins from hook callers --HG-- branch : more_plugin --- _pytest/core.py | 23 ++++++++++++----------- testing/test_core.py | 2 +- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/_pytest/core.py b/_pytest/core.py index 3bdb41b14..0296edf75 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -185,7 +185,7 @@ class PluginManager(object): for plugin in hc.plugins: meth = getattr(plugin, name, None) if meth is not None: - hc._add_method(meth) + hc.add_method(meth) return hc def register(self, plugin, name=None): @@ -219,8 +219,7 @@ class PluginManager(object): del self._name2plugin[name] hookcallers = self._plugin2hookcallers.pop(plugin) for hookcaller in hookcallers: - hookcaller.plugins.remove(plugin) - hookcaller._scan_methods() + hookcaller.remove_plugin(plugin) def addhooks(self, module_or_class): """ add new hook definitions from the given module_or_class using @@ -242,7 +241,7 @@ class PluginManager(object): hc.setspec(firstresult=firstresult, argnames=argnames) for plugin in hc.plugins: self._verify_hook(hc, specfunc, plugin) - hc._add_method(getattr(plugin, name)) + hc.add_method(getattr(plugin, name)) names.append(name) if not names: raise ValueError("did not find new %r hooks in %r" @@ -292,7 +291,7 @@ class PluginManager(object): # we have a hook spec, can verify early self._verify_hook(hook, method, plugin) hook.plugins.append(plugin) - hook._add_method(method) + hook.add_method(method) hookcallers.append(hook) return hookcallers @@ -420,13 +419,15 @@ class HookCaller: self.argnames = ["__multicall__"] + list(argnames) self.firstresult = firstresult - def _scan_methods(self): - self.wrappers[:] = [] - self.nonwrappers[:] = [] - for plugin in self.plugins: - self._add_method(getattr(plugin, self.name)) + def remove_plugin(self, plugin): + self.plugins.remove(plugin) + meth = getattr(plugin, self.name) + try: + self.nonwrappers.remove(meth) + except ValueError: + self.wrappers.remove(meth) - def _add_method(self, meth): + def add_method(self, meth): assert not self.pre if hasattr(meth, 'hookwrapper'): self.wrappers.append(meth) diff --git a/testing/test_core.py b/testing/test_core.py index c413385ed..d2b9b155e 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -96,7 +96,7 @@ class TestAddMethodOrdering: func.trylast = True if hookwrapper: func.hookwrapper = True - hc._add_method(func) + hc.add_method(func) return func return wrap return addmeth From bbbb6dc2e32fba8c0f0fb8bc47847886395a8e84 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sat, 25 Apr 2015 11:29:11 +0200 Subject: [PATCH 04/24] remove _do_register indirection between PluginManager and PytestPluginManager --HG-- branch : more_plugin --- _pytest/config.py | 16 ++++++---------- _pytest/core.py | 4 ---- testing/test_core.py | 35 +++++++++++++++++++++-------------- 3 files changed, 27 insertions(+), 28 deletions(-) diff --git a/_pytest/config.py b/_pytest/config.py index d6837f0fa..5156f3603 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -119,16 +119,13 @@ class PytestPluginManager(PluginManager): def register(self, plugin, name=None, conftest=False): ret = super(PytestPluginManager, self).register(plugin, name) - if ret and not conftest: - self._globalplugins.append(plugin) + if ret: + if not conftest: + self._globalplugins.append(plugin) + if hasattr(self, "config"): + self.config._register_plugin(plugin, name) return ret - def _do_register(self, plugin, name): - # called from core PluginManager class - if hasattr(self, "config"): - self.config._register_plugin(plugin, name) - return super(PytestPluginManager, self)._do_register(plugin, name) - def unregister(self, plugin): super(PytestPluginManager, self).unregister(plugin) try: @@ -710,8 +707,7 @@ class Config(object): def _register_plugin(self, plugin, name): call_plugin = self.pluginmanager.call_plugin - call_plugin(plugin, "pytest_addhooks", - {'pluginmanager': self.pluginmanager}) + 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 {} diff --git a/_pytest/core.py b/_pytest/core.py index 0296edf75..51fb55d2b 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -201,10 +201,6 @@ class PluginManager(object): raise ValueError("Plugin already registered: %s=%s\n%s" %( name, plugin, self._name2plugin)) #self.trace("registering", name, plugin) - # allow subclasses to intercept here by calling a helper - return self._do_register(plugin, name) - - def _do_register(self, plugin, name): self._plugin2hookcallers[plugin] = self._scan_plugin(plugin) self._name2plugin[name] = plugin self._plugins.append(plugin) diff --git a/testing/test_core.py b/testing/test_core.py index d2b9b155e..4d03433a2 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -77,6 +77,7 @@ class TestPluginManager: #assert not pm._unverified_hooks assert pm.hook.he_method1(arg=1) == [2] + class TestAddMethodOrdering: @pytest.fixture def hc(self, pm): @@ -283,24 +284,30 @@ class TestPytestPluginInteractions: pytestpm = get_plugin_manager() # fully initialized with plugins saveindent = [] class api1: - x = 41 def pytest_plugin_registered(self, plugin): saveindent.append(pytestpm.trace.root.indent) - raise ValueError(42) + class api2: + def pytest_plugin_registered(self, plugin): + saveindent.append(pytestpm.trace.root.indent) + raise ValueError() l = [] - pytestpm.set_tracing(l.append) - indent = pytestpm.trace.root.indent - p = api1() - pytestpm.register(p) + undo = pytestpm.set_tracing(l.append) + try: + indent = pytestpm.trace.root.indent + p = api1() + pytestpm.register(p) + assert pytestpm.trace.root.indent == indent + assert len(l) == 2 + assert 'pytest_plugin_registered' in l[0] + assert 'finish' in l[1] - assert pytestpm.trace.root.indent == indent - assert len(l) == 2 - assert 'pytest_plugin_registered' in l[0] - assert 'finish' in l[1] - with pytest.raises(ValueError): - pytestpm.register(api1()) - assert pytestpm.trace.root.indent == indent - assert saveindent[0] > indent + l[:] = [] + with pytest.raises(ValueError): + pytestpm.register(api2()) + assert pytestpm.trace.root.indent == indent + assert saveindent[0] > indent + finally: + undo() def test_namespace_has_default_and_env_plugins(testdir): From d2a5c7f99b8d178f170c1ca6579690ce84e00b3d Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sat, 25 Apr 2015 11:29:11 +0200 Subject: [PATCH 05/24] 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 --- CHANGELOG | 7 ++++++ _pytest/capture.py | 14 ++++++------ _pytest/core.py | 46 +++++++++++++++++++++++++++++++++++++ _pytest/helpconfig.py | 2 +- _pytest/hookspec.py | 30 ++++++++++++------------ _pytest/main.py | 4 ++-- _pytest/nose.py | 2 +- _pytest/pastebin.py | 2 +- _pytest/python.py | 8 +++---- _pytest/skipping.py | 4 ++-- _pytest/terminal.py | 4 ++-- _pytest/unittest.py | 4 ++-- doc/en/example/markers.txt | 8 +++---- doc/en/example/simple.txt | 4 ++-- doc/en/plugins.txt | 2 +- pytest.py | 1 + testing/conftest.py | 2 +- testing/python/collect.py | 2 +- testing/test_core.py | 47 ++++++++++++++++++++++++++++++++++++++ testing/test_helpconfig.py | 2 +- testing/test_mark.py | 2 +- 21 files changed, 150 insertions(+), 47 deletions(-) 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": From ea50ef15888d6051486329d82b3cb36b29f68ed5 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sat, 25 Apr 2015 11:29:11 +0200 Subject: [PATCH 06/24] split plugin documentation into "using" and "writing plugins", referencing each other. Also add tryfirst/trylast examples. --HG-- branch : more_plugin --- CHANGELOG | 3 + doc/en/index.txt | 1 + doc/en/plugins.txt | 386 +----------------------------- doc/en/writing_plugins.txt | 469 +++++++++++++++++++++++++++++++++++++ 4 files changed, 482 insertions(+), 377 deletions(-) create mode 100644 doc/en/writing_plugins.txt diff --git a/CHANGELOG b/CHANGELOG index e1776db30..b3f05c63f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -33,6 +33,9 @@ now deprecated use of ``pytest.mark`` which is meant to contain markers for test functions only. +- write/refine docs for "writing plugins" which now have their + own page and are separate from the "using/installing plugins`` page. + 2.7.1.dev (compared to 2.7.0) ----------------------------- diff --git a/doc/en/index.txt b/doc/en/index.txt index 8d1eef4c0..3836cbb71 100644 --- a/doc/en/index.txt +++ b/doc/en/index.txt @@ -56,6 +56,7 @@ pytest: helps you write better programs - all collection, reporting, running aspects are delegated to hook functions - customizations can be per-directory, per-project or per PyPI released plugin - it is easy to add command line options or customize existing behaviour + - :ref:`easy to write your own plugins ` .. _`easy`: http://bruynooghe.blogspot.com/2009/12/skipping-slow-test-by-default-in-pytest.html diff --git a/doc/en/plugins.txt b/doc/en/plugins.txt index 2abb24861..0e972bf0d 100644 --- a/doc/en/plugins.txt +++ b/doc/en/plugins.txt @@ -1,64 +1,14 @@ -.. _plugins: - -Working with plugins and conftest files -======================================= - -``pytest`` implements all aspects of configuration, collection, running and reporting by calling `well specified hooks`_. Virtually any Python module can be registered as a plugin. It can implement any number of hook functions (usually two or three) which all have a ``pytest_`` prefix, making hook functions easy to distinguish and find. There are three basic location types: - -* `builtin plugins`_: loaded from pytest's internal ``_pytest`` directory. -* `external plugins`_: modules discovered through `setuptools entry points`_ -* `conftest.py plugins`_: modules auto-discovered in test directories - -.. _`pytest/plugin`: http://bitbucket.org/pytest-dev/pytest/src/tip/pytest/plugin/ -.. _`conftest.py plugins`: -.. _`conftest.py`: -.. _`localplugin`: -.. _`conftest`: - -conftest.py: local per-directory plugins ----------------------------------------- - -local ``conftest.py`` plugins contain directory-specific hook -implementations. Session and test running activities will -invoke all hooks defined in ``conftest.py`` files closer to the -root of the filesystem. Example: Assume the following layout -and content of files:: - - a/conftest.py: - def pytest_runtest_setup(item): - # called for running each test in 'a' directory - print ("setting up", item) - - a/test_sub.py: - def test_sub(): - pass - - test_flat.py: - def test_flat(): - pass - -Here is how you might run it:: - - py.test test_flat.py # will not show "setting up" - py.test a/test_sub.py # will show "setting up" - -.. Note:: - If you have ``conftest.py`` files which do not reside in a - python package directory (i.e. one containing an ``__init__.py``) then - "import conftest" can be ambiguous because there might be other - ``conftest.py`` files as well on your PYTHONPATH or ``sys.path``. - It is thus good practise for projects to either put ``conftest.py`` - under a package scope or to never import anything from a - conftest.py file. - .. _`external plugins`: .. _`extplugins`: +.. _`using plugins`: -Installing External Plugins / Searching ---------------------------------------- +Installing and Using plugins +============================ -Installing a plugin happens through any usual Python installation -tool, for example:: +This section talks about installing and using third party plugins. +For writing your own plugins, please refer to :ref:`writing-plugins`. + +Installing a third party plugin can be easily done with ``pip``:: pip install pytest-NAME pip uninstall pytest-NAME @@ -120,118 +70,20 @@ You may also discover more plugins through a `pytest- pypi.python.org search`_. .. _`pytest- pypi.python.org search`: http://pypi.python.org/pypi?%3Aaction=search&term=pytest-&submit=search -Writing a plugin by looking at examples ---------------------------------------- - -.. _`setuptools`: http://pypi.python.org/pypi/setuptools - -If you want to write a plugin, there are many real-life examples -you can copy from: - -* a custom collection example plugin: :ref:`yaml plugin` -* around 20 `builtin plugins`_ which provide pytest's own functionality -* many `external plugins`_ providing additional features - -All of these plugins implement the documented `well specified hooks`_ -to extend and add functionality. - -You can also :ref:`contribute your plugin to pytest-dev` -once it has some happy users other than yourself. - - -.. _`setuptools entry points`: - -Making your plugin installable by others ----------------------------------------- - -If you want to make your plugin externally available, you -may define a so-called entry point for your distribution so -that ``pytest`` finds your plugin module. Entry points are -a feature that is provided by `setuptools`_. pytest looks up -the ``pytest11`` entrypoint to discover its -plugins and you can thus make your plugin available by defining -it in your setuptools-invocation: - -.. sourcecode:: python - - # sample ./setup.py file - from setuptools import setup - - setup( - name="myproject", - packages = ['myproject'] - - # the following makes a plugin available to pytest - entry_points = { - 'pytest11': [ - 'name_of_plugin = myproject.pluginmodule', - ] - }, - ) - -If a package is installed this way, ``pytest`` will load -``myproject.pluginmodule`` as a plugin which can define -`well specified hooks`_. - - -.. _`pluginorder`: - -Plugin discovery order at tool startup --------------------------------------- - -``pytest`` loads plugin modules at tool startup in the following way: - -* by loading all builtin plugins - -* by loading all plugins registered through `setuptools entry points`_. - -* by pre-scanning the command line for the ``-p name`` option - and loading the specified plugin before actual command line parsing. - -* by loading all :file:`conftest.py` files as inferred by the command line - 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. - - 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 - your conftest.py file in the top level test or project root directory. - -* by recursively loading all plugins specified by the - ``pytest_plugins`` variable in ``conftest.py`` files - - Requiring/Loading plugins in a test module or conftest file ----------------------------------------------------------- You can require plugins in a test module or a conftest file like this:: - pytest_plugins = "name1", "name2", + pytest_plugins = "myapp.testsupport.myplugin", When the test module or conftest plugin is loaded the specified plugins -will be loaded as well. You can also use dotted path like this:: +will be loaded as well. pytest_plugins = "myapp.testsupport.myplugin" which will import the specified module as a ``pytest`` plugin. - -Accessing another plugin by name --------------------------------- - -If a plugin wants to collaborate with code from -another plugin it can obtain a reference through -the plugin manager like this: - -.. sourcecode:: python - - plugin = config.pluginmanager.getplugin("name_of_plugin") - -If you want to look at the names of existing plugins, use -the ``--traceconfig`` option. - .. _`findpluginname`: Finding out which plugins are active @@ -293,223 +145,3 @@ in the `pytest repository `_. _pytest.tmpdir _pytest.unittest -.. _`well specified hooks`: - -pytest hook reference -===================== - -Hook specification and validation ---------------------------------- - -``pytest`` calls hook functions to implement initialization, running, -test execution and reporting. When ``pytest`` loads a plugin it validates -that each hook function conforms to its respective hook specification. -Each hook function name and its argument names need to match a hook -specification. However, a hook function may accept *fewer* parameters -by simply not specifying them. If you mistype argument names or the -hook name itself you get an error showing the available arguments. - -Initialization, command line and configuration hooks ----------------------------------------------------- - -.. currentmodule:: _pytest.hookspec - -.. autofunction:: pytest_load_initial_conftests -.. autofunction:: pytest_cmdline_preparse -.. autofunction:: pytest_cmdline_parse -.. autofunction:: pytest_namespace -.. autofunction:: pytest_addoption -.. autofunction:: pytest_cmdline_main -.. autofunction:: pytest_configure -.. autofunction:: pytest_unconfigure - -Generic "runtest" hooks ------------------------ - -All runtest related hooks receive a :py:class:`pytest.Item` object. - -.. autofunction:: pytest_runtest_protocol -.. autofunction:: pytest_runtest_setup -.. autofunction:: pytest_runtest_call -.. autofunction:: pytest_runtest_teardown -.. autofunction:: pytest_runtest_makereport - -For deeper understanding you may look at the default implementation of -these hooks in :py:mod:`_pytest.runner` and maybe also -in :py:mod:`_pytest.pdb` which interacts with :py:mod:`_pytest.capture` -and its input/output capturing in order to immediately drop -into interactive debugging when a test failure occurs. - -The :py:mod:`_pytest.terminal` reported specifically uses -the reporting hook to print information about a test run. - -Collection hooks ----------------- - -``pytest`` calls the following hooks for collecting files and directories: - -.. autofunction:: pytest_ignore_collect -.. autofunction:: pytest_collect_directory -.. autofunction:: pytest_collect_file - -For influencing the collection of objects in Python modules -you can use the following hook: - -.. autofunction:: pytest_pycollect_makeitem -.. autofunction:: pytest_generate_tests - -After collection is complete, you can modify the order of -items, delete or otherwise amend the test items: - -.. autofunction:: pytest_collection_modifyitems - -Reporting hooks ---------------- - -Session related reporting hooks: - -.. autofunction:: pytest_collectstart -.. autofunction:: pytest_itemcollected -.. autofunction:: pytest_collectreport -.. autofunction:: pytest_deselected - -And here is the central hook for reporting about -test execution: - -.. autofunction:: pytest_runtest_logreport - - -Debugging/Interaction hooks ---------------------------- - -There are few hooks which can be used for special -reporting or interaction with exceptions: - -.. autofunction:: pytest_internalerror -.. autofunction:: pytest_keyboard_interrupt -.. autofunction:: pytest_exception_interact - - -Declaring new hooks ------------------------- - -Plugins and ``conftest.py`` files may declare new hooks that can then be -implemented by other plugins in order to alter behaviour or interact with -the new plugin: - -.. autofunction:: pytest_addhooks - -Hooks are usually declared as do-nothing functions that contain only -documentation describing when the hook will be called and what return values -are expected. - -For an example, see `newhooks.py`_ from :ref:`xdist`. - -.. _`newhooks.py`: https://bitbucket.org/pytest-dev/pytest-xdist/src/52082f70e7dd04b00361091b8af906c60fd6700f/xdist/newhooks.py?at=default - - -Using hooks from 3rd party plugins -------------------------------------- - -Using new hooks from plugins as explained above might be a little tricky -because the standard `Hook specification and validation`_ mechanism: -if you depend on a plugin that is not installed, -validation will fail and the error message will not make much sense to your users. - -One approach is to defer the hook implementation to a new plugin instead of -declaring the hook functions directly in your plugin module, for example:: - - # contents of myplugin.py - - class DeferPlugin(object): - """Simple plugin to defer pytest-xdist hook functions.""" - - def pytest_testnodedown(self, node, error): - """standard xdist hook function. - """ - - def pytest_configure(config): - if config.pluginmanager.hasplugin('xdist'): - config.pluginmanager.register(DeferPlugin()) - - -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.hookimpl_opts(hookwrapper=True) - 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 -====================================== - -.. autoclass:: _pytest.config.Config() - :members: - -.. autoclass:: _pytest.config.Parser() - :members: - -.. autoclass:: _pytest.main.Node() - :members: - -.. autoclass:: _pytest.main.Collector() - :members: - :show-inheritance: - -.. autoclass:: _pytest.main.Item() - :members: - :show-inheritance: - -.. autoclass:: _pytest.python.Module() - :members: - :show-inheritance: - -.. autoclass:: _pytest.python.Class() - :members: - :show-inheritance: - -.. autoclass:: _pytest.python.Function() - :members: - :show-inheritance: - -.. autoclass:: _pytest.runner.CallInfo() - :members: - -.. autoclass:: _pytest.runner.TestReport() - :members: - -.. autoclass:: _pytest.core.CallOutcome() - :members: - diff --git a/doc/en/writing_plugins.txt b/doc/en/writing_plugins.txt new file mode 100644 index 000000000..d1667c2d5 --- /dev/null +++ b/doc/en/writing_plugins.txt @@ -0,0 +1,469 @@ +.. _plugins: +.. _`writing-plugins`: + +Writing plugins +=============== + +It is easy to implement `local conftest plugins`_ for your own project +or `pip-installable plugins`_ that can be used throughout many projects, +including third party projects. Please refer to :ref:`using plugins` if you +only want to use but not write plugins. + +A plugin contains one or multiple hook functions. :ref:`Writing hooks ` +explains the basics and details of how you can write a hook function yourself. +``pytest`` implements all aspects of configuration, collection, running and +reporting by calling `well specified hooks`_ of the following plugins: + +* :ref:`builtin plugins`: loaded from pytest's internal ``_pytest`` directory. + +* :ref:`external plugins `: modules discovered through + `setuptools entry points`_ + +* `conftest.py plugins`_: modules auto-discovered in test directories + +In principle, each hook call is a ``1:N`` Python function call where ``N`` is the +number of registered implementation functions for a given specification. +All specifications and implementations following the ``pytest_`` prefix +naming convention, making them easy to distinguish and find. + +.. _`pluginorder`: + +Plugin discovery order at tool startup +-------------------------------------- + +``pytest`` loads plugin modules at tool startup in the following way: + +* by loading all builtin plugins + +* by loading all plugins registered through `setuptools entry points`_. + +* by pre-scanning the command line for the ``-p name`` option + and loading the specified plugin before actual command line parsing. + +* by loading all :file:`conftest.py` files as inferred by the command line + 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. + + 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 + your conftest.py file in the top level test or project root directory. + +* by recursively loading all plugins specified by the + ``pytest_plugins`` variable in ``conftest.py`` files + + +.. _`pytest/plugin`: http://bitbucket.org/pytest-dev/pytest/src/tip/pytest/plugin/ +.. _`conftest.py plugins`: +.. _`conftest.py`: +.. _`localplugin`: +.. _`conftest`: +.. _`local conftest plugins`: + +conftest.py: local per-directory plugins +---------------------------------------- + +Local ``conftest.py`` plugins contain directory-specific hook +implementations. Hook Session and test running activities will +invoke all hooks defined in ``conftest.py`` files closer to the +root of the filesystem. Example of implementing the +``pytest_runtest_setup`` hook so that is called for tests in the ``a`` +sub directory but not for other directories:: + + a/conftest.py: + def pytest_runtest_setup(item): + # called for running each test in 'a' directory + print ("setting up", item) + + a/test_sub.py: + def test_sub(): + pass + + test_flat.py: + def test_flat(): + pass + +Here is how you might run it:: + + py.test test_flat.py # will not show "setting up" + py.test a/test_sub.py # will show "setting up" + +.. Note:: + If you have ``conftest.py`` files which do not reside in a + python package directory (i.e. one containing an ``__init__.py``) then + "import conftest" can be ambiguous because there might be other + ``conftest.py`` files as well on your PYTHONPATH or ``sys.path``. + It is thus good practise for projects to either put ``conftest.py`` + under a package scope or to never import anything from a + conftest.py file. + + +Writing a plugin by looking at examples +--------------------------------------- + +.. _`setuptools`: http://pypi.python.org/pypi/setuptools + +If you want to write a plugin, there are many real-life examples +you can copy from: + +* a custom collection example plugin: :ref:`yaml plugin` +* around 20 doc:`builtin plugins` which provide pytest's own functionality +* many :doc:`external plugins` providing additional features + +All of these plugins implement the documented `well specified hooks`_ +to extend and add functionality. + +You can also :ref:`contribute your plugin to pytest-dev` +once it has some happy users other than yourself. + + +.. _`setuptools entry points`: +.. _`pip-installable plugins`: + +Making your plugin installable by others +---------------------------------------- + +If you want to make your plugin externally available, you +may define a so-called entry point for your distribution so +that ``pytest`` finds your plugin module. Entry points are +a feature that is provided by `setuptools`_. pytest looks up +the ``pytest11`` entrypoint to discover its +plugins and you can thus make your plugin available by defining +it in your setuptools-invocation: + +.. sourcecode:: python + + # sample ./setup.py file + from setuptools import setup + + setup( + name="myproject", + packages = ['myproject'] + + # the following makes a plugin available to pytest + entry_points = { + 'pytest11': [ + 'name_of_plugin = myproject.pluginmodule', + ] + }, + ) + +If a package is installed this way, ``pytest`` will load +``myproject.pluginmodule`` as a plugin which can define +`well specified hooks`_. + + + + +Requiring/Loading plugins in a test module or conftest file +----------------------------------------------------------- + +You can require plugins in a test module or a conftest file like this:: + + pytest_plugins = "name1", "name2", + +When the test module or conftest plugin is loaded the specified plugins +will be loaded as well. You can also use dotted path like this:: + + pytest_plugins = "myapp.testsupport.myplugin" + +which will import the specified module as a ``pytest`` plugin. + + +Accessing another plugin by name +-------------------------------- + +If a plugin wants to collaborate with code from +another plugin it can obtain a reference through +the plugin manager like this: + +.. sourcecode:: python + + plugin = config.pluginmanager.getplugin("name_of_plugin") + +If you want to look at the names of existing plugins, use +the ``--traceconfig`` option. + + +.. _`writinghooks`: + +Writing hook functions +====================== + +.. _validation: + +hook function validation and execution +-------------------------------------- + +pytest calls hook functions from registered plugins for any +given hook specification. Let's look at a typical hook function +for the ``pytest_collection_modifyitems(session, config, +items)`` hook which pytest calls after collection of all test items is +completed. + +When we implement a ``pytest_collection_modifyitems`` function in our plugin +pytest will during registration verify that you use argument +names which match the specification and bail out if not. + +Let's look at a possible implementation:: + + def pytest_collection_modifyitems(config, items): + # called after collectin is completed + # you can modify the ``items`` list + +Here, ``pytest`` will pass in ``config`` (the pytest config object) +and ``items`` (the list of collected test items) but will not pass +in the ``session`` argument because we didn't list it in the function +signature. This dynamic "pruning" of arguments allows ``pytest`` to +be "future-compatible": we can introduce new hook named parameters without +breaking the signatures of existing hook implementations. It is one of +the reasons for the general long-lived compatibility of pytest plugins. + +Hook function results +--------------------- + +Most calls to ``pytest`` hooks result in a **list of results** which contains +all non-None results of the called hook functions. + +Some hooks are specified so that the hook call only executes until the +first function returned a non-None value which is then also the +result of the overall hook call. The remaining hook functions will +not be called in this case. + +Note that hook functions other than ``pytest_runtest_*`` are not +allowed to raise exceptions. Doing so will break the pytest run. + +Hook function ordering +---------------------- + +For any given hook there may be more than one implementation and we thus +generally view ``hook`` execution as a ``1:N`` function call where ``N`` +is the number of registered functions. There are ways to +influence if a hook implementation comes before or after others, i.e. +the position in the ``N``-sized list of functions:: + + @pytest.hookimpl_spec(tryfirst=True) + def pytest_collection_modifyitems(items): + # will execute as early as possible + + @pytest.hookimpl_spec(trylast=True) + def pytest_collection_modifyitems(items): + # will execute as late as possible + + +hookwrapper: executing around other hooks +------------------------------------------------- + +.. currentmodule:: _pytest.core + +.. versionadded:: 2.7 (experimental) + +pytest plugins can implement hook wrappers 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.hookimpl_opts(hookwrapper=True) + 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. + +Declaring new hooks +------------------------ + +.. currentmodule:: _pytest.hookspec + +Plugins and ``conftest.py`` files may declare new hooks that can then be +implemented by other plugins in order to alter behaviour or interact with +the new plugin: + +.. autofunction:: pytest_addhooks + +Hooks are usually declared as do-nothing functions that contain only +documentation describing when the hook will be called and what return values +are expected. + +For an example, see `newhooks.py`_ from :ref:`xdist`. + +.. _`newhooks.py`: https://bitbucket.org/pytest-dev/pytest-xdist/src/52082f70e7dd04b00361091b8af906c60fd6700f/xdist/newhooks.py?at=default + + +Using hooks from 3rd party plugins +------------------------------------- + +Using new hooks from plugins as explained above might be a little tricky +because the standard :ref:`validation mechanism `: +if you depend on a plugin that is not installed, validation will fail and +the error message will not make much sense to your users. + +One approach is to defer the hook implementation to a new plugin instead of +declaring the hook functions directly in your plugin module, for example:: + + # contents of myplugin.py + + class DeferPlugin(object): + """Simple plugin to defer pytest-xdist hook functions.""" + + def pytest_testnodedown(self, node, error): + """standard xdist hook function. + """ + + def pytest_configure(config): + if config.pluginmanager.hasplugin('xdist'): + config.pluginmanager.register(DeferPlugin()) + +This has the added benefit of allowing you to conditionally install hooks +depending on which plugins are installed. + + +.. _`well specified hooks`: + +.. currentmodule:: _pytest.hookspec + +pytest hook reference +===================== + + +Initialization, command line and configuration hooks +---------------------------------------------------- + +.. autofunction:: pytest_load_initial_conftests +.. autofunction:: pytest_cmdline_preparse +.. autofunction:: pytest_cmdline_parse +.. autofunction:: pytest_namespace +.. autofunction:: pytest_addoption +.. autofunction:: pytest_cmdline_main +.. autofunction:: pytest_configure +.. autofunction:: pytest_unconfigure + +Generic "runtest" hooks +----------------------- + +All runtest related hooks receive a :py:class:`pytest.Item` object. + +.. autofunction:: pytest_runtest_protocol +.. autofunction:: pytest_runtest_setup +.. autofunction:: pytest_runtest_call +.. autofunction:: pytest_runtest_teardown +.. autofunction:: pytest_runtest_makereport + +For deeper understanding you may look at the default implementation of +these hooks in :py:mod:`_pytest.runner` and maybe also +in :py:mod:`_pytest.pdb` which interacts with :py:mod:`_pytest.capture` +and its input/output capturing in order to immediately drop +into interactive debugging when a test failure occurs. + +The :py:mod:`_pytest.terminal` reported specifically uses +the reporting hook to print information about a test run. + +Collection hooks +---------------- + +``pytest`` calls the following hooks for collecting files and directories: + +.. autofunction:: pytest_ignore_collect +.. autofunction:: pytest_collect_directory +.. autofunction:: pytest_collect_file + +For influencing the collection of objects in Python modules +you can use the following hook: + +.. autofunction:: pytest_pycollect_makeitem +.. autofunction:: pytest_generate_tests + +After collection is complete, you can modify the order of +items, delete or otherwise amend the test items: + +.. autofunction:: pytest_collection_modifyitems + +Reporting hooks +--------------- + +Session related reporting hooks: + +.. autofunction:: pytest_collectstart +.. autofunction:: pytest_itemcollected +.. autofunction:: pytest_collectreport +.. autofunction:: pytest_deselected + +And here is the central hook for reporting about +test execution: + +.. autofunction:: pytest_runtest_logreport + + +Debugging/Interaction hooks +--------------------------- + +There are few hooks which can be used for special +reporting or interaction with exceptions: + +.. autofunction:: pytest_internalerror +.. autofunction:: pytest_keyboard_interrupt +.. autofunction:: pytest_exception_interact + + + +Reference of objects involved in hooks +====================================== + +.. autoclass:: _pytest.config.Config() + :members: + +.. autoclass:: _pytest.config.Parser() + :members: + +.. autoclass:: _pytest.main.Node() + :members: + +.. autoclass:: _pytest.main.Collector() + :members: + :show-inheritance: + +.. autoclass:: _pytest.main.Item() + :members: + :show-inheritance: + +.. autoclass:: _pytest.python.Module() + :members: + :show-inheritance: + +.. autoclass:: _pytest.python.Class() + :members: + :show-inheritance: + +.. autoclass:: _pytest.python.Function() + :members: + :show-inheritance: + +.. autoclass:: _pytest.runner.CallInfo() + :members: + +.. autoclass:: _pytest.runner.TestReport() + :members: + +.. autoclass:: _pytest.core.CallOutcome() + :members: + From a63585dcab2698ebbaa1c54a106c4c7a6df90d3e Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sat, 25 Apr 2015 11:29:11 +0200 Subject: [PATCH 07/24] 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] From 2f8a1aed6ecab1e7ece3db3ec0092d559aef8d7a Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sat, 25 Apr 2015 11:29:11 +0200 Subject: [PATCH 08/24] properly perform hook calls with extra methods --HG-- branch : more_plugin --- _pytest/core.py | 24 +++++++++++++++++++----- _pytest/python.py | 12 +++++++----- testing/test_core.py | 11 +++++++++++ 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/_pytest/core.py b/_pytest/core.py index 015999e08..9a644a42e 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -447,7 +447,7 @@ class HookRelay: self.trace = pm.trace.root.get("hook") -class HookCaller: +class HookCaller(object): def __init__(self, name, plugins, argnames=None, firstresult=None, historic=False): self.name = name @@ -462,6 +462,17 @@ class HookCaller: if self.historic: self._call_history = [] + def clone(self): + hc = object.__new__(HookCaller) + hc.name = self.name + hc.plugins = self.plugins + hc.historic = self.historic + hc.argnames = self.argnames + hc.firstresult = self.firstresult + hc.wrappers = list(self.wrappers) + hc.nonwrappers = list(self.nonwrappers) + return hc + @property def pre(self): return self.argnames is None @@ -511,8 +522,10 @@ class HookCaller: def callextra(self, methods, **kwargs): assert not self.historic - return self._docall(self.nonwrappers + methods + self.wrappers, - kwargs) + hc = self.clone() + for method in methods: + hc.add_method(method) + return hc(**kwargs) def _docall(self, methods, kwargs): return MultiCall(methods, kwargs, firstresult=self.firstresult).execute() @@ -521,10 +534,11 @@ class HookCaller: self._call_history.append((kwargs, proc)) self._docall(self.nonwrappers + self.wrappers, kwargs) - def _apply_history(self, meth): + def _apply_history(self, method): if hasattr(self, "_call_history"): for kwargs, proc in self._call_history: - res = MultiCall([meth], kwargs, firstresult=True).execute() + args = [kwargs[argname] for argname in varnames(method)] + res = method(*args) if proc is not None: proc(res) diff --git a/_pytest/python.py b/_pytest/python.py index 74ba77068..bc64d6fa3 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -375,13 +375,15 @@ class PyCollector(PyobjMixin, pytest.Collector): fixtureinfo = fm.getfixtureinfo(self, funcobj, cls) metafunc = Metafunc(funcobj, fixtureinfo, self.config, cls=cls, module=module) - try: - methods = [module.pytest_generate_tests] - except AttributeError: - methods = [] + methods = [] + if hasattr(module, "pytest_generate_tests"): + methods.append(module.pytest_generate_tests) if hasattr(cls, "pytest_generate_tests"): methods.append(cls().pytest_generate_tests) - self.ihook.pytest_generate_tests.callextra(methods, metafunc=metafunc) + if methods: + self.ihook.pytest_generate_tests.callextra(methods, metafunc=metafunc) + else: + self.ihook.pytest_generate_tests(metafunc=metafunc) Function = self._getcustomclass("Function") if not metafunc._calls: diff --git a/testing/test_core.py b/testing/test_core.py index 2368be6d7..304cff0cd 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -129,7 +129,18 @@ class TestPluginManager: return arg * 10 pm.register(Plugin()) + assert l == [10] + def test_call_extra(self, pm): + class Hooks: + def he_method1(self, arg): + pass + pm.addhooks(Hooks) + + def he_method1(arg): + return arg * 10 + + l = pm.hook.he_method1.callextra([he_method1], arg=1) assert l == [10] From e7a2e5310875065304ec02ab0b0cd86d323a3527 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sat, 25 Apr 2015 13:38:29 +0200 Subject: [PATCH 09/24] Streamline data structures --HG-- branch : more_plugin --- _pytest/core.py | 146 +++++++++++++++++++---------------------- testing/test_config.py | 2 +- testing/test_core.py | 14 ++-- 3 files changed, 77 insertions(+), 85 deletions(-) diff --git a/_pytest/core.py b/_pytest/core.py index 9a644a42e..b394913cd 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -233,14 +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: - meth = getattr(plugin, name, None) - if meth is not None: - hc.add_method(meth) - return hc + return caller.clone(plugins=plugins) def register(self, plugin, name=None): """ Register a plugin with the given name and ensure that all its @@ -278,23 +271,15 @@ class PluginManager(object): names = [] for name in dir(module_or_class): 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) + hc = HookCaller(name, module_or_class) setattr(self.hook, name, hc) else: # plugins registered this hook without knowing the spec - 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)) + hc.setspec(module_or_class) + for plugin in hc._plugins: + self._verify_hook(hc, plugin) names.append(name) if not names: raise ValueError("did not find new %r hooks in %r" @@ -330,21 +315,17 @@ class PluginManager(object): if hook is None: if self._excludefunc is not None and self._excludefunc(name): continue - hook = HookCaller(name, [plugin]) + hook = HookCaller(name) setattr(self.hook, name, hook) - elif hook.pre: - # there is only a pre non-specced stub - hook.plugins.append(plugin) - else: - # we have a hook spec, can verify early - self._verify_hook(hook, method, plugin) - hook.plugins.append(plugin) - hook.add_method(method) + elif hook.has_spec(): + self._verify_hook(hook, plugin) hook._apply_history(method) + hook.add_plugin(plugin) hookcallers.append(hook) return hookcallers - def _verify_hook(self, hook, method, plugin): + def _verify_hook(self, hook, plugin): + method = getattr(plugin, hook.name) for arg in varnames(method): if arg not in hook.argnames: pluginname = self._get_canonical_name(plugin) @@ -359,8 +340,8 @@ class PluginManager(object): for name in self.hook.__dict__: if name.startswith(self._prefix): hook = getattr(self.hook, name) - if hook.pre: - for plugin in hook.plugins: + if not hook.has_spec(): + for plugin in hook._plugins: method = getattr(plugin, hook.name) if not getattr(method, "optionalhook", False): raise PluginValidationError( @@ -448,80 +429,91 @@ class HookRelay: class HookCaller(object): - def __init__(self, name, plugins, argnames=None, firstresult=None, - historic=False): + def __init__(self, name, specmodule_or_class=None): 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 = [] + self._plugins = [] + self._wrappers = [] + self._nonwrappers = [] + if specmodule_or_class is not None: + self.setspec(specmodule_or_class) - def clone(self): + def has_spec(self): + return hasattr(self, "_specmodule_or_class") + + def clone(self, plugins=None): + assert not self.is_historic() hc = object.__new__(HookCaller) hc.name = self.name - hc.plugins = self.plugins - hc.historic = self.historic hc.argnames = self.argnames hc.firstresult = self.firstresult - hc.wrappers = list(self.wrappers) - hc.nonwrappers = list(self.nonwrappers) + if plugins is None: + hc._plugins = self._plugins + hc._wrappers = list(self._wrappers) + hc._nonwrappers = list(self._nonwrappers) + else: + hc._plugins, hc._wrappers, hc._nonwrappers = [], [], [] + for plugin in plugins: + if hasattr(plugin, hc.name): + hc.add_plugin(plugin) return hc - @property - def pre(self): - return self.argnames is None + def setspec(self, specmodule_or_class): + assert not self.has_spec() + self._specmodule_or_class = specmodule_or_class + specfunc = getattr(specmodule_or_class, self.name) + self.argnames = ["__multicall__"] + list(varnames( + specfunc, startindex=inspect.isclass(specmodule_or_class) + )) + assert "self" not in self.argnames # sanity check + self.firstresult = getattr(specfunc, 'firstresult', False) + if hasattr(specfunc, "historic"): + self._call_history = [] - 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 is_historic(self): + return hasattr(self, "_call_history") def remove_plugin(self, plugin): - self.plugins.remove(plugin) + self._plugins.remove(plugin) meth = getattr(plugin, self.name) try: - self.nonwrappers.remove(meth) + self._nonwrappers.remove(meth) except ValueError: - self.wrappers.remove(meth) + self._wrappers.remove(meth) + + def add_plugin(self, plugin): + self._plugins.append(plugin) + self.add_method(getattr(plugin, self.name)) def add_method(self, meth): - assert not self.pre if hasattr(meth, 'hookwrapper'): - assert not self.historic - self.wrappers.append(meth) + assert not self.is_historic() + self._wrappers.append(meth) elif hasattr(meth, 'trylast'): - self.nonwrappers.insert(0, meth) + self._nonwrappers.insert(0, meth) elif hasattr(meth, 'tryfirst'): - self.nonwrappers.append(meth) + self._nonwrappers.append(meth) else: - if not self.nonwrappers or not hasattr(self.nonwrappers[-1], "tryfirst"): - self.nonwrappers.append(meth) + nonwrappers = self._nonwrappers + if not nonwrappers or not hasattr(nonwrappers[-1], "tryfirst"): + nonwrappers.append(meth) else: - for i in reversed(range(len(self.nonwrappers)-1)): - if hasattr(self.nonwrappers[i], "tryfirst"): + for i in reversed(range(len(nonwrappers)-1)): + if hasattr(nonwrappers[i], "tryfirst"): continue - self.nonwrappers.insert(i+1, meth) + nonwrappers.insert(i+1, meth) break else: - self.nonwrappers.insert(0, meth) + nonwrappers.insert(0, meth) def __repr__(self): return "" %(self.name,) def __call__(self, **kwargs): - assert not self.historic - return self._docall(self.nonwrappers + self.wrappers, kwargs) + assert not self.is_historic() + return self._docall(self._nonwrappers + self._wrappers, kwargs) def callextra(self, methods, **kwargs): - assert not self.historic + assert not self.is_historic() hc = self.clone() for method in methods: hc.add_method(method) @@ -532,10 +524,10 @@ class HookCaller(object): def call_historic(self, kwargs, proc=None): self._call_history.append((kwargs, proc)) - self._docall(self.nonwrappers + self.wrappers, kwargs) + self._docall(self._nonwrappers + self._wrappers, kwargs) def _apply_history(self, method): - if hasattr(self, "_call_history"): + if self.is_historic(): for kwargs, proc in self._call_history: args = [kwargs[argname] for argname in varnames(method)] res = method(*args) diff --git a/testing/test_config.py b/testing/test_config.py index cf8a70b6f..4e416b412 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -356,7 +356,7 @@ def test_load_initial_conftest_last_ordering(testdir): m = My() pm.register(m) hc = pm.hook.pytest_load_initial_conftests - l = hc.nonwrappers + hc.wrappers + l = hc._nonwrappers + hc._wrappers assert l[-1].__module__ == "_pytest.capture" assert l[-2] == m.pytest_load_initial_conftests assert l[-3].__module__ == "_pytest.config" diff --git a/testing/test_core.py b/testing/test_core.py index 304cff0cd..d3fa0968d 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -180,7 +180,7 @@ class TestAddMethodOrdering: @addmeth() def he_method3(): pass - assert hc.nonwrappers == [he_method1, he_method2, he_method3] + assert hc._nonwrappers == [he_method1, he_method2, he_method3] def test_adding_nonwrappers_trylast(self, hc, addmeth): @addmeth() @@ -194,7 +194,7 @@ class TestAddMethodOrdering: @addmeth() def he_method1_b(): pass - assert hc.nonwrappers == [he_method1, he_method1_middle, he_method1_b] + assert hc._nonwrappers == [he_method1, he_method1_middle, he_method1_b] def test_adding_nonwrappers_trylast2(self, hc, addmeth): @addmeth() @@ -208,7 +208,7 @@ class TestAddMethodOrdering: @addmeth(trylast=True) def he_method1(): pass - assert hc.nonwrappers == [he_method1, he_method1_middle, he_method1_b] + assert hc._nonwrappers == [he_method1, he_method1_middle, he_method1_b] def test_adding_nonwrappers_tryfirst(self, hc, addmeth): @addmeth(tryfirst=True) @@ -222,7 +222,7 @@ class TestAddMethodOrdering: @addmeth() def he_method1_b(): pass - assert hc.nonwrappers == [he_method1_middle, he_method1_b, he_method1] + assert hc._nonwrappers == [he_method1_middle, he_method1_b, he_method1] def test_adding_nonwrappers_trylast(self, hc, addmeth): @addmeth() @@ -240,7 +240,7 @@ class TestAddMethodOrdering: @addmeth(trylast=True) def he_method1_d(): pass - assert hc.nonwrappers == [he_method1_d, he_method1_b, he_method1_a, he_method1_c] + assert hc._nonwrappers == [he_method1_d, he_method1_b, he_method1_a, he_method1_c] def test_adding_wrappers_ordering(self, hc, addmeth): @addmeth(hookwrapper=True) @@ -255,8 +255,8 @@ class TestAddMethodOrdering: def he_method3(): pass - assert hc.nonwrappers == [he_method1_middle] - assert hc.wrappers == [he_method1, he_method3] + assert hc._nonwrappers == [he_method1_middle] + assert hc._wrappers == [he_method1, he_method3] def test_hookspec_opts(self, pm): class HookSpec: From 4e116ed5034b48bc91bd2a6b47e259c70e8e6cc2 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sat, 25 Apr 2015 13:38:30 +0200 Subject: [PATCH 10/24] make pytest_plugin_registered a historic hook --HG-- branch : more_plugin --- _pytest/config.py | 13 +++++++------ _pytest/core.py | 37 +++++++++++++++++-------------------- _pytest/hookspec.py | 8 +++++--- _pytest/python.py | 15 ++------------- testing/test_core.py | 15 ++++++++------- 5 files changed, 39 insertions(+), 49 deletions(-) diff --git a/_pytest/config.py b/_pytest/config.py index 860f34e53..d75cb6600 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) - self.hook.pytest_plugin_registered(plugin=plugin, - manager=self) + self.hook.pytest_plugin_registered.call_historic( + kwargs=dict(plugin=plugin, manager=self)) return ret def unregister(self, plugin): @@ -707,8 +707,8 @@ class Config(object): def do_setns(dic): import pytest setns(pytest, dic) - self.hook.pytest_namespace.call_historic({}, proc=do_setns) - self.hook.pytest_addoption.call_historic(dict(parser=self._parser)) + self.hook.pytest_namespace.call_historic(do_setns, {}) + self.hook.pytest_addoption.call_historic(kwargs=dict(parser=self._parser)) def add_cleanup(self, func): """ Add a function to be called when the config object gets out of @@ -718,7 +718,7 @@ class Config(object): def _do_configure(self): assert not self._configured self._configured = True - self.hook.pytest_configure.call_historic(dict(config=self)) + self.hook.pytest_configure.call_historic(kwargs=dict(config=self)) def _ensure_unconfigure(self): if self._configured: @@ -840,7 +840,8 @@ 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.hook.pytest_addhooks.call_historic( + kwargs=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 b394913cd..af42ea914 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -2,7 +2,7 @@ PluginManager, basic initialization and tracing. """ import sys -import inspect +from inspect import isfunction, ismethod, isclass, formatargspec, getargspec import py py3 = sys.version_info > (3,0) @@ -267,7 +267,6 @@ class PluginManager(object): def addhooks(self, module_or_class): """ add new hook definitions from the given module_or_class using the prefix/excludefunc with which the PluginManager was initialized. """ - isclass = int(inspect.isclass(module_or_class)) names = [] for name in dir(module_or_class): if name.startswith(self._prefix): @@ -394,17 +393,17 @@ def varnames(func, startindex=None): return cache["_varnames"] except KeyError: pass - if inspect.isclass(func): + if isclass(func): try: func = func.__init__ except AttributeError: return () startindex = 1 else: - if not inspect.isfunction(func) and not inspect.ismethod(func): + if not isfunction(func) and not ismethod(func): func = getattr(func, '__call__', func) if startindex is None: - startindex = int(inspect.ismethod(func)) + startindex = int(ismethod(func)) rawcode = py.code.getrawcode(func) try: @@ -461,10 +460,9 @@ class HookCaller(object): assert not self.has_spec() self._specmodule_or_class = specmodule_or_class specfunc = getattr(specmodule_or_class, self.name) - self.argnames = ["__multicall__"] + list(varnames( - specfunc, startindex=inspect.isclass(specmodule_or_class) - )) - assert "self" not in self.argnames # sanity check + argnames = varnames(specfunc, startindex=isclass(specmodule_or_class)) + assert "self" not in argnames # sanity check + self.argnames = ["__multicall__"] + list(argnames) self.firstresult = getattr(specfunc, 'firstresult', False) if hasattr(specfunc, "historic"): self._call_history = [] @@ -512,27 +510,26 @@ class HookCaller(object): assert not self.is_historic() return self._docall(self._nonwrappers + self._wrappers, kwargs) - def callextra(self, methods, **kwargs): + def call_extra(self, methods, kwargs): assert not self.is_historic() hc = self.clone() for method in methods: hc.add_method(method) return hc(**kwargs) - def _docall(self, methods, kwargs): - return MultiCall(methods, kwargs, firstresult=self.firstresult).execute() - - def call_historic(self, kwargs, proc=None): - self._call_history.append((kwargs, proc)) + def call_historic(self, proc=None, kwargs=None): + self._call_history.append((kwargs or {}, proc)) self._docall(self._nonwrappers + self._wrappers, kwargs) def _apply_history(self, method): if self.is_historic(): for kwargs, proc in self._call_history: - args = [kwargs[argname] for argname in varnames(method)] - res = method(*args) - if proc is not None: - proc(res) + res = self._docall([method], kwargs) + if res and proc is not None: + proc(res[0]) + + def _docall(self, methods, kwargs): + return MultiCall(methods, kwargs, firstresult=self.firstresult).execute() class PluginValidationError(Exception): @@ -542,5 +539,5 @@ class PluginValidationError(Exception): def formatdef(func): return "%s%s" % ( func.__name__, - inspect.formatargspec(*inspect.getargspec(func)) + formatargspec(*getargspec(func)) ) diff --git a/_pytest/hookspec.py b/_pytest/hookspec.py index e230920dd..cf8947ada 100644 --- a/_pytest/hookspec.py +++ b/_pytest/hookspec.py @@ -19,6 +19,11 @@ def pytest_namespace(): time. """ +@hookspec_opts(historic=True) +def pytest_plugin_registered(plugin, manager): + """ a new pytest plugin got registered. """ + + @hookspec_opts(historic=True) def pytest_addoption(parser): """register argparse-style options and ini-style config values. @@ -259,9 +264,6 @@ def pytest_doctest_prepare_content(content): # error handling and internal debugging hooks # ------------------------------------------------------------------------- -def pytest_plugin_registered(plugin, manager): - """ a new pytest plugin got registered. """ - def pytest_internalerror(excrepr, excinfo): """ called for internal errors. """ diff --git a/_pytest/python.py b/_pytest/python.py index bc64d6fa3..e849ca6fe 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -381,7 +381,8 @@ class PyCollector(PyobjMixin, pytest.Collector): if hasattr(cls, "pytest_generate_tests"): methods.append(cls().pytest_generate_tests) if methods: - self.ihook.pytest_generate_tests.callextra(methods, metafunc=metafunc) + self.ihook.pytest_generate_tests.call_extra(methods, + dict(metafunc=metafunc)) else: self.ihook.pytest_generate_tests(metafunc=metafunc) @@ -1623,7 +1624,6 @@ class FixtureManager: self.session = session self.config = session.config self._arg2fixturedefs = {} - self._seenplugins = set() self._holderobjseen = set() self._arg2finish = {} self._nodeid_and_autousenames = [("", self.config.getini("usefixtures"))] @@ -1648,11 +1648,7 @@ class FixtureManager: node) return FuncFixtureInfo(argnames, names_closure, arg2fixturedefs) - ### XXX this hook should be called for historic events like pytest_configure - ### so that we don't have to do the below pytest_configure hook def pytest_plugin_registered(self, plugin): - if plugin in self._seenplugins: - return nodeid = None try: p = py.path.local(plugin.__file__) @@ -1667,13 +1663,6 @@ class FixtureManager: if p.sep != "/": nodeid = nodeid.replace(p.sep, "/") self.parsefactories(plugin, nodeid) - self._seenplugins.add(plugin) - - @pytest.hookimpl_opts(tryfirst=True) - def pytest_configure(self, config): - plugins = config.pluginmanager.getplugins() - for plugin in plugins: - self.pytest_plugin_registered(plugin) def _getautousenames(self, nodeid): """ return a tuple of fixture names to be used. """ diff --git a/testing/test_core.py b/testing/test_core.py index d3fa0968d..f9a1f0ea5 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -111,7 +111,7 @@ class TestPluginManager: l.append(arg*10) pm.register(Plugin2()) assert l == [1, 10] - pm.hook.he_method1.call_historic(dict(arg=12)) + pm.hook.he_method1.call_historic(kwargs=dict(arg=12)) assert l == [1, 10, 120, 12] def test_with_result_memorized(self, pm): @@ -122,7 +122,7 @@ class TestPluginManager: pm.addhooks(Hooks) he_method1 = pm.hook.he_method1 - he_method1.call_historic(proc=lambda res: l.append(res), kwargs=dict(arg=1)) + he_method1.call_historic(lambda res: l.append(res), dict(arg=1)) l = [] class Plugin: def he_method1(self, arg): @@ -140,7 +140,7 @@ class TestPluginManager: def he_method1(arg): return arg * 10 - l = pm.hook.he_method1.callextra([he_method1], arg=1) + l = pm.hook.he_method1.call_extra([he_method1], dict(arg=1)) assert l == [10] @@ -323,7 +323,8 @@ class TestPytestPluginInteractions: """) config = get_plugin_manager().config pm = config.pluginmanager - pm.hook.pytest_addhooks.call_historic(dict(pluginmanager=config.pluginmanager)) + pm.hook.pytest_addhooks.call_historic( + kwargs=dict(pluginmanager=config.pluginmanager)) config.pluginmanager._importconftest(conf) #print(config.pluginmanager.getplugins()) res = config.hook.pytest_myhook(xyz=10) @@ -399,10 +400,10 @@ class TestPytestPluginInteractions: pytestpm = get_plugin_manager() # fully initialized with plugins saveindent = [] class api1: - def pytest_plugin_registered(self, plugin): + def pytest_plugin_registered(self): saveindent.append(pytestpm.trace.root.indent) class api2: - def pytest_plugin_registered(self, plugin): + def pytest_plugin_registered(self): saveindent.append(pytestpm.trace.root.indent) raise ValueError() l = [] @@ -412,7 +413,7 @@ class TestPytestPluginInteractions: p = api1() pytestpm.register(p) assert pytestpm.trace.root.indent == indent - assert len(l) == 2 + assert len(l) >= 2 assert 'pytest_plugin_registered' in l[0] assert 'finish' in l[1] From 7364647f2f261d0bed64f56484c4b525f76b7bc6 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sat, 25 Apr 2015 18:14:39 +0200 Subject: [PATCH 11/24] fix issue732: make sure removed plugins remove all hook callers. --HG-- branch : more_plugin --- CHANGELOG | 3 ++ _pytest/core.py | 65 +++++++++++++++++++------------------------- testing/test_core.py | 21 +++++++++++++- 3 files changed, 51 insertions(+), 38 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index b3f05c63f..2d5945189 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -36,6 +36,9 @@ - write/refine docs for "writing plugins" which now have their own page and are separate from the "using/installing plugins`` page. +- fix issue732: properly unregister plugins from any hook calling + sites allowing to have temporary plugins during test execution. + 2.7.1.dev (compared to 2.7.0) ----------------------------- diff --git a/_pytest/core.py b/_pytest/core.py index af42ea914..e2db2a5bf 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -233,7 +233,14 @@ class PluginManager(object): def make_hook_caller(self, name, plugins): caller = getattr(self.hook, name) - return caller.clone(plugins=plugins) + hc = HookCaller(caller.name, caller._specmodule_or_class) + for plugin in plugins: + if hasattr(plugin, name): + hc._add_plugin(plugin) + # we also keep track of this hook caller so it + # gets properly removed on plugin unregistration + self._plugin2hookcallers.setdefault(plugin, []).append(hc) + return hc def register(self, plugin, name=None): """ Register a plugin with the given name and ensure that all its @@ -247,9 +254,8 @@ class PluginManager(object): if self.hasplugin(name): raise ValueError("Plugin already registered: %s=%s\n%s" %( name, plugin, self._name2plugin)) - #self.trace("registering", name, plugin) - self._plugin2hookcallers[plugin] = self._scan_plugin(plugin) self._name2plugin[name] = plugin + self._scan_plugin(plugin) self._plugins.append(plugin) return True @@ -260,9 +266,8 @@ class PluginManager(object): for name, value in list(self._name2plugin.items()): if value == plugin: del self._name2plugin[name] - hookcallers = self._plugin2hookcallers.pop(plugin) - for hookcaller in hookcallers: - hookcaller.remove_plugin(plugin) + for hookcaller in self._plugin2hookcallers.pop(plugin): + hookcaller._remove_plugin(plugin) def addhooks(self, module_or_class): """ add new hook definitions from the given module_or_class using @@ -276,7 +281,7 @@ class PluginManager(object): setattr(self.hook, name, hc) else: # plugins registered this hook without knowing the spec - hc.setspec(module_or_class) + hc.set_specification(module_or_class) for plugin in hc._plugins: self._verify_hook(hc, plugin) names.append(name) @@ -305,7 +310,7 @@ class PluginManager(object): return self._name2plugin.get(name) def _scan_plugin(self, plugin): - hookcallers = [] + self._plugin2hookcallers[plugin] = hookcallers = [] for name in dir(plugin): if name[0] == "_" or not name.startswith(self._prefix): continue @@ -319,9 +324,8 @@ class PluginManager(object): elif hook.has_spec(): self._verify_hook(hook, plugin) hook._apply_history(method) - hook.add_plugin(plugin) hookcallers.append(hook) - return hookcallers + hook._add_plugin(plugin) def _verify_hook(self, hook, plugin): method = getattr(plugin, hook.name) @@ -434,29 +438,12 @@ class HookCaller(object): self._wrappers = [] self._nonwrappers = [] if specmodule_or_class is not None: - self.setspec(specmodule_or_class) + self.set_specification(specmodule_or_class) def has_spec(self): return hasattr(self, "_specmodule_or_class") - def clone(self, plugins=None): - assert not self.is_historic() - hc = object.__new__(HookCaller) - hc.name = self.name - hc.argnames = self.argnames - hc.firstresult = self.firstresult - if plugins is None: - hc._plugins = self._plugins - hc._wrappers = list(self._wrappers) - hc._nonwrappers = list(self._nonwrappers) - else: - hc._plugins, hc._wrappers, hc._nonwrappers = [], [], [] - for plugin in plugins: - if hasattr(plugin, hc.name): - hc.add_plugin(plugin) - return hc - - def setspec(self, specmodule_or_class): + def set_specification(self, specmodule_or_class): assert not self.has_spec() self._specmodule_or_class = specmodule_or_class specfunc = getattr(specmodule_or_class, self.name) @@ -470,7 +457,7 @@ class HookCaller(object): def is_historic(self): return hasattr(self, "_call_history") - def remove_plugin(self, plugin): + def _remove_plugin(self, plugin): self._plugins.remove(plugin) meth = getattr(plugin, self.name) try: @@ -478,11 +465,11 @@ class HookCaller(object): except ValueError: self._wrappers.remove(meth) - def add_plugin(self, plugin): + def _add_plugin(self, plugin): self._plugins.append(plugin) - self.add_method(getattr(plugin, self.name)) + self._add_method(getattr(plugin, self.name)) - def add_method(self, meth): + def _add_method(self, meth): if hasattr(meth, 'hookwrapper'): assert not self.is_historic() self._wrappers.append(meth) @@ -511,11 +498,15 @@ class HookCaller(object): return self._docall(self._nonwrappers + self._wrappers, kwargs) def call_extra(self, methods, kwargs): - assert not self.is_historic() - hc = self.clone() + """ Call the hook with some additional temporarily participating + methods using the specified kwargs as call parameters. """ + old = list(self._nonwrappers), list(self._wrappers) for method in methods: - hc.add_method(method) - return hc(**kwargs) + self._add_method(method) + try: + return self(**kwargs) + finally: + self._nonwrappers, self._wrappers = old def call_historic(self, proc=None, kwargs=None): self._call_history.append((kwargs or {}, proc)) diff --git a/testing/test_core.py b/testing/test_core.py index f9a1f0ea5..eda1fd39c 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -143,6 +143,25 @@ class TestPluginManager: l = pm.hook.he_method1.call_extra([he_method1], dict(arg=1)) assert l == [10] + def test_make_hook_caller_unregistered(self, pm): + class Hooks: + def he_method1(self, arg): + pass + pm.addhooks(Hooks) + + l = [] + class Plugin: + def he_method1(self, arg): + l.append(arg * 10) + plugin = Plugin() + pm.register(plugin) + hc = pm.make_hook_caller("he_method1", [plugin]) + hc(arg=1) + assert l == [10] + pm.unregister(plugin) + hc(arg=2) + assert l == [10] + class TestAddMethodOrdering: @pytest.fixture @@ -163,7 +182,7 @@ class TestAddMethodOrdering: func.trylast = True if hookwrapper: func.hookwrapper = True - hc.add_method(func) + hc._add_method(func) return func return wrap return addmeth From 9c5495832cc89c6dd9b89719eb149ee379d6992b Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sat, 25 Apr 2015 18:14:41 +0200 Subject: [PATCH 12/24] avoid direct circular reference between config and pluginmanager --HG-- branch : more_plugin --- _pytest/config.py | 15 +++++++-------- testing/test_config.py | 4 ++-- testing/test_core.py | 12 ++++++------ 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/_pytest/config.py b/_pytest/config.py index d75cb6600..0604da13b 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -60,17 +60,17 @@ builtin_plugins.add("pytester") def _preloadplugins(): assert not _preinit - _preinit.append(get_plugin_manager()) + _preinit.append(get_config()) -def get_plugin_manager(): +def get_config(): if _preinit: return _preinit.pop(0) # subsequent calls to main will create a fresh instance pluginmanager = PytestPluginManager() - pluginmanager.config = Config(pluginmanager) # XXX attr needed? + config = Config(pluginmanager) for spec in default_plugins: pluginmanager.import_plugin(spec) - return pluginmanager + return config def _prepareconfig(args=None, plugins=None): if args is None: @@ -81,7 +81,7 @@ def _prepareconfig(args=None, plugins=None): if not isinstance(args, str): raise ValueError("not a string or argument list: %r" % (args,)) args = shlex.split(args) - pluginmanager = get_plugin_manager() + pluginmanager = get_config().pluginmanager if plugins: for plugin in plugins: pluginmanager.register(plugin) @@ -738,7 +738,7 @@ class Config(object): return self.pluginmanager.getplugin("terminalreporter")._tw def pytest_cmdline_parse(self, pluginmanager, args): - assert self == pluginmanager.config, (self, pluginmanager.config) + # REF1 assert self == pluginmanager.config, (self, pluginmanager.config) self.parse(args) return self @@ -768,8 +768,7 @@ class Config(object): @classmethod def fromdictargs(cls, option_dict, args): """ constructor useable for subprocesses. """ - pluginmanager = get_plugin_manager() - config = pluginmanager.config + config = get_config() config._preparse(args, addopts=False) config.option.__dict__.update(option_dict) for x in config.option.plugins: diff --git a/testing/test_config.py b/testing/test_config.py index 4e416b412..afa0617fa 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -348,8 +348,8 @@ def test_notify_exception(testdir, capfd): def test_load_initial_conftest_last_ordering(testdir): - from _pytest.config import get_plugin_manager - pm = get_plugin_manager() + from _pytest.config import get_config + pm = get_config().pluginmanager class My: def pytest_load_initial_conftests(self): pass diff --git a/testing/test_core.py b/testing/test_core.py index eda1fd39c..06394e221 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -1,6 +1,6 @@ import pytest, py, os from _pytest.core import * # noqa -from _pytest.config import get_plugin_manager +from _pytest.config import get_config @pytest.fixture @@ -41,14 +41,14 @@ class TestPluginManager: pytestpm.check_pending() def test_register_mismatch_arg(self): - pm = get_plugin_manager() + pm = get_config().pluginmanager class hello: def pytest_configure(self, asd): pass pytest.raises(Exception, lambda: pm.register(hello())) def test_register(self): - pm = get_plugin_manager() + pm = get_config().pluginmanager class MyPlugin: pass my = MyPlugin() @@ -340,7 +340,7 @@ class TestPytestPluginInteractions: def pytest_myhook(xyz): return xyz + 1 """) - config = get_plugin_manager().config + config = get_config() pm = config.pluginmanager pm.hook.pytest_addhooks.call_historic( kwargs=dict(pluginmanager=config.pluginmanager)) @@ -416,7 +416,7 @@ class TestPytestPluginInteractions: assert len(l) == 2 def test_hook_tracing(self): - pytestpm = get_plugin_manager() # fully initialized with plugins + pytestpm = get_config().pluginmanager # fully initialized with plugins saveindent = [] class api1: def pytest_plugin_registered(self): @@ -927,7 +927,7 @@ class TestPytestPluginManager: assert pytestpm.getplugin("pytest_p2").__name__ == "pytest_p2" def test_consider_module_import_module(self, testdir): - pytestpm = get_plugin_manager() + pytestpm = get_config().pluginmanager mod = py.std.types.ModuleType("x") mod.pytest_plugins = "pytest_a" aplugin = testdir.makepyfile(pytest_a="#") From 1e883f5979098299f07163def90fd2cc6a494b73 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sat, 25 Apr 2015 18:15:39 +0200 Subject: [PATCH 13/24] simplify tracing mechanics by simply going through an indirection --HG-- branch : more_plugin --- _pytest/config.py | 3 +- _pytest/core.py | 117 ++++++++++++++++++++++-------------------- _pytest/helpconfig.py | 4 +- _pytest/pytester.py | 14 ++--- testing/test_core.py | 106 +------------------------------------- 5 files changed, 74 insertions(+), 170 deletions(-) diff --git a/_pytest/config.py b/_pytest/config.py index 0604da13b..c0f1944d6 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -115,7 +115,8 @@ class PytestPluginManager(PluginManager): err = py.io.dupfile(err, encoding=encoding) except Exception: pass - self.set_tracing(err.write) + self.trace.root.setwriter(err.write) + self.enable_tracing() def register(self, plugin, name=None, conftest=False): ret = super(PytestPluginManager, self).register(plugin, name) diff --git a/_pytest/core.py b/_pytest/core.py index e2db2a5bf..f57604518 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -60,6 +60,7 @@ def hookimpl_opts(hookwrapper=False, optionalhook=False, return func return setattr_hookimpl_opts + class TagTracer: def __init__(self): self._tag2proc = {} @@ -106,6 +107,7 @@ class TagTracer: assert isinstance(tags, tuple) self._tag2proc[tags] = processor + class TagTracerSub: def __init__(self, root, tags): self.root = root @@ -118,25 +120,6 @@ class TagTracerSub: return self.__class__(self.root, self.tags + (name,)) -def add_method_wrapper(cls, wrapper_func): - """ Substitute the function named "wrapperfunc.__name__" at class - "cls" with a function that wraps the call to the original function. - Return an undo function which can be called to reset the class to use - the old method again. - - wrapper_func is called with the same arguments as the method - it wraps and its result is used as a wrap_controller for - calling the original function. - """ - name = wrapper_func.__name__ - oldcall = getattr(cls, name) - def wrap_exec(*args, **kwargs): - gen = wrapper_func(*args, **kwargs) - return wrapped_call(gen, lambda: oldcall(*args, **kwargs)) - - 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" % @@ -186,6 +169,25 @@ class CallOutcome: py.builtin._reraise(*ex) +class TracedHookExecution: + def __init__(self, pluginmanager, before, after): + self.pluginmanager = pluginmanager + self.before = before + self.after = after + self.oldcall = pluginmanager._inner_hookexec + assert not isinstance(self.oldcall, TracedHookExecution) + self.pluginmanager._inner_hookexec = self + + def __call__(self, hook, methods, kwargs): + self.before(hook, methods, kwargs) + outcome = CallOutcome(lambda: self.oldcall(hook, methods, kwargs)) + self.after(outcome, hook, methods, kwargs) + return outcome.get_result() + + def undo(self): + self.pluginmanager._inner_hookexec = self.oldcall + + class PluginManager(object): """ Core Pluginmanager class which manages registration of plugin objects and 1:N hook calling. @@ -209,31 +211,31 @@ class PluginManager(object): self._plugins = [] self._plugin2hookcallers = {} self.trace = TagTracer().get("pluginmanage") - self.hook = HookRelay(pm=self) + self.hook = HookRelay(self.trace.root.get("hook")) + self._inner_hookexec = lambda hook, methods, kwargs: \ + MultiCall(methods, kwargs, hook.firstresult).execute() - def set_tracing(self, writer): - """ turn on tracing to the given writer method and - return an undo function. """ - self.trace.root.setwriter(writer) - # reconfigure HookCalling to perform tracing - assert not hasattr(self, "_wrapping") - self._wrapping = True + def _hookexec(self, hook, methods, kwargs): + return self._inner_hookexec(hook, methods, kwargs) - hooktrace = self.hook.trace + def enable_tracing(self): + """ enable tracing of hook calls and return an undo function. """ + hooktrace = self.hook._trace - def _docall(self, methods, kwargs): + def before(hook, methods, kwargs): hooktrace.root.indent += 1 - hooktrace(self.name, kwargs) - box = yield - if box.excinfo is None: - hooktrace("finish", self.name, "-->", box.result) + hooktrace(hook.name, kwargs) + + def after(outcome, hook, methods, kwargs): + if outcome.excinfo is None: + hooktrace("finish", hook.name, "-->", outcome.result) hooktrace.root.indent -= 1 - return add_method_wrapper(HookCaller, _docall) + return TracedHookExecution(self, before, after).undo def make_hook_caller(self, name, plugins): caller = getattr(self.hook, name) - hc = HookCaller(caller.name, caller._specmodule_or_class) + hc = HookCaller(caller.name, self._hookexec, caller._specmodule_or_class) for plugin in plugins: if hasattr(plugin, name): hc._add_plugin(plugin) @@ -277,7 +279,7 @@ class PluginManager(object): if name.startswith(self._prefix): hc = getattr(self.hook, name, None) if hc is None: - hc = HookCaller(name, module_or_class) + hc = HookCaller(name, self._hookexec, module_or_class) setattr(self.hook, name, hc) else: # plugins registered this hook without knowing the spec @@ -319,7 +321,7 @@ class PluginManager(object): if hook is None: if self._excludefunc is not None and self._excludefunc(name): continue - hook = HookCaller(name) + hook = HookCaller(name, self._hookexec) setattr(self.hook, name, hook) elif hook.has_spec(): self._verify_hook(hook, plugin) @@ -362,15 +364,11 @@ class MultiCall: self.methods = methods self.kwargs = kwargs self.kwargs["__multicall__"] = self - self.results = [] self.firstresult = firstresult - def __repr__(self): - status = "%d results, %d meths" % (len(self.results), len(self.methods)) - return "" %(status, self.kwargs) - def execute(self): all_kwargs = self.kwargs + self.results = results = [] while self.methods: method = self.methods.pop() args = [all_kwargs[argname] for argname in varnames(method)] @@ -378,11 +376,18 @@ class MultiCall: return wrapped_call(method(*args), self.execute) res = method(*args) if res is not None: - self.results.append(res) if self.firstresult: return res + results.append(res) if not self.firstresult: - return self.results + return results + + def __repr__(self): + status = "%d meths" % (len(self.methods),) + if hasattr(self, "results"): + status = ("%d results, " % len(self.results)) + status + return "" %(status, self.kwargs) + def varnames(func, startindex=None): @@ -426,17 +431,17 @@ def varnames(func, startindex=None): class HookRelay: - def __init__(self, pm): - self._pm = pm - self.trace = pm.trace.root.get("hook") + def __init__(self, trace): + self._trace = trace class HookCaller(object): - def __init__(self, name, specmodule_or_class=None): + def __init__(self, name, hook_execute, specmodule_or_class=None): self.name = name self._plugins = [] self._wrappers = [] self._nonwrappers = [] + self._hookexec = hook_execute if specmodule_or_class is not None: self.set_specification(specmodule_or_class) @@ -495,7 +500,12 @@ class HookCaller(object): def __call__(self, **kwargs): assert not self.is_historic() - return self._docall(self._nonwrappers + self._wrappers, kwargs) + return self._hookexec(self, self._nonwrappers + self._wrappers, kwargs) + + def call_historic(self, proc=None, kwargs=None): + self._call_history.append((kwargs or {}, proc)) + # historizing hooks don't return results + self._hookexec(self, self._nonwrappers + self._wrappers, kwargs) def call_extra(self, methods, kwargs): """ Call the hook with some additional temporarily participating @@ -508,20 +518,13 @@ class HookCaller(object): finally: self._nonwrappers, self._wrappers = old - def call_historic(self, proc=None, kwargs=None): - self._call_history.append((kwargs or {}, proc)) - self._docall(self._nonwrappers + self._wrappers, kwargs) - def _apply_history(self, method): if self.is_historic(): for kwargs, proc in self._call_history: - res = self._docall([method], kwargs) + res = self._hookexec(self, [method], kwargs) if res and proc is not None: proc(res[0]) - def _docall(self, methods, kwargs): - return MultiCall(methods, kwargs, firstresult=self.firstresult).execute() - class PluginValidationError(Exception): """ plugin failed validation. """ diff --git a/_pytest/helpconfig.py b/_pytest/helpconfig.py index 945206312..72fae555f 100644 --- a/_pytest/helpconfig.py +++ b/_pytest/helpconfig.py @@ -34,13 +34,15 @@ def pytest_cmdline_parse(): pytest.__version__, py.__version__, ".".join(map(str, sys.version_info)), os.getcwd(), config._origargs)) - config.pluginmanager.set_tracing(debugfile.write) + config.trace.root.setwriter(debugfile.write) + undo_tracing = config.pluginmanager.enable_tracing() sys.stderr.write("writing pytestdebug information to %s\n" % path) def unset_tracing(): debugfile.close() sys.stderr.write("wrote pytestdebug information to %s\n" % debugfile.name) config.trace.root.setwriter(None) + undo_tracing() config.add_cleanup(unset_tracing) def pytest_cmdline_main(config): diff --git a/_pytest/pytester.py b/_pytest/pytester.py index 2cecf7c47..5c335c348 100644 --- a/_pytest/pytester.py +++ b/_pytest/pytester.py @@ -11,7 +11,7 @@ import subprocess import py import pytest from py.builtin import print_ -from _pytest.core import HookCaller, add_method_wrapper +from _pytest.core import HookCaller, TracedHookExecution from _pytest.main import Session, EXIT_OK @@ -79,12 +79,12 @@ class HookRecorder: self._pluginmanager = pluginmanager self.calls = [] - def _docall(hookcaller, methods, kwargs): - self.calls.append(ParsedCall(hookcaller.name, kwargs)) - yield - self._undo_wrapping = add_method_wrapper(HookCaller, _docall) - #if hasattr(pluginmanager, "config"): - # pluginmanager.add_shutdown(self._undo_wrapping) + def before(hook, method, kwargs): + self.calls.append(ParsedCall(hook.name, kwargs)) + def after(outcome, hook, method, kwargs): + pass + executor = TracedHookExecution(pluginmanager, before, after) + self._undo_wrapping = executor.undo def finish_recording(self): self._undo_wrapping() diff --git a/testing/test_core.py b/testing/test_core.py index 06394e221..8c62bd306 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -426,7 +426,8 @@ class TestPytestPluginInteractions: saveindent.append(pytestpm.trace.root.indent) raise ValueError() l = [] - undo = pytestpm.set_tracing(l.append) + pytestpm.trace.root.setwriter(l.append) + undo = pytestpm.enable_tracing() try: indent = pytestpm.trace.root.indent p = api1() @@ -788,109 +789,6 @@ def test_importplugin_issue375(testdir, pytestpm): assert "qwe" not in str(excinfo.value) assert "aaaa" in str(excinfo.value) -class TestWrapMethod: - def test_basic_hapmypath(self): - class A: - def f(self): - return "A.f" - - l = [] - def f(self): - l.append(1) - box = yield - assert box.result == "A.f" - l.append(2) - undo = add_method_wrapper(A, f) - - assert A().f() == "A.f" - assert l == [1,2] - undo() - l[:] = [] - 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): - raise ValueError(val) - - l = [] - def error(self, val): - l.append(val) - yield - l.append(None) - - undo = add_method_wrapper(A, error) - - with pytest.raises(ValueError): - A().error(42) - assert l == [42, None] - undo() - l[:] = [] - with pytest.raises(ValueError): - A().error(42) - assert l == [] - - def test_controller_swallows_method_raises(self): - class A: - def error(self, val): - raise ValueError(val) - - def error(self, val): - box = yield - box.force_result(2) - - add_method_wrapper(A, error) - assert A().error(42) == 2 - - def test_reraise_on_controller_StopIteration(self): - class A: - def error(self, val): - raise ValueError(val) - - def error(self, val): - try: - yield - except ValueError: - pass - - add_method_wrapper(A, error) - with pytest.raises(ValueError): - A().error(42) - - @pytest.mark.xfail(reason="if needed later") - def test_modify_call_args(self): - class A: - def error(self, val1, val2): - raise ValueError(val1+val2) - - l = [] - def error(self): - box = yield (1,), {'val2': 2} - assert box.excinfo[1].args == (3,) - l.append(1) - - add_method_wrapper(A, error) - with pytest.raises(ValueError): - A().error() - assert l == [1] - ### to be shifted to own test file from _pytest.config import PytestPluginManager From 1c0582eaa7b8ca6a028fafac2c062a7872c07967 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sat, 25 Apr 2015 18:15:42 +0200 Subject: [PATCH 14/24] simplify addition of method and scanning of plugins --HG-- branch : more_plugin --- _pytest/core.py | 68 ++++++++++++++++++++++---------------------- testing/test_core.py | 16 +++++++++++ 2 files changed, 50 insertions(+), 34 deletions(-) diff --git a/_pytest/core.py b/_pytest/core.py index f57604518..36f74e508 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -257,8 +257,23 @@ class PluginManager(object): raise ValueError("Plugin already registered: %s=%s\n%s" %( name, plugin, self._name2plugin)) self._name2plugin[name] = plugin - self._scan_plugin(plugin) self._plugins.append(plugin) + + # register prefix-matching hooks of the plugin + self._plugin2hookcallers[plugin] = hookcallers = [] + for name in dir(plugin): + if name.startswith(self._prefix): + hook = getattr(self.hook, name, None) + if hook is None: + if self._excludefunc is not None and self._excludefunc(name): + continue + hook = HookCaller(name, self._hookexec) + setattr(self.hook, name, hook) + elif hook.has_spec(): + self._verify_hook(hook, plugin) + hook._maybe_apply_history(getattr(plugin, name)) + hookcallers.append(hook) + hook._add_plugin(plugin) return True def unregister(self, plugin): @@ -311,29 +326,16 @@ class PluginManager(object): """ Return a plugin or None for the given name. """ return self._name2plugin.get(name) - def _scan_plugin(self, plugin): - self._plugin2hookcallers[plugin] = hookcallers = [] - for name in dir(plugin): - if name[0] == "_" or not name.startswith(self._prefix): - continue - hook = getattr(self.hook, name, None) - method = getattr(plugin, name) - if hook is None: - if self._excludefunc is not None and self._excludefunc(name): - continue - hook = HookCaller(name, self._hookexec) - setattr(self.hook, name, hook) - elif hook.has_spec(): - self._verify_hook(hook, plugin) - hook._apply_history(method) - hookcallers.append(hook) - hook._add_plugin(plugin) - def _verify_hook(self, hook, plugin): method = getattr(plugin, hook.name) + pluginname = self._get_canonical_name(plugin) + if hook.is_historic() and hasattr(method, "hookwrapper"): + raise PluginValidationError( + "Plugin %r\nhook %r\nhistoric incompatible to hookwrapper" %( + pluginname, hook.name)) + for arg in varnames(method): if arg not in hook.argnames: - pluginname = self._get_canonical_name(plugin) raise PluginValidationError( "Plugin %r\nhook %r\nargument %r not available\n" "plugin definition: %s\n" @@ -369,6 +371,8 @@ class MultiCall: def execute(self): all_kwargs = self.kwargs self.results = results = [] + firstresult = self.firstresult + while self.methods: method = self.methods.pop() args = [all_kwargs[argname] for argname in varnames(method)] @@ -376,10 +380,11 @@ class MultiCall: return wrapped_call(method(*args), self.execute) res = method(*args) if res is not None: - if self.firstresult: + if firstresult: return res results.append(res) - if not self.firstresult: + + if not firstresult: return results def __repr__(self): @@ -476,24 +481,19 @@ class HookCaller(object): def _add_method(self, meth): if hasattr(meth, 'hookwrapper'): - assert not self.is_historic() self._wrappers.append(meth) elif hasattr(meth, 'trylast'): self._nonwrappers.insert(0, meth) elif hasattr(meth, 'tryfirst'): self._nonwrappers.append(meth) else: + # find the last nonwrapper which is not tryfirst marked nonwrappers = self._nonwrappers - if not nonwrappers or not hasattr(nonwrappers[-1], "tryfirst"): - nonwrappers.append(meth) - else: - for i in reversed(range(len(nonwrappers)-1)): - if hasattr(nonwrappers[i], "tryfirst"): - continue - nonwrappers.insert(i+1, meth) - break - else: - nonwrappers.insert(0, meth) + i = len(nonwrappers) - 1 + while i >= 0 and hasattr(nonwrappers[i], "tryfirst"): + i -= 1 + # and insert right in front of the tryfirst ones + nonwrappers.insert(i+1, meth) def __repr__(self): return "" %(self.name,) @@ -518,7 +518,7 @@ class HookCaller(object): finally: self._nonwrappers, self._wrappers = old - def _apply_history(self, method): + def _maybe_apply_history(self, method): if self.is_historic(): for kwargs, proc in self._call_history: res = self._hookexec(self, [method], kwargs) diff --git a/testing/test_core.py b/testing/test_core.py index 8c62bd306..0889eb63f 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -131,6 +131,22 @@ class TestPluginManager: pm.register(Plugin()) assert l == [10] + def test_register_historic_incompat_hookwrapper(self, pm): + class Hooks: + @hookspec_opts(historic=True) + def he_method1(self, arg): + pass + pm.addhooks(Hooks) + + l = [] + class Plugin: + @hookimpl_opts(hookwrapper=True) + def he_method1(self, arg): + l.append(arg) + + with pytest.raises(PluginValidationError): + pm.register(Plugin()) + def test_call_extra(self, pm): class Hooks: def he_method1(self, arg): From 3a1374e69ce83597b4a17b832812a1c35ae9e272 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sat, 25 Apr 2015 20:17:32 +0200 Subject: [PATCH 15/24] simplify plugins bookkeeping further, refine API --HG-- branch : more_plugin --- _pytest/config.py | 28 ++++---- _pytest/core.py | 103 ++++++++++++++++------------- _pytest/pytester.py | 2 +- testing/test_config.py | 2 +- testing/test_core.py | 138 ++++++++++++++++++++------------------- testing/test_terminal.py | 2 +- 6 files changed, 144 insertions(+), 131 deletions(-) diff --git a/_pytest/config.py b/_pytest/config.py index c0f1944d6..fcb045644 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -127,13 +127,17 @@ class PytestPluginManager(PluginManager): kwargs=dict(plugin=plugin, manager=self)) return ret - def unregister(self, plugin): - super(PytestPluginManager, self).unregister(plugin) + def unregister(self, plugin=None, name=None): + plugin = super(PytestPluginManager, self).unregister(plugin, name) try: self._globalplugins.remove(plugin) except ValueError: pass + def getplugin(self, name): + # deprecated + return self.get_plugin(name) + def pytest_configure(self, config): config.addinivalue_line("markers", "tryfirst: mark a hook implementation function such that the " @@ -238,17 +242,14 @@ class PytestPluginManager(PluginManager): except ImportError: return # XXX issue a warning for ep in iter_entry_points('pytest11'): - name = ep.name - if name.startswith("pytest_"): - name = name[7:] - if ep.name in self._name2plugin or name in self._name2plugin: + if self.get_plugin(ep.name) or ep.name in self._name2plugin: continue try: plugin = ep.load() except DistributionNotFound: continue + self.register(plugin, name=ep.name) self._plugin_distinfo.append((ep.dist, plugin)) - self.register(plugin, name=name) def consider_preparse(self, args): for opt1,opt2 in zip(args, args[1:]): @@ -257,14 +258,9 @@ class PytestPluginManager(PluginManager): def consider_pluginarg(self, arg): if arg.startswith("no:"): - name = arg[3:] - plugin = self.getplugin(name) - if plugin is not None: - self.unregister(plugin) - self._name2plugin[name] = -1 + self.set_blocked(arg[3:]) else: - if self.getplugin(arg) is None: - self.import_plugin(arg) + self.import_plugin(arg) def consider_conftest(self, conftestmodule): if self.register(conftestmodule, name=conftestmodule.__file__, @@ -290,7 +286,7 @@ class PytestPluginManager(PluginManager): # basename for historic purposes but must be imported with the # _pytest prefix. assert isinstance(modname, str) - if self.getplugin(modname) is not None: + if self.get_plugin(modname) is not None: return if modname in builtin_plugins: importspec = "_pytest." + modname @@ -736,7 +732,7 @@ class Config(object): fslocation=None, nodeid=None) def get_terminal_writer(self): - return self.pluginmanager.getplugin("terminalreporter")._tw + return self.pluginmanager.get_plugin("terminalreporter")._tw def pytest_cmdline_parse(self, pluginmanager, args): # REF1 assert self == pluginmanager.config, (self, pluginmanager.config) diff --git a/_pytest/core.py b/_pytest/core.py index 36f74e508..99c36de2d 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -112,10 +112,13 @@ class TagTracerSub: def __init__(self, root, tags): self.root = root self.tags = tags + def __call__(self, *args): self.root.processmessage(self.tags, args) + def setmyprocessor(self, processor): self.root.setprocessor(self.tags, processor) + def get(self, name): return self.__class__(self.root, self.tags + (name,)) @@ -125,6 +128,7 @@ def raise_wrapfail(wrap_controller, msg): 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 which needs to yield exactly once. The yield point will trigger calling the wrapped function @@ -208,7 +212,6 @@ class PluginManager(object): self._prefix = prefix self._excludefunc = excludefunc self._name2plugin = {} - self._plugins = [] self._plugin2hookcallers = {} self.trace = TagTracer().get("pluginmanage") self.hook = HookRelay(self.trace.root.get("hook")) @@ -244,22 +247,25 @@ class PluginManager(object): self._plugin2hookcallers.setdefault(plugin, []).append(hc) return hc - def register(self, plugin, name=None): - """ Register a plugin with the given name and ensure that all its - hook implementations are integrated. If the name is not specified - we use the ``__name__`` attribute of the plugin object or, if that - doesn't exist, the id of the plugin. This method will raise a - ValueError if the eventual name is already registered. """ - name = name or self._get_canonical_name(plugin) - if self._name2plugin.get(name, None) == -1: - return - if self.hasplugin(name): - raise ValueError("Plugin already registered: %s=%s\n%s" %( - name, plugin, self._name2plugin)) - self._name2plugin[name] = plugin - self._plugins.append(plugin) + def get_canonical_name(self, plugin): + """ Return canonical name for the plugin object. """ + return getattr(plugin, "__name__", None) or str(id(plugin)) - # register prefix-matching hooks of the plugin + def register(self, plugin, name=None): + """ Register a plugin and return its canonical name or None if it was + blocked from registering. Raise a ValueError if the plugin is already + registered. """ + plugin_name = name or self.get_canonical_name(plugin) + + if plugin_name in self._name2plugin or plugin in self._plugin2hookcallers: + if self._name2plugin.get(plugin_name, -1) is None: + return # blocked plugin, return None to indicate no registration + raise ValueError("Plugin already registered: %s=%s\n%s" %( + plugin_name, plugin, self._name2plugin)) + + self._name2plugin[plugin_name] = plugin + + # register prefix-matching hook specs of the plugin self._plugin2hookcallers[plugin] = hookcallers = [] for name in dir(plugin): if name.startswith(self._prefix): @@ -274,18 +280,33 @@ class PluginManager(object): hook._maybe_apply_history(getattr(plugin, name)) hookcallers.append(hook) hook._add_plugin(plugin) - return True + return plugin_name - def unregister(self, plugin): - """ unregister the plugin object and all its contained hook implementations - from internal data structures. """ - self._plugins.remove(plugin) - for name, value in list(self._name2plugin.items()): - if value == plugin: - del self._name2plugin[name] - for hookcaller in self._plugin2hookcallers.pop(plugin): + def unregister(self, plugin=None, name=None): + """ unregister a plugin object and all its contained hook implementations + from internal data structures. One of ``plugin`` or ``name`` needs to + be specified. """ + if name is None: + assert plugin is not None + name = self.get_canonical_name(plugin) + + if plugin is None: + plugin = self.get_plugin(name) + + # None signals blocked registrations, don't delete it + if self._name2plugin.get(name): + del self._name2plugin[name] + + for hookcaller in self._plugin2hookcallers.pop(plugin, []): hookcaller._remove_plugin(plugin) + return plugin + + def set_blocked(self, name): + """ block registrations of the given name, unregister if already registered. """ + self.unregister(name=name) + self._name2plugin[name] = None + def addhooks(self, module_or_class): """ add new hook definitions from the given module_or_class using the prefix/excludefunc with which the PluginManager was initialized. """ @@ -302,33 +323,27 @@ class PluginManager(object): for plugin in hc._plugins: self._verify_hook(hc, plugin) names.append(name) + if not names: raise ValueError("did not find new %r hooks in %r" %(self._prefix, module_or_class)) - def getplugins(self): - """ return the complete list of registered plugins. NOTE that - you will get the internal list and need to make a copy if you - modify the list.""" - return self._plugins + def get_plugins(self): + """ return the set of registered plugins. """ + return set(self._plugin2hookcallers) - def isregistered(self, plugin): - """ Return True if the plugin is already registered under its - canonical name. """ - return self.hasplugin(self._get_canonical_name(plugin)) or \ - plugin in self._plugins + def is_registered(self, plugin): + """ Return True if the plugin is already registered. """ + return plugin in self._plugin2hookcallers - def hasplugin(self, name): - """ Return True if there is a registered with the given name. """ - return name in self._name2plugin - - def getplugin(self, name): + def get_plugin(self, name): """ Return a plugin or None for the given name. """ return self._name2plugin.get(name) def _verify_hook(self, hook, plugin): method = getattr(plugin, hook.name) - pluginname = self._get_canonical_name(plugin) + pluginname = self.get_canonical_name(plugin) + if hook.is_historic() and hasattr(method, "hookwrapper"): raise PluginValidationError( "Plugin %r\nhook %r\nhistoric incompatible to hookwrapper" %( @@ -344,6 +359,8 @@ class PluginManager(object): ", ".join(hook.argnames))) def check_pending(self): + """ Verify that all hooks which have not been verified against + a hook specification are optional, otherwise raise PluginValidationError""" for name in self.hook.__dict__: if name.startswith(self._prefix): hook = getattr(self.hook, name) @@ -354,10 +371,6 @@ class PluginManager(object): raise PluginValidationError( "unknown hook %r in plugin %r" %(name, plugin)) - def _get_canonical_name(self, plugin): - return getattr(plugin, "__name__", None) or str(id(plugin)) - - class MultiCall: """ execute a call into multiple python functions/methods. """ diff --git a/_pytest/pytester.py b/_pytest/pytester.py index 5c335c348..9bb040691 100644 --- a/_pytest/pytester.py +++ b/_pytest/pytester.py @@ -11,7 +11,7 @@ import subprocess import py import pytest from py.builtin import print_ -from _pytest.core import HookCaller, TracedHookExecution +from _pytest.core import TracedHookExecution from _pytest.main import Session, EXIT_OK diff --git a/testing/test_config.py b/testing/test_config.py index afa0617fa..8ea23e97b 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -313,7 +313,7 @@ def test_plugin_preparse_prevents_setuptools_loading(testdir, monkeypatch): monkeypatch.setattr(pkg_resources, 'iter_entry_points', my_iter) config = testdir.parseconfig("-p", "no:mytestplugin") plugin = config.pluginmanager.getplugin("mytestplugin") - assert plugin == -1 + assert plugin is None def test_cmdline_processargs_simple(testdir): testdir.makeconftest(""" diff --git a/testing/test_core.py b/testing/test_core.py index 0889eb63f..d369c3d1c 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -17,20 +17,34 @@ class TestPluginManager: pm.register(42, name="abc") with pytest.raises(ValueError): pm.register(42, name="abc") + with pytest.raises(ValueError): + pm.register(42, name="def") def test_pm(self, pm): class A: pass a1, a2 = A(), A() pm.register(a1) - assert pm.isregistered(a1) + assert pm.is_registered(a1) pm.register(a2, "hello") - assert pm.isregistered(a2) - l = pm.getplugins() + assert pm.is_registered(a2) + l = pm.get_plugins() assert a1 in l assert a2 in l - assert pm.getplugin('hello') == a2 - pm.unregister(a1) - assert not pm.isregistered(a1) + assert pm.get_plugin('hello') == a2 + assert pm.unregister(a1) == a1 + assert not pm.is_registered(a1) + + def test_set_blocked(self, pm): + class A: pass + a1 = A() + name = pm.register(a1) + assert pm.is_registered(a1) + pm.set_blocked(name) + assert not pm.is_registered(a1) + + pm.set_blocked("somename") + assert not pm.register(A(), "somename") + pm.unregister(name="somename") def test_register_mismatch_method(self, pytestpm): class hello: @@ -53,29 +67,16 @@ class TestPluginManager: pass my = MyPlugin() pm.register(my) - assert pm.getplugins() + assert pm.get_plugins() my2 = MyPlugin() pm.register(my2) - assert pm.getplugins()[-2:] == [my, my2] + assert set([my,my2]).issubset(pm.get_plugins()) - assert pm.isregistered(my) - assert pm.isregistered(my2) + assert pm.is_registered(my) + assert pm.is_registered(my2) pm.unregister(my) - assert not pm.isregistered(my) - assert pm.getplugins()[-1:] == [my2] - - 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] + assert not pm.is_registered(my) + assert my not in pm.get_plugins() def test_register_unknown_hooks(self, pm): class Plugin1: @@ -231,6 +232,26 @@ class TestAddMethodOrdering: pass assert hc._nonwrappers == [he_method1, he_method1_middle, he_method1_b] + def test_adding_nonwrappers_trylast3(self, hc, addmeth): + @addmeth() + def he_method1_a(): + pass + + @addmeth(trylast=True) + def he_method1_b(): + pass + + @addmeth() + def he_method1_c(): + pass + + @addmeth(trylast=True) + def he_method1_d(): + pass + assert hc._nonwrappers == [he_method1_d, he_method1_b, + he_method1_a, he_method1_c] + + def test_adding_nonwrappers_trylast2(self, hc, addmeth): @addmeth() def he_method1_middle(): @@ -259,24 +280,6 @@ class TestAddMethodOrdering: pass assert hc._nonwrappers == [he_method1_middle, he_method1_b, he_method1] - def test_adding_nonwrappers_trylast(self, hc, addmeth): - @addmeth() - def he_method1_a(): - pass - - @addmeth(trylast=True) - def he_method1_b(): - pass - - @addmeth() - def he_method1_c(): - pass - - @addmeth(trylast=True) - def he_method1_d(): - pass - assert hc._nonwrappers == [he_method1_d, he_method1_b, he_method1_a, he_method1_c] - def test_adding_wrappers_ordering(self, hc, addmeth): @addmeth(hookwrapper=True) def he_method1(): @@ -361,7 +364,7 @@ class TestPytestPluginInteractions: pm.hook.pytest_addhooks.call_historic( kwargs=dict(pluginmanager=config.pluginmanager)) config.pluginmanager._importconftest(conf) - #print(config.pluginmanager.getplugins()) + #print(config.pluginmanager.get_plugins()) res = config.hook.pytest_myhook(xyz=10) assert res == [11] @@ -814,21 +817,21 @@ class TestPytestPluginManager: pm = PytestPluginManager() mod = py.std.types.ModuleType("x.y.pytest_hello") pm.register(mod) - assert pm.isregistered(mod) - l = pm.getplugins() + assert pm.is_registered(mod) + l = pm.get_plugins() assert mod in l pytest.raises(ValueError, "pm.register(mod)") pytest.raises(ValueError, lambda: pm.register(mod)) - #assert not pm.isregistered(mod2) - assert pm.getplugins() == l + #assert not pm.is_registered(mod2) + assert pm.get_plugins() == l def test_canonical_import(self, monkeypatch): mod = py.std.types.ModuleType("pytest_xyz") monkeypatch.setitem(py.std.sys.modules, 'pytest_xyz', mod) pm = PytestPluginManager() pm.import_plugin('pytest_xyz') - assert pm.getplugin('pytest_xyz') == mod - assert pm.isregistered(mod) + assert pm.get_plugin('pytest_xyz') == mod + assert pm.is_registered(mod) def test_consider_module(self, testdir, pytestpm): testdir.syspathinsert() @@ -837,8 +840,8 @@ class TestPytestPluginManager: mod = py.std.types.ModuleType("temp") mod.pytest_plugins = ["pytest_p1", "pytest_p2"] pytestpm.consider_module(mod) - assert pytestpm.getplugin("pytest_p1").__name__ == "pytest_p1" - assert pytestpm.getplugin("pytest_p2").__name__ == "pytest_p2" + assert pytestpm.get_plugin("pytest_p1").__name__ == "pytest_p1" + assert pytestpm.get_plugin("pytest_p2").__name__ == "pytest_p2" def test_consider_module_import_module(self, testdir): pytestpm = get_config().pluginmanager @@ -880,13 +883,13 @@ class TestPytestPluginManager: testdir.syspathinsert() testdir.makepyfile(xy123="#") monkeypatch.setitem(os.environ, 'PYTEST_PLUGINS', 'xy123') - l1 = len(pytestpm.getplugins()) + l1 = len(pytestpm.get_plugins()) pytestpm.consider_env() - l2 = len(pytestpm.getplugins()) + l2 = len(pytestpm.get_plugins()) assert l2 == l1 + 1 - assert pytestpm.getplugin('xy123') + assert pytestpm.get_plugin('xy123') pytestpm.consider_env() - l3 = len(pytestpm.getplugins()) + l3 = len(pytestpm.get_plugins()) assert l2 == l3 def test_consider_setuptools_instantiation(self, monkeypatch, pytestpm): @@ -904,7 +907,7 @@ class TestPytestPluginManager: monkeypatch.setattr(pkg_resources, 'iter_entry_points', my_iter) pytestpm.consider_setuptools_entrypoints() - plugin = pytestpm.getplugin("mytestplugin") + plugin = pytestpm.get_plugin("pytest_mytestplugin") assert plugin.x == 42 def test_consider_setuptools_not_installed(self, monkeypatch, pytestpm): @@ -918,7 +921,7 @@ class TestPytestPluginManager: p = testdir.makepyfile(""" import pytest def test_hello(pytestconfig): - plugin = pytestconfig.pluginmanager.getplugin('pytest_x500') + plugin = pytestconfig.pluginmanager.get_plugin('pytest_x500') assert plugin is not None """) monkeypatch.setenv('PYTEST_PLUGINS', 'pytest_x500', prepend=",") @@ -934,13 +937,13 @@ class TestPytestPluginManager: pluginname = "pytest_hello" testdir.makepyfile(**{pluginname: ""}) pytestpm.import_plugin("pytest_hello") - len1 = len(pytestpm.getplugins()) + len1 = len(pytestpm.get_plugins()) pytestpm.import_plugin("pytest_hello") - len2 = len(pytestpm.getplugins()) + len2 = len(pytestpm.get_plugins()) assert len1 == len2 - plugin1 = pytestpm.getplugin("pytest_hello") + plugin1 = pytestpm.get_plugin("pytest_hello") assert plugin1.__name__.endswith('pytest_hello') - plugin2 = pytestpm.getplugin("pytest_hello") + plugin2 = pytestpm.get_plugin("pytest_hello") assert plugin2 is plugin1 def test_import_plugin_dotted_name(self, testdir, pytestpm): @@ -951,7 +954,7 @@ class TestPytestPluginManager: testdir.mkpydir("pkg").join("plug.py").write("x=3") pluginname = "pkg.plug" pytestpm.import_plugin(pluginname) - mod = pytestpm.getplugin("pkg.plug") + mod = pytestpm.get_plugin("pkg.plug") assert mod.x == 3 def test_consider_conftest_deps(self, testdir, pytestpm): @@ -967,15 +970,16 @@ class TestPytestPluginManagerBootstrapming: def test_plugin_prevent_register(self, pytestpm): pytestpm.consider_preparse(["xyz", "-p", "no:abc"]) - l1 = pytestpm.getplugins() + l1 = pytestpm.get_plugins() pytestpm.register(42, name="abc") - l2 = pytestpm.getplugins() + l2 = pytestpm.get_plugins() assert len(l2) == len(l1) + assert 42 not in l2 def test_plugin_prevent_register_unregistered_alredy_registered(self, pytestpm): pytestpm.register(42, name="abc") - l1 = pytestpm.getplugins() + l1 = pytestpm.get_plugins() assert 42 in l1 pytestpm.consider_preparse(["xyz", "-p", "no:abc"]) - l2 = pytestpm.getplugins() + l2 = pytestpm.get_plugins() assert 42 not in l2 diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 1ad1a569f..8e273e147 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -457,7 +457,7 @@ class TestTerminalFunctional: ]) assert result.ret == 1 - if not pytestconfig.pluginmanager.hasplugin("xdist"): + if not pytestconfig.pluginmanager.get_plugin("xdist"): pytest.skip("xdist plugin not installed") result = testdir.runpytest(p1, '-v', '-n 1') From a042c572272f0a1a199e11ce2a9249e8989443d7 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sat, 25 Apr 2015 20:42:41 +0200 Subject: [PATCH 16/24] ensure proper get_name references --HG-- branch : more_plugin --- _pytest/core.py | 16 +++++++++++++--- testing/test_core.py | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/_pytest/core.py b/_pytest/core.py index 99c36de2d..2a2ba1b7d 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -237,6 +237,10 @@ class PluginManager(object): return TracedHookExecution(self, before, after).undo def make_hook_caller(self, name, plugins): + """ Return a new HookCaller instance which manages calls to + all methods named "name" in the plugins. The new hook caller + is registered internally such that when one of the plugins gets + unregistered, its method will be removed from the hook caller. """ caller = getattr(self.hook, name) hc = HookCaller(caller.name, self._hookexec, caller._specmodule_or_class) for plugin in plugins: @@ -248,7 +252,7 @@ class PluginManager(object): return hc def get_canonical_name(self, plugin): - """ Return canonical name for the plugin object. """ + """ Return canonical name for a plugin object. """ return getattr(plugin, "__name__", None) or str(id(plugin)) def register(self, plugin, name=None): @@ -288,7 +292,7 @@ class PluginManager(object): be specified. """ if name is None: assert plugin is not None - name = self.get_canonical_name(plugin) + name = self.get_name(plugin) if plugin is None: plugin = self.get_plugin(name) @@ -340,9 +344,15 @@ class PluginManager(object): """ Return a plugin or None for the given name. """ return self._name2plugin.get(name) + def get_name(self, plugin): + """ Return name for registered plugin or None if not registered. """ + for name, val in self._name2plugin.items(): + if plugin == val: + return name + def _verify_hook(self, hook, plugin): method = getattr(plugin, hook.name) - pluginname = self.get_canonical_name(plugin) + pluginname = self.get_name(plugin) if hook.is_historic() and hasattr(method, "hookwrapper"): raise PluginValidationError( diff --git a/testing/test_core.py b/testing/test_core.py index d369c3d1c..5fef02f74 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -34,6 +34,22 @@ class TestPluginManager: assert pm.unregister(a1) == a1 assert not pm.is_registered(a1) + def test_pm_name(self, pm): + class A: pass + a1 = A() + name = pm.register(a1, name="hello") + assert name == "hello" + pm.unregister(a1) + assert pm.get_plugin(a1) is None + assert not pm.is_registered(a1) + assert not pm.get_plugins() + name2 = pm.register(a1, name="hello") + assert name2 == name + pm.unregister(name="hello") + assert pm.get_plugin(a1) is None + assert not pm.is_registered(a1) + assert not pm.get_plugins() + def test_set_blocked(self, pm): class A: pass a1 = A() From d422247433265a22d836b2616433edba7c373898 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sat, 25 Apr 2015 22:13:42 +0200 Subject: [PATCH 17/24] specialize make_hook_caller to work with a subset of the registered plugins. --HG-- branch : more_plugin --- _pytest/config.py | 2 ++ _pytest/core.py | 15 +++++++++++++++ _pytest/main.py | 3 --- testing/test_core.py | 38 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 3 deletions(-) diff --git a/_pytest/config.py b/_pytest/config.py index fcb045644..ec34e7954 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -197,6 +197,7 @@ class PytestPluginManager(PluginManager): if conftestpath.check(file=1): mod = self._importconftest(conftestpath) clist.append(mod) + self._path2confmods[path] = clist return clist @@ -220,6 +221,7 @@ class PytestPluginManager(PluginManager): mod = conftestpath.pyimport() except Exception: raise ConftestImportFailure(conftestpath, sys.exc_info()) + self._conftestpath2mod[conftestpath] = mod dirpath = conftestpath.dirpath() if dirpath in self._path2confmods: diff --git a/_pytest/core.py b/_pytest/core.py index 2a2ba1b7d..acc6183e5 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -236,6 +236,21 @@ class PluginManager(object): return TracedHookExecution(self, before, after).undo + def subset_hook_caller(self, name, remove_plugins): + """ Return a new HookCaller instance which manages calls to + the plugins but without hooks from remove_plugins taking part. """ + hc = getattr(self.hook, name) + plugins_to_remove = [plugin for plugin in remove_plugins + if hasattr(plugin, name)] + if plugins_to_remove: + hc = hc.clone() + for plugin in plugins_to_remove: + hc._remove_plugin(plugin) + # we also keep track of this hook caller so it + # gets properly removed on plugin unregistration + self._plugin2hookcallers.setdefault(plugin, []).append(hc) + return hc + def make_hook_caller(self, name, plugins): """ Return a new HookCaller instance which manages calls to all methods named "name" in the plugins. The new hook caller diff --git a/_pytest/main.py b/_pytest/main.py index 4545aa6f6..38774c50d 100644 --- a/_pytest/main.py +++ b/_pytest/main.py @@ -362,9 +362,6 @@ class Node(object): def listnames(self): return [x.name for x in self.listchain()] - def getplugins(self): - return self.config._getmatchingplugins(self.fspath) - def addfinalizer(self, fin): """ register a function to be called when this node is finalized. diff --git a/testing/test_core.py b/testing/test_core.py index 5fef02f74..295df0d2e 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -195,6 +195,44 @@ class TestPluginManager: hc(arg=2) assert l == [10] + def test_subset_hook_caller(self, pm): + class Hooks: + def he_method1(self, arg): + pass + pm.addhooks(Hooks) + + l = [] + class Plugin1: + def he_method1(self, arg): + l.append(arg) + class Plugin2: + def he_method1(self, arg): + l.append(arg*10) + class PluginNo: + pass + + plugin1, plugin2, plugin3 = Plugin1(), Plugin2(), PluginNo() + pm.register(plugin1) + pm.register(plugin2) + pm.register(plugin3) + pm.hook.he_method1(arg=1) + assert l == [10, 1] + l[:] = [] + + hc = pm.subset_hook_caller("he_method1", [plugin1]) + hc(arg=2) + assert l == [20] + l[:] = [] + + hc = pm.subset_hook_caller("he_method1", [plugin2]) + hc(arg=2) + assert l == [2] + l[:] = [] + + pm.unregister(plugin1) + hc(arg=2) + assert l == [] + class TestAddMethodOrdering: @pytest.fixture From 32165d82b1cd93c02f968b3d72f040e4f1b3b0bd Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sun, 26 Apr 2015 00:10:52 +0200 Subject: [PATCH 18/24] introduce a new subset_hook_caller instead of remove make_hook_caller and adapat and refine conftest/global plugin management accordingly --HG-- branch : more_plugin --- _pytest/config.py | 40 +++++++++++++++------------------- _pytest/core.py | 52 +++++++++++++++++++++++--------------------- _pytest/main.py | 27 ++++++++++++++++------- testing/test_core.py | 24 +++++--------------- 4 files changed, 68 insertions(+), 75 deletions(-) diff --git a/_pytest/config.py b/_pytest/config.py index ec34e7954..ff05f3184 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -99,7 +99,7 @@ class PytestPluginManager(PluginManager): excludefunc=exclude_pytest_names) self._warnings = [] self._plugin_distinfo = [] - self._globalplugins = [] + self._conftest_plugins = set() # state related to local conftest plugins self._path2confmods = {} @@ -121,21 +121,12 @@ class PytestPluginManager(PluginManager): def register(self, plugin, name=None, conftest=False): ret = super(PytestPluginManager, self).register(plugin, name) if ret: - if not conftest: - self._globalplugins.append(plugin) self.hook.pytest_plugin_registered.call_historic( kwargs=dict(plugin=plugin, manager=self)) return ret - def unregister(self, plugin=None, name=None): - plugin = super(PytestPluginManager, self).unregister(plugin, name) - try: - self._globalplugins.remove(plugin) - except ValueError: - pass - def getplugin(self, name): - # deprecated + # deprecated naming return self.get_plugin(name) def pytest_configure(self, config): @@ -189,14 +180,20 @@ class PytestPluginManager(PluginManager): try: return self._path2confmods[path] except KeyError: - clist = [] - for parent in path.parts(): - if self._confcutdir and self._confcutdir.relto(parent): - continue - conftestpath = parent.join("conftest.py") - if conftestpath.check(file=1): - mod = self._importconftest(conftestpath) - clist.append(mod) + if path.isfile(): + clist = self._getconftestmodules(path.dirpath()) + else: + # XXX these days we may rather want to use config.rootdir + # and allow users to opt into looking into the rootdir parent + # directories instead of requiring to specify confcutdir + clist = [] + for parent in path.parts(): + if self._confcutdir and self._confcutdir.relto(parent): + continue + conftestpath = parent.join("conftest.py") + if conftestpath.isfile(): + mod = self._importconftest(conftestpath) + clist.append(mod) self._path2confmods[path] = clist return clist @@ -222,6 +219,7 @@ class PytestPluginManager(PluginManager): except Exception: raise ConftestImportFailure(conftestpath, sys.exc_info()) + self._conftest_plugins.add(mod) self._conftestpath2mod[conftestpath] = mod dirpath = conftestpath.dirpath() if dirpath in self._path2confmods: @@ -782,10 +780,6 @@ class Config(object): if not hasattr(self.option, opt.dest): setattr(self.option, opt.dest, opt.default) - def _getmatchingplugins(self, fspath): - return self.pluginmanager._globalplugins + \ - self.pluginmanager._getconftestmodules(fspath) - def pytest_load_initial_conftests(self, early_config): self.pluginmanager._set_initial_conftests(early_config.known_args_namespace) pytest_load_initial_conftests.trylast = True diff --git a/_pytest/core.py b/_pytest/core.py index acc6183e5..acd7044b0 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -238,7 +238,8 @@ class PluginManager(object): def subset_hook_caller(self, name, remove_plugins): """ Return a new HookCaller instance which manages calls to - the plugins but without hooks from remove_plugins taking part. """ + the plugins but without hooks from the plugins in remove_plugins + taking part. """ hc = getattr(self.hook, name) plugins_to_remove = [plugin for plugin in remove_plugins if hasattr(plugin, name)] @@ -246,24 +247,6 @@ class PluginManager(object): hc = hc.clone() for plugin in plugins_to_remove: hc._remove_plugin(plugin) - # we also keep track of this hook caller so it - # gets properly removed on plugin unregistration - self._plugin2hookcallers.setdefault(plugin, []).append(hc) - return hc - - def make_hook_caller(self, name, plugins): - """ Return a new HookCaller instance which manages calls to - all methods named "name" in the plugins. The new hook caller - is registered internally such that when one of the plugins gets - unregistered, its method will be removed from the hook caller. """ - caller = getattr(self.hook, name) - hc = HookCaller(caller.name, self._hookexec, caller._specmodule_or_class) - for plugin in plugins: - if hasattr(plugin, name): - hc._add_plugin(plugin) - # we also keep track of this hook caller so it - # gets properly removed on plugin unregistration - self._plugin2hookcallers.setdefault(plugin, []).append(hc) return hc def get_canonical_name(self, plugin): @@ -271,8 +254,8 @@ class PluginManager(object): return getattr(plugin, "__name__", None) or str(id(plugin)) def register(self, plugin, name=None): - """ Register a plugin and return its canonical name or None if it was - blocked from registering. Raise a ValueError if the plugin is already + """ Register a plugin and return its canonical name or None if the name + is blocked from registering. Raise a ValueError if the plugin is already registered. """ plugin_name = name or self.get_canonical_name(plugin) @@ -303,16 +286,15 @@ class PluginManager(object): def unregister(self, plugin=None, name=None): """ unregister a plugin object and all its contained hook implementations - from internal data structures. One of ``plugin`` or ``name`` needs to - be specified. """ + from internal data structures. """ if name is None: - assert plugin is not None + assert plugin is not None, "one of name or plugin needs to be specified" name = self.get_name(plugin) if plugin is None: plugin = self.get_plugin(name) - # None signals blocked registrations, don't delete it + # if self._name2plugin[name] == None registration was blocked: ignore if self._name2plugin.get(name): del self._name2plugin[name] @@ -485,6 +467,7 @@ class HookCaller(object): self._wrappers = [] self._nonwrappers = [] self._hookexec = hook_execute + self._subcaller = [] if specmodule_or_class is not None: self.set_specification(specmodule_or_class) @@ -502,6 +485,21 @@ class HookCaller(object): if hasattr(specfunc, "historic"): self._call_history = [] + def clone(self): + assert not self.is_historic() + hc = object.__new__(HookCaller) + hc.name = self.name + hc._plugins = list(self._plugins) + hc._wrappers = list(self._wrappers) + hc._nonwrappers = list(self._nonwrappers) + hc._hookexec = self._hookexec + hc.argnames = self.argnames + hc.firstresult = self.firstresult + # we keep track of this hook caller so it + # gets properly removed on plugin unregistration + self._subcaller.append(hc) + return hc + def is_historic(self): return hasattr(self, "_call_history") @@ -512,6 +510,10 @@ class HookCaller(object): self._nonwrappers.remove(meth) except ValueError: self._wrappers.remove(meth) + if hasattr(self, "_subcaller"): + for hc in self._subcaller: + if plugin in hc._plugins: + hc._remove_plugin(plugin) def _add_plugin(self, plugin): self._plugins.append(plugin) diff --git a/_pytest/main.py b/_pytest/main.py index 38774c50d..b13da3529 100644 --- a/_pytest/main.py +++ b/_pytest/main.py @@ -151,18 +151,17 @@ def pytest_ignore_collect(path, config): ignore_paths.extend([py.path.local(x) for x in excludeopt]) return path in ignore_paths -class FSHookProxy(object): - def __init__(self, fspath, config): +class FSHookProxy: + def __init__(self, fspath, pm, remove_mods): self.fspath = fspath - self.config = config + self.pm = pm + self.remove_mods = remove_mods def __getattr__(self, name): - plugins = self.config._getmatchingplugins(self.fspath) - x = self.config.pluginmanager.make_hook_caller(name, plugins) + x = self.pm.subset_hook_caller(name, remove_plugins=self.remove_mods) self.__dict__[name] = x return x - def compatproperty(name): def fget(self): # deprecated - use pytest.name @@ -538,8 +537,20 @@ class Session(FSCollector): try: return self._fs2hookproxy[fspath] except KeyError: - self._fs2hookproxy[fspath] = x = FSHookProxy(fspath, self.config) - return x + # check if we have the common case of running + # hooks with all conftest.py filesall conftest.py + pm = self.config.pluginmanager + my_conftestmodules = pm._getconftestmodules(fspath) + remove_mods = pm._conftest_plugins.difference(my_conftestmodules) + if remove_mods: + # one or more conftests are not in use at this fspath + proxy = FSHookProxy(fspath, pm, remove_mods) + else: + # all plugis are active for this fspath + proxy = self.config.hook + + self._fs2hookproxy[fspath] = proxy + return proxy def perform_collect(self, args=None, genitems=True): hook = self.config.hook diff --git a/testing/test_core.py b/testing/test_core.py index 295df0d2e..344b1047a 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -176,25 +176,6 @@ class TestPluginManager: l = pm.hook.he_method1.call_extra([he_method1], dict(arg=1)) assert l == [10] - def test_make_hook_caller_unregistered(self, pm): - class Hooks: - def he_method1(self, arg): - pass - pm.addhooks(Hooks) - - l = [] - class Plugin: - def he_method1(self, arg): - l.append(arg * 10) - plugin = Plugin() - pm.register(plugin) - hc = pm.make_hook_caller("he_method1", [plugin]) - hc(arg=1) - assert l == [10] - pm.unregister(plugin) - hc(arg=2) - assert l == [10] - def test_subset_hook_caller(self, pm): class Hooks: def he_method1(self, arg): @@ -232,6 +213,11 @@ class TestPluginManager: pm.unregister(plugin1) hc(arg=2) assert l == [] + l[:] = [] + + pm.hook.he_method1(arg=1) + assert l == [10] + class TestAddMethodOrdering: From 0c961deeaab13a91f94832e0cd124b7fce722f5c Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sun, 26 Apr 2015 00:22:34 +0200 Subject: [PATCH 19/24] fix some doc strings --HG-- branch : more_plugin --- _pytest/config.py | 5 ++--- _pytest/core.py | 15 ++++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/_pytest/config.py b/_pytest/config.py index ff05f3184..bb0d07962 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -118,7 +118,7 @@ class PytestPluginManager(PluginManager): self.trace.root.setwriter(err.write) self.enable_tracing() - def register(self, plugin, name=None, conftest=False): + def register(self, plugin, name=None): ret = super(PytestPluginManager, self).register(plugin, name) if ret: self.hook.pytest_plugin_registered.call_historic( @@ -263,8 +263,7 @@ class PytestPluginManager(PluginManager): self.import_plugin(arg) def consider_conftest(self, conftestmodule): - if self.register(conftestmodule, name=conftestmodule.__file__, - conftest=True): + if self.register(conftestmodule, name=conftestmodule.__file__): self.consider_module(conftestmodule) def consider_env(self): diff --git a/_pytest/core.py b/_pytest/core.py index acd7044b0..82b9416ed 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -203,9 +203,8 @@ class PluginManager(object): plugin objects. An optional excludefunc allows to blacklist names which are not considered as hooks despite a matching prefix. - For debugging purposes you can call ``set_tracing(writer)`` - which will subsequently send debug information to the specified - write function. + For debugging purposes you can call ``enable_tracing()`` + which will subsequently send debug information to the trace helper. """ def __init__(self, prefix, excludefunc=None): @@ -219,6 +218,8 @@ class PluginManager(object): MultiCall(methods, kwargs, hook.firstresult).execute() def _hookexec(self, hook, methods, kwargs): + # called from all hookcaller instances. + # enable_tracing will set its own wrapping function at self._inner_hookexec return self._inner_hookexec(hook, methods, kwargs) def enable_tracing(self): @@ -237,9 +238,9 @@ class PluginManager(object): return TracedHookExecution(self, before, after).undo def subset_hook_caller(self, name, remove_plugins): - """ Return a new HookCaller instance which manages calls to - the plugins but without hooks from the plugins in remove_plugins - taking part. """ + """ Return a new HookCaller instance for the named method + which manages calls to all registered plugins except the + ones from remove_plugins. """ hc = getattr(self.hook, name) plugins_to_remove = [plugin for plugin in remove_plugins if hasattr(plugin, name)] @@ -496,7 +497,7 @@ class HookCaller(object): hc.argnames = self.argnames hc.firstresult = self.firstresult # we keep track of this hook caller so it - # gets properly removed on plugin unregistration + # gets properly pruned on plugin unregistration self._subcaller.append(hc) return hc From 8e009ee31cdf10d74b91dd1ae4215d12230ce415 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sun, 26 Apr 2015 00:41:29 +0200 Subject: [PATCH 20/24] move consider_setuptools_entrypoints to core pluginmanager --HG-- branch : more_plugin --- _pytest/config.py | 31 +++++---------------------- _pytest/core.py | 20 ++++++++++++++++++ testing/test_core.py | 50 +++++++++++++++++++++++--------------------- 3 files changed, 51 insertions(+), 50 deletions(-) diff --git a/_pytest/config.py b/_pytest/config.py index bb0d07962..581e14e94 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -9,7 +9,7 @@ import py # DON't import pytest here because it causes import cycle troubles import sys, os from _pytest import hookspec # the extension point definitions -from _pytest.core import PluginManager +from _pytest.core import PluginManager, hookimpl_opts # pytest startup # @@ -98,7 +98,6 @@ class PytestPluginManager(PluginManager): super(PytestPluginManager, self).__init__(prefix="pytest_", excludefunc=exclude_pytest_names) self._warnings = [] - self._plugin_distinfo = [] self._conftest_plugins = set() # state related to local conftest plugins @@ -126,16 +125,10 @@ class PytestPluginManager(PluginManager): return ret def getplugin(self, name): - # deprecated naming + # support deprecated naming because plugins (xdist e.g.) use it return self.get_plugin(name) def pytest_configure(self, config): - config.addinivalue_line("markers", - "tryfirst: mark a hook implementation function such that the " - "plugin machinery will try to call it first/as early as possible.") - config.addinivalue_line("markers", - "trylast: mark a hook implementation function such that the " - "plugin machinery will try to call it last/as late as possible.") for warning in self._warnings: config.warn(code="I1", message=warning) @@ -236,21 +229,6 @@ class PytestPluginManager(PluginManager): # # - def consider_setuptools_entrypoints(self): - try: - from pkg_resources import iter_entry_points, DistributionNotFound - except ImportError: - return # XXX issue a warning - for ep in iter_entry_points('pytest11'): - if self.get_plugin(ep.name) or ep.name in self._name2plugin: - continue - try: - plugin = ep.load() - except DistributionNotFound: - continue - self.register(plugin, name=ep.name) - self._plugin_distinfo.append((ep.dist, plugin)) - def consider_preparse(self, args): for opt1,opt2 in zip(args, args[1:]): if opt1 == "-p": @@ -679,6 +657,7 @@ class Notset: notset = Notset() FILE_OR_DIR = 'file_or_dir' + class Config(object): """ access to configuration values, pluginmanager and plugin hooks. """ @@ -779,9 +758,9 @@ class Config(object): if not hasattr(self.option, opt.dest): setattr(self.option, opt.dest, opt.default) + @hookimpl_opts(trylast=True) def pytest_load_initial_conftests(self, early_config): self.pluginmanager._set_initial_conftests(early_config.known_args_namespace) - pytest_load_initial_conftests.trylast = True def _initini(self, args): parsed_args = self._parser.parse_known_args(args) @@ -798,7 +777,7 @@ class Config(object): args[:] = self.getini("addopts") + args self._checkversion() self.pluginmanager.consider_preparse(args) - self.pluginmanager.consider_setuptools_entrypoints() + self.pluginmanager.load_setuptools_entrypoints("pytest11") self.pluginmanager.consider_env() self.known_args_namespace = ns = self._parser.parse_known_args(args) try: diff --git a/_pytest/core.py b/_pytest/core.py index 82b9416ed..9bd9819d0 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -212,6 +212,7 @@ class PluginManager(object): self._excludefunc = excludefunc self._name2plugin = {} self._plugin2hookcallers = {} + self._plugin_distinfo = [] self.trace = TagTracer().get("pluginmanage") self.hook = HookRelay(self.trace.root.get("hook")) self._inner_hookexec = lambda hook, methods, kwargs: \ @@ -379,6 +380,25 @@ class PluginManager(object): raise PluginValidationError( "unknown hook %r in plugin %r" %(name, plugin)) + def load_setuptools_entrypoints(self, entrypoint_name): + """ Load modules from querying the specified entrypoint name. + Return None if setuptools was not operable, otherwise + the number of loaded plugins. """ + try: + from pkg_resources import iter_entry_points, DistributionNotFound + except ImportError: + return # XXX issue a warning + for ep in iter_entry_points(entrypoint_name): + if self.get_plugin(ep.name) or ep.name in self._name2plugin: + continue + try: + plugin = ep.load() + except DistributionNotFound: + continue + self.register(plugin, name=ep.name) + self._plugin_distinfo.append((ep.dist, plugin)) + return len(self._plugin_distinfo) + class MultiCall: """ execute a call into multiple python functions/methods. """ diff --git a/testing/test_core.py b/testing/test_core.py index 344b1047a..1003d6e26 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -383,6 +383,32 @@ class TestAddMethodOrdering: results = pm.hook.he_myhook(arg1=17) assert results == 18 + def test_load_setuptools_instantiation(self, monkeypatch, pm): + pkg_resources = pytest.importorskip("pkg_resources") + def my_iter(name): + assert name == "hello" + class EntryPoint: + name = "myname" + dist = None + def load(self): + class PseudoPlugin: + x = 42 + return PseudoPlugin() + return iter([EntryPoint()]) + + monkeypatch.setattr(pkg_resources, 'iter_entry_points', my_iter) + num = pm.load_setuptools_entrypoints("hello") + assert num == 1 + plugin = pm.get_plugin("myname") + assert plugin.x == 42 + assert pm._plugin_distinfo == [(None, plugin)] + + def test_load_setuptools_not_installed(self, monkeypatch, pm): + monkeypatch.setitem(py.std.sys.modules, 'pkg_resources', + py.std.types.ModuleType("pkg_resources")) + assert pm.load_setuptools_entrypoints("qwe") is None + # ok, we did not explode + class TestPytestPluginInteractions: @@ -932,30 +958,6 @@ class TestPytestPluginManager: l3 = len(pytestpm.get_plugins()) assert l2 == l3 - def test_consider_setuptools_instantiation(self, monkeypatch, pytestpm): - pkg_resources = pytest.importorskip("pkg_resources") - def my_iter(name): - assert name == "pytest11" - class EntryPoint: - name = "pytest_mytestplugin" - dist = None - def load(self): - class PseudoPlugin: - x = 42 - return PseudoPlugin() - return iter([EntryPoint()]) - - monkeypatch.setattr(pkg_resources, 'iter_entry_points', my_iter) - pytestpm.consider_setuptools_entrypoints() - plugin = pytestpm.get_plugin("pytest_mytestplugin") - assert plugin.x == 42 - - def test_consider_setuptools_not_installed(self, monkeypatch, pytestpm): - monkeypatch.setitem(py.std.sys.modules, 'pkg_resources', - py.std.types.ModuleType("pkg_resources")) - pytestpm.consider_setuptools_entrypoints() - # ok, we did not explode - def test_pluginmanager_ENV_startup(self, testdir, monkeypatch): testdir.makepyfile(pytest_x500="#") p = testdir.makepyfile(""" From d2ea7387f20705746977161bbaa7d9e3c9d4ddb5 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sun, 26 Apr 2015 00:47:24 +0200 Subject: [PATCH 21/24] re-add tryfirst/trylast marker documentation, mark it as to be removed --HG-- branch : more_plugin --- _pytest/config.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/_pytest/config.py b/_pytest/config.py index 581e14e94..72f4d0a22 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -129,6 +129,14 @@ class PytestPluginManager(PluginManager): return self.get_plugin(name) def pytest_configure(self, config): + # XXX now that the pluginmanager exposes hookimpl_opts(tryfirst...) + # we should remove tryfirst/trylast as markers + config.addinivalue_line("markers", + "tryfirst: mark a hook implementation function such that the " + "plugin machinery will try to call it first/as early as possible.") + config.addinivalue_line("markers", + "trylast: mark a hook implementation function such that the " + "plugin machinery will try to call it last/as late as possible.") for warning in self._warnings: config.warn(code="I1", message=warning) From dea1c9603114d5a5983a6d1f0856c13ca2eca42c Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sun, 26 Apr 2015 17:17:59 +0200 Subject: [PATCH 22/24] actually revert back to using older simpler method for subset hook calling. It is slightly more inefficient but easier to implement and read. --HG-- branch : more_plugin --- _pytest/core.py | 36 +++++++++++------------------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/_pytest/core.py b/_pytest/core.py index 9bd9819d0..e2dd3ca8e 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -242,14 +242,19 @@ class PluginManager(object): """ Return a new HookCaller instance for the named method which manages calls to all registered plugins except the ones from remove_plugins. """ - hc = getattr(self.hook, name) + orig = getattr(self.hook, name) plugins_to_remove = [plugin for plugin in remove_plugins if hasattr(plugin, name)] if plugins_to_remove: - hc = hc.clone() - for plugin in plugins_to_remove: - hc._remove_plugin(plugin) - return hc + hc = HookCaller(orig.name, orig._hookexec, orig._specmodule_or_class) + for plugin in orig._plugins: + if plugin not in plugins_to_remove: + hc._add_plugin(plugin) + # we also keep track of this hook caller so it + # gets properly removed on plugin unregistration + self._plugin2hookcallers.setdefault(plugin, []).append(hc) + return hc + return orig def get_canonical_name(self, plugin): """ Return canonical name for a plugin object. """ @@ -488,7 +493,6 @@ class HookCaller(object): self._wrappers = [] self._nonwrappers = [] self._hookexec = hook_execute - self._subcaller = [] if specmodule_or_class is not None: self.set_specification(specmodule_or_class) @@ -506,21 +510,6 @@ class HookCaller(object): if hasattr(specfunc, "historic"): self._call_history = [] - def clone(self): - assert not self.is_historic() - hc = object.__new__(HookCaller) - hc.name = self.name - hc._plugins = list(self._plugins) - hc._wrappers = list(self._wrappers) - hc._nonwrappers = list(self._nonwrappers) - hc._hookexec = self._hookexec - hc.argnames = self.argnames - hc.firstresult = self.firstresult - # we keep track of this hook caller so it - # gets properly pruned on plugin unregistration - self._subcaller.append(hc) - return hc - def is_historic(self): return hasattr(self, "_call_history") @@ -531,10 +520,6 @@ class HookCaller(object): self._nonwrappers.remove(meth) except ValueError: self._wrappers.remove(meth) - if hasattr(self, "_subcaller"): - for hc in self._subcaller: - if plugin in hc._plugins: - hc._remove_plugin(plugin) def _add_plugin(self, plugin): self._plugins.append(plugin) @@ -553,6 +538,7 @@ class HookCaller(object): i = len(nonwrappers) - 1 while i >= 0 and hasattr(nonwrappers[i], "tryfirst"): i -= 1 + # and insert right in front of the tryfirst ones nonwrappers.insert(i+1, meth) From c54afbe42e7e47afb7605f4bc226af92dbeda0a7 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Mon, 27 Apr 2015 12:50:34 +0200 Subject: [PATCH 23/24] deprecate and warn about __multicall__ usage in hooks, refine docs about hook ordering, make hookwrappers respect tryfirst/trylast --HG-- branch : more_plugin --- CHANGELOG | 4 ++ _pytest/config.py | 23 +++++++++-- _pytest/core.py | 30 +++++++++------ _pytest/terminal.py | 2 + doc/en/writing_plugins.txt | 78 +++++++++++++++++++++++++------------- testing/conftest.py | 7 ++-- testing/test_core.py | 25 +++++++++++- 7 files changed, 122 insertions(+), 47 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 2d5945189..ef773c59f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -39,6 +39,10 @@ - fix issue732: properly unregister plugins from any hook calling sites allowing to have temporary plugins during test execution. +- deprecate and warn about ``__multicall__`` argument in hook + implementations. Use the ``hookwrapper`` mechanism instead already + introduced with pytest-2.7. + 2.7.1.dev (compared to 2.7.0) ----------------------------- diff --git a/_pytest/config.py b/_pytest/config.py index 72f4d0a22..fb7441dc1 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -9,7 +9,7 @@ import py # DON't import pytest here because it causes import cycle troubles import sys, os from _pytest import hookspec # the extension point definitions -from _pytest.core import PluginManager, hookimpl_opts +from _pytest.core import PluginManager, hookimpl_opts, varnames # pytest startup # @@ -117,6 +117,18 @@ class PytestPluginManager(PluginManager): self.trace.root.setwriter(err.write) self.enable_tracing() + + def _verify_hook(self, hook, plugin): + super(PytestPluginManager, self)._verify_hook(hook, plugin) + method = getattr(plugin, hook.name) + if "__multicall__" in varnames(method): + fslineno = py.code.getfslineno(method) + warning = dict(code="I1", + fslocation=fslineno, + message="%r hook uses deprecated __multicall__ " + "argument" % (hook.name)) + self._warnings.append(warning) + def register(self, plugin, name=None): ret = super(PytestPluginManager, self).register(plugin, name) if ret: @@ -138,7 +150,10 @@ class PytestPluginManager(PluginManager): "trylast: mark a hook implementation function such that the " "plugin machinery will try to call it last/as late as possible.") for warning in self._warnings: - config.warn(code="I1", message=warning) + if isinstance(warning, dict): + config.warn(**warning) + else: + config.warn(code="I1", message=warning) # # internal API for local conftest plugin handling @@ -712,10 +727,10 @@ class Config(object): fin = self._cleanup.pop() fin() - def warn(self, code, message): + def warn(self, code, message, fslocation=None): """ generate a warning for this test session. """ self.hook.pytest_logwarning(code=code, message=message, - fslocation=None, nodeid=None) + fslocation=fslocation, nodeid=None) def get_terminal_writer(self): return self.pluginmanager.get_plugin("terminalreporter")._tw diff --git a/_pytest/core.py b/_pytest/core.py index e2dd3ca8e..240e93928 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -408,6 +408,12 @@ class PluginManager(object): class MultiCall: """ execute a call into multiple python functions/methods. """ + # XXX note that the __multicall__ argument is supported only + # for pytest compatibility reasons. It was never officially + # supported there and is explicitely deprecated since 2.8 + # so we can remove it soon, allowing to avoid the below recursion + # in execute() and simplify/speed up the execute loop. + def __init__(self, methods, kwargs, firstresult=False): self.methods = methods self.kwargs = kwargs @@ -527,20 +533,20 @@ class HookCaller(object): def _add_method(self, meth): if hasattr(meth, 'hookwrapper'): - self._wrappers.append(meth) - elif hasattr(meth, 'trylast'): - self._nonwrappers.insert(0, meth) - elif hasattr(meth, 'tryfirst'): - self._nonwrappers.append(meth) + methods = self._wrappers else: - # find the last nonwrapper which is not tryfirst marked - nonwrappers = self._nonwrappers - i = len(nonwrappers) - 1 - while i >= 0 and hasattr(nonwrappers[i], "tryfirst"): - i -= 1 + methods = self._nonwrappers - # and insert right in front of the tryfirst ones - nonwrappers.insert(i+1, meth) + if hasattr(meth, 'trylast'): + methods.insert(0, meth) + elif hasattr(meth, 'tryfirst'): + methods.append(meth) + else: + # find last non-tryfirst method + i = len(methods) - 1 + while i >= 0 and hasattr(methods[i], "tryfirst"): + i -= 1 + methods.insert(i + 1, meth) def __repr__(self): return "" %(self.name,) diff --git a/_pytest/terminal.py b/_pytest/terminal.py index a021f5345..03c539b85 100644 --- a/_pytest/terminal.py +++ b/_pytest/terminal.py @@ -164,6 +164,8 @@ class TerminalReporter: def pytest_logwarning(self, code, fslocation, message, nodeid): warnings = self.stats.setdefault("warnings", []) + if isinstance(fslocation, tuple): + fslocation = "%s:%d" % fslocation warning = WarningReport(code=code, fslocation=fslocation, message=message, nodeid=nodeid) warnings.append(warning) diff --git a/doc/en/writing_plugins.txt b/doc/en/writing_plugins.txt index d1667c2d5..78431c8ee 100644 --- a/doc/en/writing_plugins.txt +++ b/doc/en/writing_plugins.txt @@ -221,36 +221,21 @@ be "future-compatible": we can introduce new hook named parameters without breaking the signatures of existing hook implementations. It is one of the reasons for the general long-lived compatibility of pytest plugins. -Hook function results ---------------------- +Note that hook functions other than ``pytest_runtest_*`` are not +allowed to raise exceptions. Doing so will break the pytest run. + + + +firstresult: stop at first non-None result +------------------------------------------- Most calls to ``pytest`` hooks result in a **list of results** which contains all non-None results of the called hook functions. -Some hooks are specified so that the hook call only executes until the -first function returned a non-None value which is then also the -result of the overall hook call. The remaining hook functions will -not be called in this case. - -Note that hook functions other than ``pytest_runtest_*`` are not -allowed to raise exceptions. Doing so will break the pytest run. - -Hook function ordering ----------------------- - -For any given hook there may be more than one implementation and we thus -generally view ``hook`` execution as a ``1:N`` function call where ``N`` -is the number of registered functions. There are ways to -influence if a hook implementation comes before or after others, i.e. -the position in the ``N``-sized list of functions:: - - @pytest.hookimpl_spec(tryfirst=True) - def pytest_collection_modifyitems(items): - # will execute as early as possible - - @pytest.hookimpl_spec(trylast=True) - def pytest_collection_modifyitems(items): - # will execute as late as possible +Some hook specifications use the ``firstresult=True`` option so that the hook +call only executes until the first of N registered functions returns a +non-None result which is then taken as result of the overall hook call. +The remaining hook functions will not be called in this case. hookwrapper: executing around other hooks @@ -290,6 +275,47 @@ 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. + + +Hook function ordering / call example +------------------------------------- + +For any given hook specification there may be more than one +implementation and we thus generally view ``hook`` execution as a +``1:N`` function call where ``N`` is the number of registered functions. +There are ways to influence if a hook implementation comes before or +after others, i.e. the position in the ``N``-sized list of functions:: + + # Plugin 1 + @pytest.hookimpl_spec(tryfirst=True) + def pytest_collection_modifyitems(items): + # will execute as early as possible + + # Plugin 2 + @pytest.hookimpl_spec(trylast=True) + def pytest_collection_modifyitems(items): + # will execute as late as possible + + # Plugin 3 + @pytest.hookimpl_spec(hookwrapper=True) + def pytest_collection_modifyitems(items): + # will execute even before the tryfirst one above! + outcome = yield + # will execute after all non-hookwrappers executed + +Here is the order of execution: + +1. Plugin3's pytest_collection_modifyitems called until the yield point +2. Plugin1's pytest_collection_modifyitems is called +3. Plugin2's pytest_collection_modifyitems is called +4. Plugin3's pytest_collection_modifyitems called for executing after the yield + The yield receives a :py:class:`CallOutcome` instance which encapsulates + the result from calling the non-wrappers. Wrappers cannot modify the result. + +It's possible to use ``tryfirst`` and ``trylast`` also in conjunction with +``hookwrapper=True`` in which case it will influence the ordering of hookwrappers +among each other. + Declaring new hooks ------------------------ diff --git a/testing/conftest.py b/testing/conftest.py index cdf9e4bf3..835f1e62d 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -66,13 +66,12 @@ def check_open_files(config): error.append(error[0]) raise AssertionError("\n".join(error)) -@pytest.hookimpl_opts(trylast=True) -def pytest_runtest_teardown(item, __multicall__): +@pytest.hookimpl_opts(hookwrapper=True, trylast=True) +def pytest_runtest_teardown(item): + yield item.config._basedir.chdir() if hasattr(item.config, '_openfiles'): - x = __multicall__.execute() check_open_files(item.config) - return x # XXX copied from execnet's conftest.py - needs to be merged winpymap = { diff --git a/testing/test_core.py b/testing/test_core.py index 1003d6e26..64805546c 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -336,6 +336,19 @@ class TestAddMethodOrdering: assert hc._nonwrappers == [he_method1_middle] assert hc._wrappers == [he_method1, he_method3] + def test_adding_wrappers_ordering_tryfirst(self, hc, addmeth): + @addmeth(hookwrapper=True, tryfirst=True) + def he_method1(): + pass + + @addmeth(hookwrapper=True) + def he_method2(): + pass + + assert hc._nonwrappers == [] + assert hc._wrappers == [he_method2, he_method1] + + def test_hookspec_opts(self, pm): class HookSpec: @hookspec_opts() @@ -530,6 +543,16 @@ class TestPytestPluginInteractions: finally: undo() + def test_warn_on_deprecated_multicall(self, pytestpm): + class Plugin: + def pytest_configure(self, __multicall__): + pass + + before = list(pytestpm._warnings) + pytestpm.register(Plugin()) + assert len(pytestpm._warnings) == len(before) + 1 + assert "deprecated" in pytestpm._warnings[-1]["message"] + def test_namespace_has_default_and_env_plugins(testdir): p = testdir.makepyfile(""" @@ -969,7 +992,7 @@ class TestPytestPluginManager: monkeypatch.setenv('PYTEST_PLUGINS', 'pytest_x500', prepend=",") result = testdir.runpytest(p) assert result.ret == 0 - result.stdout.fnmatch_lines(["*1 passed in*"]) + result.stdout.fnmatch_lines(["*1 passed*"]) def test_import_plugin_importname(self, testdir, pytestpm): pytest.raises(ImportError, 'pytestpm.import_plugin("qweqwex.y")') From b2d66b9e7b25d3de5e3d5b19454817b68bcc427c Mon Sep 17 00:00:00 2001 From: holger krekel Date: Mon, 27 Apr 2015 14:10:33 +0200 Subject: [PATCH 24/24] simplify load_setuptools_entrypoints and refine comments/docstrings --HG-- branch : more_plugin --- _pytest/config.py | 5 ++++- _pytest/core.py | 22 +++++++++++----------- testing/test_core.py | 4 ++-- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/_pytest/config.py b/_pytest/config.py index fb7441dc1..f37d417b2 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -800,7 +800,10 @@ class Config(object): args[:] = self.getini("addopts") + args self._checkversion() self.pluginmanager.consider_preparse(args) - self.pluginmanager.load_setuptools_entrypoints("pytest11") + try: + self.pluginmanager.load_setuptools_entrypoints("pytest11") + except ImportError as e: + self.warn("I2", "could not load setuptools entry import: %s" % (e,)) self.pluginmanager.consider_env() self.known_args_namespace = ns = self._parser.parse_known_args(args) try: diff --git a/_pytest/core.py b/_pytest/core.py index 240e93928..5dcc30801 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -256,10 +256,6 @@ class PluginManager(object): return hc return orig - def get_canonical_name(self, plugin): - """ Return canonical name for a plugin object. """ - return getattr(plugin, "__name__", None) or str(id(plugin)) - def register(self, plugin, name=None): """ Register a plugin and return its canonical name or None if the name is blocked from registering. Raise a ValueError if the plugin is already @@ -344,6 +340,13 @@ class PluginManager(object): """ Return True if the plugin is already registered. """ return plugin in self._plugin2hookcallers + def get_canonical_name(self, plugin): + """ Return canonical name for a plugin object. Note that a plugin + may be registered under a different name which was specified + by the caller of register(plugin, name). To obtain the name + of an registered plugin use ``get_name(plugin)`` instead.""" + return getattr(plugin, "__name__", None) or str(id(plugin)) + def get_plugin(self, name): """ Return a plugin or None for the given name. """ return self._name2plugin.get(name) @@ -386,14 +389,11 @@ class PluginManager(object): "unknown hook %r in plugin %r" %(name, plugin)) def load_setuptools_entrypoints(self, entrypoint_name): - """ Load modules from querying the specified entrypoint name. - Return None if setuptools was not operable, otherwise - the number of loaded plugins. """ - try: - from pkg_resources import iter_entry_points, DistributionNotFound - except ImportError: - return # XXX issue a warning + """ Load modules from querying the specified setuptools entrypoint name. + Return the number of loaded plugins. """ + from pkg_resources import iter_entry_points, DistributionNotFound for ep in iter_entry_points(entrypoint_name): + # is the plugin registered or blocked? if self.get_plugin(ep.name) or ep.name in self._name2plugin: continue try: diff --git a/testing/test_core.py b/testing/test_core.py index 64805546c..4975e7e05 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -419,8 +419,8 @@ class TestAddMethodOrdering: def test_load_setuptools_not_installed(self, monkeypatch, pm): monkeypatch.setitem(py.std.sys.modules, 'pkg_resources', py.std.types.ModuleType("pkg_resources")) - assert pm.load_setuptools_entrypoints("qwe") is None - # ok, we did not explode + with pytest.raises(ImportError): + pm.load_setuptools_entrypoints("qwe") class TestPytestPluginInteractions: