Repository: holoviz/holoviews

Issue:
DynamicMap cannot handle partial bound method callable
#### ALL software version info
HoloViews 1.18.1
Bokeh 3.3.1

#### Description of expected behavior and the observed behavior
If you provide a bound method callable with `functools.partial` to a DynamicMap it will crash. It works fine with bound functions and bound static methods.

#### Complete, minimal, self-contained example code that reproduces the issue

```python
import holoviews as hv
import numpy as np
import functools


class MyClass:

    def __init__(self):
        pass

    def make_dmap(self):
        dmap = hv.DynamicMap(functools.partial(self.make_plot, freq=1), kdims=['phase'])
        return dmap.redim.range(phase=(0.5, 1))

    def make_dmap_static(self):
        dmap = hv.DynamicMap(functools.partial(self.make_plot_static, freq=1), kdims=['phase'])
        return dmap.redim.range(phase=(0.5, 1))

    def make_plot(self, phase, freq):
        xvals = [0.1* i for i in range(100)]
        return hv.Curve((xvals, [np.sin(phase+freq*x) for x in xvals]))

    @staticmethod
    def make_plot_static(phase, freq):
        xvals = [0.1* i for i in range(100)]
        return hv.Curve((xvals, [np.sin(phase+freq*x) for x in xvals]))


MyClass().make_dmap_static()  # will work
MyClass().make_dmap()  # will not work
```

#### Stack traceback and/or browser JavaScript console output
```
 File "/home/mathurin/PycharmProjects/holoviews/toast.py", line 15, in make_dmap
    dmap = hv.DynamicMap(functools.partial(self.make_plot, freq=1), kdims=['phase'])
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/mathurin/miniconda3/envs/holoviews/lib/python3.11/site-packages/holoviews/core/spaces.py", line 807, in __init__
    self._posarg_keys = util.validate_dynamic_argspec(
                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/mathurin/miniconda3/envs/holoviews/lib/python3.11/site-packages/holoviews/core/util.py", line 499, in validate_dynamic_argspec
    raise KeyError(f'Callable {name!r} accepts more positional arguments than '
KeyError: "Callable 'functools.partial(<bound method MyClass.make_plot of <bokeh_app_45540ef9abcd44a88a8ac4cb605b7b56.MyClass object at 0x7f8a5e413310>>, freq=1)' accepts more positional arguments than there are kdims and stream parameters"
```

In the `argspec` function in holoviews/core/util.py [here](https://github.com/holoviz/holoviews/blob/main/holoviews/core/util.py#L427C8-L427C8), we could simply check whether `calllable_obj.func` for partials is a method and if so filter out the first argument:

```python
...
    elif isinstance(callable_obj, partial): # partials
        arglen = len(callable_obj.args)
        spec = inspect.getfullargspec(callable_obj.func)
        args = [arg for arg in spec.args[arglen:] if arg not in callable_obj.keywords]
        if inspect.ismethod(callable_obj.func):
            args = args[1:]
...
```

- [x] I may be interested in making a pull request to address this



Discussion:
Because partial can be nested, I think we should do something like this before the if statement:

``` python 
while isinstance(callable_obj, functools.partial):
    callable_obj = callable_obj.func
```

(This is under the assumption that the if statement can handle a class method.) 
Well `inspect.ismethod` also evaluates to `True` for class methods so I think we're good.

Resolving patch:
diff --git a/holoviews/core/util.py b/holoviews/core/util.py
--- a/holoviews/core/util.py
+++ b/holoviews/core/util.py
@@ -445,6 +445,8 @@ def argspec(callable_obj):
         arglen = len(callable_obj.args)
         spec = inspect.getfullargspec(callable_obj.func)
         args = [arg for arg in spec.args[arglen:] if arg not in callable_obj.keywords]
+        if inspect.ismethod(callable_obj.func):
+            args = args[1:]
     elif inspect.ismethod(callable_obj):    # instance and class methods
         spec = inspect.getfullargspec(callable_obj)
         args = spec.args[1:]


Test patch:
diff --git a/holoviews/tests/core/test_callable.py b/holoviews/tests/core/test_callable.py
--- a/holoviews/tests/core/test_callable.py
+++ b/holoviews/tests/core/test_callable.py
@@ -24,6 +24,9 @@ def somestaticmethod(): pass
     @classmethod
     def someclsmethod(cls): pass
 
+    def someinstancemethod(self, x, y):
+        return x + y
+
     def __call__(self, *testargs):
         return sum(testargs)
 
@@ -66,6 +69,9 @@ def test_callable_class_name(self):
     def test_callable_class_call_method(self):
         self.assertEqual(Callable(CallableClass().__call__).name, 'CallableClass')
 
+    def test_callable_instance_method(self):
+        assert Callable(CallableClass().someinstancemethod).name == 'CallableClass.someinstancemethod'
+
     def test_classmethod_name(self):
         self.assertEqual(Callable(CallableClass().someclsmethod).name,
                          'CallableClass.someclsmethod')
@@ -116,6 +122,12 @@ def test_callable_partial(self):
     def test_callable_class(self):
         self.assertEqual(Callable(CallableClass())(1,2,3,4), 10)
 
+    def test_callable_instance_method(self):
+        assert Callable(CallableClass().someinstancemethod)(1, 2) == 3
+
+    def test_callable_partial_instance_method(self):
+        assert Callable(partial(CallableClass().someinstancemethod, x=1))(2) == 3
+
     def test_callable_paramfunc(self):
         self.assertEqual(Callable(ParamFunc)(3,b=5), 15)
 
@@ -143,6 +155,14 @@ def test_callable_class_argspec(self):
         self.assertEqual(Callable(CallableClass()).argspec.keywords, None)
         self.assertEqual(Callable(CallableClass()).argspec.varargs, 'testargs')
 
+    def test_callable_instance_method(self):
+        assert Callable(CallableClass().someinstancemethod).argspec.args == ['x', 'y']
+        assert Callable(CallableClass().someinstancemethod).argspec.keywords is None
+
+    def test_callable_partial_instance_method(self):
+        assert Callable(partial(CallableClass().someinstancemethod, x=1)).argspec.args == ['y']
+        assert Callable(partial(CallableClass().someinstancemethod, x=1)).argspec.keywords is None
+
     def test_callable_paramfunc_argspec(self):
         self.assertEqual(Callable(ParamFunc).argspec.args, ['a'])
         self.assertEqual(Callable(ParamFunc).argspec.keywords, 'params')
@@ -170,6 +190,12 @@ def test_callable_lambda(self):
     def test_callable_partial(self):
         self.assertEqual(Callable(partial(lambda x,y: x+y,x=4))(y=5), 9)
 
+    def test_callable_instance_method(self):
+        assert Callable(CallableClass().someinstancemethod)(x=1, y=2) == 3
+
+    def test_callable_partial_instance_method(self):
+        assert Callable(partial(CallableClass().someinstancemethod, x=1))(y=2) == 3
+
     def test_callable_paramfunc(self):
         self.assertEqual(Callable(ParamFunc)(a=3,b=5), 15)