From 3de715ec13a7c80b27488194c06e020bee2c778f Mon Sep 17 00:00:00 2001 From: holger krekel Date: Wed, 1 Oct 2014 12:19:11 +0200 Subject: [PATCH 01/10] refine internal management of plugins and conftest files --- _pytest/config.py | 7 ++---- _pytest/core.py | 60 ++++++++++++++++++++++++++++++++--------------- _pytest/main.py | 41 +++++++++++++++++++++++++------- _pytest/python.py | 14 ++++++----- 4 files changed, 84 insertions(+), 38 deletions(-) diff --git a/_pytest/config.py b/_pytest/config.py index 89d38f8af..450693036 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -682,11 +682,8 @@ class Config(object): setattr(self.option, opt.dest, opt.default) def _getmatchingplugins(self, fspath): - allconftests = self._conftest._conftestpath2mod.values() - plugins = [x for x in self.pluginmanager.getplugins() - if x not in allconftests] - plugins += self._conftest.getconftestmodules(fspath) - return plugins + return self.pluginmanager._plugins + \ + self._conftest.getconftestmodules(fspath) def pytest_load_initial_conftests(self, early_config): self._conftest.setinitial(early_config.known_args_namespace) diff --git a/_pytest/core.py b/_pytest/core.py index de422e149..d1fdf1dba 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -72,6 +72,7 @@ class PluginManager(object): self._name2plugin = {} self._listattrcache = {} self._plugins = [] + self._conftestplugins = [] self._warnings = [] self.trace = TagTracer().get("pluginmanage") self._plugin_distinfo = [] @@ -86,7 +87,7 @@ class PluginManager(object): assert not hasattr(self, "_registercallback") self._registercallback = callback - def register(self, plugin, name=None, prepend=False): + def register(self, plugin, name=None, prepend=False, conftest=False): if self._name2plugin.get(name, None) == -1: return name = name or getattr(plugin, '__name__', str(id(plugin))) @@ -98,16 +99,22 @@ class PluginManager(object): reg = getattr(self, "_registercallback", None) if reg is not None: reg(plugin, name) - if not prepend: - self._plugins.append(plugin) + if conftest: + self._conftestplugins.append(plugin) else: - self._plugins.insert(0, plugin) + if not prepend: + self._plugins.append(plugin) + else: + self._plugins.insert(0, plugin) return True def unregister(self, plugin=None, name=None): if plugin is None: plugin = self.getplugin(name=name) - self._plugins.remove(plugin) + try: + self._plugins.remove(plugin) + except KeyError: + self._conftestplugins.remove(plugin) for name, value in list(self._name2plugin.items()): if value == plugin: del self._name2plugin[name] @@ -119,7 +126,7 @@ class PluginManager(object): while self._shutdown: func = self._shutdown.pop() func() - self._plugins = [] + self._plugins = self._conftestplugins = [] self._name2plugin.clear() self._listattrcache.clear() @@ -134,7 +141,7 @@ class PluginManager(object): self.hook._addhooks(spec, prefix=prefix) def getplugins(self): - return list(self._plugins) + return self._plugins + self._conftestplugins def skipifmissing(self, name): if not self.hasplugin(name): @@ -198,7 +205,8 @@ class PluginManager(object): self.import_plugin(arg) def consider_conftest(self, conftestmodule): - if self.register(conftestmodule, name=conftestmodule.__file__): + if self.register(conftestmodule, name=conftestmodule.__file__, + conftest=True): self.consider_module(conftestmodule) def consider_module(self, mod): @@ -233,12 +241,7 @@ class PluginManager(object): def listattr(self, attrname, plugins=None): if plugins is None: - plugins = self._plugins - key = (attrname,) + tuple(plugins) - try: - return list(self._listattrcache[key]) - except KeyError: - pass + plugins = self._plugins + self._conftestplugins l = [] last = [] wrappers = [] @@ -257,7 +260,7 @@ class PluginManager(object): l.append(meth) l.extend(last) l.extend(wrappers) - self._listattrcache[key] = list(l) + #self._listattrcache[key] = list(l) return l def call_plugin(self, plugin, methname, kwargs): @@ -397,6 +400,29 @@ class HookRelay: raise ValueError("did not find new %r hooks in %r" %( prefix, hookspecs,)) + def _getcaller(self, name, plugins): + caller = getattr(self, name) + methods = self._pm.listattr(name, plugins=plugins) + return CachedHookCaller(caller, methods) + + +class CachedHookCaller: + def __init__(self, hookmethod, methods): + self.hookmethod = hookmethod + self.methods = methods + + def __call__(self, **kwargs): + return self.hookmethod._docall(self.methods, kwargs) + + def callextra(self, methods, **kwargs): + # XXX in theory we should respect "tryfirst/trylast" if set + # on the added methods but we currently only use it for + # pytest_generate_tests and it doesn't make sense there i'd think + all = self.methods + if methods: + all = all + methods + return self.hookmethod._docall(all, kwargs) + class HookCaller: def __init__(self, hookrelay, name, firstresult): @@ -412,10 +438,6 @@ class HookCaller: methods = self.hookrelay._pm.listattr(self.name) return self._docall(methods, kwargs) - def pcall(self, plugins, **kwargs): - methods = self.hookrelay._pm.listattr(self.name, plugins=plugins) - return self._docall(methods, kwargs) - def _docall(self, methods, kwargs): self.trace(self.name, kwargs) self.trace.root.indent += 1 diff --git a/_pytest/main.py b/_pytest/main.py index 2d46c56e8..5377764de 100644 --- a/_pytest/main.py +++ b/_pytest/main.py @@ -153,19 +153,39 @@ def pytest_ignore_collect(path, config): ignore_paths.extend([py.path.local(x) for x in excludeopt]) return path in ignore_paths -class HookProxy(object): +class FSHookProxy(object): def __init__(self, fspath, config): self.fspath = fspath self.config = config def __getattr__(self, name): - config = object.__getattribute__(self, "config") - hookmethod = getattr(config.hook, name) + plugins = self.config._getmatchingplugins(self.fspath) + x = self.config.hook._getcaller(name, plugins) + self.__dict__[name] = x + return x - def call_matching_hooks(**kwargs): - plugins = self.config._getmatchingplugins(self.fspath) - return hookmethod.pcall(plugins, **kwargs) - return call_matching_hooks + hookmethod = getattr(self.config.hook, name) + methods = self.config.pluginmanager.listattr(name, plugins=plugins) + self.__dict__[name] = x = HookCaller(hookmethod, methods) + return x + + +class HookCaller: + def __init__(self, hookmethod, methods): + self.hookmethod = hookmethod + self.methods = methods + + def __call__(self, **kwargs): + return self.hookmethod._docall(self.methods, kwargs) + + def callextra(self, methods, **kwargs): + # XXX in theory we should respect "tryfirst/trylast" if set + # on the added methods but we currently only use it for + # pytest_generate_tests and it doesn't make sense there i'd think + all = self.methods + if methods: + all = all + methods + return self.hookmethod._docall(all, kwargs) def compatproperty(name): def fget(self): @@ -520,6 +540,7 @@ class Session(FSCollector): self.trace = config.trace.root.get("collection") self._norecursepatterns = config.getini("norecursedirs") self.startdir = py.path.local() + self._fs2hookproxy = {} def pytest_collectstart(self): if self.shouldstop: @@ -538,7 +559,11 @@ class Session(FSCollector): return path in self._initialpaths def gethookproxy(self, fspath): - return HookProxy(fspath, self.config) + try: + return self._fs2hookproxy[fspath] + except KeyError: + self._fs2hookproxy[fspath] = x = FSHookProxy(fspath, self.config) + return x def perform_collect(self, args=None, genitems=True): hook = self.config.hook diff --git a/_pytest/python.py b/_pytest/python.py index 9ce1d2170..997793f76 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -353,12 +353,14 @@ class PyCollector(PyobjMixin, pytest.Collector): fixtureinfo = fm.getfixtureinfo(self, funcobj, cls) metafunc = Metafunc(funcobj, fixtureinfo, self.config, cls=cls, module=module) - gentesthook = self.config.hook.pytest_generate_tests - extra = [module] - if cls is not None: - extra.append(cls()) - plugins = self.getplugins() + extra - gentesthook.pcall(plugins, metafunc=metafunc) + try: + methods = [module.pytest_generate_tests] + except AttributeError: + methods = [] + if hasattr(cls, "pytest_generate_tests"): + methods.append(cls().pytest_generate_tests) + self.ihook.pytest_generate_tests.callextra(methods, metafunc=metafunc) + Function = self._getcustomclass("Function") if not metafunc._calls: yield Function(name, parent=self) From 351931d5cadb52fcef197d018b527c4d6a3644fa Mon Sep 17 00:00:00 2001 From: holger krekel Date: Wed, 1 Oct 2014 12:20:11 +0200 Subject: [PATCH 02/10] call scanning of plugins directly, code is shifted from helpconfig.py to core.py --- _pytest/core.py | 112 +++++++++++++++++++++++++++++-------- _pytest/helpconfig.py | 67 ---------------------- _pytest/main.py | 22 -------- _pytest/pytester.py | 5 +- testing/test_helpconfig.py | 2 +- testing/test_pytester.py | 43 ++++++++------ 6 files changed, 119 insertions(+), 132 deletions(-) diff --git a/_pytest/core.py b/_pytest/core.py index d1fdf1dba..e7a441634 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -95,10 +95,11 @@ class PluginManager(object): raise ValueError("Plugin already registered: %s=%s\n%s" %( name, plugin, self._name2plugin)) #self.trace("registering", name, plugin) - self._name2plugin[name] = plugin reg = getattr(self, "_registercallback", None) if reg is not None: - reg(plugin, name) + reg(plugin, name) # may call addhooks + self.hook._scan_plugin(plugin) + self._name2plugin[name] = plugin if conftest: self._conftestplugins.append(plugin) else: @@ -403,41 +404,86 @@ class HookRelay: def _getcaller(self, name, plugins): caller = getattr(self, name) methods = self._pm.listattr(name, plugins=plugins) - return CachedHookCaller(caller, methods) - - -class CachedHookCaller: - def __init__(self, hookmethod, methods): - self.hookmethod = hookmethod - self.methods = methods - - def __call__(self, **kwargs): - return self.hookmethod._docall(self.methods, kwargs) - - def callextra(self, methods, **kwargs): - # XXX in theory we should respect "tryfirst/trylast" if set - # on the added methods but we currently only use it for - # pytest_generate_tests and it doesn't make sense there i'd think - all = self.methods if methods: - all = all + methods - return self.hookmethod._docall(all, kwargs) + return caller.new_cached_caller(methods) + return caller + + def _scan_plugin(self, plugin): + methods = collectattr(plugin) + hooks = {} + for hookspec in self._hookspecs: + hooks.update(collectattr(hookspec)) + + stringio = py.io.TextIO() + def Print(*args): + if args: + stringio.write(" ".join(map(str, args))) + stringio.write("\n") + + fail = False + while methods: + name, method = methods.popitem() + #print "checking", name + if isgenerichook(name): + continue + if name not in hooks: + if not getattr(method, 'optionalhook', False): + Print("found unknown hook:", name) + fail = True + else: + #print "checking", method + method_args = list(varnames(method)) + if '__multicall__' in method_args: + method_args.remove('__multicall__') + hook = hooks[name] + hookargs = varnames(hook) + for arg in method_args: + if arg not in hookargs: + Print("argument %r not available" %(arg, )) + Print("actual definition: %s" %(formatdef(method))) + Print("available hook arguments: %s" % + ", ".join(hookargs)) + fail = True + break + #if not fail: + # print "matching hook:", formatdef(method) + getattr(self, name).clear_method_cache() + + if fail: + name = getattr(plugin, '__name__', plugin) + raise PluginValidationError("%s:\n%s" % (name, stringio.getvalue())) class HookCaller: - def __init__(self, hookrelay, name, firstresult): + def __init__(self, hookrelay, name, firstresult, methods=None): self.hookrelay = hookrelay self.name = name self.firstresult = firstresult self.trace = self.hookrelay.trace + self.methods = methods + + def new_cached_caller(self, methods): + return HookCaller(self.hookrelay, self.name, self.firstresult, + methods=methods) def __repr__(self): return "" %(self.name,) + def clear_method_cache(self): + self.methods = None + def __call__(self, **kwargs): - methods = self.hookrelay._pm.listattr(self.name) + methods = self.methods + if self.methods is None: + self.methods = methods = self.hookrelay._pm.listattr(self.name) + methods = self.methods return self._docall(methods, kwargs) + def callextra(self, methods, **kwargs): + if self.methods is None: + self.reload_methods() + return self._docall(self.methods + methods, kwargs) + def _docall(self, methods, kwargs): self.trace(self.name, kwargs) self.trace.root.indent += 1 @@ -450,3 +496,25 @@ class HookCaller: self.trace.root.indent -= 1 return res + + +class PluginValidationError(Exception): + """ plugin failed validation. """ + +def isgenerichook(name): + return name == "pytest_plugins" or \ + name.startswith("pytest_funcarg__") + +def collectattr(obj): + methods = {} + for apiname in dir(obj): + if apiname.startswith("pytest_"): + methods[apiname] = getattr(obj, apiname) + return methods + +def formatdef(func): + return "%s%s" % ( + func.__name__, + inspect.formatargspec(*inspect.getargspec(func)) + ) + diff --git a/_pytest/helpconfig.py b/_pytest/helpconfig.py index 79c331145..028b3e3f1 100644 --- a/_pytest/helpconfig.py +++ b/_pytest/helpconfig.py @@ -127,70 +127,3 @@ def pytest_report_header(config): return lines -# ===================================================== -# validate plugin syntax and hooks -# ===================================================== - -def pytest_plugin_registered(manager, plugin): - methods = collectattr(plugin) - hooks = {} - for hookspec in manager.hook._hookspecs: - hooks.update(collectattr(hookspec)) - - stringio = py.io.TextIO() - def Print(*args): - if args: - stringio.write(" ".join(map(str, args))) - stringio.write("\n") - - fail = False - while methods: - name, method = methods.popitem() - #print "checking", name - if isgenerichook(name): - continue - if name not in hooks: - if not getattr(method, 'optionalhook', False): - Print("found unknown hook:", name) - fail = True - else: - #print "checking", method - method_args = list(varnames(method)) - if '__multicall__' in method_args: - method_args.remove('__multicall__') - hook = hooks[name] - hookargs = varnames(hook) - for arg in method_args: - if arg not in hookargs: - Print("argument %r not available" %(arg, )) - Print("actual definition: %s" %(formatdef(method))) - Print("available hook arguments: %s" % - ", ".join(hookargs)) - fail = True - break - #if not fail: - # print "matching hook:", formatdef(method) - if fail: - name = getattr(plugin, '__name__', plugin) - raise PluginValidationError("%s:\n%s" % (name, stringio.getvalue())) - -class PluginValidationError(Exception): - """ plugin failed validation. """ - -def isgenerichook(name): - return name == "pytest_plugins" or \ - name.startswith("pytest_funcarg__") - -def collectattr(obj): - methods = {} - for apiname in dir(obj): - if apiname.startswith("pytest_"): - methods[apiname] = getattr(obj, apiname) - return methods - -def formatdef(func): - return "%s%s" % ( - func.__name__, - inspect.formatargspec(*inspect.getargspec(func)) - ) - diff --git a/_pytest/main.py b/_pytest/main.py index 5377764de..025573bdb 100644 --- a/_pytest/main.py +++ b/_pytest/main.py @@ -164,28 +164,6 @@ class FSHookProxy(object): self.__dict__[name] = x return x - hookmethod = getattr(self.config.hook, name) - methods = self.config.pluginmanager.listattr(name, plugins=plugins) - self.__dict__[name] = x = HookCaller(hookmethod, methods) - return x - - -class HookCaller: - def __init__(self, hookmethod, methods): - self.hookmethod = hookmethod - self.methods = methods - - def __call__(self, **kwargs): - return self.hookmethod._docall(self.methods, kwargs) - - def callextra(self, methods, **kwargs): - # XXX in theory we should respect "tryfirst/trylast" if set - # on the added methods but we currently only use it for - # pytest_generate_tests and it doesn't make sense there i'd think - all = self.methods - if methods: - all = all + methods - return self.hookmethod._docall(all, kwargs) def compatproperty(name): def fget(self): diff --git a/_pytest/pytester.py b/_pytest/pytester.py index 9e987ae03..63e223af2 100644 --- a/_pytest/pytester.py +++ b/_pytest/pytester.py @@ -47,7 +47,6 @@ class PytestArg: def gethookrecorder(self, hook): hookrecorder = HookRecorder(hook._pm) - hookrecorder.start_recording(hook._hookspecs) self.request.addfinalizer(hookrecorder.finish_recording) return hookrecorder @@ -69,9 +68,7 @@ class HookRecorder: self.calls = [] self._recorders = {} - def start_recording(self, hookspecs): - if not isinstance(hookspecs, (list, tuple)): - hookspecs = [hookspecs] + hookspecs = self._pluginmanager.hook._hookspecs for hookspec in hookspecs: assert hookspec not in self._recorders class RecordCalls: diff --git a/testing/test_helpconfig.py b/testing/test_helpconfig.py index df78ccecc..7d4c7cab1 100644 --- a/testing/test_helpconfig.py +++ b/testing/test_helpconfig.py @@ -1,5 +1,5 @@ import py, pytest -from _pytest.helpconfig import collectattr +from _pytest.core import collectattr def test_version(testdir, pytestconfig): result = testdir.runpytest("--version") diff --git a/testing/test_pytester.py b/testing/test_pytester.py index b3c6cf795..59a294674 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -71,28 +71,38 @@ def test_testdir_runs_with_plugin(testdir): "*1 passed*" ]) -def test_hookrecorder_basic(): - rec = HookRecorder(PluginManager()) - class ApiClass: + +def make_holder(): + class apiclass: def pytest_xyz(self, arg): "x" - rec.start_recording(ApiClass) + def pytest_xyz_noarg(self): + "x" + + apimod = type(os)('api') + def pytest_xyz(arg): + "x" + def pytest_xyz_noarg(): + "x" + apimod.pytest_xyz = pytest_xyz + apimod.pytest_xyz_noarg = pytest_xyz_noarg + return apiclass, apimod + + +@pytest.mark.parametrize("holder", make_holder()) +def test_hookrecorder_basic(holder): + pm = PluginManager() + pm.hook._addhooks(holder, "pytest_") + rec = HookRecorder(pm) rec.hook.pytest_xyz(arg=123) call = rec.popcall("pytest_xyz") assert call.arg == 123 assert call._name == "pytest_xyz" pytest.raises(pytest.fail.Exception, "rec.popcall('abc')") + rec.hook.pytest_xyz_noarg() + call = rec.popcall("pytest_xyz_noarg") + assert call._name == "pytest_xyz_noarg" -def test_hookrecorder_basic_no_args_hook(): - rec = HookRecorder(PluginManager()) - apimod = type(os)('api') - def pytest_xyz(): - "x" - apimod.pytest_xyz = pytest_xyz - rec.start_recording(apimod) - rec.hook.pytest_xyz() - call = rec.popcall("pytest_xyz") - assert call._name == "pytest_xyz" def test_functional(testdir, linecomp): reprec = testdir.inline_runsource(""" @@ -102,8 +112,9 @@ def test_functional(testdir, linecomp): def test_func(_pytest): class ApiClass: def pytest_xyz(self, arg): "x" - hook = HookRelay([ApiClass], PluginManager()) - rec = _pytest.gethookrecorder(hook) + pm = PluginManager() + pm.hook._addhooks(ApiClass, "pytest_") + rec = _pytest.gethookrecorder(pm.hook) class Plugin: def pytest_xyz(self, arg): return arg + 1 From e635f9f9b2bf9d597892e9f35b49f807d19ede8e Mon Sep 17 00:00:00 2001 From: holger krekel Date: Wed, 1 Oct 2014 13:57:35 +0200 Subject: [PATCH 03/10] simplify _scan_plugin implementation and store argnames on HookCaller --- _pytest/core.py | 92 ++++++++++++++++++-------------------------- testing/test_core.py | 12 ++++++ 2 files changed, 50 insertions(+), 54 deletions(-) diff --git a/_pytest/core.py b/_pytest/core.py index e7a441634..04752dd71 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -68,7 +68,7 @@ class TagTracerSub: return self.__class__(self.root, self.tags + (name,)) class PluginManager(object): - def __init__(self, hookspecs=None): + def __init__(self, hookspecs=None, prefix="pytest_"): self._name2plugin = {} self._listattrcache = {} self._plugins = [] @@ -77,7 +77,7 @@ class PluginManager(object): self.trace = TagTracer().get("pluginmanage") self._plugin_distinfo = [] self._shutdown = [] - self.hook = HookRelay(hookspecs or [], pm=self) + self.hook = HookRelay(hookspecs or [], pm=self, prefix=prefix) def do_configure(self, config): # backward compatibility @@ -384,22 +384,25 @@ class HookRelay: self._hookspecs = [] self._pm = pm self.trace = pm.trace.root.get("hook") + self.prefix = prefix for hookspec in hookspecs: self._addhooks(hookspec, prefix) - def _addhooks(self, hookspecs, prefix): - self._hookspecs.append(hookspecs) + def _addhooks(self, hookspec, prefix): + self._hookspecs.append(hookspec) added = False - for name, method in vars(hookspecs).items(): + for name in dir(hookspec): if name.startswith(prefix): + method = getattr(hookspec, name) firstresult = getattr(method, 'firstresult', False) - hc = HookCaller(self, name, firstresult=firstresult) + hc = HookCaller(self, name, firstresult=firstresult, + argnames=varnames(method)) setattr(self, name, hc) added = True #print ("setting new hook", name) if not added: raise ValueError("did not find new %r hooks in %r" %( - prefix, hookspecs,)) + prefix, hookspec,)) def _getcaller(self, name, plugins): caller = getattr(self, name) @@ -409,62 +412,44 @@ class HookRelay: return caller def _scan_plugin(self, plugin): - methods = collectattr(plugin) - hooks = {} - for hookspec in self._hookspecs: - hooks.update(collectattr(hookspec)) + def fail(msg, *args): + name = getattr(plugin, '__name__', plugin) + raise PluginValidationError("plugin %r\n%s" %(name, msg % args)) - stringio = py.io.TextIO() - def Print(*args): - if args: - stringio.write(" ".join(map(str, args))) - stringio.write("\n") - - fail = False - while methods: - name, method = methods.popitem() - #print "checking", name - if isgenerichook(name): + for name in dir(plugin): + if not name.startswith(self.prefix): continue - if name not in hooks: - if not getattr(method, 'optionalhook', False): - Print("found unknown hook:", name) - fail = True - else: - #print "checking", method - method_args = list(varnames(method)) - if '__multicall__' in method_args: - method_args.remove('__multicall__') - hook = hooks[name] - hookargs = varnames(hook) - for arg in method_args: - if arg not in hookargs: - Print("argument %r not available" %(arg, )) - Print("actual definition: %s" %(formatdef(method))) - Print("available hook arguments: %s" % - ", ".join(hookargs)) - fail = True - break - #if not fail: - # print "matching hook:", formatdef(method) - getattr(self, name).clear_method_cache() - - if fail: - name = getattr(plugin, '__name__', plugin) - raise PluginValidationError("%s:\n%s" % (name, stringio.getvalue())) + hook = getattr(self, name, None) + method = getattr(plugin, name) + if hook is None: + is_optional = getattr(method, 'optionalhook', False) + if not isgenerichook(name) and not is_optional: + fail("found unknown hook: %r", name) + continue + 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)) + getattr(self, name).clear_method_cache() class HookCaller: - def __init__(self, hookrelay, name, firstresult, methods=None): + def __init__(self, hookrelay, name, firstresult, argnames, methods=None): self.hookrelay = hookrelay self.name = name self.firstresult = firstresult self.trace = self.hookrelay.trace self.methods = methods + self.argnames = ["__multicall__"] + self.argnames.extend(argnames) + assert "self" not in argnames def new_cached_caller(self, methods): return HookCaller(self.hookrelay, self.name, self.firstresult, - methods=methods) + argnames=self.argnames, methods=methods) def __repr__(self): return "" %(self.name,) @@ -474,14 +459,13 @@ class HookCaller: def __call__(self, **kwargs): methods = self.methods - if self.methods is None: + if methods is None: self.methods = methods = self.hookrelay._pm.listattr(self.name) - methods = self.methods return self._docall(methods, kwargs) def callextra(self, methods, **kwargs): - if self.methods is None: - self.reload_methods() + #if self.methods is None: + # self.reload_methods() return self._docall(self.methods + methods, kwargs) def _docall(self, methods, kwargs): diff --git a/testing/test_core.py b/testing/test_core.py index e04720bb5..018532cc2 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -630,6 +630,18 @@ class TestHookRelay: assert l == [4] assert not hasattr(mcm, 'world') + def test_argmismatch(self): + class Api: + def hello(self, arg): + "api hook 1" + pm = PluginManager(Api, prefix="he") + class Plugin: + def hello(self, argwrong): + return arg + 1 + with pytest.raises(PluginValidationError) as exc: + pm.register(Plugin()) + assert "argwrong" in str(exc.value) + def test_only_kwargs(self): pm = PluginManager() class Api: From de83d35994ce04bf201d6f372f6d35d28479c93a Mon Sep 17 00:00:00 2001 From: holger krekel Date: Wed, 1 Oct 2014 14:55:54 +0200 Subject: [PATCH 04/10] optimize argument slicing when calling plugin hooks --- _pytest/core.py | 23 ++++++++++------------- _pytest/hookspec.py | 2 +- testing/test_core.py | 9 ++++++--- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/_pytest/core.py b/_pytest/core.py index 04752dd71..d871e7418 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -292,6 +292,7 @@ class MultiCall: def __init__(self, methods, kwargs, firstresult=False): self.methods = list(methods) self.kwargs = kwargs + self.kwargs["__multicall__"] = self self.results = [] self.firstresult = firstresult @@ -302,11 +303,12 @@ class MultiCall: def execute(self): next_finalizers = [] try: + all_kwargs = self.kwargs while self.methods: method = self.methods.pop() - kwargs = self.getkwargs(method) + args = [all_kwargs[argname] for argname in varnames(method)] if hasattr(method, "hookwrapper"): - it = method(**kwargs) + it = method(*args) next = getattr(it, "next", None) if next is None: next = getattr(it, "__next__", None) @@ -316,7 +318,7 @@ class MultiCall: res = next() next_finalizers.append((method, next)) else: - res = method(**kwargs) + res = method(*args) if res is not None: self.results.append(res) if self.firstresult: @@ -334,16 +336,6 @@ class MultiCall: "wrapper contain more than one yield") - def getkwargs(self, method): - kwargs = {} - for argname in varnames(method): - try: - kwargs[argname] = self.kwargs[argname] - except KeyError: - if argname == "__multicall__": - kwargs[argname] = self - return kwargs - def varnames(func): """ return argument name tuple for a function, method, class or callable. @@ -371,12 +363,17 @@ def varnames(func): x = rawcode.co_varnames[ismethod:rawcode.co_argcount] except AttributeError: x = () + else: + defaults = func.__defaults__ + if defaults: + x = x[:-len(defaults)] try: cache["_varnames"] = x except TypeError: pass return x + class HookRelay: def __init__(self, hookspecs, pm, prefix="pytest_"): if not isinstance(hookspecs, list): diff --git a/_pytest/hookspec.py b/_pytest/hookspec.py index 6d1420740..0cc59f259 100644 --- a/_pytest/hookspec.py +++ b/_pytest/hookspec.py @@ -142,7 +142,7 @@ def pytest_generate_tests(metafunc): # ------------------------------------------------------------------------- # generic runtest related hooks # ------------------------------------------------------------------------- -def pytest_itemstart(item, node=None): +def pytest_itemstart(item, node): """ (deprecated, use pytest_runtest_logstart). """ def pytest_runtest_protocol(item, nextitem): diff --git a/testing/test_core.py b/testing/test_core.py index 018532cc2..2fd7ace10 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -436,6 +436,11 @@ def test_varnames(): assert varnames(A().f) == ('y',) assert varnames(B()) == ('z',) +def test_varnames_default(): + def f(x, y=3): + pass + assert varnames(f) == ("x",) + def test_varnames_class(): class C: def __init__(self, x): @@ -494,12 +499,10 @@ class TestMultiCall: return x + z reslist = MultiCall([f], dict(x=23, y=24)).execute() assert reslist == [24] - reslist = MultiCall([f], dict(x=23, z=2)).execute() - assert reslist == [25] def test_tags_call_error(self): multicall = MultiCall([lambda x: x], {}) - pytest.raises(TypeError, multicall.execute) + pytest.raises(KeyError, multicall.execute) def test_call_subexecute(self): def m(__multicall__): From 2161b5455533eb420993430bcb8f232b8fceccc2 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Thu, 2 Oct 2014 15:25:42 +0200 Subject: [PATCH 05/10] remove overhead for tracing of hook calls and remove some old unused code --- _pytest/config.py | 2 +- _pytest/core.py | 64 +++++++++++++++++++------------------- _pytest/helpconfig.py | 5 ++- testing/test_core.py | 6 +--- testing/test_helpconfig.py | 15 +-------- 5 files changed, 37 insertions(+), 55 deletions(-) diff --git a/_pytest/config.py b/_pytest/config.py index 450693036..c92374569 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -98,7 +98,7 @@ class PytestPluginManager(PluginManager): err = py.io.dupfile(err, encoding=encoding) except Exception: pass - self.trace.root.setwriter(err.write) + self.set_tracing(err.write) def pytest_configure(self, config): config.addinivalue_line("markers", diff --git a/_pytest/core.py b/_pytest/core.py index d871e7418..937703654 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -70,7 +70,6 @@ class TagTracerSub: class PluginManager(object): def __init__(self, hookspecs=None, prefix="pytest_"): self._name2plugin = {} - self._listattrcache = {} self._plugins = [] self._conftestplugins = [] self._warnings = [] @@ -79,6 +78,26 @@ class PluginManager(object): self._shutdown = [] self.hook = HookRelay(hookspecs or [], pm=self, prefix=prefix) + def set_tracing(self, writer): + self.trace.root.setwriter(writer) + # we reconfigure HookCalling to perform tracing + # and we avoid doing the "do we need to trace" check dynamically + # for speed reasons + assert HookCaller._docall.__name__ == "_docall" + real_docall = HookCaller._docall + def docall_tracing(self, methods, kwargs): + trace = self.hookrelay.trace + trace.root.indent += 1 + trace(self.name, kwargs) + try: + res = real_docall(self, methods, kwargs) + if res: + trace("finish", self.name, "-->", res) + finally: + trace.root.indent -= 1 + return res + HookCaller._docall = docall_tracing + def do_configure(self, config): # backward compatibility config.do_configure() @@ -129,7 +148,6 @@ class PluginManager(object): func() self._plugins = self._conftestplugins = [] self._name2plugin.clear() - self._listattrcache.clear() def isregistered(self, plugin, name=None): if self.getplugin(name) is not None: @@ -261,7 +279,6 @@ class PluginManager(object): l.append(meth) l.extend(last) l.extend(wrappers) - #self._listattrcache[key] = list(l) return l def call_plugin(self, plugin, methname, kwargs): @@ -336,7 +353,7 @@ class MultiCall: "wrapper contain more than one yield") -def varnames(func): +def varnames(func, startindex=None): """ return argument name tuple for a function, method, class or callable. In case of a class, its "__init__" method is considered. @@ -353,14 +370,16 @@ def varnames(func): func = func.__init__ except AttributeError: return () - ismethod = True + startindex = 1 else: if not inspect.isfunction(func) and not inspect.ismethod(func): func = getattr(func, '__call__', func) - ismethod = inspect.ismethod(func) + if startindex is None: + startindex = int(inspect.ismethod(func)) + rawcode = py.code.getrawcode(func) try: - x = rawcode.co_varnames[ismethod:rawcode.co_argcount] + x = rawcode.co_varnames[startindex:rawcode.co_argcount] except AttributeError: x = () else: @@ -388,12 +407,12 @@ class HookRelay: def _addhooks(self, hookspec, prefix): self._hookspecs.append(hookspec) added = False - for name in dir(hookspec): + isclass = int(inspect.isclass(hookspec)) + for name, method in vars(hookspec).items(): if name.startswith(prefix): - method = getattr(hookspec, name) firstresult = getattr(method, 'firstresult', False) hc = HookCaller(self, name, firstresult=firstresult, - argnames=varnames(method)) + argnames=varnames(method, startindex=isclass)) setattr(self, name, hc) added = True #print ("setting new hook", name) @@ -438,11 +457,10 @@ class HookCaller: self.hookrelay = hookrelay self.name = name self.firstresult = firstresult - self.trace = self.hookrelay.trace self.methods = methods self.argnames = ["__multicall__"] self.argnames.extend(argnames) - assert "self" not in argnames + assert "self" not in argnames # prevent oversights def new_cached_caller(self, methods): return HookCaller(self.hookrelay, self.name, self.firstresult, @@ -461,22 +479,11 @@ class HookCaller: return self._docall(methods, kwargs) def callextra(self, methods, **kwargs): - #if self.methods is None: - # self.reload_methods() return self._docall(self.methods + methods, kwargs) def _docall(self, methods, kwargs): - self.trace(self.name, kwargs) - self.trace.root.indent += 1 - mc = MultiCall(methods, kwargs, firstresult=self.firstresult) - try: - res = mc.execute() - if res: - self.trace("finish", self.name, "-->", res) - finally: - self.trace.root.indent -= 1 - return res - + return MultiCall(methods, kwargs, + firstresult=self.firstresult).execute() class PluginValidationError(Exception): @@ -486,13 +493,6 @@ def isgenerichook(name): return name == "pytest_plugins" or \ name.startswith("pytest_funcarg__") -def collectattr(obj): - methods = {} - for apiname in dir(obj): - if apiname.startswith("pytest_"): - methods[apiname] = getattr(obj, apiname) - return methods - def formatdef(func): return "%s%s" % ( func.__name__, diff --git a/_pytest/helpconfig.py b/_pytest/helpconfig.py index 028b3e3f1..bffada8d4 100644 --- a/_pytest/helpconfig.py +++ b/_pytest/helpconfig.py @@ -1,8 +1,7 @@ """ version info, help messages, tracing configuration. """ import py import pytest -import os, inspect, sys -from _pytest.core import varnames +import os, sys def pytest_addoption(parser): group = parser.getgroup('debugconfig') @@ -32,7 +31,7 @@ def pytest_cmdline_parse(__multicall__): f.write("versions pytest-%s, py-%s, python-%s\ncwd=%s\nargs=%s\n\n" %( pytest.__version__, py.__version__, ".".join(map(str, sys.version_info)), os.getcwd(), config._origargs)) - config.trace.root.setwriter(f.write) + config.pluginmanager.set_tracing(f.write) sys.stderr.write("writing pytestdebug information to %s\n" % path) return config diff --git a/testing/test_core.py b/testing/test_core.py index 2fd7ace10..cd8fa8982 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -274,7 +274,7 @@ class TestBootstrapping: saveindent.append(pm.trace.root.indent) raise ValueError(42) l = [] - pm.trace.root.setwriter(l.append) + pm.set_tracing(l.append) indent = pm.trace.root.indent p = api1() pm.register(p) @@ -405,11 +405,7 @@ class TestPytestPluginInteractions: 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'] - pytest.mark.trylast(getattr(P2.m, 'im_func', P2.m)) methods = pluginmanager.listattr('m') assert methods == [p2.m, p1.m, p3.m] diff --git a/testing/test_helpconfig.py b/testing/test_helpconfig.py index 7d4c7cab1..30ce9c9f2 100644 --- a/testing/test_helpconfig.py +++ b/testing/test_helpconfig.py @@ -1,5 +1,4 @@ -import py, pytest -from _pytest.core import collectattr +import pytest def test_version(testdir, pytestconfig): result = testdir.runpytest("--version") @@ -25,18 +24,6 @@ def test_help(testdir): *to see*fixtures*py.test --fixtures* """) -def test_collectattr(): - class A: - def pytest_hello(self): - pass - class B(A): - def pytest_world(self): - pass - methods = py.builtin.sorted(collectattr(B)) - assert list(methods) == ['pytest_hello', 'pytest_world'] - methods = py.builtin.sorted(collectattr(B())) - assert list(methods) == ['pytest_hello', 'pytest_world'] - def test_hookvalidation_unknown(testdir): testdir.makeconftest(""" def pytest_hello(xyz): From 3d794b6b3891c673ba6947243dee0e986581b3ec Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sat, 4 Oct 2014 15:49:31 +0200 Subject: [PATCH 06/10] factor out a small "wrapping" helper --- _pytest/core.py | 39 +++++++++++++++++++++++++++++---------- testing/test_core.py | 20 ++++++++++++++++++++ 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/_pytest/core.py b/_pytest/core.py index 937703654..cd54072bb 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -67,6 +67,24 @@ class TagTracerSub: def get(self, name): return self.__class__(self.root, self.tags + (name,)) +def add_method_controller(cls, func): + name = func.__name__ + oldcall = getattr(cls, name) + def wrap_exec(*args, **kwargs): + gen = func(*args, **kwargs) + gen.next() # first yield + res = oldcall(*args, **kwargs) + try: + gen.send(res) + except StopIteration: + pass + else: + raise ValueError("expected StopIteration") + return res + setattr(cls, name, wrap_exec) + return lambda: setattr(cls, name, oldcall) + + class PluginManager(object): def __init__(self, hookspecs=None, prefix="pytest_"): self._name2plugin = {} @@ -80,23 +98,24 @@ class PluginManager(object): def set_tracing(self, writer): self.trace.root.setwriter(writer) - # we reconfigure HookCalling to perform tracing - # and we avoid doing the "do we need to trace" check dynamically - # for speed reasons - assert HookCaller._docall.__name__ == "_docall" - real_docall = HookCaller._docall - def docall_tracing(self, methods, kwargs): + # reconfigure HookCalling to perform tracing + assert not hasattr(self, "_wrapping") + self._wrapping = True + + def _docall(self, methods, kwargs): trace = self.hookrelay.trace trace.root.indent += 1 trace(self.name, kwargs) + res = None try: - res = real_docall(self, methods, kwargs) + res = yield + finally: if res: trace("finish", self.name, "-->", res) - finally: trace.root.indent -= 1 - return res - HookCaller._docall = docall_tracing + + undo = add_method_controller(HookCaller, _docall) + self.add_shutdown(undo) def do_configure(self, config): # backward compatibility diff --git a/testing/test_core.py b/testing/test_core.py index cd8fa8982..6cafc8812 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -765,3 +765,23 @@ def test_importplugin_issue375(testdir): assert "qwe" not in str(excinfo.value) assert "aaaa" in str(excinfo.value) + +def test_wrapping(): + class A: + def f(self): + return "A.f" + + shutdown = [] + l = [] + def f(self): + l.append(1) + x = yield + l.append(2) + undo = add_method_controller(A, f) + + assert A().f() == "A.f" + assert l == [1,2] + undo() + l[:] = [] + assert A().f() == "A.f" + assert l == [] From 63f070317c0d6985a0de75faf8a933ea44057eaa Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sat, 4 Oct 2014 15:49:31 +0200 Subject: [PATCH 07/10] simplify method to record calls --- _pytest/core.py | 2 -- _pytest/pytester.py | 67 +++++++--------------------------------- testing/test_pytester.py | 6 ++-- 3 files changed, 15 insertions(+), 60 deletions(-) diff --git a/_pytest/core.py b/_pytest/core.py index cd54072bb..abab607b2 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -416,7 +416,6 @@ class HookRelay: def __init__(self, hookspecs, pm, prefix="pytest_"): if not isinstance(hookspecs, list): hookspecs = [hookspecs] - self._hookspecs = [] self._pm = pm self.trace = pm.trace.root.get("hook") self.prefix = prefix @@ -424,7 +423,6 @@ class HookRelay: self._addhooks(hookspec, prefix) def _addhooks(self, hookspec, prefix): - self._hookspecs.append(hookspec) added = False isclass = int(inspect.isclass(hookspec)) for name, method in vars(hookspec).items(): diff --git a/_pytest/pytester.py b/_pytest/pytester.py index 63e223af2..f1a9e3f8e 100644 --- a/_pytest/pytester.py +++ b/_pytest/pytester.py @@ -12,7 +12,7 @@ import subprocess import py import pytest from py.builtin import print_ -from _pytest.core import HookRelay +from _pytest.core import HookRelay, HookCaller, Wrapping from _pytest.main import Session, EXIT_OK @@ -51,10 +51,8 @@ class PytestArg: return hookrecorder class ParsedCall: - def __init__(self, name, locals): - assert '_name' not in locals - self.__dict__.update(locals) - self.__dict__.pop('self') + def __init__(self, name, kwargs): + self.__dict__.update(kwargs) self._name = name def __repr__(self): @@ -62,64 +60,24 @@ class ParsedCall: del d['_name'] return "" %(self._name, d) + class HookRecorder: def __init__(self, pluginmanager): self._pluginmanager = pluginmanager self.calls = [] - self._recorders = {} - - hookspecs = self._pluginmanager.hook._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._pluginmanager.register(recorder) - self.hook = HookRelay(hookspecs, pm=self._pluginmanager, - prefix="pytest_") + self.wrapping = Wrapping() + @self.wrapping.method(HookCaller) + def _docall(hookcaller, methods, kwargs): + self.calls.append(ParsedCall(hookcaller.name, kwargs)) + yield def finish_recording(self): - for recorder in self._recorders.values(): - if self._pluginmanager.isregistered(recorder): - self._pluginmanager.unregister(recorder) - self._recorders.clear() - - def _makecallparser(self, method): - name = method.__name__ - args, varargs, varkw, default = inspect.getargspec(method) - if not args or args[0] != "self": - args.insert(0, 'self') - fspec = inspect.formatargspec(args, varargs, varkw, default) - # we use exec because we want to have early type - # errors on wrong input arguments, using - # *args/**kwargs delays this and gives errors - # elsewhere - exec (py.code.compile(""" - def %(name)s%(fspec)s: - self._recorder.calls.append( - ParsedCall(%(name)r, locals())) - """ % locals())) - return locals()[name] + self.wrapping.undo() def getcalls(self, names): if isinstance(names, str): names = names.split() - for name in names: - for cls in self._recorders: - if name in vars(cls): - break - else: - raise ValueError("callname %r not found in %r" %( - name, self._recorders.keys())) - l = [] - for call in self.calls: - if call._name in names: - l.append(call) - return l + return [call for call in self.calls if call._name in names] def contains(self, entries): __tracebackhide__ = True @@ -228,10 +186,9 @@ class TmpTestdir: obj = obj.config if hasattr(obj, 'hook'): obj = obj.hook - assert hasattr(obj, '_hookspecs'), obj + assert isinstance(obj, HookRelay) reprec = ReportRecorder(obj) reprec.hookrecorder = self._pytest.gethookrecorder(obj) - reprec.hook = reprec.hookrecorder.hook return reprec def chdir(self): diff --git a/testing/test_pytester.py b/testing/test_pytester.py index 59a294674..ef817e68c 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -94,12 +94,12 @@ def test_hookrecorder_basic(holder): pm = PluginManager() pm.hook._addhooks(holder, "pytest_") rec = HookRecorder(pm) - rec.hook.pytest_xyz(arg=123) + pm.hook.pytest_xyz(arg=123) call = rec.popcall("pytest_xyz") assert call.arg == 123 assert call._name == "pytest_xyz" pytest.raises(pytest.fail.Exception, "rec.popcall('abc')") - rec.hook.pytest_xyz_noarg() + pm.hook.pytest_xyz_noarg() call = rec.popcall("pytest_xyz_noarg") assert call._name == "pytest_xyz_noarg" @@ -119,7 +119,7 @@ def test_functional(testdir, linecomp): def pytest_xyz(self, arg): return arg + 1 rec._pluginmanager.register(Plugin()) - res = rec.hook.pytest_xyz(arg=41) + res = pm.hook.pytest_xyz(arg=41) assert res == [42] """) reprec.assertoutcome(passed=1) From 8cfec56a82749be52bc76c58121e132c751e99a8 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Mon, 6 Oct 2014 13:37:57 +0200 Subject: [PATCH 08/10] simplify internal pytester machinery --- _pytest/core.py | 2 +- _pytest/pytester.py | 200 +++++++++++++++---------------------- bench/bench.py | 2 +- testing/test_collection.py | 16 +-- testing/test_core.py | 5 +- testing/test_pytester.py | 25 +---- 6 files changed, 96 insertions(+), 154 deletions(-) diff --git a/_pytest/core.py b/_pytest/core.py index abab607b2..af888024e 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -72,7 +72,7 @@ def add_method_controller(cls, func): oldcall = getattr(cls, name) def wrap_exec(*args, **kwargs): gen = func(*args, **kwargs) - gen.next() # first yield + next(gen) # first yield res = oldcall(*args, **kwargs) try: gen.send(res) diff --git a/_pytest/pytester.py b/_pytest/pytester.py index f1a9e3f8e..ed4580f4a 100644 --- a/_pytest/pytester.py +++ b/_pytest/pytester.py @@ -1,5 +1,4 @@ """ (disabled by default) support for testing pytest and pytest plugins. """ -import inspect import sys import os import codecs @@ -12,7 +11,7 @@ import subprocess import py import pytest from py.builtin import print_ -from _pytest.core import HookRelay, HookCaller, Wrapping +from _pytest.core import HookCaller, add_method_controller from _pytest.main import Session, EXIT_OK @@ -38,17 +37,6 @@ def pytest_configure(config): _pytest_fullpath = os.path.abspath(pytest.__file__.rstrip("oc")) _pytest_fullpath = _pytest_fullpath.replace("$py.class", ".py") -def pytest_funcarg___pytest(request): - return PytestArg(request) - -class PytestArg: - def __init__(self, request): - self.request = request - - def gethookrecorder(self, hook): - hookrecorder = HookRecorder(hook._pm) - self.request.addfinalizer(hookrecorder.finish_recording) - return hookrecorder class ParsedCall: def __init__(self, name, kwargs): @@ -65,21 +53,22 @@ class HookRecorder: def __init__(self, pluginmanager): self._pluginmanager = pluginmanager self.calls = [] - self.wrapping = Wrapping() - @self.wrapping.method(HookCaller) + def _docall(hookcaller, methods, kwargs): self.calls.append(ParsedCall(hookcaller.name, kwargs)) yield + self._undo_wrapping = add_method_controller(HookCaller, _docall) + pluginmanager.add_shutdown(self._undo_wrapping) def finish_recording(self): - self.wrapping.undo() + self._undo_wrapping() def getcalls(self, names): if isinstance(names, str): names = names.split() return [call for call in self.calls if call._name in names] - def contains(self, entries): + def assert_contains(self, entries): __tracebackhide__ = True i = 0 entries = list(entries) @@ -115,6 +104,69 @@ class HookRecorder: assert len(l) == 1, (name, l) return l[0] + # functionality for test reports + + def getreports(self, + names="pytest_runtest_logreport pytest_collectreport"): + return [x.report for x in self.getcalls(names)] + + def matchreport(self, inamepart="", + names="pytest_runtest_logreport pytest_collectreport", when=None): + """ return a testreport whose dotted import path matches """ + l = [] + for rep in self.getreports(names=names): + try: + if not when and rep.when != "call" and rep.passed: + # setup/teardown passing reports - let's ignore those + continue + except AttributeError: + pass + if when and getattr(rep, 'when', None) != when: + continue + if not inamepart or inamepart in rep.nodeid.split("::"): + l.append(rep) + if not l: + raise ValueError("could not find test report matching %r: " + "no test reports at all!" % (inamepart,)) + if len(l) > 1: + raise ValueError( + "found 2 or more testreports matching %r: %s" %(inamepart, l)) + return l[0] + + def getfailures(self, + names='pytest_runtest_logreport pytest_collectreport'): + return [rep for rep in self.getreports(names) if rep.failed] + + def getfailedcollections(self): + return self.getfailures('pytest_collectreport') + + def listoutcomes(self): + passed = [] + skipped = [] + failed = [] + for rep in self.getreports( + "pytest_collectreport pytest_runtest_logreport"): + if rep.passed: + if getattr(rep, "when", None) == "call": + passed.append(rep) + elif rep.skipped: + skipped.append(rep) + elif rep.failed: + failed.append(rep) + return passed, skipped, failed + + def countoutcomes(self): + return [len(x) for x in self.listoutcomes()] + + def assertoutcome(self, passed=0, skipped=0, failed=0): + realpassed, realskipped, realfailed = self.listoutcomes() + assert passed == len(realpassed) + assert skipped == len(realskipped) + assert failed == len(realfailed) + + def clear(self): + self.calls[:] = [] + def pytest_funcarg__linecomp(request): return LineComp() @@ -150,7 +202,6 @@ class TmpTestdir: def __init__(self, request): self.request = request self.Config = request.config.__class__ - self._pytest = request.getfuncargvalue("_pytest") # XXX remove duplication with tmpdir plugin basetmp = request.config._tmpdirhandler.ensuretemp("testdir") name = request.function.__name__ @@ -181,14 +232,10 @@ class TmpTestdir: if fn and fn.startswith(str(self.tmpdir)): del sys.modules[name] - def getreportrecorder(self, obj): - if hasattr(obj, 'config'): - obj = obj.config - if hasattr(obj, 'hook'): - obj = obj.hook - assert isinstance(obj, HookRelay) - reprec = ReportRecorder(obj) - reprec.hookrecorder = self._pytest.gethookrecorder(obj) + def make_hook_recorder(self, pluginmanager): + assert not hasattr(pluginmanager, "reprec") + pluginmanager.reprec = reprec = HookRecorder(pluginmanager) + self.request.addfinalizer(reprec.finish_recording) return reprec def chdir(self): @@ -307,26 +354,23 @@ class TmpTestdir: def inline_genitems(self, *args): return self.inprocess_run(list(args) + ['--collectonly']) - def inline_run(self, *args): - items, rec = self.inprocess_run(args) - return rec + def inprocess_run(self, args, plugins=()): + rec = self.inline_run(*args, plugins=plugins) + items = [x.item for x in rec.getcalls("pytest_itemcollected")] + return items, rec - def inprocess_run(self, args, plugins=None): + def inline_run(self, *args, **kwargs): rec = [] - items = [] class Collect: def pytest_configure(x, config): - rec.append(self.getreportrecorder(config)) - def pytest_itemcollected(self, item): - items.append(item) - if not plugins: - plugins = [] + rec.append(self.make_hook_recorder(config.pluginmanager)) + plugins = kwargs.get("plugins") or [] plugins.append(Collect()) ret = pytest.main(list(args), plugins=plugins) + assert len(rec) == 1 reprec = rec[0] reprec.ret = ret - assert len(rec) == 1 - return items, reprec + return reprec def parseconfig(self, *args): args = [str(x) for x in args] @@ -501,86 +545,6 @@ def getdecoded(out): return "INTERNAL not-utf8-decodeable, truncated string:\n%s" % ( py.io.saferepr(out),) -class ReportRecorder(object): - def __init__(self, hook): - self.hook = hook - self.pluginmanager = hook._pm - self.pluginmanager.register(self) - - def getcall(self, name): - return self.hookrecorder.getcall(name) - - def popcall(self, name): - return self.hookrecorder.popcall(name) - - def getcalls(self, names): - """ return list of ParsedCall instances matching the given eventname. """ - return self.hookrecorder.getcalls(names) - - # functionality for test reports - - def getreports(self, names="pytest_runtest_logreport pytest_collectreport"): - return [x.report for x in self.getcalls(names)] - - def matchreport(self, inamepart="", - names="pytest_runtest_logreport pytest_collectreport", when=None): - """ return a testreport whose dotted import path matches """ - l = [] - for rep in self.getreports(names=names): - try: - if not when and rep.when != "call" and rep.passed: - # setup/teardown passing reports - let's ignore those - continue - except AttributeError: - pass - if when and getattr(rep, 'when', None) != when: - continue - if not inamepart or inamepart in rep.nodeid.split("::"): - l.append(rep) - if not l: - raise ValueError("could not find test report matching %r: no test reports at all!" % - (inamepart,)) - if len(l) > 1: - raise ValueError("found more than one testreport matching %r: %s" %( - inamepart, l)) - return l[0] - - def getfailures(self, names='pytest_runtest_logreport pytest_collectreport'): - return [rep for rep in self.getreports(names) if rep.failed] - - def getfailedcollections(self): - return self.getfailures('pytest_collectreport') - - def listoutcomes(self): - passed = [] - skipped = [] - failed = [] - for rep in self.getreports( - "pytest_collectreport pytest_runtest_logreport"): - if rep.passed: - if getattr(rep, "when", None) == "call": - passed.append(rep) - elif rep.skipped: - skipped.append(rep) - elif rep.failed: - failed.append(rep) - return passed, skipped, failed - - def countoutcomes(self): - return [len(x) for x in self.listoutcomes()] - - def assertoutcome(self, passed=0, skipped=0, failed=0): - realpassed, realskipped, realfailed = self.listoutcomes() - assert passed == len(realpassed) - assert skipped == len(realskipped) - assert failed == len(realfailed) - - def clear(self): - self.hookrecorder.calls[:] = [] - - def unregister(self): - self.pluginmanager.unregister(self) - self.hookrecorder.finish_recording() class LineComp: def __init__(self): diff --git a/bench/bench.py b/bench/bench.py index c99bc3234..ce9496417 100644 --- a/bench/bench.py +++ b/bench/bench.py @@ -9,4 +9,4 @@ if __name__ == '__main__': p = pstats.Stats("prof") p.strip_dirs() p.sort_stats('cumulative') - print(p.print_stats(250)) + print(p.print_stats(500)) diff --git a/testing/test_collection.py b/testing/test_collection.py index 4adf46886..754f3c9ab 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -334,9 +334,9 @@ class TestSession: assert item.name == "test_func" newid = item.nodeid assert newid == id - py.std.pprint.pprint(hookrec.hookrecorder.calls) + py.std.pprint.pprint(hookrec.calls) topdir = testdir.tmpdir # noqa - hookrec.hookrecorder.contains([ + hookrec.assert_contains([ ("pytest_collectstart", "collector.fspath == topdir"), ("pytest_make_collect_report", "collector.fspath == topdir"), ("pytest_collectstart", "collector.fspath == p"), @@ -381,9 +381,9 @@ class TestSession: id = p.basename items, hookrec = testdir.inline_genitems(id) - py.std.pprint.pprint(hookrec.hookrecorder.calls) + py.std.pprint.pprint(hookrec.calls) assert len(items) == 2 - hookrec.hookrecorder.contains([ + hookrec.assert_contains([ ("pytest_collectstart", "collector.fspath == collector.session.fspath"), ("pytest_collectstart", @@ -404,8 +404,8 @@ class TestSession: items, hookrec = testdir.inline_genitems() assert len(items) == 1 - py.std.pprint.pprint(hookrec.hookrecorder.calls) - hookrec.hookrecorder.contains([ + py.std.pprint.pprint(hookrec.calls) + hookrec.assert_contains([ ("pytest_collectstart", "collector.fspath == test_aaa"), ("pytest_pycollect_makeitem", "name == 'test_func'"), ("pytest_collectreport", @@ -425,8 +425,8 @@ class TestSession: items, hookrec = testdir.inline_genitems(id) assert len(items) == 2 - py.std.pprint.pprint(hookrec.hookrecorder.calls) - hookrec.hookrecorder.contains([ + py.std.pprint.pprint(hookrec.calls) + hookrec.assert_contains([ ("pytest_collectstart", "collector.fspath == test_aaa"), ("pytest_pycollect_makeitem", "name == 'test_func'"), ("pytest_collectreport", "report.nodeid == 'aaa/test_aaa.py'"), diff --git a/testing/test_core.py b/testing/test_core.py index 6cafc8812..5e7113974 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -149,7 +149,7 @@ class TestBootstrapping: mod.pytest_plugins = "pytest_a" aplugin = testdir.makepyfile(pytest_a="#") pluginmanager = get_plugin_manager() - reprec = testdir.getreportrecorder(pluginmanager) + reprec = testdir.make_hook_recorder(pluginmanager) #syspath.prepend(aplugin.dirpath()) py.std.sys.path.insert(0, str(aplugin.dirpath())) pluginmanager.consider_module(mod) @@ -771,11 +771,10 @@ def test_wrapping(): def f(self): return "A.f" - shutdown = [] l = [] def f(self): l.append(1) - x = yield + yield l.append(2) undo = add_method_controller(A, f) diff --git a/testing/test_pytester.py b/testing/test_pytester.py index ef817e68c..ac57f2c87 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -3,9 +3,9 @@ import os from _pytest.pytester import HookRecorder from _pytest.core import PluginManager -def test_reportrecorder(testdir): +def test_make_hook_recorder(testdir): item = testdir.getitem("def test_func(): pass") - recorder = testdir.getreportrecorder(item.config) + recorder = testdir.make_hook_recorder(item.config.pluginmanager) assert not recorder.getfailures() pytest.xfail("internal reportrecorder tests need refactoring") @@ -104,27 +104,6 @@ def test_hookrecorder_basic(holder): assert call._name == "pytest_xyz_noarg" -def test_functional(testdir, linecomp): - reprec = testdir.inline_runsource(""" - import pytest - from _pytest.core import HookRelay, PluginManager - pytest_plugins="pytester" - def test_func(_pytest): - class ApiClass: - def pytest_xyz(self, arg): "x" - pm = PluginManager() - pm.hook._addhooks(ApiClass, "pytest_") - rec = _pytest.gethookrecorder(pm.hook) - class Plugin: - def pytest_xyz(self, arg): - return arg + 1 - rec._pluginmanager.register(Plugin()) - res = pm.hook.pytest_xyz(arg=41) - assert res == [42] - """) - reprec.assertoutcome(passed=1) - - def test_makepyfile_unicode(testdir): global unichr try: From 1d10db4bab7529f7788291936932e09440071e0d Mon Sep 17 00:00:00 2001 From: holger krekel Date: Mon, 6 Oct 2014 14:06:17 +0200 Subject: [PATCH 09/10] cleanup core collection of python methods and remove unncessary cache --- _pytest/python.py | 42 ++++++++++++++++-------------------------- 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/_pytest/python.py b/_pytest/python.py index 997793f76..810baf155 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -363,7 +363,7 @@ class PyCollector(PyobjMixin, pytest.Collector): Function = self._getcustomclass("Function") if not metafunc._calls: - yield Function(name, parent=self) + yield Function(name, parent=self, fixtureinfo=fixtureinfo) else: # add funcargs() as fixturedefs to fixtureinfo.arg2fixturedefs add_funcarg_pseudo_fixture_def(self, metafunc, fm) @@ -372,6 +372,7 @@ class PyCollector(PyobjMixin, pytest.Collector): subname = "%s[%s]" %(name, callspec.id) yield Function(name=subname, parent=self, callspec=callspec, callobj=funcobj, + fixtureinfo=fixtureinfo, keywords={callspec.id:True}) def add_funcarg_pseudo_fixture_def(collector, metafunc, fixturemanager): @@ -1067,28 +1068,27 @@ class Function(FunctionMixin, pytest.Item, FuncargnamesCompatAttr): """ _genid = None def __init__(self, name, parent, args=None, config=None, - callspec=None, callobj=NOTSET, keywords=None, session=None): + callspec=None, callobj=NOTSET, keywords=None, session=None, + fixtureinfo=None): super(Function, self).__init__(name, parent, config=config, session=session) self._args = args if callobj is not NOTSET: self.obj = callobj - for name, val in (py.builtin._getfuncdict(self.obj) or {}).items(): - self.keywords[name] = val + self.keywords.update(self.obj.__dict__) if callspec: - for name, val in callspec.keywords.items(): - self.keywords[name] = val - if keywords: - for name, val in keywords.items(): - self.keywords[name] = val - - isyield = self._isyieldedfunction() - self._fixtureinfo = fi = self.session._fixturemanager.getfixtureinfo( - self.parent, self.obj, self.cls, funcargs=not isyield) - self.fixturenames = fi.names_closure - if callspec is not None: self.callspec = callspec + self.keywords.update(callspec.keywords) + if keywords: + self.keywords.update(keywords) + + if fixtureinfo is None: + fixtureinfo = self.session._fixturemanager.getfixtureinfo( + self.parent, self.obj, self.cls, + funcargs=not self._isyieldedfunction()) + self._fixtureinfo = fixtureinfo + self.fixturenames = fixtureinfo.names_closure self._initrequest() def _initrequest(self): @@ -1573,15 +1573,8 @@ class FixtureManager: self._nodeid_and_autousenames = [("", self.config.getini("usefixtures"))] session.config.pluginmanager.register(self, "funcmanage") - self._nodename2fixtureinfo = {} def getfixtureinfo(self, node, func, cls, funcargs=True): - # node is the "collection node" for "func" - key = (node, func) - try: - return self._nodename2fixtureinfo[key] - except KeyError: - pass if funcargs and not hasattr(node, "nofuncargs"): if cls is not None: startindex = 1 @@ -1597,10 +1590,7 @@ class FixtureManager: fm = node.session._fixturemanager names_closure, arg2fixturedefs = fm.getfixtureclosure(initialnames, node) - fixtureinfo = FuncFixtureInfo(argnames, names_closure, - arg2fixturedefs) - self._nodename2fixtureinfo[key] = fixtureinfo - return fixtureinfo + 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 From a43fb9cd93a301eb24a8725fc78564cc541403e6 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Tue, 7 Oct 2014 16:16:47 +0200 Subject: [PATCH 10/10] fix add_method_controller to deal properly in the event of exceptions. add a docstring as well. --- _pytest/core.py | 29 +++++++++--- testing/test_core.py | 104 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 113 insertions(+), 20 deletions(-) diff --git a/_pytest/core.py b/_pytest/core.py index af888024e..f2c120cf3 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -67,20 +67,39 @@ class TagTracerSub: def get(self, name): return self.__class__(self.root, self.tags + (name,)) + def add_method_controller(cls, func): + """ Use func as the method controler for the method found + at the class named func.__name__. + + A method controler is invoked with the same arguments + as the function it substitutes and is required to yield once + which will trigger calling the controlled method. + If it yields a second value, the value will be returned + as the result of the invocation. Errors in the controlled function + are re-raised to the controller during the first yield. + """ name = func.__name__ oldcall = getattr(cls, name) def wrap_exec(*args, **kwargs): gen = func(*args, **kwargs) next(gen) # first yield - res = oldcall(*args, **kwargs) try: - gen.send(res) - except StopIteration: - pass + res = oldcall(*args, **kwargs) + except Exception: + excinfo = sys.exc_info() + try: + # reraise exception to controller + res = gen.throw(*excinfo) + except StopIteration: + py.builtin._reraise(*excinfo) else: - raise ValueError("expected StopIteration") + try: + res = gen.send(res) + except StopIteration: + pass return res + setattr(cls, name, wrap_exec) return lambda: setattr(cls, name, oldcall) diff --git a/testing/test_core.py b/testing/test_core.py index 5e7113974..03bc4813e 100644 --- a/testing/test_core.py +++ b/testing/test_core.py @@ -765,22 +765,96 @@ def test_importplugin_issue375(testdir): assert "qwe" not in str(excinfo.value) assert "aaaa" in str(excinfo.value) +class TestWrapMethod: + def test_basic_happypath(self): + class A: + def f(self): + return "A.f" -def test_wrapping(): - class A: + l = [] def f(self): - return "A.f" + l.append(1) + yield + l.append(2) + undo = add_method_controller(A, f) - l = [] - def f(self): - l.append(1) - yield - l.append(2) - undo = add_method_controller(A, f) + assert A().f() == "A.f" + assert l == [1,2] + undo() + l[:] = [] + assert A().f() == "A.f" + assert l == [] - assert A().f() == "A.f" - assert l == [1,2] - undo() - l[:] = [] - assert A().f() == "A.f" - assert l == [] + def test_method_raises(self): + class A: + def error(self, val): + raise ValueError(val) + + l = [] + def error(self, val): + l.append(val) + try: + yield + except ValueError: + l.append(None) + raise + + + undo = add_method_controller(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): + try: + yield + except ValueError: + yield 2 + + add_method_controller(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_controller(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): + try: + yield (1,), {'val2': 2} + except ValueError as ex: + assert ex.args == (3,) + l.append(1) + + add_method_controller(A, error) + with pytest.raises(ValueError): + A().error() + assert l == [1]