Skip to content

open-fsm

An open, lightweight, ORM-agnostic finite state machine for Python.

open-fsm lets you declare the states an object can be in, and the methods that move it between them, on a plain Python class. Calling a method that is not legal from the current state is refused rather than silently allowed, and the state field cannot be assigned by hand.

Get started Read the overview

Every example on this site is executed

Code blocks written as a console transcript (>>>) are run by the test suite on every commit, and the output shown is the output the library produced. See How these docs are tested.

Installation

pip install open-fsm

Requires Python 3.10 or newer. There are no runtime dependencies — the library is pure standard library, and ships a PEP 561 py.typed marker.

A first machine

States are ordinary values. A string enum reads well and keeps them together:

publication.py
class ReviewState(str, Enum):
    DRAFT = 'DRAFT'
    IN_REVIEW = 'IN_REVIEW'
    APPROVED = 'APPROVED'
    REJECTED = 'REJECTED'
    PUBLISHED = 'PUBLISHED'
    ARCHIVED = 'ARCHIVED'

Declare a State field on the class, then mark the methods that move between states:

publication.py
class Article(StateEngine):
    state = State(ReviewState, default=ReviewState.DRAFT)

    def __init__(self, title: str, body: str = '') -> None:
        self.title = title
        self.body = body

    @state.transition(
        source=ReviewState.DRAFT,
        target=ReviewState.IN_REVIEW,
        conditions=[lambda article: bool(article.body)],
    )
    def submit(self) -> None:
        """Send a draft to the review queue, once it has a body."""

    @state.transition(source=ReviewState.IN_REVIEW, target=ReviewState.APPROVED)
    def approve(self) -> None:
        """Accept a reviewed article."""

    @state.transition(source=ReviewState.IN_REVIEW, target=ReviewState.REJECTED)
    def reject(self) -> None:
        """Send a reviewed article back to its author."""

    @state.transition(source=ReviewState.APPROVED, target=ReviewState.PUBLISHED)
    def publish(self) -> None:
        """Make an approved article public."""

    @state.transition(source=State.ANY, target=ReviewState.ARCHIVED)
    def archive(self) -> None:
        """Retire an article, from wherever it currently is."""

Five transitions. submit, approve, reject and publish each move between two specific states; archive is declared with State.ANY, so it is reachable from anywhere.

Calling a transition method runs its body and moves the state:

>>> article = publication.Article('Hello, FSM', body='A body long enough to submit.')
>>> article.state
<ReviewState.DRAFT: 'DRAFT'>
>>> article.submit()
>>> article.approve()
>>> article.publish()
>>> article.state
<ReviewState.PUBLISHED: 'PUBLISHED'>

A transition that does not exist from the current state is refused:

>>> article.publish.can_proceed()
False
>>> article.publish()
Traceback (most recent call last):
    ...
open_fsm.base.NoTransition: Publish :: no transition from "PUBLISHED"

And the state is never assignable by hand, so the machine is the only way in:

>>> article.state = publication.ReviewState.DRAFT
Traceback (most recent call last):
    ...
AttributeError: Direct state modification is not allowed

Asking what is possible

Inheriting StateEngine gives every instance three introspection methods:

>>> draft = publication.Article('No body yet')
>>> [transition.slug for transition in draft.get_outgoing_transitions()]
['archive', 'submit']
>>> [transition.slug for transition in draft.get_available_transitions()]
['archive']

submit leaves DRAFT, so it is outgoing — but its condition (a non-empty body) is unmet, so it is not available. That distinction is what lets a UI render the buttons a user may actually press.

What you get

A declarative machine

States and transitions are declared on the class, and enforced at call time. See Transitions.

Conditions that explain themselves

A refused transition names the predicate that refused it, and the reason. See Conditions.

Targets decided at call time

State.RETURN_VALUE and State.GET_STATE compute the target from the call. See Dynamic targets.

Introspection

Ask an instance what it can do now, or what leads out of a state it is not in. See Introspection.

Where to go next

If you want to Read
Understand the pieces and their boundaries Overview
Follow a worked tutorial from an empty file Getting Started
Declare sources, targets, labels and wildcards Transitions
Store the state in a database row Binding state to storage
Use it with Django, Tortoise or SQLAlchemy Django ORM
Look up an exception Errors

Scope

open-fsm models one state machine per class and enforces its transitions. It deliberately does not persist anything, does not draw diagrams, and does not provide async transitions. Those boundaries are stated in full under Overview → Boundaries.

Credits

open-fsm is maintained by Open Byte. It is a fork of the finite state machine inside Viewflow, written by Mikhail Podgurskiy — see Credits and Attribution.