Skip to content

Commit d308b47

Browse files
authored
fix: Regression that causes an exception when registering listeners with unbounded callables. (#466)
1 parent aeae747 commit d308b47

6 files changed

Lines changed: 71 additions & 15 deletions

File tree

docs/releases/2.3.4.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# StateMachine 2.3.4
2+
3+
*July 11, 2024*
4+
5+
6+
## Bugfixes in 2.3.4
7+
8+
- Fixes [#465](https://github.com/fgmacedo/python-statemachine/issues/465) regression that caused exception when registering a listener with unbounded callbacks.

docs/releases/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Below are release notes through StateMachine and its patch releases.
1515
```{toctree}
1616
:maxdepth: 2
1717
18+
2.3.4
1819
2.3.3
1920
2.3.2
2021
2.3.1

statemachine/callbacks.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from collections import defaultdict
44
from collections import deque
55
from enum import IntEnum
6+
from enum import IntFlag
67
from enum import auto
78
from inspect import isawaitable
89
from inspect import iscoroutinefunction
@@ -26,10 +27,14 @@ class CallbackPriority(IntEnum):
2627
AFTER = 40
2728

2829

29-
class SpecReference(IntEnum):
30-
NAME = 1
31-
CALLABLE = 2
32-
PROPERTY = 3
30+
class SpecReference(IntFlag):
31+
NAME = auto()
32+
CALLABLE = auto()
33+
PROPERTY = auto()
34+
35+
36+
SPECS_ALL = SpecReference.NAME | SpecReference.CALLABLE | SpecReference.PROPERTY
37+
SPECS_SAFE = SpecReference.NAME
3338

3439

3540
class CallbackGroup(IntEnum):

statemachine/dispatcher.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
from typing import Set
99
from typing import Tuple
1010

11-
from statemachine.callbacks import SpecReference
12-
11+
from .callbacks import SPECS_ALL
12+
from .callbacks import SpecReference
1313
from .signature import SignatureAdapter
1414

1515
if TYPE_CHECKING:
@@ -54,10 +54,18 @@ def from_listeners(cls, listeners: Iterable["Listener"]) -> "Listeners":
5454
all_attrs = set().union(*(listener.all_attrs for listener in listeners))
5555
return cls(listeners, all_attrs)
5656

57-
def resolve(self, specs: "CallbackSpecList", registry):
57+
def resolve(
58+
self,
59+
specs: "CallbackSpecList",
60+
registry,
61+
allowed_references: SpecReference = SPECS_ALL,
62+
):
5863
found_convention_specs = specs.conventional_specs & self.all_attrs
5964
filtered_specs = [
60-
spec for spec in specs if not spec.is_convention or spec.func in found_convention_specs
65+
spec
66+
for spec in specs
67+
if spec.reference in allowed_references
68+
and (not spec.is_convention or spec.func in found_convention_specs)
6169
]
6270
if not filtered_specs:
6371
return
@@ -101,7 +109,7 @@ def _search_callable(self, spec) -> "Callable":
101109
if func is not None and func.__func__ is spec.func:
102110
return callable_method(spec.attr_name, func, config.resolver_id)
103111

104-
return callable_method(spec.func, spec.func, None)
112+
return callable_method(spec.attr_name, spec.func, None)
105113

106114
def _search_name(self, name) -> Generator["Callable", None, None]:
107115
for config in self.items:

statemachine/statemachine.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@
99
from typing import Dict
1010
from typing import List
1111

12-
from statemachine.graph import iterate_states_and_transitions
13-
from statemachine.utils import run_async_from_sync
14-
12+
from .callbacks import SPECS_ALL
13+
from .callbacks import SPECS_SAFE
1514
from .callbacks import CallbacksExecutor
1615
from .callbacks import CallbacksRegistry
16+
from .callbacks import SpecReference
1717
from .dispatcher import Listener
1818
from .dispatcher import Listeners
1919
from .engines.async_ import AsyncEngine
@@ -24,8 +24,10 @@
2424
from .exceptions import InvalidStateValue
2525
from .exceptions import TransitionNotAllowed
2626
from .factory import StateMachineMetaclass
27+
from .graph import iterate_states_and_transitions
2728
from .i18n import _
2829
from .model import Model
30+
from .utils import run_async_from_sync
2931

3032
if TYPE_CHECKING:
3133
from .state import State
@@ -177,8 +179,12 @@ def bind_events_to(self, *targets):
177179
continue
178180
setattr(target, event.name, trigger)
179181

180-
def _add_listener(self, listeners: "Listeners"):
181-
register = partial(listeners.resolve, registry=self._callbacks_registry)
182+
def _add_listener(self, listeners: "Listeners", allowed_references: SpecReference = SPECS_ALL):
183+
register = partial(
184+
listeners.resolve,
185+
registry=self._callbacks_registry,
186+
allowed_references=allowed_references,
187+
)
182188
for visited in iterate_states_and_transitions(self.states):
183189
register(visited._specs)
184190

@@ -228,7 +234,8 @@ def add_listener(self, *listeners):
228234
"""
229235
self._listeners.update({o: None for o in listeners})
230236
return self._add_listener(
231-
Listeners.from_listeners(Listener.from_obj(o) for o in listeners)
237+
Listeners.from_listeners(Listener.from_obj(o) for o in listeners),
238+
allowed_references=SPECS_SAFE,
232239
)
233240

234241
def _repr_html_(self):

tests/test_listener.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import pytest
22

3+
from statemachine.state import State
4+
from statemachine.statemachine import StateMachine
5+
36
EXPECTED_LOG_ADD = """Frodo on: draft--(add_job)-->draft
47
Frodo enter: draft from add_job
58
Frodo on: draft--(produce)-->producing
@@ -78,3 +81,27 @@ def on_enter_state(self, target, event):
7881

7982
captured = capsys.readouterr()
8083
assert captured.out == EXPECTED_LOG_ADD
84+
85+
86+
def test_regression_456():
87+
class TestListener:
88+
def __init__(self):
89+
pass
90+
91+
class MyMachine(StateMachine):
92+
first = State("FIRST", initial=True)
93+
94+
second = State("SECOND")
95+
96+
first_selected = second.to(first)
97+
98+
second_selected = first.to(second)
99+
100+
@first.exit
101+
def exit_first(self) -> None:
102+
print("exit SLEEPING")
103+
104+
m = MyMachine()
105+
m.add_listener(TestListener())
106+
107+
m.send("second_selected")

0 commit comments

Comments
 (0)