Repository: fgmacedo/python-statemachine

Issue:
Multiple Observers not working on singular State machine
* Python State Machine version:
* Python version:
* Operating System:

### Description

I would like to have multiple observers responding to an event from a statemachine.

### What I Did

```
from statemachine import StateMachine, State


class MyObs(object):

    def __init__(self):
        pass

    def on_move_car(self):
        print("I observed moving from 1")
        return self

class MyObs2(object):
    def __init__(self):
        pass

    def on_move_car(self):
        print("I observed moving from 2")
        return self

class Car(StateMachine):
    def __init__(self):
        super().__init__()

    stopped = State(initial=True)
    moving = State()

    move_car = stopped.to(moving)
    stop_car = moving.to(stopped)

    @move_car.on
    def on_move_car(self):
        print("Im moving")


if __name__ == "__main__":

    car = Car()
    obs = MyObs()
    obs2 = MyObs2()
    car.add_observer(obs)
    car.add_observer(obs2)

    car.move_car()
```

I would expect to see both "I observed moving from 1" and "I observed moving from 2" but it seems the first observer to encounter the event will swallow the callback.


Discussion:
sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=fgmacedo_python-statemachine&pullRequest=387)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=387&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=387&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=387&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=387&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=387&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=387&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=fgmacedo_python-statemachine&pullRequest=387&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=fgmacedo_python-statemachine&pullRequest=387&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=fgmacedo_python-statemachine&pullRequest=387&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=387&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=387&resolved=false&types=CODE_SMELL) [1 Code Smell](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=387&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=fgmacedo_python-statemachine&pullRequest=387) No Coverage information  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=fgmacedo_python-statemachine&pullRequest=387&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=fgmacedo_python-statemachine&pullRequest=387&metric=new_duplicated_lines_density&view=list)


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/fgmacedo/python-statemachine/pull/387?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo) Report
Patch coverage: **`100.00`**% and no project coverage change.
> Comparison is base [(`c60f7fe`)](https://app.codecov.io/gh/fgmacedo/python-statemachine/commit/c60f7febed5c176b5a45d5cf35d2f6474cc70370?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo) 100.00% compared to head [(`c22dcda`)](https://app.codecov.io/gh/fgmacedo/python-statemachine/pull/387?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo) 100.00%.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##           develop      #387   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           20        20           
  Lines          976       985    +9     
  Branches       161       163    +2     
=========================================
+ Hits           976       985    +9     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `100.00% <100.00%> (ø)` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://app.codecov.io/gh/fgmacedo/python-statemachine/pull/387?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo) | Coverage Δ | |
|---|---|---|
| [statemachine/callbacks.py](https://app.codecov.io/gh/fgmacedo/python-statemachine/pull/387?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo#diff-c3RhdGVtYWNoaW5lL2NhbGxiYWNrcy5weQ==) | `100.00% <100.00%> (ø)` | |
| [statemachine/dispatcher.py](https://app.codecov.io/gh/fgmacedo/python-statemachine/pull/387?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo#diff-c3RhdGVtYWNoaW5lL2Rpc3BhdGNoZXIucHk=) | `100.00% <100.00%> (ø)` | |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/fgmacedo/python-statemachine/pull/387?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo).


Patch:
diff --git a/docs/releases/2.1.0.md b/docs/releases/2.1.0.md
index 5c3caf2..e182ca5 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 85a0e17..f10122b 100644
--- a/statemachine/callbacks.py
+++ b/statemachine/callbacks.py
@@ -19,6 +19,7 @@ class CallbackWrapper:
         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 @@ class CallbackWrapper:
         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 @@ class CallbackWrapper:
         """
         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 @@ class Callbacks:
         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 d96a3f2..83c9b49 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 ensure_callable(attr, *objects):
 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


Test patch:
diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py
index c384f22..1c24228 100644
--- a/tests/test_dispatcher.py
+++ b/tests/test_dispatcher.py
@@ -112,3 +112,13 @@ class TestResolverFactory:
         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 0000000..83f252b
--- /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]
+
+```