Skip to content

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 requiredState(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:

>>> [transition.slug for transition in Task().get_outgoing_transitions()]
['start']

Where the value is stored

By default, on the instance, under a name derived from the attribute:

>>> Task.status.propname
'__fsm_status'

Nothing is written until the first transition — the default is returned by the descriptor, not assigned in __init__:

>>> task = Task()
>>> task.__dict__
{}
>>> task.start()
>>> task.__dict__
{'__fsm_status': 'doing'}

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.