Skip to content

Errors

Every exception open-fsm raises, what triggers it, and what it carries.

>>> from open_fsm import (
...     InvalidTargetState,
...     NoTransition,
...     TransitionConditionsUnmet,
...     TransitionNotAllowed,
... )

The hierarchy

Exception
└── TransitionNotAllowed
    ├── NoTransition
    ├── TransitionConditionsUnmet
    └── InvalidTargetState
Exception Raised when State afterwards
NoTransition No transition is registered for the current state Unchanged
TransitionConditionsUnmet A condition returned falsy Unchanged
InvalidTargetState A dynamic target resolved outside its declared states Unchanged
AttributeError instance.state = value Unchanged
ValueError target=None, or @state.super() with no base
TypeError A class declares no State, or several

Catch TransitionNotAllowed to handle any refusal:

>>> issubclass(NoTransition, TransitionNotAllowed)
True

TransitionNotAllowed

The base class. It carries nothing of its own — catch it when the reason does not change what you do, and a subclass when it does.

NoTransition

The current state has no transition registered for the method that was called, and there is no State.ANY fallback.

Attribute Description
label Label of the transition method that was called
state The state the instance was in
>>> article = publication.Article('Ready', body='A body.')
>>> article.publish()
Traceback (most recent call last):
    ...
open_fsm.base.NoTransition: Publish :: no transition from "DRAFT"
>>> try:
...     article.publish()
... except NoTransition as error:
...     print(error.label, '|', error.state)
Publish | ReviewState.DRAFT

The method body never ran.

TransitionConditionsUnmet

The transition was legal from the current state, but a condition returned falsy. Conditions are evaluated in declaration order and the first failure raises.

Attribute Description
transition The Transition that was refused
failed_condition The first callable that returned falsy
unmet_message The State.CONDITION message, or ''
>>> conditions.ExplainedSubmission('too short').submit()
Traceback (most recent call last):
    ...
open_fsm.base.TransitionConditionsUnmet: 'Submit' transition conditions have not been met: is_long_enough (the body is 9 characters, 80 are required)
>>> try:
...     conditions.ExplainedSubmission('too short').submit()
... except TransitionConditionsUnmet as error:
...     print(error.transition.slug)
...     print(error.failed_condition.__name__)
...     print(error.unmet_message)
submit
is_long_enough
the body is 9 characters, 80 are required

unmet_message is empty for a plain falsy result

A condition returning False gives the exception nothing to report. Return State.CONDITION(False, unmet='...') to put a reason in front of a user — see Conditions.

InvalidTargetState

A State.RETURN_VALUE or State.GET_STATE target resolved to a state outside its declared set.

Attribute Description
transition The Transition that was attempted
target The resolved, invalid state
allowed_states The declared states — a tuple for RETURN_VALUE, a list for GET_STATE
>>> class Review(StateEngine):
...     state = State(['new', 'approved'], default='new')
...     @state.transition(source='new', target=State.RETURN_VALUE('approved'))
...     def decide(self):
...         return 'deleted'
>>> Review().decide()
Traceback (most recent call last):
    ...
open_fsm.base.InvalidTargetState: 'Decide' resolved to 'deleted', which is not in the allowed target states ['approved']
>>> try:
...     Review().decide()
... except InvalidTargetState as error:
...     print(error.target, '|', error.allowed_states)
deleted | ('approved',)

When it raises depends on the target kind

A GET_STATE target is validated before the body runs, so nothing happened. A RETURN_VALUE target is validated after, so the body's side effects are already done and only the state is rolled back. See Dynamic targets.

AttributeError

Assigning to the state field. Raised by State.__set__, always.

>>> article.state = publication.ReviewState.PUBLISHED
Traceback (most recent call last):
    ...
AttributeError: Direct state modification is not allowed

A dataclass with an annotated state: State field raises this from its generated __init__ — see Dataclasses → The annotation trap.

ValueError

Two cases, both at class-definition time rather than at call time.

target=None is ambiguous with no target:

>>> class Broken(StateEngine):
...     state = State(['a', 'b'], default='a')
...     @state.transition(source='a', target=None)
...     def go(self):
...         """Never 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.

@state.super() with no base transition raises ValueError('Base transition not found') when the wrapper is first resolved.

TypeError

Raised by get_state_field(), so it surfaces on the first introspection call rather than at import.

More than one State field:

>>> class TwoMachines(StateEngine):
...     state = State(['a'], default='a')
...     stage = State(['x'], 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

None at all:

>>> get_state_field(object)
Traceback (most recent call last):
    ...
TypeError: object declares no State field

A third TypeError comes from the mixin's argument-free signature, when a state is passed to it — see Engine.

Handling them in a view

views.py
from open_fsm import TransitionConditionsUnmet, TransitionNotAllowed


def submit_article(request, pk):
    flow = ArticleFlow(get_object_or_404(Article, pk=pk))

    try:
        flow.submit()
    except TransitionConditionsUnmet as error:
        return JsonResponse({'detail': error.unmet_message or str(error)}, status=422)
    except TransitionNotAllowed as error:
        return JsonResponse({'detail': str(error)}, status=409)

    return JsonResponse({'state': flow.state})

The split is worth making. TransitionConditionsUnmet means the object is not ready — something the client can fix, so tell them what. Any other TransitionNotAllowed means this is not a legal move from here, which is a conflict rather than a bad request.

ValueError and TypeError are programming errors — a machine that was declared wrong. Let them surface.