From c22dcda4017781f0f254fc86c30a7af303479c7b Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Fri, 5 May 2023 09:44:05 -0300 Subject: [PATCH] fix: Multiple observers can watch the same callback --- docs/releases/2.1.0.md | 1 + statemachine/callbacks.py | 10 ++-- statemachine/dispatcher.py | 45 +++++++++------ tests/test_dispatcher.py | 10 ++++ .../testcases/issue384_multiple_observers.md | 57 +++++++++++++++++++ 5 files changed, 103 insertions(+), 20 deletions(-) create mode 100644 tests/testcases/issue384_multiple_observers.md diff --git a/docs/releases/2.1.0.md b/docs/releases/2.1.0.md index 5c3caf2b..e182ca5e 100644 --- a/docs/releases/2.1.0.md +++ b/docs/releases/2.1.0.md @@ -40,3 +40,4 @@ See {ref}`States from Enum types`. - Fixes [#369](https://github.com/fgmacedo/python-statemachine/issues/369) adding support to wrap methods used as {ref}`Actions` decorated with `functools.partial`. +- Fixes [#384](https://github.com/fgmacedo/python-statemachine/issues/384) so multiple observers can watch the same callback. diff --git a/statemachine/callbacks.py b/statemachine/callbacks.py index 85a0e178..f10122ba 100644 --- a/statemachine/callbacks.py +++ b/statemachine/callbacks.py @@ -19,6 +19,7 @@ def __init__(self, func, suppress_errors=False, cond=None): self.suppress_errors = suppress_errors self.cond = Callbacks(factory=ConditionWrapper).add(cond) self._callback = None + self._resolver_id = None def __repr__(self): return f"{type(self).__name__}({self.func!r})" @@ -27,7 +28,7 @@ def __str__(self): return getattr(self.func, "__name__", self.func) def __eq__(self, other): - return self.func == getattr(other, "func", other) + return self.func == other.func and self._resolver_id == other._resolver_id def __hash__(self): return id(self) @@ -45,6 +46,7 @@ def setup(self, resolver): """ self.cond.setup(resolver) try: + self._resolver_id = getattr(resolver, "id", id(resolver)) self._callback = resolver(self.func) return True except AttrNotFound: @@ -144,15 +146,15 @@ def all(self, *args, **kwargs): return all(condition(*args, **kwargs) for condition in self) def _add(self, func, resolver=None, prepend=False, **kwargs): - if func in self.items: - return - resolver = resolver or self._resolver callback = self.factory(func, **kwargs) if resolver is not None and not callback.setup(resolver): return + if callback in self.items: + return + if prepend: self.items.insert(0, callback) else: diff --git a/statemachine/dispatcher.py b/statemachine/dispatcher.py index d96a3f2e..83c9b49b 100644 --- a/statemachine/dispatcher.py +++ b/statemachine/dispatcher.py @@ -37,6 +37,28 @@ def _get_func_by_attr(attr, *configs): return func, config.obj +def _build_attr_wrapper(attr: str, obj): + # if `attr` is not callable, then it's an attribute or property, + # so `func` contains it's current value. + # we'll build a method that get's the fresh value for each call + getter = attrgetter(attr) + + def wrapper(*args, **kwargs): + return getter(obj) + + return wrapper + + +def _build_sm_event_wrapper(func): + "Events already have the 'machine' parameter defined." + + def wrapper(*args, **kwargs): + kwargs.pop("machine", None) + return func(*args, **kwargs) + + return wrapper + + def ensure_callable(attr, *objects): """Ensure that `attr` is a callable, if not, tries to retrieve one from any of the given `objects`. @@ -56,24 +78,10 @@ def ensure_callable(attr, *objects): func, obj = _get_func_by_attr(attr, *configs) if not callable(func): - # if `attr` is not callable, then it's an attribute or property, - # so `func` contains it's current value. - # we'll build a method that get's the fresh value for each call - getter = attrgetter(attr) - - def wrapper(*args, **kwargs): - return getter(obj) - - return wrapper + return _build_attr_wrapper(attr, obj) if getattr(func, "_is_sm_event", False): - "Events already have the 'machine' parameter defined." - - def wrapper(*args, **kwargs): - kwargs.pop("machine") - return func(*args, **kwargs) - - return wrapper + return _build_sm_event_wrapper(func) return SignatureAdapter.wrap(func) @@ -81,8 +89,13 @@ def wrapper(*args, **kwargs): def resolver_factory(*objects): """Factory that returns a configured resolver.""" + objects = [ObjectConfig.from_obj(obj) for obj in objects] + @wraps(ensure_callable) def wrapper(attr): return ensure_callable(attr, *objects) + resolver_id = ".".join(str(id(obj.obj)) for obj in objects) + wrapper.id = resolver_id + return wrapper diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index c384f223..1c242281 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -112,3 +112,13 @@ def test_should_ignore_list_of_attrs(self, attr, expected_value): resolver = resolver_factory(org_config, person) resolved_method = resolver(attr) assert resolved_method() == expected_value + + def test_should_generate_unique_ids(self): + person = Person("Frodo", "Bolseiro", "cpf") + org = Organization("The Lord fo the Rings", "cnpj") + + resolver1 = resolver_factory(org, person) + resolver2 = resolver_factory(org, person) + resolver3 = resolver_factory(org, person) + + assert resolver1.id == resolver2.id == resolver3.id diff --git a/tests/testcases/issue384_multiple_observers.md b/tests/testcases/issue384_multiple_observers.md new file mode 100644 index 00000000..83f252b6 --- /dev/null +++ b/tests/testcases/issue384_multiple_observers.md @@ -0,0 +1,57 @@ +### Issue 384 + +A StateMachine that exercises the example given on issue +#[384](https://github.com/fgmacedo/python-statemachine/issues/384). + +In this example, we register multiple observers to the same named callback. + +This works also as a regression test. + +```py +>>> from statemachine import State +>>> from statemachine import StateMachine + +>>> class MyObs: +... def on_move_car(self): +... print("I observed moving from 1") + +>>> class MyObs2: +... def on_move_car(self): +... print("I observed moving from 2") +... + + +>>> class Car(StateMachine): +... stopped = State(initial=True) +... moving = State() +... +... move_car = stopped.to(moving) +... stop_car = moving.to(stopped) +... +... def on_move_car(self): +... print("I'm moving") + +``` + +Running: + +```py +>>> car = Car() +>>> obs = MyObs() +>>> obs2 = MyObs2() +>>> car.add_observer(obs) +Car(model=Model(state=stopped), state_field='state', current_state='stopped') + +>>> car.add_observer(obs2) +Car(model=Model(state=stopped), state_field='state', current_state='stopped') + +>>> car.add_observer(obs2) # test to not register duplicated observer callbacks +Car(model=Model(state=stopped), state_field='state', current_state='stopped') + +>>> car.move_car() +I'm moving +I observed moving from 1 +I observed moving from 2 +[None, None, None] + +```