diff --git a/src/pluggy/hooks.py b/src/pluggy/hooks.py index 0a1c2871..79d246ac 100644 --- a/src/pluggy/hooks.py +++ b/src/pluggy/hooks.py @@ -73,6 +73,7 @@ def __call__( optionalhook=False, tryfirst=False, trylast=False, + specname=None, ): """ if passed a function, directly sets attributes on the function @@ -96,6 +97,9 @@ def __call__( representing the exception or result outcome of the inner calls (including other hookwrapper calls). + If ``specname`` is provided, it will be used instead of the function name when + matching this hook implementation to a hook specification during registration. + """ def setattr_hookimpl_opts(func): @@ -107,6 +111,7 @@ def setattr_hookimpl_opts(func): optionalhook=optionalhook, tryfirst=tryfirst, trylast=trylast, + specname=specname, ), ) return func @@ -122,6 +127,7 @@ def normalize_hookimpl_opts(opts): opts.setdefault("trylast", False) opts.setdefault("hookwrapper", False) opts.setdefault("optionalhook", False) + opts.setdefault("specname", None) if hasattr(inspect, "getfullargspec"): diff --git a/src/pluggy/manager.py b/src/pluggy/manager.py index 07b42cba..22dd78d6 100644 --- a/src/pluggy/manager.py +++ b/src/pluggy/manager.py @@ -118,6 +118,7 @@ def register(self, plugin, name=None): normalize_hookimpl_opts(hookimpl_opts) method = getattr(plugin, name) hookimpl = HookImpl(plugin, plugin_name, method, hookimpl_opts) + name = hookimpl_opts.get("specname") or name hook = getattr(self.hook, name, None) if hook is None: hook = _HookCaller(name, self._hookexec) diff --git a/testing/test_hookcaller.py b/testing/test_hookcaller.py index 5664f2bb..cc345d4c 100644 --- a/testing/test_hookcaller.py +++ b/testing/test_hookcaller.py @@ -1,6 +1,6 @@ import pytest -from pluggy import HookimplMarker, HookspecMarker +from pluggy import HookimplMarker, HookspecMarker, PluginValidationError from pluggy.hooks import HookImpl hookspec = HookspecMarker("example") @@ -213,3 +213,60 @@ def hello(self, arg): assert not hasattr(hook, "world") pm.unregister(plugin) assert hook.hello(arg=3) == [] + + +def test_hookrelay_registration_by_specname(pm): + """Verify hook caller instances may also be registered by specifying a + specname option to the hookimpl""" + + class Api(object): + @hookspec + def hello(self, arg): + "api hook 1" + + pm.add_hookspecs(Api) + hook = pm.hook + assert hasattr(hook, "hello") + assert len(pm.hook.hello.get_hookimpls()) == 0 + + class Plugin(object): + @hookimpl(specname="hello") + def foo(self, arg): + return arg + 1 + + plugin = Plugin() + pm.register(plugin) + out = hook.hello(arg=3) + assert out == [4] + + +def test_hookrelay_registration_by_specname_raises(pm): + """Verify using specname still raises the types of errors during registration as it + would have without using specname.""" + + class Api(object): + @hookspec + def hello(self, arg): + "api hook 1" + + pm.add_hookspecs(Api) + + # make sure a bad signature still raises an error when using specname + class Plugin(object): + @hookimpl(specname="hello") + def foo(self, arg, too, many, args): + return arg + 1 + + with pytest.raises(PluginValidationError): + pm.register(Plugin()) + + # make sure check_pending still fails if specname doesn't have a + # corresponding spec. EVEN if the function name matches one. + class Plugin2(object): + @hookimpl(specname="bar") + def hello(self, arg): + return arg + 1 + + pm.register(Plugin2()) + with pytest.raises(PluginValidationError): + pm.check_pending()