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:
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:
It raises TypeError for a class with no field, or more than one — see
The State field → One field per class.
Related guides¶
- Conditions — why a transition is outgoing but not available
- Transitions —
custom, labels and stacking - Engine reference — the full signatures