From 85d35f74182dfe220bb9ded7e264e82990fa4153 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Thu, 22 Apr 2010 11:57:57 +0200 Subject: [PATCH] introduce an experimental approach for allowing dynamic addition of hooks from plugin. Plugins may register new hooks by implementing the new pytest_registerhooks(pluginmanager) and call pluginmanager.registerhooks(module) with the referenced 'module' object containing the hooks. The new pytest_registerhooks is called after pytest_addoption and before pytest_configure. --HG-- branch : trunk --- CHANGELOG | 5 ++- py/__init__.py | 3 +- py/_plugin/hookspec.py | 28 ++----------- py/_plugin/pytest__pytest.py | 21 ++++++---- py/_plugin/pytest_helpconfig.py | 13 ++++-- py/_plugin/pytest_terminal.py | 52 ++++++++++++++---------- py/_test/pluginmanager.py | 19 +++++++-- setup.py | 2 +- testing/plugin/test_pytest__pytest.py | 2 +- testing/plugin/test_pytest_helpconfig.py | 21 ++++++++++ 10 files changed, 97 insertions(+), 69 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index f652eb1f2..066dff0d7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,7 @@ -Changes between 1.2.1 and XXX -===================================== +Changes between 1.2.1 and 1.2.2 (release pending) +================================================== +- new mechanism to allow plugins to register new hooks - added links to the new capturelog and coverage plugins - (issue87) fix unboundlocal error in assertionold code - (issue86) improve documentation for looponfailing diff --git a/py/__init__.py b/py/__init__.py index c3f4c97b5..2b9f46a50 100644 --- a/py/__init__.py +++ b/py/__init__.py @@ -8,9 +8,8 @@ dictionary or an import path. (c) Holger Krekel and others, 2004-2010 """ -version = "1.2.1" +_version__ = version = "1.2.2" -__version__ = version = version or "1.2.x" import py.apipkg py.apipkg.initpkg(__name__, dict( diff --git a/py/_plugin/hookspec.py b/py/_plugin/hookspec.py index 426412708..b43b46783 100644 --- a/py/_plugin/hookspec.py +++ b/py/_plugin/hookspec.py @@ -9,6 +9,9 @@ hook specifications for py.test plugins def pytest_addoption(parser): """ called before commandline parsing. """ +def pytest_registerhooks(pluginmanager): + """ called after commandline parsing before pytest_configure. """ + def pytest_namespace(): """ return dict of name->object which will get stored at py.test. namespace""" @@ -133,31 +136,6 @@ def pytest_doctest_prepare_content(content): """ return processed content for a given doctest""" pytest_doctest_prepare_content.firstresult = True -# ------------------------------------------------------------------------- -# distributed testing -# ------------------------------------------------------------------------- - -def pytest_gwmanage_newgateway(gateway, platinfo): - """ called on new raw gateway creation. """ - -def pytest_gwmanage_rsyncstart(source, gateways): - """ called before rsyncing a directory to remote gateways takes place. """ - -def pytest_gwmanage_rsyncfinish(source, gateways): - """ called after rsyncing a directory to remote gateways takes place. """ - -def pytest_testnodeready(node): - """ Test Node is ready to operate. """ - -def pytest_testnodedown(node, error): - """ Test Node is down. """ - -def pytest_rescheduleitems(items): - """ reschedule Items from a node that went down. """ - -def pytest_looponfailinfo(failreports, rootdirs): - """ info for repeating failing tests. """ - # ------------------------------------------------------------------------- # error handling and internal debugging hooks diff --git a/py/_plugin/pytest__pytest.py b/py/_plugin/pytest__pytest.py index 430ea3ca0..49a7f7c32 100644 --- a/py/_plugin/pytest__pytest.py +++ b/py/_plugin/pytest__pytest.py @@ -34,15 +34,18 @@ class HookRecorder: self._recorders = {} def start_recording(self, hookspecs): - assert hookspecs not in self._recorders - class RecordCalls: - _recorder = self - for name, method in vars(hookspecs).items(): - if name[0] != "_": - setattr(RecordCalls, name, self._makecallparser(method)) - recorder = RecordCalls() - self._recorders[hookspecs] = recorder - self._registry.register(recorder) + if not isinstance(hookspecs, (list, tuple)): + hookspecs = [hookspecs] + for hookspec in hookspecs: + assert hookspec not in self._recorders + class RecordCalls: + _recorder = self + for name, method in vars(hookspec).items(): + if name[0] != "_": + setattr(RecordCalls, name, self._makecallparser(method)) + recorder = RecordCalls() + self._recorders[hookspec] = recorder + self._registry.register(recorder) self.hook = HookRelay(hookspecs, registry=self._registry) def finish_recording(self): diff --git a/py/_plugin/pytest_helpconfig.py b/py/_plugin/pytest_helpconfig.py index 0bdd9775b..480b57d93 100644 --- a/py/_plugin/pytest_helpconfig.py +++ b/py/_plugin/pytest_helpconfig.py @@ -93,9 +93,11 @@ def pytest_report_header(config): # ===================================================== def pytest_plugin_registered(manager, plugin): - hookspec = manager.hook._hookspecs methods = collectattr(plugin) - hooks = collectattr(hookspec) + hooks = {} + for hookspec in manager.hook._hookspecs: + hooks.update(collectattr(hookspec)) + stringio = py.io.TextIO() def Print(*args): if args: @@ -109,10 +111,13 @@ def pytest_plugin_registered(manager, plugin): if isgenerichook(name): continue if name not in hooks: - Print("found unknown hook:", name) - fail = True + if not getattr(method, 'optionalhook', False): + Print("found unknown hook:", name) + fail = True else: + #print "checking", method method_args = getargs(method) + #print "method_args", method_args if '__multicall__' in method_args: method_args.remove('__multicall__') hook = hooks[name] diff --git a/py/_plugin/pytest_terminal.py b/py/_plugin/pytest_terminal.py index 3fcc30350..f67dfacb4 100644 --- a/py/_plugin/pytest_terminal.py +++ b/py/_plugin/pytest_terminal.py @@ -6,6 +6,8 @@ This is a good source for looking at the various reporting hooks. import py import sys +optionalhook = py.test.mark.optionalhook + def pytest_addoption(parser): group = parser.getgroup("terminal reporting", "reporting", after="general") group._addoption('-v', '--verbose', action="count", @@ -130,6 +132,15 @@ class TerminalReporter: for line in str(excrepr).split("\n"): self.write_line("INTERNALERROR> " + line) + def pytest_plugin_registered(self, plugin): + if self.config.option.traceconfig: + msg = "PLUGIN registered: %s" %(plugin,) + # XXX this event may happen during setup/teardown time + # which unfortunately captures our output here + # which garbles our output if we use self.write_line + self.write_line(msg) + + @optionalhook def pytest_gwmanage_newgateway(self, gateway, platinfo): #self.write_line("%s instantiated gateway from spec %r" %(gateway.id, gateway.spec._spec)) d = {} @@ -149,30 +160,37 @@ class TerminalReporter: self.write_line(infoline) self.gateway2info[gateway] = infoline - def pytest_plugin_registered(self, plugin): - if self.config.option.traceconfig: - msg = "PLUGIN registered: %s" %(plugin,) - # XXX this event may happen during setup/teardown time - # which unfortunately captures our output here - # which garbles our output if we use self.write_line - self.write_line(msg) - + @optionalhook def pytest_testnodeready(self, node): self.write_line("[%s] txnode ready to receive tests" %(node.gateway.id,)) + @optionalhook def pytest_testnodedown(self, node, error): if error: self.write_line("[%s] node down, error: %s" %(node.gateway.id, error)) + @optionalhook + def pytest_rescheduleitems(self, items): + if self.config.option.debug: + self.write_sep("!", "RESCHEDULING %s " %(items,)) + + @optionalhook + def pytest_looponfailinfo(self, failreports, rootdirs): + if failreports: + self.write_sep("#", "LOOPONFAILING", red=True) + for report in failreports: + loc = self._getcrashline(report) + self.write_line(loc, red=True) + self.write_sep("#", "waiting for changes") + for rootdir in rootdirs: + self.write_line("### Watching: %s" %(rootdir,), bold=True) + + def pytest_trace(self, category, msg): if self.config.option.debug or \ self.config.option.traceconfig and category.find("config") != -1: self.write_line("[%s] %s" %(category, msg)) - def pytest_rescheduleitems(self, items): - if self.config.option.debug: - self.write_sep("!", "RESCHEDULING %s " %(items,)) - def pytest_deselected(self, items): self.stats.setdefault('deselected', []).append(items) @@ -274,16 +292,6 @@ class TerminalReporter: else: excrepr.reprcrash.toterminal(self._tw) - def pytest_looponfailinfo(self, failreports, rootdirs): - if failreports: - self.write_sep("#", "LOOPONFAILING", red=True) - for report in failreports: - loc = self._getcrashline(report) - self.write_line(loc, red=True) - self.write_sep("#", "waiting for changes") - for rootdir in rootdirs: - self.write_line("### Watching: %s" %(rootdir,), bold=True) - def _getcrashline(self, report): try: return report.longrepr.reprcrash diff --git a/py/_test/pluginmanager.py b/py/_test/pluginmanager.py index 7428265ca..6b80d4e45 100644 --- a/py/_test/pluginmanager.py +++ b/py/_test/pluginmanager.py @@ -7,7 +7,7 @@ from py._plugin import hookspec from py._test.outcome import Skipped default_plugins = ( - "default runner capture terminal mark skipping tmpdir monkeypatch " + "default runner capture mark terminal skipping tmpdir monkeypatch " "recwarn pdb pastebin unittest helpconfig nose assertion genscript " "junitxml doctest").split() @@ -20,7 +20,7 @@ class PluginManager(object): self.registry = Registry() self._name2plugin = {} self._hints = [] - self.hook = HookRelay(hookspecs=hookspec, registry=self.registry) + self.hook = HookRelay([hookspec], registry=self.registry) self.register(self) for spec in default_plugins: self.import_plugin(spec) @@ -40,6 +40,8 @@ class PluginManager(object): if name in self._name2plugin: return False self._name2plugin[name] = plugin + self.call_plugin(plugin, "pytest_registerhooks", + {'pluginmanager': self}) self.hook.pytest_plugin_registered(manager=self, plugin=plugin) self.registry.register(plugin) return True @@ -58,6 +60,9 @@ class PluginManager(object): if plugin == val: return True + def registerhooks(self, spec): + self.hook._registerhooks(spec) + def getplugins(self): return list(self.registry) @@ -301,13 +306,21 @@ class Registry: class HookRelay: def __init__(self, hookspecs, registry): - self._hookspecs = hookspecs + if not isinstance(hookspecs, list): + hookspecs = [hookspecs] + self._hookspecs = [] self._registry = registry + for hookspec in hookspecs: + self._registerhooks(hookspec) + + def _registerhooks(self, hookspecs): + self._hookspecs.append(hookspecs) for name, method in vars(hookspecs).items(): if name[:1] != "_": firstresult = getattr(method, 'firstresult', False) hc = HookCaller(self, name, firstresult=firstresult) setattr(self, name, hc) + #print ("setting new hook", name) def _performcall(self, name, multicall): return multicall.execute() diff --git a/setup.py b/setup.py index 58aefd7d1..428aa4592 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ def main(): name='py', description='py.test and pylib: rapid testing and development utils.', long_description = long_description, - version= trunk or '1.2.1', + version= trunk or '1.2.2', url='http://pylib.org', license='MIT license', platforms=['unix', 'linux', 'osx', 'cygwin', 'win32'], diff --git a/testing/plugin/test_pytest__pytest.py b/testing/plugin/test_pytest__pytest.py index d9fbf31d8..182efe564 100644 --- a/testing/plugin/test_pytest__pytest.py +++ b/testing/plugin/test_pytest__pytest.py @@ -34,7 +34,7 @@ def test_functional(testdir, linecomp): def test_func(_pytest): class ApiClass: def xyz(self, arg): pass - hook = HookRelay(ApiClass, Registry()) + hook = HookRelay([ApiClass], Registry()) rec = _pytest.gethookrecorder(hook) class Plugin: def xyz(self, arg): diff --git a/testing/plugin/test_pytest_helpconfig.py b/testing/plugin/test_pytest_helpconfig.py index dc3b6a598..dda34e263 100644 --- a/testing/plugin/test_pytest_helpconfig.py +++ b/testing/plugin/test_pytest_helpconfig.py @@ -29,3 +29,24 @@ def test_collectattr(): methods = py.builtin.sorted(collectattr(B())) assert list(methods) == ['pytest_hello', 'pytest_world'] +def test_hookvalidation_unknown(testdir): + testdir.makeconftest(""" + def pytest_hello(xyz): + pass + """) + result = testdir.runpytest() + assert result.ret != 0 + assert result.stderr.fnmatch_lines([ + '*unknown hook*pytest_hello*' + ]) + +def test_hookvalidation_optional(testdir): + testdir.makeconftest(""" + import py + @py.test.mark.optionalhook + def pytest_hello(xyz): + pass + """) + result = testdir.runpytest() + assert result.ret == 0 +