Skip to content

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.

publication.py
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:

>>> [transition.slug for transition in article.get_outgoing_transitions()]
[]

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:

>>> article.archive()
>>> article.state
<ReviewState.ARCHIVED: 'ARCHIVED'>

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:

>>> transition = list(Invoice.preview.get_transitions())[0]
>>> transition.target is DEFAULT
True

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:

>>> article = publication.Article('Titled', body='A body.')
>>> article.submit.label
'Submit'

.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.