GitHub - serialx/pygenfsm: A minimal, clean, typed and asynchronous FSM implementation inspired by Erlang's gen_fsm

7 min read Original article โ†—

๐Ÿ”„ pygenfsm

PyPI version Python License: MIT Code style: ruff Type checked: pyright Test Coverage

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


๐ŸŽฏ Why pygenfsm?

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.

โœจ Features

๐ŸŽจ Clean API

@fsm.on(State.IDLE, StartEvent)
def handle_start(fsm, event):
    return State.RUNNING

๐Ÿ”„ Async Native

@fsm.on(State.RUNNING, DataEvent)
async def handle_data(fsm, event):
    await process_data(event.data)
    return State.DONE

๐ŸŽฏ Type Safe

# Full typing with generics
FSM[StateEnum, EventType, ContextType]

๐Ÿš€ Zero Dependencies

# Minimal and fast
pip install pygenfsm

Key Benefits

  • ๐Ÿ”’ 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

๐Ÿ“ฆ Installation

# Using pip
pip install pygenfsm

# Using uv (recommended)
uv add pygenfsm

# Using poetry
poetry add pygenfsm

๐Ÿš€ Quick Start

Basic Example

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())

๐ŸŽฏ Core Concepts

States, Events, and Context

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

Handler Types

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

๐Ÿ“š Examples

Traffic Light System

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

Connection Manager with Retry Logic

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

๐Ÿ—๏ธ Advanced Patterns

Late Context Injection with FSMBuilder

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,
))

Cloning for Testing Scenarios

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

๐Ÿ”Œ API Reference

Core Classes

FSM[S, E, C]

The main FSM class with generic parameters:

  • S: State enum type
  • E: Event type (can be a Union)
  • C: Context type

Methods:

  • on(state: S, event_type: type[E]): Decorator to register handlers
  • async send(event: E) -> S: Send event and transition state
  • send_sync(event: E) -> S: Synchronous send (only for sync handlers)
  • clone() -> FSM[S, E, C]: Create independent copy
  • replace_context(context: C) -> None: Replace context

FSMBuilder[S, E, C]

Builder for late context injection:

  • on(state: S, event_type: type[E]): Register handlers
  • build(context: C) -> FSM[S, E, C]: Create FSM with context

Best Practices

  1. Use sync handlers for:

    • Simple state transitions
    • Pure computations
    • Context updates
  2. Use async handlers for:

    • Network I/O
    • Database operations
    • File system access
    • Long computations
  3. Event Design:

    • Make events immutable (use frozen dataclasses)
    • Include all necessary data in events
    • Use Union types for multiple events per state
  4. Context Design:

    • Keep context focused and minimal
    • Use dataclasses for structure
    • Avoid circular references

๐Ÿค Contributing

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 .

๐Ÿ“Š Comparison with transitions

Feature Comparison

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 โš ๏ธ Runtime checks only
State Definition โœ… Enums (type-safe) โš ๏ธ Strings/objects
Handler Registration โœ… Decorators โŒ Configuration dicts
Context/Model โœ… Explicit, typed โš ๏ธ Implicit on model
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 โš ๏ธ In handler logic โœ… Built-in
Callbacks โš ๏ธ In handlers โœ… before/after/prepare
Size ~300 LOC ~3000 LOC

When to Use Each

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

๐Ÿ”— Links

๐Ÿ“œ License

MIT License - see LICENSE file for details.


Made with โค๏ธ by developers who love clean state machines