The State field¶
State is the descriptor that holds a machine's current state. One per class,
declared in the class body, and read through the attribute you assigned it to.
For the methods that move between states, see Transitions.
Declaring it¶
>>> class Task(StateEngine):
... status = State(['todo', 'doing', 'done'], default='todo')
... @status.transition(source='todo', target='doing')
... def start(self):
... """Pick the task up."""
>>> Task().status
'todo'
Two things about that first argument. It is required — State(default='todo')
raises TypeError. And it is not enforced: the library stores neither the
list nor any check against it, so it documents the machine for a reader rather
than constraining it. What is enforced is the transition table.
Use an enum for anything non-trivial
Strings are fine for a three-state machine. Beyond that, an enum gives you autocompletion, one place to rename a state, and a typo that fails at import rather than silently declaring a transition from a state that does not exist.
The field does not have to be called state. Task.status above works exactly
the same, and the decorator is spelled with whatever name you chose:
Where the value is stored¶
By default, on the instance, under a name derived from the attribute:
Nothing is written until the first transition — the default is returned by the
descriptor, not assigned in __init__:
Because propname is derived from the attribute name and not from object
identity, the storage name is stable across processes — which is what makes an
instance picklable, as long as its class is importable:
>>> article = publication.Article('Hello', body='A body.')
>>> article.submit()
>>> pickle.loads(pickle.dumps(article)).state
<ReviewState.IN_REVIEW: 'IN_REVIEW'>
To keep the value somewhere else — a column, a Redis key, a nested record —
override the access with @state.getter() and @state.setter().
Assignment is refused¶
>>> task.status = 'done'
Traceback (most recent call last):
...
AttributeError: Direct state modification is not allowed
This is the whole point of the field. If the state could be assigned, every
guarantee the transition table makes would be advisory. The only way to done
is a transition that declares it as a target.
It only guards the descriptor, not the storage
task.__dict__['__fsm_status'] = 'done' still works, as does writing the
underlying column directly when the field has a setter. The field prevents
accidents, not a determined caller — it is an invariant of your code, not
of your database.
Falsy values are real states¶
0, '' and None are legitimate states, and the field is careful not to
confuse them with "unset":
>>> class Level(StateEngine):
... state = State([0, 1, 2], default=0)
... @state.transition(source=0, target=1)
... def up(self):
... """Advance one level."""
>>> level = Level()
>>> level.state
0
>>> level.up()
>>> level.state
1
None works as both a default and a source:
>>> class Nullable(StateEngine):
... state = State([None, 'ready'], default=None)
... @state.transition(source=None, target='ready')
... def start(self):
... """Leave the null state."""
>>> nullable = Nullable()
>>> nullable.state is None
True
>>> nullable.start()
>>> nullable.state
'ready'
None is a legal source but never a legal target
target=None is rejected at declaration time, because it is ambiguous with
no target:
>>> class Broken(StateEngine):
... state = State([None, 'ready'], default='ready')
... @state.transition(source='ready', target=None)
... def clear(self):
... """Never gets declared."""
Traceback (most recent call last):
...
ValueError: target=None is ambiguous with 'no target'. Omit the target argument entirely for a transition that doesn't change state.
See Transitions → Transitions without a target.
There is one asymmetry to know about when the state is stored elsewhere: a
getter returning None or '' falls back to the declared default, while
0 does not. See
Binding state to storage → The empty-value rule.
One field per class¶
open-fsm models a single machine per class. Declaring two is an error, raised
the first time the field is looked up:
>>> class TwoMachines(StateEngine):
... state = State(['a', 'b'], default='a')
... stage = State(['x', 'y'], default='x')
>>> TwoMachines().get_transitions()
Traceback (most recent call last):
...
TypeError: TwoMachines declares 2 State fields ('state', 'stage'); open-fsm models one state machine per class
And a class with no field at all:
>>> get_state_field(object)
Traceback (most recent call last):
...
TypeError: object declares no State field
An object that genuinely has two independent lifecycles — an invoice that is separately approved and paid — wants two flow classes over the same record, each with its own field. That keeps each transition table readable and lets each one carry its own conditions.
The lookup is cached, and the cache is not inherited
get_state_field() walks the MRO once and caches the result on the class
under _fsm_state_field_cache. A subclass that redeclares the field gets
its own entry rather than the base's.
Related guides¶
- Dataclasses — declaring the field on a generated class
- Transitions — declaring the moves between states
- Binding state to storage — getters, setters and saving
- Introspection — asking what the machine can do
- State reference — the full API