A minimal, clean, typed and async-native FSM (Finite State Machine) implementation for Python, inspired by Erlang's gen_fsm
Installation β’ Quick Start β’ Features β’ Examples β’ API Reference β’ Contributing
Building robust state machines in Python often involves:
- π€― Complex if/elif chains that grow unmaintainable
- π Implicit state that's hard to reason about
- π Scattered transition logic across your codebase
- β No type safety for states and events
- π« Mixing sync and async code awkwardly
pygenfsm solves these problems with a minimal, elegant API that leverages Python's type system and async capabilities.
@fsm.on(State.IDLE, StartEvent)
def handle_start(fsm, event):
return State.RUNNING |
@fsm.on(State.RUNNING, DataEvent)
async def handle_data(fsm, event):
await process_data(event.data)
return State.DONE |
# Full typing with generics
FSM[StateEnum, EventType, ContextType] |
# Minimal and fast
pip install pygenfsm |
- π Type-safe: Full typing support with generics for states, events, and context
- π Flexible: Mix sync and async handlers in the same FSM
- π¦ Minimal: Zero dependencies, clean API surface
- π Pythonic: Decorator-based, intuitive design
- π Async-native: Built for modern async Python
- π Context-aware: Carry data between transitions
- 𧬠Cloneable: Fork FSM instances for testing scenarios
- ποΈ Builder pattern: Late context injection support
# Using pip
pip install pygenfsm
# Using uv (recommended)
uv add pygenfsm
# Using poetry
poetry add pygenfsm
import asyncio
from dataclasses import dataclass
from enum import Enum, auto
from pygenfsm import FSM
# 1. Define states as an enum
class State(Enum):
IDLE = auto()
RUNNING = auto()
DONE = auto()
# 2. Define events as dataclasses
@dataclass
class StartEvent:
task_id: str
@dataclass
class CompleteEvent:
result: str
# 3. Create FSM with initial state
fsm = FSM[State, StartEvent | CompleteEvent, None](
state=State.IDLE,
context=None, # No context needed for simple FSM
)
# 4. Define handlers with decorators
@fsm.on(State.IDLE, StartEvent)
def start_handler(fsm, event: StartEvent) -> State:
print(f"Starting task {event.task_id}")
return State.RUNNING
@fsm.on(State.RUNNING, CompleteEvent)
def complete_handler(fsm, event: CompleteEvent) -> State:
print(f"Task completed: {event.result}")
return State.DONE
# 5. Run the FSM
async def main():
await fsm.send(StartEvent(task_id="123"))
await fsm.send(CompleteEvent(result="Success!"))
print(f"Final state: {fsm.state}")
asyncio.run(main())
pygenfsm is built on three core concepts:
Concept | Purpose | Implementation |
---|---|---|
States | The finite set of states your system can be in | Python Enum |
Events | Things that happen to trigger transitions | Dataclasses |
Context | Data that persists across transitions | Any Python type |
pygenfsm seamlessly supports both sync and async handlers:
# Sync handler - for simple state transitions
@fsm.on(State.IDLE, SimpleEvent)
def sync_handler(fsm, event) -> State:
# Fast, synchronous logic
return State.NEXT
# Async handler - for I/O operations
@fsm.on(State.LOADING, DataEvent)
async def async_handler(fsm, event) -> State:
# Async I/O, network calls, etc.
data = await fetch_data(event.url)
fsm.context.data = data
return State.READY
from enum import Enum, auto
from dataclasses import dataclass
from pygenfsm import FSM
class Color(Enum):
RED = auto()
YELLOW = auto()
GREEN = auto()
@dataclass
class TimerEvent:
"""Timer expired event"""
pass
@dataclass
class EmergencyEvent:
"""Emergency button pressed"""
pass
# Create FSM
traffic_light = FSM[Color, TimerEvent | EmergencyEvent, None](
state=Color.RED,
context=None,
)
@traffic_light.on(Color.RED, TimerEvent)
def red_to_green(fsm, event) -> Color:
print("π΄ β π’")
return Color.GREEN
@traffic_light.on(Color.GREEN, TimerEvent)
def green_to_yellow(fsm, event) -> Color:
print("π’ β π‘")
return Color.YELLOW
@traffic_light.on(Color.YELLOW, TimerEvent)
def yellow_to_red(fsm, event) -> Color:
print("π‘ β π΄")
return Color.RED
# Emergency overrides from any state
for color in Color:
@traffic_light.on(color, EmergencyEvent)
def emergency(fsm, event) -> Color:
print("π¨ EMERGENCY β RED")
return Color.RED
import asyncio
from dataclasses import dataclass, field
from enum import Enum, auto
from pygenfsm import FSM
class ConnState(Enum):
DISCONNECTED = auto()
CONNECTING = auto()
CONNECTED = auto()
ERROR = auto()
@dataclass
class ConnectEvent:
host: str
port: int
@dataclass
class ConnectionContext:
retries: int = 0
max_retries: int = 3
last_error: str = ""
fsm = FSM[ConnState, ConnectEvent, ConnectionContext](
state=ConnState.DISCONNECTED,
context=ConnectionContext(),
)
@fsm.on(ConnState.DISCONNECTED, ConnectEvent)
async def start_connection(fsm, event: ConnectEvent) -> ConnState:
print(f"π Connecting to {event.host}:{event.port}")
return ConnState.CONNECTING
@fsm.on(ConnState.CONNECTING, ConnectEvent)
async def attempt_connect(fsm, event: ConnectEvent) -> ConnState:
try:
# Simulate connection attempt
await asyncio.sleep(1)
if fsm.context.retries < 2: # Simulate failures
raise ConnectionError("Network timeout")
print("β
Connected!")
fsm.context.retries = 0
return ConnState.CONNECTED
except ConnectionError as e:
fsm.context.retries += 1
fsm.context.last_error = str(e)
if fsm.context.retries >= fsm.context.max_retries:
print(f"β Max retries reached: {e}")
return ConnState.ERROR
print(f"π Retry {fsm.context.retries}/{fsm.context.max_retries}")
return ConnState.CONNECTING
Perfect for dependency injection and testing:
from pygenfsm import FSMBuilder
# Define builder without context
builder = FSMBuilder[State, Event, AppContext](
initial_state=State.INIT
)
@builder.on(State.INIT, StartEvent)
async def initialize(fsm, event) -> State:
# Access context that will be injected later
await fsm.context.database.connect()
return State.READY
# Later, when dependencies are ready...
database = Database(connection_string)
logger = Logger(level="INFO")
# Build FSM with context
fsm = builder.build(AppContext(
database=database,
logger=logger,
))
Test different paths without affecting the original:
# Create base FSM
original_fsm = FSM[State, Event, Context](
state=State.INITIAL,
context=Context(data=[]),
)
# Clone for testing
test_scenario_1 = original_fsm.clone()
test_scenario_2 = original_fsm.clone()
# Run different scenarios
await test_scenario_1.send(SuccessEvent())
await test_scenario_2.send(FailureEvent())
# Original remains unchanged
assert original_fsm.state == State.INITIAL
The main FSM class with generic parameters:
S
: State enum typeE
: Event type (can be a Union)C
: Context type
Methods:
on(state: S, event_type: type[E])
: Decorator to register handlersasync send(event: E) -> S
: Send event and transition statesend_sync(event: E) -> S
: Synchronous send (only for sync handlers)clone() -> FSM[S, E, C]
: Create independent copyreplace_context(context: C) -> None
: Replace context
Builder for late context injection:
on(state: S, event_type: type[E])
: Register handlersbuild(context: C) -> FSM[S, E, C]
: Create FSM with context
-
Use sync handlers for:
- Simple state transitions
- Pure computations
- Context updates
-
Use async handlers for:
- Network I/O
- Database operations
- File system access
- Long computations
-
Event Design:
- Make events immutable (use frozen dataclasses)
- Include all necessary data in events
- Use Union types for multiple events per state
-
Context Design:
- Keep context focused and minimal
- Use dataclasses for structure
- Avoid circular references
We love contributions! Please see our Contributing Guide for details.
# Setup development environment
git clone https://github.com/serialx/pygenfsm
cd pygenfsm
uv sync
# Run tests
uv run pytest
# Run linting
uv run ruff check .
uv run pyright .
Feature | pygenfsm | transitions |
---|---|---|
Event Data | β First-class with dataclasses | β Limited (callbacks, conditions) |
Async Support | β Native async/await | β No built-in support |
Type Safety | β Full generics | |
State Definition | β Enums (type-safe) | |
Handler Registration | β Decorators | β Configuration dicts |
Context/Model | β Explicit, typed | |
Dependencies | β Zero | β Multiple (six, etc.) |
Visualization | β Not built-in | β GraphViz support |
Hierarchical States | β No | β Yes (HSM) |
Parallel States | β No | β Yes |
State History | β No | β Yes |
Guards/Conditions | β Built-in | |
Callbacks | β before/after/prepare | |
Size | ~300 LOC | ~3000 LOC |
Use pygenfsm when you need:
- π Strong type safety with IDE support
- π Native async/await support
- π¦ Zero dependencies
- π― Event-driven architecture with rich data
- π Modern Python patterns (3.11+)
- π§ͺ Easy testing with full typing
Use transitions when you need:
- π State diagram visualization
- π Hierarchical states (HSM)
- β‘ Parallel state machines
- π State history tracking
- π Complex transition guards/conditions
- ποΈ Legacy Python support
- GitHub: github.com/serialx/pygenfsm
- PyPI: pypi.org/project/pygenfsm
- Documentation: Full API Docs
- Issues: Report bugs or request features
MIT License - see LICENSE file for details.