Overview¶
The pieces open-fsm is made of, what each is for, and where it stops.
The public API¶
| Object | Use it when |
|---|---|
State |
You are declaring the state field on a class. This is the main entry point. |
@state.transition(...) |
You are marking a method as a legal move between states. |
State.ANY |
A transition is reachable from every state. |
State.CONDITION |
A condition should carry the reason it refused. |
State.RETURN_VALUE / State.GET_STATE |
The target is decided at call time rather than declared. |
StateEngine |
You want get_available_transitions() and friends on the instance. |
get_outgoing_transitions() and friends |
Same answers, for a class that cannot inherit the mixin, or about a state the instance is not in. |
TransitionNotAllowed |
You are catching a refused transition. |
How a transition call runs¶
Calling article.submit() does not just call your method. The descriptor wraps
it, and the wrapper is where the machine lives:
- Read the current state.
- Look up the transition registered for that state, falling back to the
State.ANYone. If there is none, raiseNoTransition— your method never runs. - Evaluate the conditions in declaration order, short-circuiting on the
first falsy result and raising
TransitionConditionsUnmet. - Set the state to the target, before the body runs — unless the target
is a
State.RETURN_VALUE, which cannot be known yet. - Run your method body.
- Commit: resolve a
State.RETURN_VALUEtarget from what the body returned, then call the@state.on_success()hook.
If the body raises, step 6 never happens and the state is rolled back to what it was before the call:
>>> class Deployment(StateEngine):
... state = State(['pending', 'live'], default='pending')
... @state.transition(source='pending', target='live')
... def deploy(self):
... raise RuntimeError('the registry was unreachable')
>>> deployment = Deployment()
>>> deployment.deploy()
Traceback (most recent call last):
...
RuntimeError: the registry was unreachable
>>> deployment.state
'pending'
The exception propagates unchanged — open-fsm does not swallow it, it only
undoes the state change.
Reading and writing the state¶
state is a descriptor, so what it gives you depends on where you ask from:
| Access | You get |
|---|---|
instance.state |
The current state value |
Article.state |
A StateDescriptor, for asking about the whole machine |
instance.submit |
A TransitionBoundMethod — callable, plus can_proceed() and label |
Article.submit |
A TransitionMethod, for listing that method's transitions |
The bound method is the interesting one. It is callable, but it also answers questions without calling anything:
>>> article = publication.Article('Hello', body='A body.')
>>> article.submit.can_proceed()
True
>>> article.submit.label
'Submit'
And original() calls your undecorated function, skipping the machine
entirely — useful in a @state.super() override, and a foot-gun everywhere
else:
Writing is the one thing the descriptor refuses. instance.state = value raises
AttributeError, so a state can only change by taking a transition. To store
the value somewhere other than the instance — a database row, say — give the
field a getter and setter.
Boundaries¶
These are deliberate design decisions, not gaps to work around.
One state machine per class
get_state_field() collects State fields across the MRO and raises
TypeError if a class declares two. An object that genuinely needs two
independent lifecycles wants two flow objects, each with its own field, over
the same underlying record.
Nothing is persisted for you
open-fsm has no runtime dependencies and no knowledge of your storage. It
reads and writes an attribute; where that attribute lives, and when it is
saved, is expressed with @state.getter(), @state.setter() and
@state.on_success() — see
Binding state to storage. Wrappers for specific
ORMs are planned, not shipped.
Transitions are synchronous
A transition method is called directly, so an async def transition would
return a coroutine that the wrapper commits as if it had already run.
Do the awaiting outside the transition, or drive the flow from a worker.
There is no diagram renderer
Viewflow ships a chart helper that draws a machine as a graph. It is not
part of this fork. The data it needs is public —
get_transitions() returns
every transition with its source and target — so a renderer is
straightforward to write against the introspection API.
Typical flow¶
- Declare the states as an enum, or any hashable values.
- Declare one
Statefield with a default. - Mark each legal move with
@state.transition(source=..., target=...). - Put the work in the method body. It runs only when the move is legal.
- Attach conditions for rules the state alone does not capture.
- Inherit
StateEngineand askget_available_transitions()when rendering.
Relationship to Viewflow¶
open-fsm is a fork of the FSM inside
Viewflow, extracted so it can be used
without the framework around it. The declarative API is unchanged. What is
different: Django is no longer a dependency, State.UNMET is spelled
State.CONDITION, permission= is not accepted by state.transition(), and the
introspection helpers gained the StateEngine mixin. See
Credits and Attribution.
Related pages¶
- Getting Started — a worked tutorial
- The State field — declaring and storing the state
- Errors — every exception and what triggers it