diff --git a/CHANGELOG b/CHANGELOG index 09de8bba0..e577f912d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -9,6 +9,10 @@ Changes between 1.X and 1.1.1 - new "pytestconfig" funcarg allows access to test config object +- collection/item node specific runtest/collect hooks are only called exactly + on matching conftest.py files, i.e. ones which are exactly below + the filesystem path of an item + - robustify capturing to survive if custom pytest_runtest_setup code failed and prevented the capturing setup code from running. diff --git a/py/impl/test/collect.py b/py/impl/test/collect.py index fd667d0e2..c47eb3890 100644 --- a/py/impl/test/collect.py +++ b/py/impl/test/collect.py @@ -11,6 +11,18 @@ def configproperty(name): return self.config._getcollectclass(name, self.fspath) return property(fget) +class HookProxy: + def __init__(self, node): + self.node = node + def __getattr__(self, name): + if name[0] == "_": + raise AttributeError(name) + hookmethod = getattr(self.node.config.hook, name) + def call_matching_hooks(**kwargs): + plugins = self.node.config.getmatchingplugins(self.node.fspath) + return hookmethod.pcall(plugins, **kwargs) + return call_matching_hooks + class Node(object): """ base class for Nodes in the collection tree. Collector nodes have children and @@ -29,6 +41,7 @@ class Node(object): self.parent = parent self.config = getattr(parent, 'config', None) self.fspath = getattr(parent, 'fspath', None) + self.ihook = HookProxy(self) def _checkcollectable(self): if not hasattr(self, 'fspath'): @@ -426,13 +439,12 @@ class Directory(FSCollector): return res def consider_file(self, path): - return self.config.hook.pytest_collect_file(path=path, parent=self) + return self.ihook.pytest_collect_file(path=path, parent=self) def consider_dir(self, path, usefilters=None): if usefilters is not None: py.log._apiwarn("0.99", "usefilters argument not needed") - return self.config.hook.pytest_collect_directory( - path=path, parent=self) + return self.ihook.pytest_collect_directory(path=path, parent=self) class Item(Node): """ a basic test item. """ diff --git a/py/impl/test/config.py b/py/impl/test/config.py index f4519086c..9b174d13f 100644 --- a/py/impl/test/config.py +++ b/py/impl/test/config.py @@ -45,6 +45,13 @@ class Config(object): self.trace("loaded conftestmodule %r" %(conftestmodule,)) self.pluginmanager.consider_conftest(conftestmodule) + def getmatchingplugins(self, fspath): + conftests = self._conftest._conftestpath2mod.values() + plugins = [x for x in self.pluginmanager.getplugins() + if x not in conftests] + plugins += self._conftest.getconftestmodules(fspath) + return plugins + def trace(self, msg): if getattr(self.option, 'traceconfig', None): self.hook.pytest_trace(category="config", msg=msg) diff --git a/py/impl/test/conftesthandle.py b/py/impl/test/conftesthandle.py index 08aa4c13e..49b7ad3fc 100644 --- a/py/impl/test/conftesthandle.py +++ b/py/impl/test/conftesthandle.py @@ -11,6 +11,7 @@ class Conftest(object): def __init__(self, onimport=None): self._path2confmods = {} self._onimport = onimport + self._conftestpath2mod = {} def setinitial(self, args): """ try to find a first anchor path for looking up global values @@ -65,17 +66,20 @@ class Conftest(object): raise KeyError(name) def importconftest(self, conftestpath): - # Using caching here looks redundant since ultimately - # sys.modules caches already assert conftestpath.check(), conftestpath - if not conftestpath.dirpath('__init__.py').check(file=1): - # HACK: we don't want any "globally" imported conftest.py, - # prone to conflicts and subtle problems - modname = str(conftestpath).replace('.', conftestpath.sep) - mod = conftestpath.pyimport(modname=modname) - else: - mod = conftestpath.pyimport() - return self._postimport(mod) + try: + return self._conftestpath2mod[conftestpath] + except KeyError: + if not conftestpath.dirpath('__init__.py').check(file=1): + # HACK: we don't want any "globally" imported conftest.py, + # prone to conflicts and subtle problems + modname = str(conftestpath).replace('.', conftestpath.sep) + mod = conftestpath.pyimport(modname=modname) + else: + mod = conftestpath.pyimport() + self._postimport(mod) + self._conftestpath2mod[conftestpath] = mod + return mod def _postimport(self, mod): if self._onimport: diff --git a/py/impl/test/dist/dsession.py b/py/impl/test/dist/dsession.py index e435d7f90..4cc5b2d31 100644 --- a/py/impl/test/dist/dsession.py +++ b/py/impl/test/dist/dsession.py @@ -223,7 +223,7 @@ class DSession(Session): nodes = self.item2nodes.setdefault(item, []) assert node not in nodes nodes.append(node) - self.config.hook.pytest_itemstart(item=item, node=node) + item.ihook.pytest_itemstart(item=item, node=node) tosend[:] = tosend[room:] # update inplace if tosend: # we have some left, give it to the main loop @@ -242,7 +242,7 @@ class DSession(Session): # "sending same item %r to multiple " # "not implemented" %(item,)) self.item2nodes.setdefault(item, []).append(node) - self.config.hook.pytest_itemstart(item=item, node=node) + item.ihook.pytest_itemstart(item=item, node=node) pending.extend(sending) tosend[:] = tosend[room:] # update inplace if not tosend: @@ -267,7 +267,7 @@ class DSession(Session): info = "!!! Node %r crashed during running of test %r" %(node, item) rep = runner.ItemTestReport(item=item, excinfo=info, when="???") rep.node = node - self.config.hook.pytest_runtest_logreport(report=rep) + item.ihook.pytest_runtest_logreport(report=rep) def setup(self): """ setup any neccessary resources ahead of the test run. """ diff --git a/py/impl/test/funcargs.py b/py/impl/test/funcargs.py index bbf043ae3..87ab44c1c 100644 --- a/py/impl/test/funcargs.py +++ b/py/impl/test/funcargs.py @@ -93,7 +93,7 @@ class FuncargRequest: self.fspath = pyfuncitem.fspath if hasattr(pyfuncitem, '_requestparam'): self.param = pyfuncitem._requestparam - self._plugins = self.config.pluginmanager.getplugins() + self._plugins = self.config.getmatchingplugins(self.fspath) self._plugins.append(self.module) if self.instance is not None: self._plugins.append(self.instance) diff --git a/py/impl/test/pluginmanager.py b/py/impl/test/pluginmanager.py index 2c1f3a7a5..28951b24b 100644 --- a/py/impl/test/pluginmanager.py +++ b/py/impl/test/pluginmanager.py @@ -136,8 +136,8 @@ class PluginManager(object): # API for interacting with registered and instantiated plugin objects # # - def listattr(self, attrname, plugins=None, extra=()): - return self.registry.listattr(attrname, plugins=plugins, extra=extra) + def listattr(self, attrname, plugins=None): + return self.registry.listattr(attrname, plugins=plugins) def notify_exception(self, excinfo=None): if excinfo is None: @@ -271,12 +271,11 @@ class Registry: def __iter__(self): return iter(self._plugins) - def listattr(self, attrname, plugins=None, extra=(), reverse=False): + def listattr(self, attrname, plugins=None, reverse=False): l = [] if plugins is None: plugins = self._plugins - candidates = list(plugins) + list(extra) - for plugin in candidates: + for plugin in plugins: try: l.append(getattr(plugin, attrname)) except AttributeError: @@ -291,32 +290,29 @@ class HookRelay: self._registry = registry for name, method in vars(hookspecs).items(): if name[:1] != "_": - setattr(self, name, self._makecall(name)) - - def _makecall(self, name, extralookup=None): - hookspecmethod = getattr(self._hookspecs, name) - firstresult = getattr(hookspecmethod, 'firstresult', False) - return HookCaller(self, name, firstresult=firstresult, - extralookup=extralookup) - - def _getmethods(self, name, extralookup=()): - return self._registry.listattr(name, extra=extralookup) + firstresult = getattr(method, 'firstresult', False) + hc = HookCaller(self, name, firstresult=firstresult) + setattr(self, name, hc) def _performcall(self, name, multicall): return multicall.execute() class HookCaller: - def __init__(self, hookrelay, name, firstresult, extralookup=None): + def __init__(self, hookrelay, name, firstresult): self.hookrelay = hookrelay self.name = name self.firstresult = firstresult - self.extralookup = extralookup and [extralookup] or () def __repr__(self): return "" %(self.name,) def __call__(self, **kwargs): - methods = self.hookrelay._getmethods(self.name, self.extralookup) + methods = self.hookrelay._registry.listattr(self.name) + mc = MultiCall(methods, kwargs, firstresult=self.firstresult) + return self.hookrelay._performcall(self.name, mc) + + def pcall(self, plugins, **kwargs): + methods = self.hookrelay._registry.listattr(self.name, plugins=plugins) mc = MultiCall(methods, kwargs, firstresult=self.firstresult) return self.hookrelay._performcall(self.name, mc) diff --git a/py/impl/test/pycollect.py b/py/impl/test/pycollect.py index b9d5292cd..420c9c3c9 100644 --- a/py/impl/test/pycollect.py +++ b/py/impl/test/pycollect.py @@ -120,7 +120,7 @@ class PyCollectorMixin(PyobjMixin, py.test.collect.Collector): return self.join(name) def makeitem(self, name, obj): - return self.config.hook.pytest_pycollect_makeitem( + return self.ihook.pytest_pycollect_makeitem( collector=self, name=name, obj=obj) def _istestclasscandidate(self, name, obj): @@ -137,9 +137,9 @@ class PyCollectorMixin(PyobjMixin, py.test.collect.Collector): cls = clscol and clscol.obj or None metafunc = funcargs.Metafunc(funcobj, config=self.config, cls=cls, module=module) - gentesthook = self.config.hook._makecall( - "pytest_generate_tests", extralookup=module) - gentesthook(metafunc=metafunc) + gentesthook = self.config.hook.pytest_generate_tests + plugins = self.config.getmatchingplugins(self.fspath) + [module] + gentesthook.pcall(plugins, metafunc=metafunc) if not metafunc._calls: return self.Function(name, parent=self) return funcargs.FunctionCollector(name=name, @@ -338,7 +338,7 @@ class Function(FunctionMixin, py.test.collect.Item): def runtest(self): """ execute the underlying test function. """ - self.config.hook.pytest_pyfunc_call(pyfuncitem=self) + self.ihook.pytest_pyfunc_call(pyfuncitem=self) def setup(self): super(Function, self).setup() diff --git a/py/plugin/pytest_runner.py b/py/plugin/pytest_runner.py index 7e6fe9827..acc399c94 100644 --- a/py/plugin/pytest_runner.py +++ b/py/plugin/pytest_runner.py @@ -39,7 +39,7 @@ def pytest_runtest_protocol(item): if item.config.getvalue("boxed"): reports = forked_run_report(item) for rep in reports: - item.config.hook.pytest_runtest_logreport(report=rep) + item.ihook.pytest_runtest_logreport(report=rep) else: runtestprotocol(item) return True @@ -85,7 +85,7 @@ def pytest_report_teststatus(report): def call_and_report(item, when, log=True): call = call_runtest_hook(item, when) - hook = item.config.hook + hook = item.ihook report = hook.pytest_runtest_makereport(item=item, call=call) if log and (when == "call" or not report.passed): hook.pytest_runtest_logreport(report=report) @@ -93,8 +93,8 @@ def call_and_report(item, when, log=True): def call_runtest_hook(item, when): hookname = "pytest_runtest_" + when - hook = getattr(item.config.hook, hookname) - return CallInfo(lambda: hook(item=item), when=when) + ihook = getattr(item.ihook, hookname) + return CallInfo(lambda: ihook(item=item), when=when) class CallInfo: excinfo = None diff --git a/testing/pytest/test_funcargs.py b/testing/pytest/test_funcargs.py index 64072d77d..420dd5827 100644 --- a/testing/pytest/test_funcargs.py +++ b/testing/pytest/test_funcargs.py @@ -482,3 +482,25 @@ class TestGenfuncFunctional: "*test_myfunc*world*FAIL*", "*1 failed, 1 passed*" ]) + + +def test_conftest_funcargs_only_available_in_subdir(testdir): + sub1 = testdir.mkpydir("sub1") + sub2 = testdir.mkpydir("sub2") + sub1.join("conftest.py").write(py.code.Source(""" + import py + def pytest_funcarg__arg1(request): + py.test.raises(Exception, "request.getfuncargvalue('arg2')") + """)) + sub2.join("conftest.py").write(py.code.Source(""" + import py + def pytest_funcarg__arg2(request): + py.test.raises(Exception, "request.getfuncargvalue('arg1')") + """)) + + sub1.join("test_in_sub1.py").write("def test_1(arg1): pass") + sub2.join("test_in_sub2.py").write("def test_2(arg2): pass") + result = testdir.runpytest("-v") + result.stdout.fnmatch_lines([ + "*2 passed*" + ]) diff --git a/testing/pytest/test_pluginmanager.py b/testing/pytest/test_pluginmanager.py index 721cb90cc..0ab444185 100644 --- a/testing/pytest/test_pluginmanager.py +++ b/testing/pytest/test_pluginmanager.py @@ -395,12 +395,6 @@ class TestRegistry: l = list(plugins.listattr('x', reverse=True)) assert l == [43, 42, 41] - class api4: - x = 44 - l = list(plugins.listattr('x', extra=(api4,))) - assert l == [41,42,43,44] - assert len(list(plugins)) == 3 # otherwise extra added - class TestHookRelay: def test_happypath(self): registry = Registry() @@ -441,23 +435,3 @@ class TestHookRelay: res = mcm.hello(arg=3) assert res == 4 - def test_hooks_extra_plugins(self): - registry = Registry() - class Api: - def hello(self, arg): - pass - hookrelay = HookRelay(hookspecs=Api, registry=registry) - hook_hello = hookrelay.hello - class Plugin: - def hello(self, arg): - return arg + 1 - registry.register(Plugin()) - class Plugin2: - def hello(self, arg): - return arg + 2 - newhook = hookrelay._makecall("hello", extralookup=Plugin2()) - l = newhook(arg=3) - assert l == [5, 4] - l2 = hook_hello(arg=3) - assert l2 == [4] - diff --git a/testing/pytest/test_pycollect.py b/testing/pytest/test_pycollect.py index 39cd3ad74..a0cbdb2f8 100644 --- a/testing/pytest/test_pycollect.py +++ b/testing/pytest/test_pycollect.py @@ -461,3 +461,49 @@ class TestReportinfo: def test_method(self): pass """ + +def test_setup_only_available_in_subdir(testdir): + sub1 = testdir.mkpydir("sub1") + sub2 = testdir.mkpydir("sub2") + sub1.join("conftest.py").write(py.code.Source(""" + import py + def pytest_runtest_setup(item): + assert item.fspath.purebasename == "test_in_sub1" + def pytest_runtest_call(item): + assert item.fspath.purebasename == "test_in_sub1" + def pytest_runtest_teardown(item): + assert item.fspath.purebasename == "test_in_sub1" + """)) + sub2.join("conftest.py").write(py.code.Source(""" + import py + def pytest_runtest_setup(item): + assert item.fspath.purebasename == "test_in_sub2" + def pytest_runtest_call(item): + assert item.fspath.purebasename == "test_in_sub2" + def pytest_runtest_teardown(item): + assert item.fspath.purebasename == "test_in_sub2" + """)) + sub1.join("test_in_sub1.py").write("def test_1(): pass") + sub2.join("test_in_sub2.py").write("def test_2(): pass") + result = testdir.runpytest("-v", "-s") + result.stdout.fnmatch_lines([ + "*2 passed*" + ]) + +def test_generate_tests_only_done_in_subdir(testdir): + sub1 = testdir.mkpydir("sub1") + sub2 = testdir.mkpydir("sub2") + sub1.join("conftest.py").write(py.code.Source(""" + def pytest_generate_tests(metafunc): + assert metafunc.function.__name__ == "test_1" + """)) + sub2.join("conftest.py").write(py.code.Source(""" + def pytest_generate_tests(metafunc): + assert metafunc.function.__name__ == "test_2" + """)) + sub1.join("test_in_sub1.py").write("def test_1(): pass") + sub2.join("test_in_sub2.py").write("def test_2(): pass") + result = testdir.runpytest("-v", "-s", sub1, sub2, sub1) + result.stdout.fnmatch_lines([ + "*3 passed*" + ])