fgmacedo / python-statemachine

Python Finite State Machines made easy.
MIT License
858 stars 84 forks source link

Incompatible with spy wrapper from pytest-mock #391

Closed wawa19933 closed 1 year ago

wawa19933 commented 1 year ago

Description

Throws exception during construction, when using pytest-mock.

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

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()
wawa19933 commented 1 year ago

Here is a dirty workaround:

--- a/signature.py  2023-06-12 04:14:29.750363476 +0200
+++ b/signature.py  2023-06-12 04:14:43.009982670 +0200
@@ -15,6 +15,14 @@
         """Build a wrapper that adapts the received arguments to the inner ``method`` signature"""

         sig = cls.from_callable(method)
+        if not isinstance(sig, SignatureAdapter):
+            n = SignatureAdapter()
+            n._parameters = sig._parameters
+            n.__slots__ = sig.__slots__
+            n._parameter_cls = sig._parameter_cls
+            n._bound_arguments_cls = sig._bound_arguments_cls
+            n.empty = sig.empty
+            sig = n
         sig.method = method
         sig.__name__ = (
             method.func.__name__ if isinstance(method, partial) else method.__name__
wawa19933 commented 1 year ago

This doesn't affect underlying unittest.mock patch functions, though.

So, this example works:

    class Observer:
        def on_enter_state(self, event, model, source, target, state):
            ...

    obs = Observer()
    # on_enter_state = mocker.spy(obs, "on_enter_state")
    on_enter_state = mocker.patch.object(obs, "on_enter_state")

    class Machine(StateMachine):
        a = State("Init", initial=True)
        b = State("Fin")

        cycle = a.to(b) | b.to(a)

        def on_exit_state(self, source, target):
            assert source != target

    state = Machine().add_observer(obs)
    assert state.a.is_active

    state.cycle()

    assert state.b.is_active
    on_enter_state.assert_called_once()
fgmacedo commented 1 year ago

Hi @wawa19933 , thanks for reporting this and the extra care on the context and details. I've added pytest-mock as a dependency to the project to be able to reproduce.