Python finite state machine library with declarative API for sync and async applications
—
Error scenarios, exception types, and error handling patterns including transition validation errors, invalid state values, and configuration errors.
Comprehensive exception hierarchy for different types of state machine errors.
class StateMachineError(Exception):
"""
Base exception for all state machine errors.
All exceptions raised by the state machine library inherit from this class,
making it easy to catch any state machine-related error.
"""
class InvalidDefinition(StateMachineError):
"""
Raised when the state machine has a definition error.
This includes errors in state machine class definition, invalid state
configurations, or incorrect transition specifications.
"""
class InvalidStateValue(InvalidDefinition):
"""
Raised when the current model state value is not mapped to a state definition.
Occurs when external model has a state value that doesn't correspond
to any defined State object in the state machine.
Attributes:
- value: The invalid state value that was encountered
"""
def __init__(self, value, msg=None): ...
@property
def value(self):
"""Get the invalid state value."""
class AttrNotFound(InvalidDefinition):
"""
Raised when there's no method or property with the given name.
Occurs when callback references point to non-existent methods or
when accessing undefined state machine attributes.
"""
class TransitionNotAllowed(StateMachineError):
"""
Raised when there's no transition that can run from the current state.
This is the most common runtime exception, occurring when trying to
trigger an event that has no valid transition from the current state.
Attributes:
- event: The Event object that couldn't be processed
- state: The State object where the transition was attempted
"""
def __init__(self, event: Event, state: State): ...
@property
def event(self) -> Event:
"""Get the event that couldn't be processed."""
@property
def state(self) -> State:
"""Get the state where transition was attempted."""from statemachine import StateMachine, State
from statemachine.exceptions import TransitionNotAllowed
class LightSwitch(StateMachine):
off = State(initial=True)
on = State()
turn_on = off.to(on)
turn_off = on.to(off)
# Basic exception handling
switch = LightSwitch()
try:
switch.send("turn_off") # Can't turn off when already off
except TransitionNotAllowed as e:
print(f"Cannot {e.event.name} when in {e.state.name}")
print(f"Current state: {switch.current_state.id}")
# Output: Cannot turn_off when in Off
# Current state: off
# Checking allowed events before sending
if "turn_on" in [event.id for event in switch.allowed_events]:
switch.send("turn_on")
else:
print("Turn on event not allowed")
# Alternative: Check if event is allowed
if switch.is_allowed("turn_on"):
switch.send("turn_on")class RobustMachine(StateMachine):
state1 = State(initial=True)
state2 = State()
state3 = State(final=True)
advance = state1.to(state2) | state2.to(state3)
def __init__(self, *args, **kwargs):
# Allow events without transitions (won't raise exceptions)
super().__init__(*args, allow_event_without_transition=True, **kwargs)
def handle_unknown_event(self, event: str):
"""Custom handler for events without transitions."""
print(f"Event '{event}' ignored - no valid transition")
# Usage
machine = RobustMachine()
machine.send("unknown_event") # Won't raise exception
machine.send("advance") # Normal transition
machine.send("unknown_event") # Still won't raise exceptionfrom statemachine.exceptions import InvalidStateValue
class Document:
def __init__(self):
self.status = "invalid_status" # This will cause an error
class DocumentMachine(StateMachine):
draft = State(initial=True, value="draft_status")
review = State(value="review_status")
published = State(value="published_status", final=True)
submit = draft.to(review)
publish = review.to(published)
# Handle invalid external state
doc = Document()
try:
machine = DocumentMachine(doc, state_field="status")
except InvalidStateValue as e:
print(f"Invalid state value: {e.value}")
print("Available states:", [s.value for s in DocumentMachine._states()])
# Fix the model and retry
doc.status = "draft_status"
machine = DocumentMachine(doc, state_field="status")
print(f"Current state: {machine.current_state.id}")class ValidatedMachine(StateMachine):
idle = State(initial=True)
processing = State()
completed = State(final=True)
error = State(final=True)
start = idle.to(processing)
complete = processing.to(completed)
fail = processing.to(error)
def before_start(self, data: dict = None):
"""Validator that may raise exceptions."""
if not data:
raise ValueError("Data is required to start processing")
if not isinstance(data, dict):
raise TypeError("Data must be a dictionary")
required_fields = ["id", "type", "content"]
missing = [field for field in required_fields if field not in data]
if missing:
raise ValueError(f"Missing required fields: {missing}")
print("Validation passed")
def on_start(self, data: dict = None):
"""Action that may fail."""
try:
self.process_data(data)
except Exception as e:
print(f"Processing failed: {e}")
# Trigger failure transition
self.send("fail")
raise # Re-raise to caller
def process_data(self, data: dict):
"""Simulate data processing that may fail."""
if data.get("type") == "error_test":
raise RuntimeError("Simulated processing error")
print(f"Successfully processed data: {data['id']}")
# Usage with error handling
machine = ValidatedMachine()
# Test validation errors
try:
machine.send("start") # Missing data
except ValueError as e:
print(f"Validation error: {e}")
try:
machine.send("start", data="not_a_dict") # Wrong type
except TypeError as e:
print(f"Type error: {e}")
try:
machine.send("start", data={"id": "123"}) # Missing fields
except ValueError as e:
print(f"Validation error: {e}")
# Test processing error
try:
machine.send("start", data={"id": "test", "type": "error_test", "content": "test"})
except RuntimeError as e:
print(f"Processing error: {e}")
print(f"Machine state after error: {machine.current_state.id}")
# Successful processing
try:
machine = ValidatedMachine() # Reset machine
machine.send("start", data={"id": "test", "type": "normal", "content": "test"})
machine.send("complete")
print(f"Final state: {machine.current_state.id}")
except Exception as e:
print(f"Unexpected error: {e}")class RecoveryMachine(StateMachine):
normal = State(initial=True)
error = State()
recovery = State()
failed = State(final=True)
trigger_error = normal.to(error)
attempt_recovery = error.to(recovery)
recover = recovery.to(normal)
give_up = error.to(failed) | recovery.to(failed)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.error_count = 0
self.max_retries = 3
def on_enter_error(self, error_info: str = "Unknown error"):
"""Handle entering error state."""
self.error_count += 1
print(f"Error #{self.error_count}: {error_info}")
if self.error_count <= self.max_retries:
print("Attempting recovery...")
self.send("attempt_recovery")
else:
print("Max retries exceeded, giving up")
self.send("give_up")
def on_enter_recovery(self):
"""Attempt recovery."""
import random
if random.choice([True, False]): # 50% success rate
print("Recovery successful!")
self.error_count = 0 # Reset error count
self.send("recover")
else:
print("Recovery failed")
self.send("give_up")
def on_enter_failed(self):
"""Handle permanent failure."""
print("System has failed permanently")
# Usage
machine = RecoveryMachine()
# Simulate multiple errors
for i in range(5):
try:
if machine.current_state.id == "normal":
machine.send("trigger_error", error_info=f"Error scenario {i+1}")
# Machine handles recovery automatically
if machine.current_state.id in ["failed"]:
break
except Exception as e:
print(f"Unexpected error: {e}")
break
print(f"Final state: {machine.current_state.id}")import logging
from statemachine.exceptions import StateMachineError
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class LoggingMachine(StateMachine):
start = State(initial=True)
middle = State()
end = State(final=True)
error = State(final=True)
proceed = start.to(middle) | middle.to(end)
fail = start.to(error) | middle.to(error)
def on_transition(self, event: str, source: State, target: State, **kwargs):
"""Log all transitions."""
logger.info(f"Transition: {event} ({source.id} -> {target.id})")
def on_enter_state(self, state: State, **kwargs):
"""Log state entries."""
logger.info(f"Entering state: {state.name}")
def send(self, event: str, *args, **kwargs):
"""Override send to add error logging."""
try:
logger.debug(f"Sending event: {event} with args={args}, kwargs={kwargs}")
result = super().send(event, *args, **kwargs)
logger.debug(f"Event {event} processed successfully")
return result
except StateMachineError as e:
logger.error(f"State machine error in event {event}: {e}")
logger.error(f"Current state: {self.current_state.id}")
logger.error(f"Allowed events: {[ev.id for ev in self.allowed_events]}")
raise
except Exception as e:
logger.error(f"Unexpected error in event {event}: {e}")
# Try to transition to error state if possible
if self.is_allowed("fail"):
logger.info("Attempting automatic error state transition")
super().send("fail")
raise
# Usage with logging
machine = LoggingMachine()
machine.send("proceed")
try:
machine.send("invalid_event")
except TransitionNotAllowed:
logger.warning("Handled transition not allowed error")
machine.send("fail") # Transition to error stateInstall with Tessl CLI
npx tessl i tessl/pypi-python-statemachine