Repository: fgmacedo/python-statemachine

Issue:
bug: The dynamic dispatcher is not filling default values for parameters on callbacks.
* Python State Machine version: 1.0.3
* Python version: 3.11
* Operating System: Any

### Description

The dynamic dispatcher is not filling default values for parameters on callbacks.

``` py
from statemachine import State
from statemachine import StateMachine


class XMachine(StateMachine):
    initial = State(initial=True, enter="enter_with_default_value")

    test = initial.to.itself()

    def enter_with_default_value(self, value: int = 42):
        assert value == 42, value

XMachine()
```

Raises:

```py
    assert value == 42, value
           ^^^^^^^^^^^
AssertionError: None
```

Development notes: Migrate `dispatcher.py` to [inspect.signature](https://docs.python.org/3/library/inspect.html#inspect.signature) that has a nicer API instead of the currently [inspect.getfullargspec](https://docs.python.org/3/library/inspect.html#inspect.getfullargspec).

Comments:

Patch:
diff --git a/docs/releases/2.0.0.md b/docs/releases/2.0.0.md
index f20779c..67c375a 100644
--- a/docs/releases/2.0.0.md
+++ b/docs/releases/2.0.0.md
@@ -49,6 +49,12 @@ See {ref}`internal transition` for more details.
   guards using decorators is now possible.
 
 
+## Bugfixes in 2.0
+
+- [#341](https://github.com/fgmacedo/python-statemachine/issues/341): Fix dynamic dispatch
+  on methods with default parameters.
+
+
 ## Backward incompatible changes in 2.0
 
 - TODO
diff --git a/statemachine/dispatcher.py b/statemachine/dispatcher.py
index d3f2c42..29841a8 100644
--- a/statemachine/dispatcher.py
+++ b/statemachine/dispatcher.py
@@ -1,9 +1,9 @@
-import inspect
 from collections import namedtuple
 from functools import wraps
 from operator import attrgetter
 
 from .exceptions import AttrNotFound
+from .signature import SignatureAdapter
 from .utils import ugettext as _
 
 
@@ -23,42 +23,6 @@ class ObjectConfig(namedtuple("ObjectConfig", "obj skip_attrs")):
             return cls(obj, set())
 
 
-def methodcaller(method):
-    """Build a wrapper that adapts the received arguments to the inner method signature"""
-
-    # spec is a named tuple ArgSpec(args, varargs, keywords, defaults)
-    # args is a list of the argument names (it may contain nested lists)
-    # varargs and keywords are the names of the * and ** arguments or None
-    # defaults is a tuple of default argument values or None if there are no default arguments
-    spec = inspect.getfullargspec(method)
-    keywords = spec.varkw
-    expected_args = list(spec.args)
-    expected_kwargs = spec.defaults or {}
-
-    # discart "self" argument for bounded methods
-    if hasattr(method, "__self__") and expected_args and expected_args[0] == "self":
-        expected_args = expected_args[1:]
-
-    @wraps(method)
-    def wrapper(*args, **kwargs):
-        if spec.varargs is not None:
-            filtered_args = args
-        else:
-            filtered_args = [
-                kwargs.get(k, (args[idx] if idx < len(args) else None))
-                for idx, k in enumerate(expected_args)
-            ]
-
-        if keywords is not None:
-            filtered_kwargs = kwargs
-        else:
-            filtered_kwargs = {k: v for k, v in kwargs.items() if k in expected_kwargs}
-
-        return method(*filtered_args, **filtered_kwargs)
-
-    return wrapper
-
-
 def _get_func_by_attr(attr, *configs):
     for config in configs:
         if attr in config.skip_attrs:
@@ -84,7 +48,7 @@ def ensure_callable(attr, *objects):
             has the given ``attr``.
     """
     if callable(attr) or isinstance(attr, property):
-        return methodcaller(attr)
+        return SignatureAdapter.wrap(attr)
 
     # Setup configuration if not present to normalize the internal API
     configs = [ObjectConfig.from_obj(obj) for obj in objects]
@@ -102,7 +66,7 @@ def ensure_callable(attr, *objects):
 
         return wrapper
 
-    return methodcaller(func)
+    return SignatureAdapter.wrap(func)
 
 
 def resolver_factory(*objects):
diff --git a/statemachine/signature.py b/statemachine/signature.py
new file mode 100644
index 0000000..bef064b
--- /dev/null
+++ b/statemachine/signature.py
@@ -0,0 +1,148 @@
+import itertools
+from inspect import BoundArguments
+from inspect import Parameter
+from inspect import Signature
+from typing import Any
+from typing import Callable
+
+
+class SignatureAdapter(Signature):
+    method: Callable[..., Any]
+
+    @classmethod
+    def wrap(cls, method):
+        """Build a wrapper that adapts the received arguments to the inner ``method`` signature"""
+
+        sig = cls.from_callable(method)
+        sig.method = method
+        sig.__name__ = method.__name__
+        return sig
+
+    def __call__(self, *args: Any, **kwargs: Any) -> Any:
+        ba = self.bind_expected(*args, **kwargs)
+        return self.method(*ba.args, **ba.kwargs)
+
+    def bind_expected(self, *args: Any, **kwargs: Any) -> BoundArguments:  # noqa: C901
+        """Get a BoundArguments object, that maps the passed `args`
+        and `kwargs` to the function's signature.  It avoids to raise `TypeError`
+        trying to fill all the required arguments and ignoring the unknown ones.
+
+        Adapted from the internal `inspect.Signature._bind`.
+        """
+        arguments = {}
+
+        parameters = iter(self.parameters.values())
+        arg_vals = iter(args)
+        parameters_ex: Any = ()
+        kwargs_param = None
+
+        while True:
+            # Let's iterate through the positional arguments and corresponding
+            # parameters
+            try:
+                arg_val = next(arg_vals)
+            except StopIteration:
+                # No more positional arguments
+                try:
+                    param = next(parameters)
+                except StopIteration:
+                    # No more parameters. That's it. Just need to check that
+                    # we have no `kwargs` after this while loop
+                    break
+                else:
+                    if param.kind == Parameter.VAR_POSITIONAL:
+                        # That's OK, just empty *args.  Let's start parsing
+                        # kwargs
+                        break
+                    elif param.name in kwargs:
+                        if param.kind == Parameter.POSITIONAL_ONLY:
+                            msg = (
+                                "{arg!r} parameter is positional only, "
+                                "but was passed as a keyword"
+                            )
+                            msg = msg.format(arg=param.name)
+                            raise TypeError(msg) from None
+                        parameters_ex = (param,)
+                        break
+                    elif (
+                        param.kind == Parameter.VAR_KEYWORD
+                        or param.default is not Parameter.empty
+                    ):
+                        # That's fine too - we have a default value for this
+                        # parameter.  So, lets start parsing `kwargs`, starting
+                        # with the current parameter
+                        parameters_ex = (param,)
+                        break
+                    else:
+                        # No default, not VAR_KEYWORD, not VAR_POSITIONAL,
+                        # not in `kwargs`
+                        parameters_ex = (param,)
+                        break
+            else:
+                # We have a positional argument to process
+                try:
+                    param = next(parameters)
+                except StopIteration:
+                    # raise TypeError('too many positional arguments') from None
+                    break
+                else:
+                    if param.kind == Parameter.VAR_KEYWORD:
+                        # Memorize that we have a '**kwargs'-like parameter
+                        kwargs_param = param
+                        break
+
+                    if param.kind == Parameter.KEYWORD_ONLY:
+                        # Looks like we have no parameter for this positional
+                        # argument
+                        # 'too many positional arguments' forgiven
+                        break
+
+                    if param.kind == Parameter.VAR_POSITIONAL:
+                        # We have an '*args'-like argument, let's fill it with
+                        # all positional arguments we have left and move on to
+                        # the next phase
+                        values = [arg_val]
+                        values.extend(arg_vals)
+                        arguments[param.name] = tuple(values)
+                        break
+
+                    if param.name in kwargs and param.kind != Parameter.POSITIONAL_ONLY:
+                        arguments[param.name] = kwargs.pop(param.name)
+                    else:
+                        arguments[param.name] = arg_val
+
+        # Now, we iterate through the remaining parameters to process
+        # keyword arguments
+        for param in itertools.chain(parameters_ex, parameters):
+            if param.kind == Parameter.VAR_KEYWORD:
+                # Memorize that we have a '**kwargs'-like parameter
+                kwargs_param = param
+                continue
+
+            if param.kind == Parameter.VAR_POSITIONAL:
+                # Named arguments don't refer to '*args'-like parameters.
+                # We only arrive here if the positional arguments ended
+                # before reaching the last parameter before *args.
+                continue
+
+            param_name = param.name
+            try:
+                arg_val = kwargs.pop(param_name)
+            except KeyError:
+                # We have no value for this parameter.  It's fine though,
+                # if it has a default value, or it is an '*args'-like
+                # parameter, left alone by the processing of positional
+                # arguments.
+                pass
+            else:
+                arguments[param_name] = arg_val  #
+
+        if kwargs:
+            if kwargs_param is not None:
+                # Process our '**kwargs'-like parameter
+                arguments[kwargs_param.name] = kwargs  # type: ignore [assignment]
+            else:
+                # 'ignoring we got an unexpected keyword argument'
+                pass
+
+        return BoundArguments(self, arguments)  # type: ignore [arg-type]


Test patch:
diff --git a/tests/test_signature.py b/tests/test_signature.py
new file mode 100644
index 0000000..133e36d
--- /dev/null
+++ b/tests/test_signature.py
@@ -0,0 +1,187 @@
+import inspect
+import sys
+
+import pytest
+
+from statemachine.signature import SignatureAdapter
+
+
+def single_positional_param(a):
+    return a
+
+
+def single_default_keyword_param(a=42):
+    return a
+
+
+def args_param(*args):
+    return args
+
+
+def kwargs_param(**kwargs):
+    return kwargs
+
+
+def args_and_kwargs_param(*args, **kwargs):
+    return args, kwargs
+
+
+def positional_optional_catchall(a, b="ham", *args):
+    return a, b, args
+
+
+def ignored_param(a, b, *, c, d=10):
+    return a, b, c, d
+
+
+def positional_and_kw_arguments(source, target, event):
+    return source, target, event
+
+
+def default_kw_arguments(source: str = "A", target: str = "B", event: str = "go"):
+    return source, target, event
+
+
+class MyObject:
+    def __init__(self, value=42):
+        self.value = value
+
+    def method_no_argument(self):
+        return self.value
+
+
+class TestSignatureAdapter:
+    @pytest.mark.parametrize(
+        ("func", "args", "kwargs", "expected"),
+        [
+            (single_positional_param, [10], {}, 10),
+            (single_positional_param, [], {"a": 10}, 10),
+            pytest.param(single_positional_param, [], {}, TypeError),
+            (single_default_keyword_param, [10], {}, 10),
+            (single_default_keyword_param, [], {"a": 10}, 10),
+            pytest.param(single_default_keyword_param, [], {}, 42),
+            (MyObject().method_no_argument, [], {}, 42),
+            (MyObject().method_no_argument, ["ignored"], {"x": True}, 42),
+            (MyObject.method_no_argument, [MyObject()], {"x": True}, 42),
+            pytest.param(MyObject.method_no_argument, [], {}, TypeError),
+            (args_param, [], {}, ()),
+            (args_param, [42], {}, (42,)),
+            (args_param, [1, 1, 2, 3, 5, 8, 13], {}, (1, 1, 2, 3, 5, 8, 13)),
+            (
+                args_param,
+                [1, 1, 2, 3, 5, 8, 13],
+                {"x": True, "other": 42},
+                (1, 1, 2, 3, 5, 8, 13),
+            ),
+            (kwargs_param, [], {}, {}),
+            (kwargs_param, [1], {}, {}),
+            (kwargs_param, [1, 3, 5, 8, "x", True], {}, {}),
+            (kwargs_param, [], {"x": True}, {"x": True}),
+            (kwargs_param, [], {"x": True, "n": 42}, {"x": True, "n": 42}),
+            (
+                kwargs_param,
+                [10, "x", False],
+                {"x": True, "n": 42},
+                {"x": True, "n": 42},
+            ),
+            (args_and_kwargs_param, [], {}, ((), {})),
+            (args_and_kwargs_param, [1], {}, ((1,), {})),
+            (
+                args_and_kwargs_param,
+                [1, 3, 5, False, "n"],
+                {"x": True, "n": 42},
+                ((1, 3, 5, False, "n"), {"x": True, "n": 42}),
+            ),
+            (positional_optional_catchall, [], {}, TypeError),
+            (positional_optional_catchall, [42], {}, (42, "ham", ())),
+            pytest.param(
+                positional_optional_catchall,
+                [True],
+                {"b": "spam"},
+                (True, "spam", ()),
+            ),
+            pytest.param(
+                positional_optional_catchall,
+                ["a", "b"],
+                {"b": "spam"},
+                ("a", "spam", ()),
+            ),
+            (
+                positional_optional_catchall,
+                ["a", "b", "c"],
+                {"b": "spam"},
+                ("a", "spam", ("c",)),
+            ),
+            (
+                positional_optional_catchall,
+                ["a", "b", "c", False, 10],
+                {"other": 42},
+                ("a", "b", ("c", False, 10)),
+            ),
+            (ignored_param, [], {}, TypeError),
+            (ignored_param, [1, 2, 3], {}, TypeError),
+            pytest.param(
+                ignored_param,
+                [1, 2],
+                {"c": 42},
+                (1, 2, 42, 10),
+            ),
+            pytest.param(
+                ignored_param,
+                [1, 2],
+                {"c": 42, "d": 21},
+                (1, 2, 42, 21),
+            ),
+            pytest.param(positional_and_kw_arguments, [], {}, TypeError),
+            (positional_and_kw_arguments, [1, 2, 3], {}, (1, 2, 3)),
+            (positional_and_kw_arguments, [1, 2], {"event": "foo"}, (1, 2, "foo")),
+            (
+                positional_and_kw_arguments,
+                [],
+                {"source": "A", "target": "B", "event": "foo"},
+                ("A", "B", "foo"),
+            ),
+            pytest.param(default_kw_arguments, [], {}, ("A", "B", "go")),
+            (default_kw_arguments, [1, 2, 3], {}, (1, 2, 3)),
+            (default_kw_arguments, [1, 2], {"event": "wait"}, (1, 2, "wait")),
+        ],
+    )
+    def test_wrap_fn_single_positional_parameter(self, func, args, kwargs, expected):
+
+        wrapped_func = SignatureAdapter.wrap(func)
+
+        if inspect.isclass(expected) and issubclass(expected, Exception):
+            with pytest.raises(expected):
+                wrapped_func(*args, **kwargs)
+        else:
+            assert wrapped_func(*args, **kwargs) == expected
+
+    @pytest.mark.parametrize(
+        ("args", "kwargs", "expected"),
+        [
+            ([], {}, TypeError),
+            ([1, 2, 3], {}, TypeError),
+            ([1, 2], {"kw_only_param": 42}, (1, 2, 42)),
+            ([1], {"pos_or_kw_param": 21, "kw_only_param": 42}, (1, 21, 42)),
+            (
+                [],
+                {"pos_only": 10, "pos_or_kw_param": 21, "kw_only_param": 42},
+                TypeError,
+            ),
+        ],
+    )
+    @pytest.mark.skipif(
+        sys.version_info < (3, 8), reason="requires python3.8 or higher"
+    )
+    def test_positional_ony(self, args, kwargs, expected):
+        def func(pos_only, /, pos_or_kw_param, *, kw_only_param):
+            # https://peps.python.org/pep-0570/
+            return pos_only, pos_or_kw_param, kw_only_param
+
+        wrapped_func = SignatureAdapter.wrap(func)
+
+        if inspect.isclass(expected) and issubclass(expected, Exception):
+            with pytest.raises(expected):
+                wrapped_func(*args, **kwargs)
+        else:
+            assert wrapped_func(*args, **kwargs) == expected