|
2 | 2 | from functools import partial |
3 | 3 |
|
4 | 4 | import pytest |
| 5 | + |
5 | 6 | from statemachine.dispatcher import callable_method |
| 7 | +from statemachine.signature import SignatureAdapter |
6 | 8 |
|
7 | 9 |
|
8 | 10 | def single_positional_param(a): |
@@ -161,3 +163,131 @@ def test_support_for_partial(self): |
161 | 163 |
|
162 | 164 | assert wrapped_func("A", "B") == ("A", "B", "activated") |
163 | 165 | 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