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¶
| 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:
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:
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:
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¶
- Transitions — sources, targets, labels and wildcards
- Conditions — refusals that explain themselves
- Introspection — driving a UI from the machine
- Binding state to storage — keeping the state in a row