Repository: fgmacedo/python-statemachine

Issue:
Incompatible with spy wrapper from pytest-mock
* Python State Machine version: 2.0.0+ (2.0.0, 2.1.0)
* Python version: 3.10+
* Operating System: Linux

### Description

Throws exception during construction, when using [pytest-mock](https://pytest-mock.readthedocs.io/en/latest/index.html).

from traceback:
```
cls = <class 'statemachine.signature.SignatureAdapter'>
method = <function on_enter_state at 0x7fa0ca393740>

    @classmethod
    def wrap(cls, method):
        """Build a wrapper that adapts the received arguments to the inner ``method`` signature"""
    
        sig = cls.from_callable(method)
>       sig.method = method
E       AttributeError: 'Signature' object has no attribute 'method'

.venv/lib/python3.11/site-packages/statemachine/signature.py:18: AttributeError
```

Issue is caused by mock, that replaces class method with a wrapper, which already contains `Signature` instance. This instance is returned from `cls.from_callable` instead of `SignatureAdapter`.

### What I Did

here is a minimal code example
```python
def test_minimal(mocker):
    class Observer:
        def on_enter_state(self, event, model, source, target, state):
            ...

    obs = Observer()
    on_enter_state = mocker.spy(obs, "on_enter_state")

    class Machine(StateMachine):
        a = State("Init", initial=True)
        b = State("Fin")

        cycle = a.to(b) | b.to(a)

    state = Machine().add_observer(obs)
    assert state.a.is_active

    state.cycle()

    assert state.b.is_active
    on_enter_state.assert_called_once()
```


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=392)

[![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=392&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=392&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=392&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=392&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=392&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=392&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=392&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=392&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=fgmacedo_python-statemachine&pullRequest=392&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=392&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=392&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=392&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=392) 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=392&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=fgmacedo_python-statemachine&pullRequest=392&metric=new_duplicated_lines_density&view=list)


codesee-maps[bot]: <details open>
<summary><b>👇 <a href="https://app.codesee.io/r/reviews?pr=392&src=https%3A%2F%2Fgithub.com%2Ffgmacedo%2Fpython-statemachine">Click on the image</a> for a new way to code review</b>
</summary>

####

[![Review these changes using an interactive CodeSee Map](https://s3.us-east-2.amazonaws.com/maps.codesee.io/images/github/fgmacedo/python-statemachine/392/48dfd2bc/2f17db95b060d96dd62727fa9b3567307972811e.svg)](https://app.codesee.io/r/reviews?pr=392&src=https%3A%2F%2Fgithub.com%2Ffgmacedo%2Fpython-statemachine)

#### Legend
<img src="https://app.codesee.io/Legend_v3.png" alt="CodeSee Map legend" width="220" height="156">
</details>

Patch:
diff --git a/docs/releases/2.1.1.md b/docs/releases/2.1.1.md
new file mode 100644
index 0000000..77d6308
--- /dev/null
+++ b/docs/releases/2.1.1.md
@@ -0,0 +1,8 @@
+# StateMachine 2.1.1
+
+*Not released yet*
+
+## Bugfixes in 2.1.1
+
+- Fixes [#391](https://github.com/fgmacedo/python-statemachine/issues/391) adding support to
+  [pytest-mock](https://pytest-mock.readthedocs.io/en/latest/index.html) `spy` method.
diff --git a/docs/releases/index.md b/docs/releases/index.md
index 3722949..2dba15b 100644
--- a/docs/releases/index.md
+++ b/docs/releases/index.md
@@ -15,6 +15,7 @@ Below are release notes through StateMachine and its patch releases.
 ```{toctree}
 :maxdepth: 1
 
+2.1.1
 2.1.0
 2.0.0
 
diff --git a/poetry.lock b/poetry.lock
index ddcbaa1..c78e519 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -971,6 +971,24 @@ pytest = ">=4.6"
 [package.extras]
 testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"]
 
+[[package]]
+name = "pytest-mock"
+version = "3.10.0"
+description = "Thin-wrapper around the mock package for easier use with pytest"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "pytest-mock-3.10.0.tar.gz", hash = "sha256:fbbdb085ef7c252a326fd8cdcac0aa3b1333d8811f131bdcc701002e1be7ed4f"},
+    {file = "pytest_mock-3.10.0-py3-none-any.whl", hash = "sha256:f4c973eeae0282963eb293eb173ce91b091a79c1334455acfac9ddee8a1c784b"},
+]
+
+[package.dependencies]
+pytest = ">=5.0"
+
+[package.extras]
+dev = ["pre-commit", "pytest-asyncio", "tox"]
+
 [[package]]
 name = "pytest-sugar"
 version = "0.9.7"
@@ -1495,4 +1513,4 @@ diagrams = []
 [metadata]
 lock-version = "2.0"
 python-versions = ">=3.7, <3.12"
-content-hash = "1a2ce5412b6332aebdd5068efed3e098593ebaaf9b2a0fa2437f3f0ee84b82c6"
+content-hash = "b0b43742909aa984bdf9fd4edb93d8e8143ca09aaff3d9aa7110eeb8902c3b55"
diff --git a/pyproject.toml b/pyproject.toml
index f1a475d..8c5bc9f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -45,6 +45,7 @@ pre-commit = "^2.21.0"
 mypy = "^0.991"
 black = "^22.12.0"
 pdbpp = "^0.10.3"
+pytest-mock = "^3.10.0"
 
 [tool.poetry.group.docs.dependencies]
 Sphinx = "4.5.0"
diff --git a/statemachine/signature.py b/statemachine/signature.py
index e3dca83..62d5642 100644
--- a/statemachine/signature.py
+++ b/statemachine/signature.py
@@ -25,6 +25,16 @@ class SignatureAdapter(Signature):
         ba = self.bind_expected(*args, **kwargs)
         return self.method(*ba.args, **ba.kwargs)
 
+    @classmethod
+    def from_callable(cls, method):
+        if hasattr(method, "__signature__"):
+            sig = method.__signature__
+            return SignatureAdapter(
+                sig.parameters.values(),
+                return_annotation=sig.return_annotation,
+            )
+        return super().from_callable(method)
+
     def bind_expected(self, *args: Any, **kwargs: Any) -> BoundArguments:  # noqa: C901
         """Get a BoundArguments object, that maps the passed `args`
         and `kwargs` to the function's signature.  It avoids to raise `TypeError`


Test patch:
diff --git a/tests/test_mock_compatibility.py b/tests/test_mock_compatibility.py
new file mode 100644
index 0000000..abefd0d
--- /dev/null
+++ b/tests/test_mock_compatibility.py
@@ -0,0 +1,25 @@
+from statemachine import State
+from statemachine import StateMachine
+
+
+def test_minimal(mocker):
+    class Observer:
+        def on_enter_state(self, event, model, source, target, state):
+            ...
+
+    obs = Observer()
+    on_enter_state = mocker.spy(obs, "on_enter_state")
+
+    class Machine(StateMachine):
+        a = State("Init", initial=True)
+        b = State("Fin")
+
+        cycle = a.to(b) | b.to(a)
+
+    state = Machine().add_observer(obs)
+    assert state.a.is_active
+
+    state.cycle()
+
+    assert state.b.is_active
+    on_enter_state.assert_called_once()