Skip to content

Introspection

Rather than hard-coding which actions a screen offers, ask the instance. The machine already knows.

Three questions, in narrowing order:

Question Method Considers the state? Considers conditions?
What can this class ever do? get_transitions() No No
What leads out of here? get_outgoing_transitions() Yes No
What can this instance do now? get_available_transitions() Yes Yes

The whole machine

get_transitions() ignores the current state. It is the transition table, keyed by the method that owns each entry:

>>> article = publication.Article('No body yet')
>>> sorted(method.slug for method in article.get_transitions())
['approve', 'archive', 'publish', 'reject', 'submit']

The values are the transitions for each method, which is where the edges live:

>>> for method, transitions in sorted(article.get_transitions().items(), key=lambda item: item[0].slug):
...     for transition in transitions:
...         print(f'{transition.slug:8} {str(transition.source)} -> {str(transition.target)}')
approve  ReviewState.IN_REVIEW -> ReviewState.APPROVED
archive  ANY -> ReviewState.ARCHIVED
publish  ReviewState.APPROVED -> ReviewState.PUBLISHED
reject   ReviewState.IN_REVIEW -> ReviewState.REJECTED
submit   ReviewState.DRAFT -> ReviewState.IN_REVIEW

The explicit str() is not decoration

A str-mixin enum formats as its bare value on Python 3.10 and 3.11, and as ReviewState.DRAFT from 3.12 on, because Enum.__format__ changed. str() and repr() behave the same on every version. State.ANY is a marker rather than an enum member, which is why it prints as ANY.

A method with stacked decorators contributes several entries here, which is why the mapping is method → list. This is everything a diagram renderer needs.

Outgoing versus available

The distinction that earns its keep:

>>> [transition.slug for transition in article.get_outgoing_transitions()]
['archive', 'submit']
>>> [transition.slug for transition in article.get_available_transitions()]
['archive']

submit leaves DRAFT, so it is outgoing. Its condition — a non-empty body — is unmet, so it is not available. Give the article a body and the answers converge:

>>> ready = publication.Article('Ready', body='A body.')
>>> [transition.slug for transition in ready.get_available_transitions()]
['archive', 'submit']

Use the difference: outgoing minus available is the set of actions to show disabled, with a reason attached, rather than hiding them and leaving the user to wonder.

Driving a UI

A transition carries everything a control needs — a stable identifier, a caption, and whatever you put in custom:

>>> for transition in ready.get_available_transitions():
...     print(transition.slug, '|', transition.label, '|', transition.custom)
archive | Archive | {}
submit | Submit | {}
Attribute Use it for
slug The method name — the identifier to post back
label The caption
source / target Where it goes, for a tooltip or a diagram
custom Your own metadata — icon, required role, confirmation copy

Which makes a serialiser a one-liner:

>>> [
...     {'action': transition.slug, 'label': transition.label}
...     for transition in ready.get_available_transitions()
... ]
[{'action': 'archive', 'label': 'Archive'}, {'action': 'submit', 'label': 'Submit'}]

The order is alphabetical by method name

Transitions are discovered with inspect.getmembers, which sorts. The order is stable across runs, but it is not declaration order and it is not a priority — sort by something in custom if presentation order matters.

Asking about another state

The mixin's methods deliberately take no arguments. To ask about a state the instance is not in, use the module-level functions:

>>> from open_fsm import get_outgoing_transitions
>>> sorted(
...     transition.slug
...     for transition in get_outgoing_transitions(article, publication.ReviewState.IN_REVIEW)
... )
['approve', 'archive', 'reject']

The instance is untouched — this is a question, not a move:

>>> article.state
<ReviewState.DRAFT: 'DRAFT'>

Passing a state to the mixin is a TypeError, on purpose, so the two questions never get confused:

>>> article.get_outgoing_transitions(publication.ReviewState.IN_REVIEW)
Traceback (most recent call last):
    ...
TypeError: StateEngine.get_outgoing_transitions() takes 1 positional argument but 2 were given

The default is the CURRENT marker, so the functions and the methods agree:

>>> from open_fsm import CURRENT
>>> [transition.slug for transition in get_outgoing_transitions(article, CURRENT)]
['archive', 'submit']

Conditions are evaluated against the instance as it is now

get_available_transitions(article, SOME_OTHER_STATE) answers "what could this instance do from there", not "what will be possible once it gets there". A condition that will be satisfied by the time the flow reaches that state is reported unmet today. That is the honest answer for a preview, and the wrong one for a plan.

Without the mixin

StateEngine is a convenience, not a requirement. The same three functions work on any object with a State field — useful when the class already has a base you cannot change, or when you are keeping a slotted class free of a __dict__:

>>> class Plain:
...     state = State(['a', 'b'], default='a')
...     @state.transition(source='a', target='b')
...     def go(self):
...         """No mixin in sight."""
>>> [transition.slug for transition in get_outgoing_transitions(Plain())]
['go']

StateEngine declares no __init__, no metaclass and no fields of its own, so inheriting it is normally safe — including on an ORM model or a dataclass.

Finding the field

get_state_field() is the lookup the others build on, and is occasionally useful directly — reading the propname, say, or checking a class is a flow at all:

>>> field = get_state_field(publication.Article)
>>> field.propname
'__fsm_state'

It raises TypeError for a class with no field, or more than one — see The State field → One field per class.