Repository: pytest-dev/pluggy (python)

Issue:
One hookspec to many hookimpl (implementations that don't match spec function names)
This is a proposal for an enhancement.  Before I make my request, thank you for all the work associated with this project.

My use case involves the need to support any number of hook implementations, which are essentially operations for an application to perform.  For example:

- someapp_operation_do_something
- someapp_operation_do_something_else
- someapp_operation_do_something_different
... etc

Maintaining flexibility here is important for our use case.  Having to go back and create a spec every time someone wants to implement a new operation would be cumbersome.  It would be great if I could define a hookspec like:

```
    @hookspec
    def someapp_operation(self, context):
        pass
```

And then the implementations became something like this:

```
    @hookimpl(spec="someapp_operation")
    def someapp_operation_with_a_different_name(self, context):
        do_some_stuff()
```

If I happen to call an operation that doesn't exist (wasn't actually implemented), then I can deal with the AttributeError.  That being said, it gets us away from the need to maintain N specs in our application.

The other way that I was thinking that this could be handled is just having each operation implement someapp_operation, but that would probably be something like:

```
    @hookimpl
    def someapp_operation(self, operation_name, context):
        if operation_name == "some_operation_name":
            do_something()
```

This just feels really ugly to me and prone to error.

I did some initial research into _HookCaller and set_specification(), but I won't get ahead of myself until it is clear that this might be worth investigating further and that there isn't already a pattern for handling this.

Thanks again!

Pull request description:
add specname option to hookimpl
This PR adds a `specname` option to `@hookimpl`.  If `specname` is provided, it will be used instead of the function name when matching this hook implementation to a hook specification during registration (allowing a plugin to register a hook implementation that was not named the same thing as the corresponding `hookspec`). 
(Edited:) This is related to #218 (though likely different than that poster’s intentions)

Most of the interesting discussion is happening in #218 (@RonnyPfannschmidt voiced some opposition to the idea at one point), and you can see my use-case/rationale for wanting this feature in https://github.com/pytest-dev/pluggy/issues/218#issuecomment-578554932.  But I figured since it's a small change, I would submit this so you could see exactly what I was proposing to make the discussion more concrete.  Curious to hear your thoughts.

EDIT:
I should add that I'm not 100% sure this addresses the original poster's request in #218.  They wanted a many-to-one registration, I simply want registration of non-matching function names.

Patch:
diff --git a/src/pluggy/hooks.py b/src/pluggy/hooks.py
index 0a1c287..79d246a 100644
--- a/src/pluggy/hooks.py
+++ b/src/pluggy/hooks.py
@@ -73,6 +73,7 @@ class HookimplMarker(object):
         optionalhook=False,
         tryfirst=False,
         trylast=False,
+        specname=None,
     ):
 
         """ if passed a function, directly sets attributes on the function
@@ -96,6 +97,9 @@ class HookimplMarker(object):
         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 @@ class HookimplMarker(object):
                     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 07b42cb..22dd78d 100644
--- a/src/pluggy/manager.py
+++ b/src/pluggy/manager.py
@@ -118,6 +118,7 @@ class PluginManager(object):
                 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)


Test patch:
diff --git a/testing/test_hookcaller.py b/testing/test_hookcaller.py
index 5664f2b..cc345d4 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 test_hookrelay_registry(pm):
     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()