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).

Discussion:
sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=fgmacedo_python-statemachine&pullRequest=343)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=CODE_SMELL) [2 Code Smells](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=fgmacedo_python-statemachine&pullRequest=343) No Coverage information  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=fgmacedo_python-statemachine&pullRequest=343&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=fgmacedo_python-statemachine&pullRequest=343&metric=new_duplicated_lines_density&view=list)


codecov[bot]: # [Codecov](https://codecov.io/gh/fgmacedo/python-statemachine/pull/343?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo) Report
Base: **100.00**% // Head: **100.00**% // No change to project coverage :thumbsup:
> Coverage data is based on head [(`9af7a92`)](https://codecov.io/gh/fgmacedo/python-statemachine/pull/343?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo) compared to base [(`f1afe5a`)](https://codecov.io/gh/fgmacedo/python-statemachine/commit/f1afe5a28cdc8c54c1353a4cd2ca745e7051548d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo).
> Patch coverage: 100.00% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##           develop      #343   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           17        18    +1     
  Lines          800       813   +13     
  Branches       112       112           
=========================================
+ Hits           800       813   +13     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `100.00% <100.00%> (ø)` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/fgmacedo/python-statemachine/pull/343?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo) | Coverage Δ | |
|---|---|---|
| [statemachine/dispatcher.py](https://codecov.io/gh/fgmacedo/python-statemachine/pull/343?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo#diff-c3RhdGVtYWNoaW5lL2Rpc3BhdGNoZXIucHk=) | `100.00% <100.00%> (ø)` | |
| [statemachine/signature.py](https://codecov.io/gh/fgmacedo/python-statemachine/pull/343?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo#diff-c3RhdGVtYWNoaW5lL3NpZ25hdHVyZS5weQ==) | `100.00% <100.00%> (ø)` | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/fgmacedo/python-statemachine/pull/343?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo).

sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=fgmacedo_python-statemachine&pullRequest=343)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=CODE_SMELL) [3 Code Smells](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=fgmacedo_python-statemachine&pullRequest=343) No Coverage information  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=fgmacedo_python-statemachine&pullRequest=343&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=fgmacedo_python-statemachine&pullRequest=343&metric=new_duplicated_lines_density&view=list)


sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=fgmacedo_python-statemachine&pullRequest=343)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=CODE_SMELL) [3 Code Smells](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=343&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=fgmacedo_python-statemachine&pullRequest=343) No Coverage information  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=fgmacedo_python-statemachine&pullRequest=343&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=fgmacedo_python-statemachine&pullRequest=343&metric=new_duplicated_lines_density&view=list)



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