open-fsm¶
An open, lightweight, ORM-agnostic finite state machine for Python.
open-fsm lets you declare the states an object can be in, and the methods that
move it between them, on a plain Python class. Calling a method that is not
legal from the current state is refused rather than silently allowed, and the
state field cannot be assigned by hand.
Every example on this site is executed
Code blocks written as a console transcript (>>>) are run by the test suite
on every commit, and the output shown is the output the library produced.
See How these docs are tested.
Installation¶
Requires Python 3.10 or newer. There are no runtime dependencies — the library
is pure standard library, and ships a PEP 561 py.typed marker.
A first machine¶
States are ordinary values. A string enum reads well and keeps them together:
class ReviewState(str, Enum):
DRAFT = 'DRAFT'
IN_REVIEW = 'IN_REVIEW'
APPROVED = 'APPROVED'
REJECTED = 'REJECTED'
PUBLISHED = 'PUBLISHED'
ARCHIVED = 'ARCHIVED'
Declare a State field on the class, then mark the methods that move between
states:
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."""
Five transitions. submit, approve, reject and publish each move between
two specific states; archive is declared with State.ANY, so it is reachable
from anywhere.
Calling a transition method runs its body and moves the state:
>>> article = publication.Article('Hello, FSM', body='A body long enough to submit.')
>>> article.state
<ReviewState.DRAFT: 'DRAFT'>
>>> article.submit()
>>> article.approve()
>>> article.publish()
>>> article.state
<ReviewState.PUBLISHED: 'PUBLISHED'>
A transition that does not exist from the current state is refused:
>>> article.publish.can_proceed()
False
>>> article.publish()
Traceback (most recent call last):
...
open_fsm.base.NoTransition: Publish :: no transition from "PUBLISHED"
And the state is never assignable by hand, so the machine is the only way in:
>>> article.state = publication.ReviewState.DRAFT
Traceback (most recent call last):
...
AttributeError: Direct state modification is not allowed
Asking what is possible¶
Inheriting StateEngine gives every instance three introspection methods:
>>> draft = publication.Article('No body yet')
>>> [transition.slug for transition in draft.get_outgoing_transitions()]
['archive', 'submit']
>>> [transition.slug for transition in draft.get_available_transitions()]
['archive']
submit leaves DRAFT, so it is outgoing — but its condition (a non-empty
body) is unmet, so it is not available. That distinction is what lets a UI
render the buttons a user may actually press.
What you get¶
A declarative machine¶
States and transitions are declared on the class, and enforced at call time. See Transitions.
Conditions that explain themselves¶
A refused transition names the predicate that refused it, and the reason. See Conditions.
Targets decided at call time¶
State.RETURN_VALUE and State.GET_STATE compute the target from the call.
See Dynamic targets.
Introspection¶
Ask an instance what it can do now, or what leads out of a state it is not in. See Introspection.
Where to go next¶
| If you want to | Read |
|---|---|
| Understand the pieces and their boundaries | Overview |
| Follow a worked tutorial from an empty file | Getting Started |
| Declare sources, targets, labels and wildcards | Transitions |
| Store the state in a database row | Binding state to storage |
| Use it with Django, Tortoise or SQLAlchemy | Django ORM |
| Look up an exception | Errors |
Scope¶
open-fsm models one state machine per class and enforces its transitions. It
deliberately does not persist anything, does not draw diagrams, and does not
provide async transitions. Those boundaries are stated in full under
Overview → Boundaries.
Credits¶
open-fsm is maintained by Open Byte. It is a
fork of the finite state machine inside
Viewflow, written by Mikhail
Podgurskiy — see Credits and Attribution.