Python finite state machine library with declarative API for sync and async applications
—
Domain model integration patterns, mixin classes for automatic state machine binding, and framework integration including Django support and model field binding.
Mixin class that enables models to automatically instantiate and manage state machines with configurable binding options.
class MachineMixin:
"""
Mixin that allows a model to automatically instantiate and assign a StateMachine.
This mixin provides seamless integration between domain models and state machines,
automatically handling state machine instantiation and event binding.
Class Attributes:
- state_field_name: The model's field name that holds the state value (default: "state")
- state_machine_name: Fully qualified name of the StateMachine class for import
- state_machine_attr: Name of the model attribute that will hold the machine instance (default: "statemachine")
- bind_events_as_methods: If True, state machine events are bound as model methods (default: False)
"""
state_field_name: str = "state"
"""The model's state field name that will hold the state value."""
state_machine_name: str = None
"""A fully qualified name of the StateMachine class, where it can be imported."""
state_machine_attr: str = "statemachine"
"""Name of the model's attribute that will hold the machine instance."""
bind_events_as_methods: bool = False
"""If True, state machine event triggers will be bound to the model as methods."""
def __init__(self, *args, **kwargs):
"""
Initialize the mixin and create state machine instance.
Raises:
- ValueError: If state_machine_name is not set or invalid
"""Functions for state machine registration and discovery, particularly useful for framework integration.
# Registry module functions for state machine discovery
def get_machine_cls(qualname: str):
"""
Get state machine class by fully qualified name.
Parameters:
- qualname: Fully qualified class name (e.g., "myapp.machines.OrderMachine")
Returns:
StateMachine class
Raises:
- ImportError: If the module or class cannot be imported
"""
def register_machine(machine_cls):
"""
Register a state machine class for discovery.
Parameters:
- machine_cls: StateMachine class to register
"""Utilities for integrating state machines with various model systems and frameworks.
def bind_events_to(machine: StateMachine, target_object: object):
"""
Bind state machine events as methods on target object.
Parameters:
- machine: StateMachine instance
- target_object: Object to bind events to
Creates methods on target_object for each event in the machine.
"""
class Model:
"""
Base model class with state machine integration support.
Provides foundation for external model integration with
automatic state field management.
"""
def __init__(self, **kwargs):
"""Initialize model with optional state field."""from statemachine import StateMachine, State
from statemachine.mixins import MachineMixin
# Define the state machine
class OrderStateMachine(StateMachine):
pending = State(initial=True, value="pending")
paid = State(value="paid")
shipped = State(value="shipped")
delivered = State(value="delivered", final=True)
cancelled = State(value="cancelled", final=True)
pay = pending.to(paid)
ship = paid.to(shipped)
deliver = shipped.to(delivered)
cancel = (
pending.to(cancelled)
| paid.to(cancelled)
| shipped.to(cancelled)
)
def on_enter_paid(self, payment_info: dict = None):
print(f"Payment received: {payment_info}")
def on_enter_shipped(self, tracking_number: str = None):
print(f"Order shipped with tracking: {tracking_number}")
def on_enter_delivered(self):
print("Order delivered successfully!")
# Define the model using MachineMixin
class Order(MachineMixin):
state_machine_name = "OrderStateMachine" # Could be full path like "myapp.machines.OrderStateMachine"
state_field_name = "status"
bind_events_as_methods = True
def __init__(self, order_id: str, **kwargs):
self.order_id = order_id
self.status = "pending" # Initial state value
self.customer_email = kwargs.get("customer_email")
super().__init__(**kwargs) # Initialize MachineMixin
def send_notification(self, message: str):
"""Custom model method."""
print(f"Notification to {self.customer_email}: {message}")
# Usage
order = Order("ORD-001", customer_email="customer@example.com")
# Access state machine via configured attribute
print(f"Current state: {order.statemachine.current_state.id}") # "pending"
print(f"Model status field: {order.status}") # "pending"
# Use bound event methods (since bind_events_as_methods=True)
order.pay(payment_info={"method": "credit_card", "amount": 99.99})
print(f"After payment - Status: {order.status}") # "paid"
order.ship(tracking_number="TRK123456")
print(f"After shipping - Status: {order.status}") # "shipped"
order.deliver()
print(f"Final status: {order.status}") # "delivered"import json
from datetime import datetime
class AdvancedOrder(MachineMixin):
state_machine_name = "OrderStateMachine"
state_field_name = "workflow_state"
state_machine_attr = "workflow"
def __init__(self, order_id: str, **kwargs):
self.order_id = order_id
self.workflow_state = "pending"
self.created_at = datetime.now()
self.state_history = []
super().__init__(**kwargs)
# Add state change logging
self.workflow.add_callback(
self.log_state_change,
group=CallbackGroup.ACTIONS
)
def log_state_change(self, event: str, source: State, target: State, **kwargs):
"""Log all state changes."""
self.state_history.append({
"timestamp": datetime.now().isoformat(),
"event": event,
"from": source.id,
"to": target.id,
"metadata": kwargs
})
print(f"State change logged: {source.id} -> {target.id} via {event}")
def get_state_history(self) -> str:
"""Get formatted state history."""
return json.dumps(self.state_history, indent=2)
def current_state_info(self) -> dict:
"""Get current state information."""
return {
"state": self.workflow_state,
"state_name": self.workflow.current_state.name,
"is_final": self.workflow.current_state.final,
"allowed_events": [e.id for e in self.workflow.allowed_events]
}
# Usage
order = AdvancedOrder("ORD-002")
print("Initial state:", order.current_state_info())
order.workflow.send("pay", payment_method="paypal", amount=75.50)
order.workflow.send("ship", carrier="UPS", tracking="1Z999AA1234567890")
order.workflow.send("deliver")
print("\nFinal state:", order.current_state_info())
print("\nState history:")
print(order.get_state_history())# Django model example (requires Django)
try:
from django.db import models
from statemachine.mixins import MachineMixin
class DjangoOrder(models.Model, MachineMixin):
# Django model fields
order_id = models.CharField(max_length=50, unique=True)
customer_email = models.EmailField()
total_amount = models.DecimalField(max_digits=10, decimal_places=2)
status = models.CharField(max_length=20, default="pending")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# MachineMixin configuration
state_machine_name = "myapp.machines.OrderStateMachine"
state_field_name = "status"
bind_events_as_methods = True
class Meta:
db_table = "orders"
def save(self, *args, **kwargs):
"""Override save to sync state machine state."""
if hasattr(self, 'statemachine'):
# Ensure model field matches state machine state
self.status = self.statemachine.current_state.value
super().save(*args, **kwargs)
def __str__(self):
return f"Order {self.order_id} - {self.status}"
# Usage in Django views/services
def process_payment(order_id: str, payment_data: dict):
"""Service function to process order payment."""
order = DjangoOrder.objects.get(order_id=order_id)
try:
# Use bound event method
order.pay(payment_info=payment_data)
order.save() # Persist state change
return {"success": True, "new_state": order.status}
except TransitionNotAllowed as e:
return {"success": False, "error": str(e)}
except ImportError:
print("Django not available - skipping Django example")# SQLAlchemy model example
try:
from sqlalchemy import Column, Integer, String, DateTime, create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from datetime import datetime
Base = declarative_base()
class SQLAlchemyOrder(Base, MachineMixin):
__tablename__ = "orders"
# SQLAlchemy columns
id = Column(Integer, primary_key=True)
order_id = Column(String(50), unique=True, nullable=False)
customer_email = Column(String(255), nullable=False)
status = Column(String(20), default="pending")
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# MachineMixin configuration
state_machine_name = "OrderStateMachine"
state_field_name = "status"
def __init__(self, order_id: str, customer_email: str, **kwargs):
self.order_id = order_id
self.customer_email = customer_email
super().__init__(**kwargs)
def update_state(self, session):
"""Helper to update and persist state changes."""
if hasattr(self, 'statemachine'):
self.status = self.statemachine.current_state.value
self.updated_at = datetime.now()
session.commit()
# Usage
def create_order_with_workflow():
engine = create_engine("sqlite:///orders.db")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
# Create new order
order = SQLAlchemyOrder(
order_id="ORD-003",
customer_email="customer@example.com"
)
session.add(order)
session.commit()
# Process through workflow
order.statemachine.send("pay", payment_method="stripe")
order.update_state(session)
order.statemachine.send("ship", carrier="FedEx")
order.update_state(session)
print(f"Order {order.order_id} status: {order.status}")
session.close()
except ImportError:
print("SQLAlchemy not available - skipping SQLAlchemy example")from abc import ABC, abstractmethod
class StateMachineModel(ABC):
"""Abstract base class for state machine model integration."""
def __init__(self, machine_class, initial_state_value=None, **kwargs):
self.machine_class = machine_class
self._state_value = initial_state_value or self.get_initial_state_value()
self._machine = None
self.initialize_machine(**kwargs)
@abstractmethod
def get_initial_state_value(self):
"""Get the initial state value for this model."""
pass
@abstractmethod
def persist_state(self):
"""Persist the current state to storage."""
pass
def initialize_machine(self, **kwargs):
"""Initialize the state machine instance."""
self._machine = self.machine_class(
model=self,
state_field="_state_value",
**kwargs
)
@property
def machine(self):
"""Get the state machine instance."""
return self._machine
def send_event(self, event: str, *args, **kwargs):
"""Send event and persist state change."""
try:
result = self._machine.send(event, *args, **kwargs)
self.persist_state()
return result
except Exception as e:
# Optionally log error or handle rollback
raise
class FileBasedOrder(StateMachineModel):
"""Example model that persists state to file."""
def __init__(self, order_id: str, **kwargs):
self.order_id = order_id
self.filename = f"order_{order_id}.json"
super().__init__(OrderStateMachine, **kwargs)
def get_initial_state_value(self):
"""Load state from file or return default."""
try:
with open(self.filename, 'r') as f:
data = json.load(f)
return data.get('state', 'pending')
except FileNotFoundError:
return 'pending'
def persist_state(self):
"""Save current state to file."""
data = {
'order_id': self.order_id,
'state': self._state_value,
'timestamp': datetime.now().isoformat()
}
with open(self.filename, 'w') as f:
json.dump(data, f)
print(f"State persisted: {self._state_value}")
# Usage
order = FileBasedOrder("ORD-004")
print(f"Initial state: {order.machine.current_state.id}")
order.send_event("pay", payment_method="bitcoin")
order.send_event("ship", carrier="DHL")
order.send_event("deliver")
print(f"Final state: {order.machine.current_state.id}")class EventBoundModel(MachineMixin):
state_machine_name = "OrderStateMachine"
bind_events_as_methods = True
def __init__(self, order_id: str, **kwargs):
self.order_id = order_id
self.state = "pending"
self.notifications = []
super().__init__(**kwargs)
# Add custom event handling
self.setup_event_notifications()
def setup_event_notifications(self):
"""Setup automatic notifications for events."""
# Override bound event methods to add notifications
original_pay = self.pay
original_ship = self.ship
original_deliver = self.deliver
original_cancel = self.cancel
def notify_pay(*args, **kwargs):
result = original_pay(*args, **kwargs)
self.notifications.append("Payment confirmation sent")
return result
def notify_ship(*args, **kwargs):
result = original_ship(*args, **kwargs)
self.notifications.append("Shipping notification sent")
return result
def notify_deliver(*args, **kwargs):
result = original_deliver(*args, **kwargs)
self.notifications.append("Delivery confirmation sent")
return result
def notify_cancel(*args, **kwargs):
result = original_cancel(*args, **kwargs)
self.notifications.append("Cancellation notice sent")
return result
# Replace bound methods with notification versions
self.pay = notify_pay
self.ship = notify_ship
self.deliver = notify_deliver
self.cancel = notify_cancel
def get_notifications(self):
"""Get all notifications sent."""
return self.notifications
# Usage
order = EventBoundModel("ORD-005")
order.pay(payment_method="visa")
order.ship(tracking_number="ABC123")
order.deliver()
print("Notifications sent:")
for notification in order.get_notifications():
print(f"- {notification}")Install with Tessl CLI
npx tessl i tessl/pypi-python-statemachine