Skip to content

Commit 28c9616

Browse files
committed
Merge remote-tracking branch 'origin/develop' into macedo/scxml
2 parents c68741d + 37e6c1a commit 28c9616

3 files changed

Lines changed: 209 additions & 5 deletions

File tree

docs/authors.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
* [Rafael Rêgo](mailto:crafards@gmail.com)
1111
* [Raphael Schrader](mailto:raphael@schradercloud.de)
1212
* [João S. O. Bueno](mailto:gwidion@gmail.com)
13+
* [Rodrigo Nogueira](mailto:rodrigo.b.nogueira@gmail.com)
1314

1415

1516
## Scaffolding

statemachine/signature.py

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
from functools import partial
24
from inspect import BoundArguments
35
from inspect import Parameter
@@ -6,6 +8,12 @@
68
from itertools import chain
79
from types import MethodType
810
from typing import Any
11+
from typing import FrozenSet
12+
from typing import Optional
13+
from typing import Tuple
14+
15+
BindCacheKey = Tuple[int, FrozenSet[str]]
16+
BindTemplate = Tuple[Tuple[str, ...], Optional[str], Optional[str]] # noqa: UP007
917

1018

1119
def _make_key(method):
@@ -44,6 +52,11 @@ def cached_function(cls, method):
4452

4553
class SignatureAdapter(Signature):
4654
is_coroutine: bool = False
55+
_bind_cache: dict[BindCacheKey, BindTemplate]
56+
57+
def __init__(self, *args, **kwargs):
58+
super().__init__(*args, **kwargs)
59+
self._bind_cache = {}
4760

4861
@classmethod
4962
@signature_cache
@@ -60,19 +73,71 @@ def from_callable(cls, method):
6073
adapter.is_coroutine = iscoroutinefunction(method)
6174
return adapter
6275

63-
def bind_expected(self, *args: Any, **kwargs: Any) -> BoundArguments: # noqa: C901
76+
def bind_expected(self, *args: Any, **kwargs: Any) -> BoundArguments:
77+
cache_key: BindCacheKey = (len(args), frozenset(kwargs.keys()))
78+
template = self._bind_cache.get(cache_key)
79+
80+
if template is not None:
81+
return self._fast_bind(args, kwargs, template)
82+
83+
result = self._full_bind(cache_key, *args, **kwargs)
84+
return result
85+
86+
def _fast_bind(
87+
self,
88+
args: tuple[Any, ...],
89+
kwargs: dict[str, Any],
90+
template: BindTemplate,
91+
) -> BoundArguments:
92+
param_names, kwargs_param_name, var_positional_name = template
93+
arguments: dict[str, Any] = {}
94+
past_var_positional = False
95+
96+
for i, name in enumerate(param_names):
97+
if name == var_positional_name:
98+
# Collect all remaining positional args into a tuple
99+
arguments[name] = args[i:]
100+
past_var_positional = True
101+
elif past_var_positional:
102+
# After *args, remaining params are keyword-only
103+
arguments[name] = kwargs.get(name)
104+
elif i < len(args):
105+
# Match _full_bind: if param is also in kwargs, kwargs wins
106+
# (POSITIONAL_OR_KEYWORD params prefer kwargs over positional args)
107+
if name in kwargs:
108+
arguments[name] = kwargs[name]
109+
else:
110+
arguments[name] = args[i]
111+
else:
112+
arguments[name] = kwargs.get(name)
113+
114+
if kwargs_param_name is not None:
115+
matched = set(param_names)
116+
arguments[kwargs_param_name] = {k: v for k, v in kwargs.items() if k not in matched}
117+
118+
return BoundArguments(self, arguments) # type: ignore[arg-type]
119+
120+
def _full_bind( # noqa: C901
121+
self,
122+
cache_key: BindCacheKey,
123+
*args: Any,
124+
**kwargs: Any,
125+
) -> BoundArguments:
64126
"""Get a BoundArguments object, that maps the passed `args`
65127
and `kwargs` to the function's signature. It avoids to raise `TypeError`
66128
trying to fill all the required arguments and ignoring the unknown ones.
67129
68130
Adapted from the internal `inspect.Signature._bind`.
69131
"""
70-
arguments = {}
132+
arguments: dict[str, Any] = {}
133+
param_names_used: list[str] = []
71134

72135
parameters = iter(self.parameters.values())
73136
arg_vals = iter(args)
74137
parameters_ex: Any = ()
75138
kwargs_param = None
139+
kwargs_param_name: str | None = None
140+
var_positional_name: str | None = None
76141

77142
while True:
78143
# Let's iterate through the positional arguments and corresponding
@@ -140,12 +205,15 @@ def bind_expected(self, *args: Any, **kwargs: Any) -> BoundArguments: # noqa: C
140205
values = [arg_val]
141206
values.extend(arg_vals)
142207
arguments[param.name] = tuple(values)
208+
param_names_used.append(param.name)
209+
var_positional_name = param.name
143210
break
144211

145212
if param.name in kwargs and param.kind != Parameter.POSITIONAL_ONLY:
146213
arguments[param.name] = kwargs.pop(param.name)
147214
else:
148215
arguments[param.name] = arg_val
216+
param_names_used.append(param.name)
149217

150218
# Now, we iterate through the remaining parameters to process
151219
# keyword arguments
@@ -171,14 +239,19 @@ def bind_expected(self, *args: Any, **kwargs: Any) -> BoundArguments: # noqa: C
171239
# arguments.
172240
pass
173241
else:
174-
arguments[param_name] = arg_val #
242+
arguments[param_name] = arg_val
243+
param_names_used.append(param_name)
175244

176245
if kwargs:
177246
if kwargs_param is not None:
178247
# Process our '**kwargs'-like parameter
179-
arguments[kwargs_param.name] = kwargs # type: ignore [assignment]
248+
arguments[kwargs_param.name] = kwargs # type: ignore[assignment]
249+
kwargs_param_name = kwargs_param.name
180250
else:
181251
# 'ignoring we got an unexpected keyword argument'
182252
pass
183253

184-
return BoundArguments(self, arguments) # type: ignore [arg-type]
254+
template: BindTemplate = (tuple(param_names_used), kwargs_param_name, var_positional_name)
255+
self._bind_cache[cache_key] = template
256+
257+
return BoundArguments(self, arguments) # type: ignore[arg-type]

tests/test_signature.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
from functools import partial
33

44
import pytest
5+
56
from statemachine.dispatcher import callable_method
7+
from statemachine.signature import SignatureAdapter
68

79

810
def single_positional_param(a):
@@ -161,3 +163,131 @@ def test_support_for_partial(self):
161163

162164
assert wrapped_func("A", "B") == ("A", "B", "activated")
163165
assert wrapped_func.__name__ == positional_and_kw_arguments.__name__
166+
167+
168+
def named_and_kwargs(source, **kwargs):
169+
return source, kwargs
170+
171+
172+
class TestCachedBindExpected:
173+
"""Tests that exercise the cache fast-path by calling the same
174+
wrapped function twice with the same argument shape."""
175+
176+
def setup_method(self):
177+
SignatureAdapter.from_callable.clear_cache()
178+
179+
def test_named_param_not_leaked_into_kwargs(self):
180+
"""Named params should not appear in the **kwargs dict on cache hit."""
181+
wrapped = callable_method(named_and_kwargs)
182+
183+
# 1st call: cache miss -> _full_bind
184+
result1 = wrapped(source="A", target="B", event="go")
185+
assert result1 == ("A", {"target": "B", "event": "go"})
186+
187+
# 2nd call: cache hit -> _fast_bind
188+
result2 = wrapped(source="X", target="Y", event="stop")
189+
assert result2 == ("X", {"target": "Y", "event": "stop"})
190+
191+
def test_kwargs_only_receives_unmatched_keys_with_positional(self):
192+
"""When mixing positional and keyword args with **kwargs."""
193+
wrapped = callable_method(named_and_kwargs)
194+
195+
result1 = wrapped("A", target="B")
196+
assert result1 == ("A", {"target": "B"})
197+
198+
result2 = wrapped("X", target="Y")
199+
assert result2 == ("X", {"target": "Y"})
200+
201+
def test_var_positional_collected_as_tuple(self):
202+
"""VAR_POSITIONAL (*args) must be collected into a tuple on cache hit."""
203+
204+
def fn(*args, **kwargs):
205+
return args, kwargs
206+
207+
wrapped = callable_method(fn)
208+
209+
result1 = wrapped(1, 2, 3, key="val")
210+
assert result1 == ((1, 2, 3), {"key": "val"})
211+
212+
result2 = wrapped(4, 5, key="other")
213+
assert result2 == ((4, 5), {"key": "other"})
214+
215+
def test_keyword_only_after_var_positional(self):
216+
"""KEYWORD_ONLY params after *args must be extracted from kwargs on cache hit."""
217+
218+
def fn(*args, event, **kwargs):
219+
return args, event, kwargs
220+
221+
wrapped = callable_method(fn)
222+
223+
result1 = wrapped(100, event="ev1", source="s0")
224+
assert result1 == ((100,), "ev1", {"source": "s0"})
225+
226+
result2 = wrapped(200, event="ev2", source="s1")
227+
assert result2 == ((200,), "ev2", {"source": "s1"})
228+
229+
def test_positional_or_keyword_prefers_kwargs_over_positional(self):
230+
"""When a POSITIONAL_OR_KEYWORD param is in both args and kwargs, kwargs wins."""
231+
232+
def fn(event, source, target):
233+
return event, source, target
234+
235+
wrapped = callable_method(fn)
236+
237+
# 1st call: positional arg provided but 'event' also in kwargs -> kwargs wins
238+
result1 = wrapped("discarded_content", event="ev1", source="s0", target="t0")
239+
assert result1 == ("ev1", "s0", "t0")
240+
241+
# 2nd call: cache hit, same behavior expected
242+
result2 = wrapped("other_content", event="ev2", source="s1", target="t1")
243+
assert result2 == ("ev2", "s1", "t1")
244+
245+
def test_empty_var_positional(self):
246+
"""Empty *args is handled correctly on cache hit."""
247+
248+
def fn(*args, **kwargs):
249+
return args, kwargs
250+
251+
wrapped = callable_method(fn)
252+
253+
# 1st call with args
254+
result1 = wrapped(1, key="val")
255+
assert result1 == ((1,), {"key": "val"})
256+
257+
# 2nd call: only kwargs, no positional args — different cache key (len=0)
258+
result2 = wrapped(key="val2")
259+
assert result2 == ((), {"key": "val2"})
260+
261+
# 3rd call: hits cache for len=0
262+
result3 = wrapped(key="val3")
263+
assert result3 == ((), {"key": "val3"})
264+
265+
def test_named_params_before_var_positional(self):
266+
"""Named params before *args are filled correctly on cache hit."""
267+
268+
def fn(a, b, *args, **kwargs):
269+
return a, b, args, kwargs
270+
271+
wrapped = callable_method(fn)
272+
273+
result1 = wrapped(1, 2, 3, 4, key="val")
274+
assert result1 == (1, 2, (3, 4), {"key": "val"})
275+
276+
result2 = wrapped(10, 20, 30, key="val2")
277+
assert result2 == (10, 20, (30,), {"key": "val2"})
278+
279+
def test_kwargs_wins_with_var_positional_present(self):
280+
"""POSITIONAL_OR_KEYWORD before *args prefers kwargs when name matches."""
281+
282+
def fn(event, *args, **kwargs):
283+
return event, args, kwargs
284+
285+
wrapped = callable_method(fn)
286+
287+
# 1st call: 'event' in both positional and kwargs — kwargs wins
288+
result1 = wrapped("discarded", "extra", event="ev1", key="a")
289+
assert result1 == ("ev1", ("extra",), {"key": "a"})
290+
291+
# 2nd call: cache hit, same behavior
292+
result2 = wrapped("other", "more", event="ev2", key="b")
293+
assert result2 == ("ev2", ("more",), {"key": "b"})

0 commit comments

Comments
 (0)