Skip to content

Conditions

The state says where an object is. A condition says whether the rest of it is ready. submit may be legal from DRAFT and still wrong for a draft with no body.

Conditions are predicates called with the instance, evaluated in declaration order, short-circuiting on the first falsy result.

A predicate

Anything callable with the instance works, including a lambda:

>>> class Draft(StateEngine):
...     state = State(['draft', 'submitted'], default='draft')
...     def __init__(self, body=''):
...         self.body = body
...     @state.transition(
...         source='draft',
...         target='submitted',
...         conditions=[lambda draft: bool(draft.body)],
...     )
...     def submit(self):
...         """Submit, once there is something to submit."""
>>> Draft().submit()
Traceback (most recent call last):
    ...
open_fsm.base.TransitionConditionsUnmet: 'Submit' transition conditions have not been met: <lambda>

That message is the problem with lambdas: <lambda> tells a user nothing. The rest of this page is about fixing that.

A method on the same class

A condition is usually a method. Declare it above the transition that uses it and reference it by name — inside a class body it is an ordinary local:

conditions.py
class Submission(StateEngine):
    state = State(DeskState, default=DeskState.DRAFT)

    def __init__(self, body: str = '') -> None:
        self.body = body

    def is_long_enough(self) -> bool:
        return len(self.body) >= MINIMUM_BODY_LENGTH

    @state.transition(
        source=DeskState.DRAFT,
        target=DeskState.IN_REVIEW,
        conditions=[is_long_enough],
    )
    def submit(self) -> None:
        """Hand the submission to the desk, once it is long enough."""

Now the failure names the method that refused, instead of <lambda>:

>>> short = conditions.Submission('too short')
>>> short.submit()
Traceback (most recent call last):
    ...
open_fsm.base.TransitionConditionsUnmet: 'Submit' transition conditions have not been met: is_long_enough

The predicate is called with the instance, so a plain def is_long_enough(self) has exactly the right signature. It only has to return something truthy or falsy — bool is enough.

Only if the method is declared first

The decorator is evaluated when the class body runs, so the name has to exist by then. Move is_long_enough below submit and you get a NameError at import.

For the cases where you cannot reorder — a predicate supplied by a subclass, or shared through a mixin — there is this.

Explaining a refusal

State.CONDITION(is_true, unmet=...) is a boolean-like object that carries the reason with it. It behaves as a plain bool everywhere:

>>> bool(State.CONDITION(True))
True
>>> bool(State.CONDITION(False, unmet='too short'))
False

…and exposes the reason only when it actually refused, so a passing condition never has a stale message:

>>> State.CONDITION(False, unmet='too short').message
'too short'
>>> State.CONDITION(True, unmet='too short').message
''

Return one from the condition and the reason travels with the refusal:

conditions.py
class ExplainedSubmission(StateEngine):
    state = State(DeskState, default=DeskState.DRAFT)

    def __init__(self, body: str = '') -> None:
        self.body = body

    def is_long_enough(self) -> State.CONDITION:
        return State.CONDITION(
            len(self.body) >= MINIMUM_BODY_LENGTH,
            unmet=f'the body is {len(self.body)} characters, {MINIMUM_BODY_LENGTH} are required',
        )

    @state.transition(
        source=DeskState.DRAFT,
        target=DeskState.IN_REVIEW,
        conditions=[is_long_enough],
    )
    def submit(self) -> None:
        """The same rule, with the reason attached."""
>>> explained = conditions.ExplainedSubmission('too short')
>>> explained.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)

Same rule, same declaration — the only change is what the predicate returns.

Forward references with this

When the predicate cannot be declared before the transition, this.name stands in for it and is resolved against the instance's class at call time:

>>> class Reviewed(StateEngine):
...     state = State(['draft', 'submitted'], default='draft')
...     def __init__(self, body=''):
...         self.body = body
...     @state.transition(source='draft', target='submitted', conditions=[this.is_ready])
...     def submit(self):
...         """`is_ready` does not exist yet, and need not."""
...     def is_ready(self):
...         return bool(self.body)
>>> Reviewed().submit.can_proceed()
False
>>> Reviewed('a body').submit.can_proceed()
True

Because it resolves against the instance's class, a subclass can override the predicate without touching the transition — see Inheritance.

this is not exported yet

It lives in open_fsm._compat, so the import is from open_fsm._compat import this. The name is stable — the library's own tests depend on it — but the module is private and the import path may change when it is promoted. Prefer a direct reference where ordering allows one, which is most of the time.

Handling the refusal

The exception carries the pieces separately, so a handler does not have to parse the message:

>>> try:
...     explained.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
Attribute Value
transition The Transition that was refused
failed_condition The first callable that returned falsy
unmet_message The State.CONDITION message, or '' for a plain falsy result

unmet_message is the one to show a user. It is empty when the condition returned a bare False, which is another argument for State.CONDITION.

Asking instead of catching

can_proceed() answers the same question without raising:

>>> short.submit.can_proceed()
False
>>> conditions.Submission('x' * 100).submit.can_proceed()
True

Pass check_conditions=False to ask only about the state, ignoring the predicates:

>>> short.submit.can_proceed(check_conditions=False)
True

The two answers together are how you distinguish "this is not the right stage" from "this is the right stage, but something is missing" — a distinction worth making in a UI, where the first case hides a button and the second shows it disabled with a reason.

Read the reason without attempting the transition

A condition is an ordinary method, so nothing stops you calling it directly to render the reason next to a disabled control:

>>> explained.is_long_enough().message
'the body is 9 characters, 80 are required'

Several conditions

They are evaluated in order and stop at the first failure, so the reported condition is the first unmet one, not all of them:

>>> class Release(StateEngine):
...     state = State(['staged', 'live'], default='staged')
...     def __init__(self, tests_pass=False, approved=False):
...         self.tests_pass = tests_pass
...         self.approved = approved
...     def tests_green(self):
...         return State.CONDITION(self.tests_pass, unmet='the test suite is red')
...     def is_approved(self):
...         return State.CONDITION(self.approved, unmet='no release manager signed off')
...     @state.transition(
...         source='staged',
...         target='live',
...         conditions=[tests_green, is_approved],
...     )
...     def ship(self):
...         """Ship the release."""
>>> Release().ship()
Traceback (most recent call last):
    ...
open_fsm.base.TransitionConditionsUnmet: 'Ship' transition conditions have not been met: tests_green (the test suite is red)

Fix the first and the second surfaces:

>>> Release(tests_pass=True).ship()
Traceback (most recent call last):
    ...
open_fsm.base.TransitionConditionsUnmet: 'Ship' transition conditions have not been met: is_approved (no release manager signed off)

Order them cheapest-first: a condition that hits the database should not run ahead of one that reads an attribute.

Conditions must not have side effects

They are called by can_proceed(), by get_available_transitions(), and again on every attempted transition — often several times while rendering a single page. Keep them pure reads.