Skip to content

Getting Started

Build a working state machine from an empty file, one piece at a time. By the end you will have the Article flow the rest of the guides use.

Install

uv add open-fsm
pip install open-fsm
Requirement Version
Python 3.10 or newer
Runtime dependencies None

Step 1 — Name the states

A state is any hashable value. open-fsm never inspects the type, so strings, integers and sentinels all work. An enum is the readable choice, and a str mixin keeps the values JSON- and database-friendly:

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

Step 2 — Declare the field

State takes the states as its first argument and the starting value as default:

>>> class Draft:
...     state = State(ReviewState, default=ReviewState.DRAFT)
>>> Draft().state
<ReviewState.DRAFT: 'DRAFT'>

The first argument is documentation, not validation

State(ReviewState, ...) records what the states are for a human reader. The library does not store or check it — a transition may target a value that is not in there. What is enforced is the transition table you declare next.

The argument is still required. State(default=...) alone is a TypeError.

Step 3 — Mark the transitions

A transition is a method decorated with @state.transition(...), naming the state it leaves and the state it lands in:

>>> class Article:
...     state = State(ReviewState, default=ReviewState.DRAFT)
...     def __init__(self, title, body=''):
...         self.title = title
...         self.body = body
...     @state.transition(source=ReviewState.DRAFT, target=ReviewState.IN_REVIEW)
...     def submit(self):
...         """Send a draft to the review queue."""
...     @state.transition(source=ReviewState.IN_REVIEW, target=ReviewState.APPROVED)
...     def approve(self):
...         """Accept a reviewed article."""
...     @state.transition(source=ReviewState.APPROVED, target=ReviewState.PUBLISHED)
...     def publish(self):
...         """Make an approved article public."""

Calling the method runs the body and moves the state:

>>> article = Article('Hello, FSM', body='A body.')
>>> article.submit()
>>> article.state
<ReviewState.IN_REVIEW: 'IN_REVIEW'>
>>> article.approve()
>>> article.publish()
>>> article.state
<ReviewState.PUBLISHED: 'PUBLISHED'>

The method bodies above are empty, which is worth pausing on: the transition is the declaration, and the body is where your work goes — sending the notification, writing the audit row, calling the payment provider. It runs only when the move is legal.

Step 4 — Watch it refuse

The value of a machine is the moves it rejects. publish is not reachable from PUBLISHED:

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

Ask first, and no exception is needed:

>>> article.publish.can_proceed()
False

Nor can the state be moved by assignment, which is what keeps the transition table honest:

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

Catch the base class

NoTransition is one of three subclasses of TransitionNotAllowed. Catch the base to handle any refusal — a wrong state, an unmet condition, or an invalid computed target — and return a 409. See Errors.

Step 5 — Add a condition

Some rules are not about the state. A draft with no body should not be submittable, even though it is in DRAFT. That is a conditions predicate, called with the instance:

>>> class Article(Article):
...     @Article.state.transition(
...         source=ReviewState.DRAFT,
...         target=ReviewState.IN_REVIEW,
...         conditions=[lambda article: bool(article.body)],
...     )
...     def submit(self):
...         """Send a draft to the review queue, once it has a body."""
>>> empty = Article('No body yet')
>>> empty.submit()
Traceback (most recent call last):
    ...
open_fsm.base.TransitionConditionsUnmet: 'Submit' transition conditions have not been met: <lambda>

The state is unchanged, and the body never ran:

>>> empty.state
<ReviewState.DRAFT: 'DRAFT'>

Give the condition a body and it passes:

>>> ready = Article('Ready', body='A body.')
>>> ready.submit()
>>> ready.state
<ReviewState.IN_REVIEW: 'IN_REVIEW'>

A bare lambda reports itself as <lambda>, which is not much of an explanation. Conditions shows how to return a reason instead.

Step 6 — Add a wildcard

State.ANY declares a transition reachable from every state — the escape hatch every real workflow ends up needing:

>>> class Article(Article):
...     @Article.state.transition(source=State.ANY, target=ReviewState.ARCHIVED)
...     def archive(self):
...         """Retire an article, from wherever it currently is."""
>>> fresh = Article('Anywhere')
>>> fresh.archive()
>>> fresh.state
<ReviewState.ARCHIVED: 'ARCHIVED'>

Step 7 — Ask what is possible

Rather than guessing which buttons to render, ask the instance. Inherit StateEngine and the question is a method call:

>>> class Article(Article, StateEngine):
...     pass
>>> draft = 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. Its condition is unmet, so it is not available. That is the distinction a UI needs.

Using it in an application

Nothing above assumed a web framework. A view is just the machine plus error handling:

views.py
from open_fsm import TransitionNotAllowed

from .models import ArticleRecord
from .flows import ArticleFlow


def submit_article(request, pk):
    flow = ArticleFlow(ArticleRecord.objects.get(pk=pk))

    try:
        flow.submit()
    except TransitionNotAllowed as error:
        return JsonResponse({'detail': str(error)}, status=409)

    return JsonResponse({'state': flow.state, 'next': [
        transition.slug for transition in flow.get_available_transitions()
    ]})

ArticleFlow here reads and writes the record rather than holding state itself. That binding is the subject of Binding state to storage.

Next steps