Transitions¶
A transition is a method that is only callable from certain states. Decorating it registers the move; the body is your work, and it runs only when the move is legal.
class Article(StateEngine):
state = State(ReviewState, default=ReviewState.DRAFT)
def __init__(self, title: str, body: str = '') -> None:
self.title = title
self.body = body
@state.transition(
source=ReviewState.DRAFT,
target=ReviewState.IN_REVIEW,
conditions=[lambda article: bool(article.body)],
)
def submit(self) -> None:
"""Send a draft to the review queue, once it has a body."""
@state.transition(source=ReviewState.IN_REVIEW, target=ReviewState.APPROVED)
def approve(self) -> None:
"""Accept a reviewed article."""
@state.transition(source=ReviewState.IN_REVIEW, target=ReviewState.REJECTED)
def reject(self) -> None:
"""Send a reviewed article back to its author."""
@state.transition(source=ReviewState.APPROVED, target=ReviewState.PUBLISHED)
def publish(self) -> None:
"""Make an approved article public."""
@state.transition(source=State.ANY, target=ReviewState.ARCHIVED)
def archive(self) -> None:
"""Retire an article, from wherever it currently is."""
The decorator¶
@state.transition(
source, # a state, or a list/tuple/set of states
target=DEFAULT, # omit for a transition that does not change state
label=None, # human-readable name; defaults to the method name, titled
conditions=None, # predicates called with the instance
custom=None, # arbitrary metadata, stored and never read by the library
)
def method(self): ...
| Argument | Required | Notes |
|---|---|---|
source |
Yes | One state, several states, or State.ANY |
target |
No | Omit it for no state change. None is rejected — see below |
label |
No | Falls back to method.__name__.title() |
conditions |
No | See Conditions |
custom |
No | A free-form dict for your own use |
Several sources at once¶
A list, tuple or set of sources registers the same transition from each of them:
>>> class Document(StateEngine):
... state = State(['new', 'published', 'hidden'], default='new')
... @state.transition(source={'new', 'hidden'}, target='published')
... def publish(self):
... """Publish, whether the document is new or was hidden."""
>>> sorted((transition.source, transition.target) for transition in Document.publish.get_transitions())
[('hidden', 'published'), ('new', 'published')]
One Transition object is built per source, all sharing the label, conditions
and custom you passed.
State.ANY — a transition from anywhere¶
State.ANY is a wildcard source, consulted only when no exact match exists:
>>> article = publication.Article('Anywhere')
>>> article.archive()
>>> article.state
<ReviewState.ARCHIVED: 'ARCHIVED'>
The lookup order matters. A transition declared for the current state always
wins over the wildcard, so State.ANY is a fallback rather than an override.
A wildcard is hidden from listings of its own target
archive targets ARCHIVED, so it is not reported as outgoing from
ARCHIVED — otherwise every wildcard would show up as a self-loop on every
listing:
That filter applies to the listing only. The lookup a call performs is the
plain one, so calling it again from ARCHIVED still matches the wildcard
and succeeds as a no-op:
If re-archiving must be refused, say so with a condition rather than relying on the listing.
Transitions without a target¶
Omit target and the method is still gated on source, but the state does not
move. This is how you attach an action to a state — sending a reminder,
recomputing a total — without inventing a state for it:
>>> class Invoice(StateEngine):
... state = State(['draft', 'sent'], default='draft')
... @state.transition(source='draft')
... def preview(self):
... """Callable only while the invoice is a draft."""
... return 'preview generated'
>>> invoice = Invoice()
>>> invoice.preview()
'preview generated'
>>> invoice.state
'draft'
The transition's target reads as the DEFAULT marker, which is what
"unchanged" is spelled as internally:
target=None is rejected, deliberately
None is a legitimate state value, so target=None cannot mean both "land
on None" and "do not move". Rather than guess, the decorator refuses:
>>> class Broken(StateEngine):
... state = State(['draft'], default='draft')
... @state.transition(source='draft', target=None)
... def nothing(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.
Stacking declarations on one method¶
Decorators stack, so a single method can serve several moves — each with its own label:
>>> class Toggle(StateEngine):
... state = State(['new', 'published'], default='new')
... @state.transition(source='published', target='new', label='Return to draft')
... @state.transition(source='new', target='published', label='Publish')
... def toggle(self):
... """Flip between the two states."""
The label an instance reports is the one for the transition it would take from where it is now:
>>> toggle = Toggle()
>>> toggle.toggle.label
'Publish'
>>> toggle.toggle()
>>> toggle.toggle.label
'Return to draft'
That is what makes a single button's caption follow the state without a lookup table in the template.
Labels¶
Without a label, the name is derived from the method:
.title() on the method name is a rough default — submit_for_legal_review
becomes Submit_For_Legal_Review. Pass label= for anything a user will read.
Carrying your own metadata¶
custom is stored on the transition and never read by the library. Use it for
whatever your presentation layer needs:
>>> class Deploy(StateEngine):
... state = State(['staged', 'live'], default='staged')
... @state.transition(
... source='staged',
... target='live',
... custom={'icon': 'rocket', 'requires_role': 'release-manager'},
... )
... def release(self):
... """Ship it."""
>>> list(Deploy.release.get_transitions())[0].custom
{'icon': 'rocket', 'requires_role': 'release-manager'}
Combined with get_available_transitions(), this is enough
to render a toolbar without the template knowing anything about the machine.
Calling one transition from another¶
A transition body may call another transition. The inner call runs its own lookup, from the state the outer one has already set:
>>> class Ticket(StateEngine):
... state = State(['open', 'triaged', 'closed'], default='open')
... @state.transition(source='open', target='triaged')
... def triage(self, trivial=False):
... """Triage, and close immediately when there is nothing to do."""
... if trivial:
... self.close()
... @state.transition(source='triaged', target='closed')
... def close(self):
... """Close the ticket."""
>>> ticket = Ticket()
>>> ticket.triage(trivial=True)
>>> ticket.state
'closed'
triage had already moved the state to triaged before its body ran, which is
exactly what makes the nested close() legal.
When the body raises¶
The state is rolled back and the exception propagates unchanged:
>>> class Payment(StateEngine):
... state = State(['pending', 'settled'], default='pending')
... @state.transition(source='pending', target='settled')
... def settle(self):
... raise TimeoutError('the payment gateway did not answer')
>>> payment = Payment()
>>> payment.settle()
Traceback (most recent call last):
...
TimeoutError: the payment gateway did not answer
>>> payment.state
'pending'
Rollback restores the state, not your side effects
Anything the body did before raising — a row written, an email sent, a counter incremented — stays done. If the body has side effects that must not outlive a failure, wrap them yourself; the machine only manages the state field.
Escaping the machine¶
original() calls your undecorated function directly, with no lookup, no
conditions and no state change:
>>> article = publication.Article('Direct')
>>> article.submit.original()
>>> article.state
<ReviewState.DRAFT: 'DRAFT'>
There is one good reason to reach for it: a
@state.super() override
calling the base implementation. Anywhere else it is a way to defeat the
guarantees you declared the machine for.
Related guides¶
- Conditions — refusing a legal move for another reason
- Dynamic targets — computing the target at call time
- Inheritance — extending and overriding transitions
- Transitions reference — the full API