Skip to content

Commit 6187213

Browse files
authored
chore: Improved enum support; Allow binding eventes to objects (#454)
1 parent d446d99 commit 6187213

11 files changed

Lines changed: 230 additions & 19 deletions

File tree

docs/mixins.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class.
4444
... state_machine_name = '__main__.CampaignMachineWithKeys'
4545
... state_machine_attr = 'sm'
4646
... state_field_name = 'workflow_step'
47+
... bind_events_as_methods = True
4748
...
4849
... workflow_step = 1
4950
...
@@ -65,7 +66,11 @@ True
6566
>>> model.sm.current_state == model.sm.draft
6667
True
6768

68-
>>> model.sm.cancel()
69+
>>> model.produce() # `bind_events_as_methods = True` adds triggers to events in the mixin instance
70+
>>> model.workflow_step
71+
2
72+
73+
>>> model.sm.cancel() # You can still call the SM directly
6974

7075
>>> model.workflow_step
7176
4

docs/releases/2.3.2.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,17 @@ Paulista Avenue after: green--(cycle)-->yellow
4141
See {ref}`listeners` for more details.
4242
```
4343

44+
### Binding event triggers to external objects
45+
46+
Now it's possible to bind events to external objets. One expected use case is in conjunction with the {ref}`Mixins` models,
47+
that wrap state machines internally. This way you don't need to expose the state machine.
48+
49+
50+
```{seealso}
51+
See {ref}`sphx_glr_auto_examples_user_machine.py` for an example binding event triggers with a state machine.
52+
```
53+
54+
4455
## Bugfixes in 2.3.2
4556

4657
- Fixes [#446](https://github.com/fgmacedo/python-statemachine/issues/446): Regression that broke sync callbacks

statemachine/mixins.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,30 @@ class MachineMixin:
77
``StateMachine``.
88
"""
99

10-
state_field_name = "state" # type: str
10+
state_field_name: str = "state"
1111
"""The model's state field name that will hold the state value."""
1212

13-
state_machine_name = None # type: str
13+
state_machine_name: "str | None" = None
1414
"""A fully qualified name of the class, where it can be imported."""
1515

16-
state_machine_attr = "statemachine" # type: str
16+
state_machine_attr: str = "statemachine"
1717
"""Name of the model's attribute that will hold the machine instance."""
1818

19+
bind_events_as_methods: bool = False
20+
"""If ``True`` the state machine events triggers will be bound to the model as methods."""
21+
1922
def __init__(self, *args, **kwargs):
2023
super().__init__(*args, **kwargs)
2124
if not self.state_machine_name:
2225
raise ValueError(
2326
_("{!r} is not a valid state machine name.").format(self.state_machine_name)
2427
)
2528
machine_cls = registry.get_machine_cls(self.state_machine_name)
29+
sm = machine_cls(self, state_field=self.state_field_name)
2630
setattr(
2731
self,
2832
self.state_machine_attr,
29-
machine_cls(self, state_field=self.state_field_name),
33+
sm,
3034
)
35+
if self.bind_events_as_methods:
36+
sm.bind_events_to(self)

statemachine/statemachine.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,22 @@ def _get_initial_state(self):
141141
except KeyError as err:
142142
raise InvalidStateValue(current_state_value) from err
143143

144+
def bind_events_to(self, *targets):
145+
"""Bind the state machine events to the target objects."""
146+
147+
for event in self.events:
148+
trigger = getattr(self, event.name)
149+
for target in targets:
150+
if hasattr(target, event.name):
151+
warnings.warn(
152+
f"Attribute {event.name!r} already exists on {target!r}. "
153+
f"Skipping binding.",
154+
UserWarning,
155+
stacklevel=2,
156+
)
157+
continue
158+
setattr(target, event.name, trigger)
159+
144160
async def activate_initial_state(self):
145161
"""
146162
Activate the initial state.
@@ -173,9 +189,6 @@ async def activate_initial_state(self):
173189
)
174190
await self._activate(event_data)
175191

176-
async def _ensure_is_initialized(self):
177-
await self.activate_initial_state()
178-
179192
def _add_listener(self, listeners: "Listeners"):
180193
register = partial(listeners.resolve, registry=self._callbacks_registry)
181194
for visited in iterate_states_and_transitions(self.states):
@@ -295,7 +308,7 @@ def allowed_events(self):
295308

296309
async def _trigger(self, trigger_data: TriggerData):
297310
event_data = None
298-
await self._ensure_is_initialized()
311+
await self.activate_initial_state()
299312

300313
state = self.current_state
301314
for transition in state.transitions:

statemachine/states.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ def __eq__(self, other):
5454
return list(self) == list(other)
5555

5656
def __getattr__(self, name: str):
57+
name = name.lower()
5758
if name in self._states:
5859
return self._states[name]
5960
raise AttributeError(f"{name} not found in {self.__class__.__name__}")
@@ -80,7 +81,7 @@ def items(self):
8081
return self._states.items()
8182

8283
@classmethod
83-
def from_enum(cls, enum_type: EnumType, initial: Enum, final=None):
84+
def from_enum(cls, enum_type: EnumType, initial, final=None):
8485
"""
8586
Creates a new instance of the ``States`` class from an enumeration.
8687
@@ -124,7 +125,7 @@ def from_enum(cls, enum_type: EnumType, initial: Enum, final=None):
124125
True
125126
126127
>>> sm.current_state_value
127-
2
128+
<Status.completed: 2>
128129
129130
Args:
130131
enum_type: An enumeration containing the states of the machine.
@@ -137,7 +138,7 @@ def from_enum(cls, enum_type: EnumType, initial: Enum, final=None):
137138
final_set = set(ensure_iterable(final))
138139
return cls(
139140
{
140-
e.name: State(value=e.value, initial=e is initial, final=e in final_set)
141+
e.name.lower(): State(value=e, initial=e is initial, final=e in final_set)
141142
for e in enum_type
142143
}
143144
)

tests/django_project/workflow/models.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,18 @@
66
User = get_user_model()
77

88

9+
class WorkflowSteps(models.TextChoices):
10+
DRAFT = "draft"
11+
PUBLISHED = "published"
12+
13+
914
class Workflow(models.Model, MachineMixin):
1015
state_machine_name = "workflow.statemachines.WorfklowStateMachine"
1116
state_machine_attr = "wf"
17+
bind_events_as_methods = True
1218

13-
state = models.CharField(max_length=30, blank=True, null=True)
19+
state = models.CharField(
20+
max_length=30, choices=WorkflowSteps.choices, default=WorkflowSteps.DRAFT
21+
)
1422
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True)
1523
is_active = models.BooleanField(default=False)
Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
from statemachine import State
21
from statemachine import StateMachine
2+
from statemachine.states import States
3+
4+
from .models import WorkflowSteps
35

46

57
class WorfklowStateMachine(StateMachine):
6-
draft = State(initial=True)
7-
published = State(final=True)
8+
_ = States.from_enum(WorkflowSteps, initial=WorkflowSteps.DRAFT, final=WorkflowSteps.PUBLISHED)
89

9-
publish = draft.to(published, cond="is_active")
10-
notify_user = draft.to.itself(internal=True, cond="has_user")
10+
publish = _.draft.to(_.published, cond="is_active")
11+
notify_user = _.draft.to.itself(internal=True, cond="has_user")
1112

1213
def has_user(self):
1314
return bool(self.model.user)

tests/django_project/workflow/tests.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pytest
22

33
from statemachine.exceptions import TransitionNotAllowed
4+
from workflow.models import WorkflowSteps
45
from workflow.statemachines import WorfklowStateMachine
56

67
pytestmark = [
@@ -59,3 +60,12 @@ def test_async_with_db_operation(self, one, User, Workflow):
5960

6061
wf = WorfklowStateMachine(one)
6162
wf.send("notify_user")
63+
64+
def test_should_publish(self, one):
65+
one.is_active = True
66+
one.publish()
67+
one.save()
68+
69+
assert one.state == "published"
70+
assert one.wf.current_state_value == "published"
71+
assert one.wf.current_state_value == WorkflowSteps.PUBLISHED

tests/examples/enum_campaign_machine.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,4 @@ class CampaignMachine(StateMachine):
5454
assert sm.producing.is_active is True
5555
assert sm.closed.is_active is False
5656
assert sm.current_state == sm.producing
57-
assert sm.current_state_value == CampaignStatus.producing.value
57+
assert sm.current_state_value == CampaignStatus.producing

tests/examples/user_machine.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""
2+
User workflow machine
3+
=====================
4+
5+
This machine binds the events to the User model, the StateMachine is wrapped internally
6+
in the `User` class.
7+
8+
Demonstrates that multiple state machines can be used in the same model.
9+
10+
And that logic can be reused with listeners.
11+
12+
"""
13+
14+
from dataclasses import dataclass
15+
from enum import Enum
16+
17+
from statemachine import State
18+
from statemachine import StateMachine
19+
from statemachine.states import States
20+
21+
22+
class UserStatus(str, Enum):
23+
signup_incomplete = "SIGNUP_INCOMPLETE"
24+
signup_complete = "SIGNUP_COMPLETE"
25+
signup_rejected = "SIGNUP_REJECTED"
26+
operational_enabled = "OPERATIONAL_ENABLED"
27+
operational_disabled = "OPERATIONAL_DISABLED"
28+
operational_rescinded = "OPERATIONAL_RESCINDED"
29+
30+
31+
class UserExperience(str, Enum):
32+
basic = "BASIC"
33+
premium = "PREMIUM"
34+
35+
36+
@dataclass
37+
class User:
38+
name: str
39+
email: str
40+
status: UserStatus = UserStatus.signup_incomplete
41+
experience: UserExperience = UserExperience.basic
42+
43+
verified: bool = False
44+
45+
def __post_init__(self):
46+
self._status_sm = UserStatusMachine(
47+
self, state_field="status", listeners=[MachineChangeListenter()]
48+
)
49+
self._status_sm.bind_events_to(self)
50+
51+
self._experience_sm = UserExperienceMachine(
52+
self, state_field="experience", listeners=[MachineChangeListenter()]
53+
)
54+
self._experience_sm.bind_events_to(self)
55+
56+
57+
class MachineChangeListenter:
58+
def before_transition(self, event: str, state: State):
59+
print(f"Before {event} in {state}")
60+
61+
def on_enter_state(self, state: State, event: str):
62+
print(f"Entering {state} from {event}")
63+
64+
65+
class UserStatusMachine(StateMachine):
66+
_states = States.from_enum(
67+
UserStatus,
68+
initial=UserStatus.signup_incomplete,
69+
final=[
70+
UserStatus.operational_rescinded,
71+
UserStatus.signup_rejected,
72+
],
73+
)
74+
75+
signup = _states.signup_incomplete.to(_states.signup_complete)
76+
reject = _states.signup_rejected.from_(
77+
_states.signup_incomplete,
78+
_states.signup_complete,
79+
)
80+
enable = _states.signup_complete.to(_states.operational_enabled)
81+
disable = _states.operational_enabled.to(_states.operational_disabled)
82+
rescind = _states.operational_rescinded.from_(
83+
_states.operational_enabled,
84+
_states.operational_disabled,
85+
)
86+
87+
def on_signup(self, token: str):
88+
if token == "":
89+
raise ValueError("Token is required")
90+
self.model.verified = True
91+
92+
93+
class UserExperienceMachine(StateMachine):
94+
_states = States.from_enum(
95+
UserExperience,
96+
initial=UserExperience.basic,
97+
)
98+
99+
upgrade = _states.basic.to(_states.premium)
100+
downgrade = _states.premium.to(_states.basic)
101+
102+
103+
# %%
104+
# Executing
105+
106+
107+
def main(): # type: ignore[attr-defined]
108+
# By binding the events to the User model, the events can be fired directly from the model
109+
user = User(name="Frodo", email="frodo@lor.com")
110+
111+
try:
112+
# Trying to signup with an empty token should raise an exception
113+
user.signup("")
114+
except Exception as e:
115+
print(e)
116+
117+
assert user.verified is False
118+
119+
user.signup("1234")
120+
121+
assert user.status == UserStatus.signup_complete
122+
assert user.verified is True
123+
124+
print(user.experience)
125+
user.upgrade()
126+
print(user.experience)
127+
128+
129+
if __name__ == "__main__":
130+
main()

0 commit comments

Comments
 (0)