Skip to content

Commit 2de95fb

Browse files
authored
fix: Multiple observers can watch the same callback (#387)
1 parent c60f7fe commit 2de95fb

5 files changed

Lines changed: 103 additions & 20 deletions

File tree

docs/releases/2.1.0.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,4 @@ See {ref}`States from Enum types`.
4040

4141
- Fixes [#369](https://github.com/fgmacedo/python-statemachine/issues/369) adding support to wrap
4242
methods used as {ref}`Actions` decorated with `functools.partial`.
43+
- Fixes [#384](https://github.com/fgmacedo/python-statemachine/issues/384) so multiple observers can watch the same callback.

statemachine/callbacks.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ def __init__(self, func, suppress_errors=False, cond=None):
1919
self.suppress_errors = suppress_errors
2020
self.cond = Callbacks(factory=ConditionWrapper).add(cond)
2121
self._callback = None
22+
self._resolver_id = None
2223

2324
def __repr__(self):
2425
return f"{type(self).__name__}({self.func!r})"
@@ -27,7 +28,7 @@ def __str__(self):
2728
return getattr(self.func, "__name__", self.func)
2829

2930
def __eq__(self, other):
30-
return self.func == getattr(other, "func", other)
31+
return self.func == other.func and self._resolver_id == other._resolver_id
3132

3233
def __hash__(self):
3334
return id(self)
@@ -45,6 +46,7 @@ def setup(self, resolver):
4546
"""
4647
self.cond.setup(resolver)
4748
try:
49+
self._resolver_id = getattr(resolver, "id", id(resolver))
4850
self._callback = resolver(self.func)
4951
return True
5052
except AttrNotFound:
@@ -144,15 +146,15 @@ def all(self, *args, **kwargs):
144146
return all(condition(*args, **kwargs) for condition in self)
145147

146148
def _add(self, func, resolver=None, prepend=False, **kwargs):
147-
if func in self.items:
148-
return
149-
150149
resolver = resolver or self._resolver
151150

152151
callback = self.factory(func, **kwargs)
153152
if resolver is not None and not callback.setup(resolver):
154153
return
155154

155+
if callback in self.items:
156+
return
157+
156158
if prepend:
157159
self.items.insert(0, callback)
158160
else:

statemachine/dispatcher.py

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,28 @@ def _get_func_by_attr(attr, *configs):
3737
return func, config.obj
3838

3939

40+
def _build_attr_wrapper(attr: str, obj):
41+
# if `attr` is not callable, then it's an attribute or property,
42+
# so `func` contains it's current value.
43+
# we'll build a method that get's the fresh value for each call
44+
getter = attrgetter(attr)
45+
46+
def wrapper(*args, **kwargs):
47+
return getter(obj)
48+
49+
return wrapper
50+
51+
52+
def _build_sm_event_wrapper(func):
53+
"Events already have the 'machine' parameter defined."
54+
55+
def wrapper(*args, **kwargs):
56+
kwargs.pop("machine", None)
57+
return func(*args, **kwargs)
58+
59+
return wrapper
60+
61+
4062
def ensure_callable(attr, *objects):
4163
"""Ensure that `attr` is a callable, if not, tries to retrieve one from any of the given
4264
`objects`.
@@ -56,33 +78,24 @@ def ensure_callable(attr, *objects):
5678
func, obj = _get_func_by_attr(attr, *configs)
5779

5880
if not callable(func):
59-
# if `attr` is not callable, then it's an attribute or property,
60-
# so `func` contains it's current value.
61-
# we'll build a method that get's the fresh value for each call
62-
getter = attrgetter(attr)
63-
64-
def wrapper(*args, **kwargs):
65-
return getter(obj)
66-
67-
return wrapper
81+
return _build_attr_wrapper(attr, obj)
6882

6983
if getattr(func, "_is_sm_event", False):
70-
"Events already have the 'machine' parameter defined."
71-
72-
def wrapper(*args, **kwargs):
73-
kwargs.pop("machine")
74-
return func(*args, **kwargs)
75-
76-
return wrapper
84+
return _build_sm_event_wrapper(func)
7785

7886
return SignatureAdapter.wrap(func)
7987

8088

8189
def resolver_factory(*objects):
8290
"""Factory that returns a configured resolver."""
8391

92+
objects = [ObjectConfig.from_obj(obj) for obj in objects]
93+
8494
@wraps(ensure_callable)
8595
def wrapper(attr):
8696
return ensure_callable(attr, *objects)
8797

98+
resolver_id = ".".join(str(id(obj.obj)) for obj in objects)
99+
wrapper.id = resolver_id
100+
88101
return wrapper

tests/test_dispatcher.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,13 @@ def test_should_ignore_list_of_attrs(self, attr, expected_value):
112112
resolver = resolver_factory(org_config, person)
113113
resolved_method = resolver(attr)
114114
assert resolved_method() == expected_value
115+
116+
def test_should_generate_unique_ids(self):
117+
person = Person("Frodo", "Bolseiro", "cpf")
118+
org = Organization("The Lord fo the Rings", "cnpj")
119+
120+
resolver1 = resolver_factory(org, person)
121+
resolver2 = resolver_factory(org, person)
122+
resolver3 = resolver_factory(org, person)
123+
124+
assert resolver1.id == resolver2.id == resolver3.id
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
### Issue 384
2+
3+
A StateMachine that exercises the example given on issue
4+
#[384](https://github.com/fgmacedo/python-statemachine/issues/384).
5+
6+
In this example, we register multiple observers to the same named callback.
7+
8+
This works also as a regression test.
9+
10+
```py
11+
>>> from statemachine import State
12+
>>> from statemachine import StateMachine
13+
14+
>>> class MyObs:
15+
... def on_move_car(self):
16+
... print("I observed moving from 1")
17+
18+
>>> class MyObs2:
19+
... def on_move_car(self):
20+
... print("I observed moving from 2")
21+
...
22+
23+
24+
>>> class Car(StateMachine):
25+
... stopped = State(initial=True)
26+
... moving = State()
27+
...
28+
... move_car = stopped.to(moving)
29+
... stop_car = moving.to(stopped)
30+
...
31+
... def on_move_car(self):
32+
... print("I'm moving")
33+
34+
```
35+
36+
Running:
37+
38+
```py
39+
>>> car = Car()
40+
>>> obs = MyObs()
41+
>>> obs2 = MyObs2()
42+
>>> car.add_observer(obs)
43+
Car(model=Model(state=stopped), state_field='state', current_state='stopped')
44+
45+
>>> car.add_observer(obs2)
46+
Car(model=Model(state=stopped), state_field='state', current_state='stopped')
47+
48+
>>> car.add_observer(obs2) # test to not register duplicated observer callbacks
49+
Car(model=Model(state=stopped), state_field='state', current_state='stopped')
50+
51+
>>> car.move_car()
52+
I'm moving
53+
I observed moving from 1
54+
I observed moving from 2
55+
[None, None, None]
56+
57+
```

0 commit comments

Comments
 (0)