Skip to content

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:

  1. Read the current state.
  2. Look up the transition registered for that state, falling back to the State.ANY one. If there is none, raise NoTransition — your method never runs.
  3. Evaluate the conditions in declaration order, short-circuiting on the first falsy result and raising TransitionConditionsUnmet.
  4. Set the state to the target, before the body runs — unless the target is a State.RETURN_VALUE, which cannot be known yet.
  5. Run your method body.
  6. Commit: resolve a State.RETURN_VALUE target 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:

>>> article.submit.original()
>>> article.state
<ReviewState.DRAFT: 'DRAFT'>

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

  1. Declare the states as an enum, or any hashable values.
  2. Declare one State field with a default.
  3. Mark each legal move with @state.transition(source=..., target=...).
  4. Put the work in the method body. It runs only when the move is legal.
  5. Attach conditions for rules the state alone does not capture.
  6. Inherit StateEngine and ask get_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.