diff --git a/CHANGELOG b/CHANGELOG index 75357d6d5..ef773c59f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -26,6 +26,23 @@ 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. + +- 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. + +- 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/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/config.py b/_pytest/config.py index 7948a7c39..f37d417b2 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, varnames # pytest startup # @@ -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 @@ -59,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: @@ -80,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) @@ -97,8 +98,7 @@ class PytestPluginManager(PluginManager): super(PytestPluginManager, self).__init__(prefix="pytest_", excludefunc=exclude_pytest_names) self._warnings = [] - self._plugin_distinfo = [] - self._globalplugins = [] + self._conftest_plugins = set() # state related to local conftest plugins self._path2confmods = {} @@ -114,28 +114,35 @@ 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): + + 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 and not conftest: - self._globalplugins.append(plugin) + if ret: + self.hook.pytest_plugin_registered.call_historic( + kwargs=dict(plugin=plugin, manager=self)) 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: - self._globalplugins.remove(plugin) - except ValueError: - pass + def getplugin(self, name): + # support deprecated naming because plugins (xdist e.g.) use it + 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.") @@ -143,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 @@ -186,14 +196,21 @@ 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 @@ -217,6 +234,8 @@ class PytestPluginManager(PluginManager): mod = conftestpath.pyimport() 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: @@ -233,24 +252,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'): - name = ep.name - if name.startswith("pytest_"): - name = name[7:] - if ep.name in self._name2plugin or name in self._name2plugin: - continue - try: - plugin = ep.load() - except DistributionNotFound: - continue - 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:]): if opt1 == "-p": @@ -258,18 +259,12 @@ 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__, - conftest=True): + if self.register(conftestmodule, name=conftestmodule.__file__): self.consider_module(conftestmodule) def consider_env(self): @@ -291,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 @@ -685,6 +680,7 @@ class Notset: notset = Notset() FILE_OR_DIR = 'file_or_dir' + class Config(object): """ access to configuration values, pluginmanager and plugin hooks. """ @@ -706,20 +702,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(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 @@ -729,26 +716,27 @@ 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(kwargs=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() - 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.getplugin("terminalreporter")._tw + return self.pluginmanager.get_plugin("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 @@ -778,8 +766,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: @@ -794,13 +781,9 @@ 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) - + @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) @@ -817,7 +800,10 @@ class Config(object): args[:] = self.getini("addopts") + args self._checkversion() self.pluginmanager.consider_preparse(args) - self.pluginmanager.consider_setuptools_entrypoints() + 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: @@ -850,6 +836,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( + 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 ae3da5381..5dcc30801 100644 --- a/_pytest/core.py +++ b/_pytest/core.py @@ -2,11 +2,65 @@ 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) +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 + + +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 = {} @@ -53,42 +107,28 @@ class TagTracer: assert isinstance(tags, tuple) self._tag2proc[tags] = processor + 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,)) -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" % (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 @@ -133,6 +173,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. @@ -144,197 +203,228 @@ 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): self._prefix = prefix self._excludefunc = excludefunc self._name2plugin = {} - self._plugins = [] self._plugin2hookcallers = {} + self._plugin_distinfo = [] 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): + # called from all hookcaller instances. + # enable_tracing will set its own wrapping function at self._inner_hookexec + 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) - methods = self.listattr(name, plugins=plugins) - return HookCaller(caller.name, caller.firstresult, - argnames=caller.argnames, methods=methods) + def subset_hook_caller(self, name, remove_plugins): + """ Return a new HookCaller instance for the named method + which manages calls to all registered plugins except the + ones from remove_plugins. """ + orig = getattr(self.hook, name) + plugins_to_remove = [plugin for plugin in remove_plugins + if hasattr(plugin, name)] + if plugins_to_remove: + 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 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): + """ 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) + + 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" %( - name, plugin, self._name2plugin)) - #self.trace("registering", name, plugin) - # allow subclasses to intercept here by calling a helper - return self._do_register(plugin, name) + plugin_name, plugin, self._name2plugin)) - def _do_register(self, plugin, name): - hookcallers = list(self._scan_plugin(plugin)) - self._plugin2hookcallers[plugin] = hookcallers - 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 + self._name2plugin[plugin_name] = plugin - def unregister(self, plugin): - """ unregister the plugin object and all its contained hook implementations + # register prefix-matching hook specs 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 plugin_name + + def unregister(self, plugin=None, name=None): + """ unregister a 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] - hookcallers = self._plugin2hookcallers.pop(plugin) - for hookcaller in hookcallers: - self._scan_methods(hookcaller) + if name is 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) + + # if self._name2plugin[name] == None registration was blocked: ignore + 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. """ - isclass = int(inspect.isclass(module_or_class)) 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) + hc = getattr(self.hook, name, None) + if hc is None: + hc = HookCaller(name, self._hookexec, module_or_class) + setattr(self.hook, name, hc) + else: + # plugins registered this hook without knowing the spec + hc.set_specification(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" %(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 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 getplugin(self, name): + def get_plugin(self, name): """ 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: + 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_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: + 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): + """ 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) + if not hook.has_spec(): + 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 load_setuptools_entrypoints(self, entrypoint_name): + """ 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: - meth = getattr(plugin, attrname) - except AttributeError: + plugin = ep.load() + except DistributionNotFound: 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) - - 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)) - - 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 - 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 - - def _get_canonical_name(self, plugin): - return getattr(plugin, "__name__", None) or str(id(plugin)) - + 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. """ + # 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 = list(methods) + 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 = [] + firstresult = self.firstresult + while self.methods: method = self.methods.pop() args = [all_kwargs[argname] for argname in varnames(method)] @@ -342,11 +432,19 @@ class MultiCall: return wrapped_call(method(*args), self.execute) res = method(*args) if res is not None: - self.results.append(res) - if self.firstresult: + if firstresult: return res - if not self.firstresult: - return self.results + results.append(res) + + if not firstresult: + 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): @@ -361,17 +459,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: @@ -390,32 +488,95 @@ 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: - def __init__(self, name, firstresult, argnames, methods=()): +class HookCaller(object): + def __init__(self, name, hook_execute, specmodule_or_class=None): self.name = name - self.firstresult = firstresult - self.argnames = ["__multicall__"] - self.argnames.extend(argnames) + self._plugins = [] + self._wrappers = [] + self._nonwrappers = [] + self._hookexec = hook_execute + if specmodule_or_class is not None: + self.set_specification(specmodule_or_class) + + def has_spec(self): + return hasattr(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) + argnames = varnames(specfunc, startindex=isclass(specmodule_or_class)) assert "self" not in argnames # sanity check - self.methods = methods + self.argnames = ["__multicall__"] + list(argnames) + self.firstresult = getattr(specfunc, 'firstresult', False) + if hasattr(specfunc, "historic"): + self._call_history = [] + + def is_historic(self): + return hasattr(self, "_call_history") + + 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_plugin(self, plugin): + self._plugins.append(plugin) + self._add_method(getattr(plugin, self.name)) + + def _add_method(self, meth): + if hasattr(meth, 'hookwrapper'): + methods = self._wrappers + else: + methods = self._nonwrappers + + 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,) def __call__(self, **kwargs): - return self._docall(self.methods, kwargs) + assert not self.is_historic() + return self._hookexec(self, self._nonwrappers + self._wrappers, kwargs) - def callextra(self, methods, **kwargs): - return self._docall(self.methods + methods, 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 _docall(self, methods, kwargs): - return MultiCall(methods, kwargs, - firstresult=self.firstresult).execute() + def call_extra(self, methods, kwargs): + """ 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: + self._add_method(method) + try: + return self(**kwargs) + finally: + self._nonwrappers, self._wrappers = old + + def _maybe_apply_history(self, method): + if self.is_historic(): + for kwargs, proc in self._call_history: + res = self._hookexec(self, [method], kwargs) + if res and proc is not None: + proc(res[0]) class PluginValidationError(Exception): @@ -425,5 +586,5 @@ class PluginValidationError(Exception): def formatdef(func): return "%s%s" % ( func.__name__, - inspect.formatargspec(*inspect.getargspec(func)) + formatargspec(*getargspec(func)) ) diff --git a/_pytest/helpconfig.py b/_pytest/helpconfig.py index 7976ae826..72fae555f 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() @@ -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/hookspec.py b/_pytest/hookspec.py index d0bc33936..cf8947ada 100644 --- a/_pytest/hookspec.py +++ b/_pytest/hookspec.py @@ -1,27 +1,30 @@ """ hook specifications for pytest plugins, invoked from main.py and builtin plugins. """ +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. """ -def pytest_cmdline_parse(pluginmanager, args): - """return initialized config object, parsing the specified args. """ -pytest_cmdline_parse.firstresult = True +@hookspec_opts(historic=True) +def pytest_plugin_registered(plugin, manager): + """ a new pytest plugin got registered. """ -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. @@ -47,35 +50,43 @@ 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 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 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. """ - -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 +95,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 +123,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.""" @@ -142,9 +153,16 @@ 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). """ +@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 +176,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 +195,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 @@ -199,6 +216,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 @@ -220,9 +240,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,17 +256,14 @@ 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 # ------------------------------------------------------------------------- -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/main.py b/_pytest/main.py index ed7d6aad9..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 @@ -362,9 +361,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. @@ -519,12 +515,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 @@ -541,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/_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/pytester.py b/_pytest/pytester.py index 2cecf7c47..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, add_method_wrapper +from _pytest.core import 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/_pytest/python.py b/_pytest/python.py index 9071d03de..e849ca6fe 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() @@ -375,13 +375,16 @@ 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.call_extra(methods, + dict(metafunc=metafunc)) + else: + self.ihook.pytest_generate_tests(metafunc=metafunc) Function = self._getcustomclass("Function") if not metafunc._calls: @@ -1621,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"))] @@ -1646,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__) @@ -1665,13 +1663,6 @@ class FixtureManager: if p.sep != "/": nodeid = nodeid.replace(p.sep, "/") self.parsefactories(plugin, nodeid) - self._seenplugins.add(plugin) - - @pytest.mark.tryfirst - 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/_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..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) @@ -265,7 +267,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 +352,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/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 2e10417fe..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.mark.hookwrapper - 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..78431c8ee --- /dev/null +++ b/doc/en/writing_plugins.txt @@ -0,0 +1,495 @@ +.. _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. + +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 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 +------------------------------------------------- + +.. 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. + + + +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 +------------------------ + +.. 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: + 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..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.mark.trylast -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/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_config.py b/testing/test_config.py index f1e3c5eb9..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(""" @@ -348,14 +348,15 @@ 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 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 bc4546cd6..4975e7e05 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 @@ -17,65 +17,410 @@ 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_register_mismatch_method(self): - pm = get_plugin_manager() + 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() + 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: 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() + 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() 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] + assert not pm.is_registered(my) + assert my not in pm.get_plugins() - 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): + 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(kwargs=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(lambda res: l.append(res), dict(arg=1)) + l = [] + class Plugin: + def he_method1(self, arg): + return arg * 10 + + 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): + pass + pm.addhooks(Hooks) + + def he_method1(arg): + return arg * 10 + + l = pm.hook.he_method1.call_extra([he_method1], dict(arg=1)) + 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 == [] + l[:] = [] + + pm.hook.he_method1(arg=1) + assert l == [10] + + + +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_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(): + 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_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] + + 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() + 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 + + 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")) + with pytest.raises(ImportError): + pm.load_setuptools_entrypoints("qwe") class TestPytestPluginInteractions: @@ -93,9 +438,12 @@ 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)) config.pluginmanager._importconftest(conf) - print(config.pluginmanager.getplugins()) + #print(config.pluginmanager.get_plugins()) res = config.hook.pytest_myhook(xyz=10) assert res == [11] @@ -166,64 +514,44 @@ 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: - x = 41 - def pytest_plugin_registered(self, plugin): + def pytest_plugin_registered(self): saveindent.append(pytestpm.trace.root.indent) - raise ValueError(42) + class api2: + def pytest_plugin_registered(self): + saveindent.append(pytestpm.trace.root.indent) + raise ValueError() l = [] - pytestpm.set_tracing(l.append) - indent = pytestpm.trace.root.indent - p = api1() - pytestpm.register(p) + pytestpm.trace.root.setwriter(l.append) + undo = pytestpm.enable_tracing() + 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() - # lower level API + def test_warn_on_deprecated_multicall(self, pytestpm): + class Plugin: + def pytest_configure(self, __multicall__): + pass - 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] + 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): @@ -373,35 +701,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 @@ -598,109 +897,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 @@ -710,21 +906,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() @@ -733,11 +929,11 @@ 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_plugin_manager() + pytestpm = get_config().pluginmanager mod = py.std.types.ModuleType("x") mod.pytest_plugins = "pytest_a" aplugin = testdir.makepyfile(pytest_a="#") @@ -776,51 +972,27 @@ 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): - 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.getplugin("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(""" 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=",") 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")') @@ -830,13 +1002,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): @@ -847,7 +1019,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): @@ -863,15 +1035,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_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": 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')