Skip to content

Commit cb28828

Browse files
authored
fix: Signature adapter (#343)
* fix: Dynamic dispatch on methods with default parameters. Closes #341
1 parent f1afe5a commit cb28828

4 files changed

Lines changed: 344 additions & 39 deletions

File tree

docs/releases/2.0.0.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ See {ref}`internal transition` for more details.
4949
guards using decorators is now possible.
5050

5151

52+
## Bugfixes in 2.0
53+
54+
- [#341](https://github.com/fgmacedo/python-statemachine/issues/341): Fix dynamic dispatch
55+
on methods with default parameters.
56+
57+
5258
## Backward incompatible changes in 2.0
5359

5460
- TODO

statemachine/dispatcher.py

Lines changed: 3 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import inspect
21
from collections import namedtuple
32
from functools import wraps
43
from operator import attrgetter
54

65
from .exceptions import AttrNotFound
6+
from .signature import SignatureAdapter
77
from .utils import ugettext as _
88

99

@@ -23,42 +23,6 @@ def from_obj(cls, obj):
2323
return cls(obj, set())
2424

2525

26-
def methodcaller(method):
27-
"""Build a wrapper that adapts the received arguments to the inner method signature"""
28-
29-
# spec is a named tuple ArgSpec(args, varargs, keywords, defaults)
30-
# args is a list of the argument names (it may contain nested lists)
31-
# varargs and keywords are the names of the * and ** arguments or None
32-
# defaults is a tuple of default argument values or None if there are no default arguments
33-
spec = inspect.getfullargspec(method)
34-
keywords = spec.varkw
35-
expected_args = list(spec.args)
36-
expected_kwargs = spec.defaults or {}
37-
38-
# discart "self" argument for bounded methods
39-
if hasattr(method, "__self__") and expected_args and expected_args[0] == "self":
40-
expected_args = expected_args[1:]
41-
42-
@wraps(method)
43-
def wrapper(*args, **kwargs):
44-
if spec.varargs is not None:
45-
filtered_args = args
46-
else:
47-
filtered_args = [
48-
kwargs.get(k, (args[idx] if idx < len(args) else None))
49-
for idx, k in enumerate(expected_args)
50-
]
51-
52-
if keywords is not None:
53-
filtered_kwargs = kwargs
54-
else:
55-
filtered_kwargs = {k: v for k, v in kwargs.items() if k in expected_kwargs}
56-
57-
return method(*filtered_args, **filtered_kwargs)
58-
59-
return wrapper
60-
61-
6226
def _get_func_by_attr(attr, *configs):
6327
for config in configs:
6428
if attr in config.skip_attrs:
@@ -84,7 +48,7 @@ def ensure_callable(attr, *objects):
8448
has the given ``attr``.
8549
"""
8650
if callable(attr) or isinstance(attr, property):
87-
return methodcaller(attr)
51+
return SignatureAdapter.wrap(attr)
8852

8953
# Setup configuration if not present to normalize the internal API
9054
configs = [ObjectConfig.from_obj(obj) for obj in objects]
@@ -102,7 +66,7 @@ def wrapper(*args, **kwargs):
10266

10367
return wrapper
10468

105-
return methodcaller(func)
69+
return SignatureAdapter.wrap(func)
10670

10771

10872
def resolver_factory(*objects):

statemachine/signature.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import itertools
2+
from inspect import BoundArguments
3+
from inspect import Parameter
4+
from inspect import Signature
5+
from typing import Any
6+
from typing import Callable
7+
8+
9+
class SignatureAdapter(Signature):
10+
method: Callable[..., Any]
11+
12+
@classmethod
13+
def wrap(cls, method):
14+
"""Build a wrapper that adapts the received arguments to the inner ``method`` signature"""
15+
16+
sig = cls.from_callable(method)
17+
sig.method = method
18+
sig.__name__ = method.__name__
19+
return sig
20+
21+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
22+
ba = self.bind_expected(*args, **kwargs)
23+
return self.method(*ba.args, **ba.kwargs)
24+
25+
def bind_expected(self, *args: Any, **kwargs: Any) -> BoundArguments: # noqa: C901
26+
"""Get a BoundArguments object, that maps the passed `args`
27+
and `kwargs` to the function's signature. It avoids to raise `TypeError`
28+
trying to fill all the required arguments and ignoring the unknown ones.
29+
30+
Adapted from the internal `inspect.Signature._bind`.
31+
"""
32+
arguments = {}
33+
34+
parameters = iter(self.parameters.values())
35+
arg_vals = iter(args)
36+
parameters_ex: Any = ()
37+
kwargs_param = None
38+
39+
while True:
40+
# Let's iterate through the positional arguments and corresponding
41+
# parameters
42+
try:
43+
arg_val = next(arg_vals)
44+
except StopIteration:
45+
# No more positional arguments
46+
try:
47+
param = next(parameters)
48+
except StopIteration:
49+
# No more parameters. That's it. Just need to check that
50+
# we have no `kwargs` after this while loop
51+
break
52+
else:
53+
if param.kind == Parameter.VAR_POSITIONAL:
54+
# That's OK, just empty *args. Let's start parsing
55+
# kwargs
56+
break
57+
elif param.name in kwargs:
58+
if param.kind == Parameter.POSITIONAL_ONLY:
59+
msg = (
60+
"{arg!r} parameter is positional only, "
61+
"but was passed as a keyword"
62+
)
63+
msg = msg.format(arg=param.name)
64+
raise TypeError(msg) from None
65+
parameters_ex = (param,)
66+
break
67+
elif (
68+
param.kind == Parameter.VAR_KEYWORD
69+
or param.default is not Parameter.empty
70+
):
71+
# That's fine too - we have a default value for this
72+
# parameter. So, lets start parsing `kwargs`, starting
73+
# with the current parameter
74+
parameters_ex = (param,)
75+
break
76+
else:
77+
# No default, not VAR_KEYWORD, not VAR_POSITIONAL,
78+
# not in `kwargs`
79+
parameters_ex = (param,)
80+
break
81+
else:
82+
# We have a positional argument to process
83+
try:
84+
param = next(parameters)
85+
except StopIteration:
86+
# raise TypeError('too many positional arguments') from None
87+
break
88+
else:
89+
if param.kind == Parameter.VAR_KEYWORD:
90+
# Memorize that we have a '**kwargs'-like parameter
91+
kwargs_param = param
92+
break
93+
94+
if param.kind == Parameter.KEYWORD_ONLY:
95+
# Looks like we have no parameter for this positional
96+
# argument
97+
# 'too many positional arguments' forgiven
98+
break
99+
100+
if param.kind == Parameter.VAR_POSITIONAL:
101+
# We have an '*args'-like argument, let's fill it with
102+
# all positional arguments we have left and move on to
103+
# the next phase
104+
values = [arg_val]
105+
values.extend(arg_vals)
106+
arguments[param.name] = tuple(values)
107+
break
108+
109+
if param.name in kwargs and param.kind != Parameter.POSITIONAL_ONLY:
110+
arguments[param.name] = kwargs.pop(param.name)
111+
else:
112+
arguments[param.name] = arg_val
113+
114+
# Now, we iterate through the remaining parameters to process
115+
# keyword arguments
116+
for param in itertools.chain(parameters_ex, parameters):
117+
if param.kind == Parameter.VAR_KEYWORD:
118+
# Memorize that we have a '**kwargs'-like parameter
119+
kwargs_param = param
120+
continue
121+
122+
if param.kind == Parameter.VAR_POSITIONAL:
123+
# Named arguments don't refer to '*args'-like parameters.
124+
# We only arrive here if the positional arguments ended
125+
# before reaching the last parameter before *args.
126+
continue
127+
128+
param_name = param.name
129+
try:
130+
arg_val = kwargs.pop(param_name)
131+
except KeyError:
132+
# We have no value for this parameter. It's fine though,
133+
# if it has a default value, or it is an '*args'-like
134+
# parameter, left alone by the processing of positional
135+
# arguments.
136+
pass
137+
else:
138+
arguments[param_name] = arg_val #
139+
140+
if kwargs:
141+
if kwargs_param is not None:
142+
# Process our '**kwargs'-like parameter
143+
arguments[kwargs_param.name] = kwargs # type: ignore [assignment]
144+
else:
145+
# 'ignoring we got an unexpected keyword argument'
146+
pass
147+
148+
return BoundArguments(self, arguments) # type: ignore [arg-type]

0 commit comments

Comments
 (0)