diff --git a/docs/index.rst b/docs/index.rst index 264c18db..4b019b39 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -384,6 +384,21 @@ dynamically loaded plugins. For more info see :ref:`call_historic`. +Warnings on hook implementation +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +As projects evolve new hooks may be introduced and/or deprecated. + +if a hookspec specifies a ``warn_on_impl``, pluggy will trigger it for any plugin implementing the hook. + + +.. code-block:: python + + @hookspec(warn_on_impl=DeprecationWarning("oldhook is deprecated and will be removed soon")) + def oldhook(): + pass + + .. links .. _@contextlib.contextmanager: https://docs.python.org/3.6/library/contextlib.html#contextlib.contextmanager diff --git a/pluggy/hooks.py b/pluggy/hooks.py index 8e8afce4..fe385988 100644 --- a/pluggy/hooks.py +++ b/pluggy/hooks.py @@ -17,7 +17,7 @@ class HookspecMarker(object): def __init__(self, project_name): self.project_name = project_name - def __call__(self, function=None, firstresult=False, historic=False): + def __call__(self, function=None, firstresult=False, historic=False, warn_on_impl=None): """ if passed a function, directly sets attributes on the function which will make it discoverable to add_hookspecs(). If passed no function, returns a decorator which can be applied to a function @@ -35,7 +35,8 @@ def setattr_hookspec_opts(func): if historic and firstresult: raise ValueError("cannot have a historic firstresult hook") setattr(func, self.project_name + "_spec", - dict(firstresult=firstresult, historic=historic)) + dict(firstresult=firstresult, historic=historic, + warn_on_impl=warn_on_impl,)) return func if function is not None: @@ -195,6 +196,7 @@ def set_specification(self, specmodule_or_class, spec_opts): self.spec_opts.update(spec_opts) if spec_opts.get("historic"): self._call_history = [] + self.warn_on_impl = spec_opts.get('warn_on_impl') def is_historic(self): return hasattr(self, "_call_history") diff --git a/pluggy/manager.py b/pluggy/manager.py index 36034d8a..9f549e09 100644 --- a/pluggy/manager.py +++ b/pluggy/manager.py @@ -1,12 +1,23 @@ import inspect from . import _tracing from .hooks import HookImpl, _HookRelay, _HookCaller, normalize_hookimpl_opts +import warnings + + +def _warn_for_function(warning, function): + warnings.warn_explicit( + warning, + type(warning), + lineno=function.__code__.co_firstlineno, + filename=function.__code__.co_filename, + ) class PluginValidationError(Exception): - """ plugin failed validation. + """ plugin failed validation. - :param object plugin: the plugin which failed validation, may be a module or an arbitrary object. + :param object plugin: the plugin which failed validation, + may be a module or an arbitrary object. """ def __init__(self, plugin, message): @@ -188,7 +199,8 @@ def _verify_hook(self, hook, hookimpl): hookimpl.plugin, "Plugin %r\nhook %r\nhistoric incompatible to hookwrapper" % (hookimpl.plugin_name, hook.name)) - + if hook.warn_on_impl: + _warn_for_function(hook.warn_on_impl, hookimpl.function) # positional arg checking notinspec = set(hookimpl.argnames) - set(hook.argnames) if notinspec: diff --git a/testing/test_details.py b/testing/test_details.py index 233b77b9..083f3f05 100644 --- a/testing/test_details.py +++ b/testing/test_details.py @@ -46,6 +46,30 @@ def x1meth2(self): assert pm.hook.x1meth2._wrappers[0].hookwrapper +def test_warn_when_deprecated_specified(recwarn): + warning = DeprecationWarning("foo is deprecated") + + class Spec(object): + @hookspec(warn_on_impl=warning) + def foo(self): + pass + + class Plugin(object): + @hookimpl + def foo(self): + pass + + pm = PluginManager(hookspec.project_name) + pm.add_hookspecs(Spec) + + with pytest.warns(DeprecationWarning) as records: + pm.register(Plugin()) + (record,) = records + assert record.message is warning + assert record.filename == Plugin.foo.__code__.co_filename + assert record.lineno == Plugin.foo.__code__.co_firstlineno + + def test_plugin_getattr_raises_errors(): """Pluggy must be able to handle plugins which raise weird exceptions when getattr() gets called (#11).