Repository: fgmacedo/python-statemachine

Issue:
Models instantiated in Djangos setUpTestData seem to skip conditions
* Python State Machine version: 2.1.2
* Python version: 3.12
* Operating System: OsX 12.7.2
* Django: 5.0.3

### Description

The MachineMixin for Django seems to behave differently when models are used within django tests.
Oddly enough, any objects created during setUpTestData seem to cause transitions to bypass checks like
conditions.

### What I Did
This is a simple django application. Create migrations, run tests.
(I'm skipping some imports for stuff you probably know)

statemachines.py
```
class MySM(StateMachine):
    draft = State("Draft", initial=True, value="draft")
    published = State("Published", value="published")

    publish = draft.to(published, cond="let_me_be_visible")

```

models.py
```
class MyContext(models.Model, MachineMixin):
    state = models.CharField(max_length=30, blank=True, null=True)
    state_machine_name = "myapp.statemachines.MySM"
    state_machine_attr = "wf"
    let_me_be_visible = False

```

tests.py
```
from django.test import TestCase
from statemachine.exceptions import TransitionNotAllowed

from myapp.models import MyContext
from myapp.statemachines import MySM


class WFTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        # Runs once
        cls.one = MyContext.objects.create()

    def setUp(self):
        # Runs before every test
        self.two = MyContext.objects.create()

    def test_one(self):
        # This test will fail
        with self.assertRaises(TransitionNotAllowed):
            self.one.wf.send("publish")

    def test_two(self):
        # This works as expected
        with self.assertRaises(TransitionNotAllowed):
            self.two.wf.send("publish")

    def test_three(self):
        # Managing this instance works if I call it like this instead.
        # So this test works
        wf = MySM(self.one)
        with self.assertRaises(TransitionNotAllowed):
            wf.send("publish")
```

The inner workings of the transitions are still a bit opaque to me, so sorry if there's something obvious I'm missing here.

Thanks for maintaining this great library!

Comments:

Patch:
diff --git a/statemachine/callbacks.py b/statemachine/callbacks.py
index 20d9126..3d8153d 100644
--- a/statemachine/callbacks.py
+++ b/statemachine/callbacks.py
@@ -240,6 +240,9 @@ class CallbacksRegistry:
         executor_list.add(callbacks, resolver)
         return executor_list
 
+    def clear(self):
+        self._registry.clear()
+
     def __getitem__(self, callbacks: CallbackMetaList) -> CallbacksExecutor:
         return self._registry[callbacks]
 
diff --git a/statemachine/statemachine.py b/statemachine/statemachine.py
index d0ccfae..855fdbe 100644
--- a/statemachine/statemachine.py
+++ b/statemachine/statemachine.py
@@ -1,4 +1,5 @@
 from collections import deque
+from copy import deepcopy
 from functools import partial
 from typing import TYPE_CHECKING
 from typing import Any
@@ -78,9 +79,9 @@ class StateMachine(metaclass=StateMachineMetaclass):
         if self._abstract:
             raise InvalidDefinition(_("There are no states or transitions."))
 
-        initial_transition = Transition(None, self._get_initial_state(), event="__initial__")
-        self._setup(initial_transition)
-        self._activate_initial_state(initial_transition)
+        self._initial_transition = Transition(None, self._get_initial_state(), event="__initial__")
+        self._setup()
+        self._activate_initial_state()
 
     def __init_subclass__(cls, strict_states: bool = False):
         cls._strict_states = strict_states
@@ -98,6 +99,18 @@ class StateMachine(metaclass=StateMachineMetaclass):
             f"current_state={current_state_id!r})"
         )
 
+    def __deepcopy__(self, memo):
+        deepcopy_method = self.__deepcopy__
+        self.__deepcopy__ = None
+        try:
+            cp = deepcopy(self, memo)
+        finally:
+            self.__deepcopy__ = deepcopy_method
+            cp.__deepcopy__ = deepcopy_method
+        cp._callbacks_registry.clear()
+        cp._setup()
+        return cp
+
     def _get_initial_state(self):
         current_state_value = self.start_value if self.start_value else self.initial_state.value
         try:
@@ -105,20 +118,20 @@ class StateMachine(metaclass=StateMachineMetaclass):
         except KeyError as err:
             raise InvalidStateValue(current_state_value) from err
 
-    def _activate_initial_state(self, initial_transition):
+    def _activate_initial_state(self):
         if self.current_state_value is None:
             # send an one-time event `__initial__` to enter the current state.
             # current_state = self.current_state
-            initial_transition.before.clear()
-            initial_transition.on.clear()
-            initial_transition.after.clear()
+            self._initial_transition.before.clear()
+            self._initial_transition.on.clear()
+            self._initial_transition.after.clear()
 
             event_data = EventData(
                 trigger_data=TriggerData(
                     machine=self,
-                    event=initial_transition.event,
+                    event=self._initial_transition.event,
                 ),
-                transition=initial_transition,
+                transition=self._initial_transition,
             )
             self._activate(event_data)
 
@@ -142,12 +155,7 @@ class StateMachine(metaclass=StateMachineMetaclass):
             for transition in state.transitions:
                 visitor(transition)
 
-    def _setup(self, initial_transition: Transition):
-        """
-        Args:
-            initial_transition: A special :ref:`transition` that triggers the enter on the
-                `initial` :ref:`State`.
-        """
+    def _setup(self):
         machine = ObjectConfig.from_obj(self, skip_attrs=self._get_protected_attrs())
         model = ObjectConfig.from_obj(self.model, skip_attrs={self.state_field})
         default_resolver = resolver_factory(machine, model)
@@ -162,7 +170,7 @@ class StateMachine(metaclass=StateMachineMetaclass):
 
         self._visit_states_and_transitions(setup_visitor)
 
-        initial_transition._setup(register)
+        self._initial_transition._setup(register)
 
     def _build_observers_visitor(self, *observers):
         registry_callbacks = [


Test patch:
diff --git a/tests/test_deepcopy.py b/tests/test_deepcopy.py
new file mode 100644
index 0000000..bbfdfc8
--- /dev/null
+++ b/tests/test_deepcopy.py
@@ -0,0 +1,25 @@
+from copy import deepcopy
+
+import pytest
+
+from statemachine import State
+from statemachine import StateMachine
+from statemachine.exceptions import TransitionNotAllowed
+
+
+def test_deepcopy():
+    class MySM(StateMachine):
+        draft = State("Draft", initial=True, value="draft")
+        published = State("Published", value="published")
+
+        publish = draft.to(published, cond="let_me_be_visible")
+
+    class MyModel:
+        let_me_be_visible = False
+
+    sm = MySM(MyModel())
+
+    sm2 = deepcopy(sm)
+
+    with pytest.raises(TransitionNotAllowed):
+        sm2.send("publish")