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:
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:
…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:
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:
Pass check_conditions=False to ask only about the state, ignoring the
predicates:
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:
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.
Related guides¶
- Transitions — declaring the moves themselves
- Introspection — outgoing versus available transitions
- Errors — the full exception reference