Managing finite state machines around business logic with decorators
Project description
Python State Machines
This is an extremely small and new library I wrote to help manage state machines in classes in Python.
Inspiration
This library was inspired by this talk. I found the concept very fascinating, as I have run into this problem many times, but I also found the syntax clunky. One of the nice things about Python is its dynamism. This can be a real struggle when size and complexity grows, but it at least allows for very nice, often magical feeling, syntax around a lot of things. Naturally, rather than passing large, in situ lists and dictionaries, I thought it would be nicer to employ dictionaries, and some inference to make the code more pleasant and (hopefully) more self-documenting
Find on pypi
This project uses this template to create a pypi package.
You can find the project here and install it using pip install statemachine-elunico
and then import it with import statemachine
Explanation
The library consists of 3 main items: Machine
, MachineError
, and allows_access
.
Machine
is a decorator that provides a class with the logic needed to implement a finite state machine.
This class can then be used to guard access to particular
methods in your class without you having to write any if
based logic.
This allows all the business logic of your program to live in the methods you write unclutters,
while still allowing a robust state management system to exist around method calls in your class.
The logic for handling the state checking and changing is done by the Machine
while you add a simple
declarative decorator statement to the methods you want checked when defining them.
Watching the talk given in the above link and looking at the example file might be a good way of getting a feel for how this works.
Explanation through Example
Briefly, imagine you have a music player that be be started, stopped, and paused and so has the methods start
, stop
, and pause
.
You want to be able to pause while started but not stopped. You want to be able to stop while either started, paused, or stopped. Finally,
you want to be able to start while stopped or paused. You begin in the stopped state.
Immediately, you might be able to see there is a fair bit going on, and we only have 3 states and a few rules.
You can imagine how much more complex this might get if we have more options for states and the ways of moving between them.
In order to implement these rules, we could imagine a Player
class with a source URL for the video it will play and
several booleans for state. It would be the task of the creator of the Player
class, then, to maintain the proper state
of all these booleans and to ensure they are correctly checked and updated in every method. Once again, I will point
to the talk in the link I included for an idea of what this looks like (spoiler alert: it is not pretty)
This is where this library comes in. Rather than focus on the state management and checking, we leave that
to the Machine
class. It can take care of changing state and all the management and checking needed (mostly,
more on that in a bit)
We simply use the @Machine
annotation and then write our Player
class
completely normally. We must also specify what our initial state will be. This will be true for every instance of a class
@Machine(init_state='stop')
class Player:
def __init__(self, src: str):
self.src = src
def start(self):
...
# business logic to start playing
def stop(self):
...
# business logic to stop playing
def pause(self):
...
# business logic to pause playing
Important Note
Let me point out several things before we continue: 1) the use of strings is a deliberate, but noteworthy choice. It is critical that you specify States
to the machine as strings, however these strings must exactly match the names of methods which trigger those states in your class.
For example, here, the initial state is called 'stop' because the state 'stop' is always triggered by calling the stop
method.
We could not call this state 'stopped' unless we also called the method stopped
. Doing it this way (while risky) greatly simplifies the code
needed to implement and maintain. Because more time will be spent using the methods than the states themselves (since the Machine
manages state logic)
I recommend you write methods as normal and have grammatically dubious state names rather than the other way around.
---
There is still something missing from this example. While we have created the machine and defined the initial state, we are currently not guarding access to the methods of the class in any way. Using this library allows you to have unlimited methods that do not interact or pay any attention to the state machine. The state machine will only protect access to calling methods on which you have explicitly chosen to define limitations.
These limitations are defined using the allows_access
decorator. You
add this decorator to the methods on the class which interact with the state machine.
It accepts 1 keyword argument: an iterable of strings (Iterable[str]
).
These strings must exactly match the names of the states which are valid source states for the particular method
being called. What does this mean? Put simply, you define the states from which it is legal to transition
to the state indicated by your method. Once again, the names of methods are used to indicate the destination
state and are used to set the current state after a method call. Therefore, it is important that you correctly name
the methods in the decorator. We will now see the class example from above, but fully written out to implement
the state checking logic described in the beginning of the article.
Note that because you are only passing strings into the decorators, it is trivially easy to allow transitions to the state being defined by the method by simply including the name of the method in the list of valid 'from states'
@Machine(init_state='stop')
class Player:
def __init__(self, video):
self.video = video
@allows_access(from_states=['pause', 'stop'])
def start(self):
...
@allows_access(from_states=['start'])
def pause(self):
...
@allows_access(from_states=['start', 'pause', 'stop'])
def stop(self):
...
Imagine now we create a Player
object. This object begins in the 'stop' state. We may only call start
from the
'stop' state, as it is the only method whose from_states
list includes 'stop'. Calling the start
method
will not only check to make sure we are in an acceptable state, but it will also transition the Machine to the
'start' state, meaning it is no longer valid to call start
but is valid to call pause
and stop
In the event that a method call cannot be executed due to the state machine's current state, then a MachineError
is raised and the current state of the machine is unchanged
The library also allows you to define a to_states
parameter. This allows you to define the states that can be
transitioned to from a paritcular method/state rather than transitioned from. You can also mix and match these
as you like in the same decorator, but **do not double up allows_access
decorators on 1 method.
Here is another way of implementing the same logic above
@Machine(init_state='stop')
class Player:
def __init__(self, video):
self.video = video
@allows_access(from_states=['pause', 'stop'], to_states=['pause'])
def start(self):
...
@allows_access(to_states=['start', 'stop'])
def pause(self):
...
@allows_access(from_states=['start', 'pause', 'stop'])
def stop(self):
...
You do not have to provide both from_states
and to_states
. It is possible to define every transition using only
1 of these keyword arguments. Both exist simply to make it easier to express the most natural description of the
domain.
Also that any combination of parameters will be used and the transitions between states are the union of all the
specified states in all decorators in order of execution. Therefore, there is no precedence. Any rule
specified in any decorator will have the same effect and there is no preference or priorty to the from_states
or two_states
parameter
There are no protections in place if you make a state that is inescapable or one that can never be reached. Note that if you do not decorate a method it will always be callable, but if it is decorated there are no checks for islands or cycles or deadends, that is up to you
Extending this example
Aside from the declarative nature of this system and the ability to keep state checking logic out of the way of business logic, this library really demonstrates its usefulness when adding additional states and functionality.
Let's say we want to extend the above example to include the ability to rewind. We will want to add some rewind()
method with the necessary business logic (not shown) to perform the rewind. But we also have to make sure we are in an
acceptable state before calling rewind. We are able to rewind
if we are in the 'pause' state or the 'start' state.
Furthermore, we are able to go to the 'pause', 'start', and 'stop', state if in the 'rewind' state.
There are two ways we can implement this change. The first way is to use the same from_states
parameter on
allows_acces
and retroactively add states to the existing methods, keeping everything consistent.
@Machine(init_state='stop')
class Player:
def __init__(self, video):
self.video = video
@allows_access(from_states=['pause', 'stop', 'rewind'])
def start(self):
...
@allows_access(from_states=['start', 'rewind'])
def pause(self):
...
@allows_access(from_states=['start', 'pause', 'stop', 'rewind'])
def stop(self):
...
@allows_access(from_states=['pause', 'start'])
def rewind(self):
...
This has the advantage of remaining consitent. It also can work by adding the business logic for the new state in the method and asking, "in what states can I peform this action?" and filling in the values accordingly. Then, visit every method and ask "can I do this, if I am currently in state X" where X is the new state that is being added.
Alternatively, it is possible to only add information to the program and leave the other states untouched. This will work programmatically, but it has the disadvantage of obfuscating the nice declarative nature of the decorators. You can no longer see exactly which states go to a state above the method declaration.
@Machine(init_state='stop')
class Player:
def __init__(self, video):
self.video = video
@allows_access(from_states=['pause', 'stop'])
def start(self):
...
@allows_access(from_states=['start'])
def pause(self):
...
@allows_access(from_states=['start', 'pause', 'stop'])
def stop(self):
...
@allows_access(from_states=['pause', 'start'], to_states=['start', 'pause', 'stop'])
def rewind(self):
...
The way a new state is implemented is up to you. Either will work equally well in terms of the implementation of the Machine
While it is true that it is less clear to read in source when using a mix of from_states
and to_states
, you can interrogate instances the class being managed
(in this case Player
) to determine the allowed states for transition to and from other states.
Determining all Transitions To and From a State
You can interrogate the machine to find out what methods are valid
'to' states and valid 'from' states for a given state. When using the @Machine
decorator
a method called get_all_states
is injected into your decorated class. This method accepts a single str
argument which is the
name of the state/method you are interested in. It returns a dict
object with two keys: the 'from_states' key
maps to a set of strings that are all the states you may transition to the given state from and the 'to_states'
key which maps to a set of all the valid next states for that state.
You can say something like
p = Player()
p.get_all_states('start')
# returns { 'to_states': {'pause', 'stop', 'rewind'},
# 'from_states': {'pause', 'stop', 'rewind'} }
Wrapping up
I believe this implementation is elegant, concise, declarative, and easy to use. The library
is very young and new, and I am currently the only person maintaining it. If you find any bugs
or issues or features that would be useful that are not present, please see the CONTRIBUTING.md
document and open an issue or a pull request.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
File details
Details for the file statemachine-elunico-1.0.0.tar.gz
.
File metadata
- Download URL: statemachine-elunico-1.0.0.tar.gz
- Upload date:
- Size: 19.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/3.4.1 importlib_metadata/4.5.0 pkginfo/1.7.0 requests/2.25.1 requests-toolbelt/0.9.1 tqdm/4.61.1 CPython/3.9.1
File hashes
Algorithm | Hash digest | |
---|---|---|
SHA256 | 123eeb32f099fb88ac917b2f6cccbc0f070d65ced5c7148bb6adfa1266ccbdc0 |
|
MD5 | 89444bba98b7ea62d06084a1ba371700 |
|
BLAKE2b-256 | 07a03fa5cb6491f73688a879726a0c5b5ca00d0dad47b3f8f5fe800a866ae08d |
File details
Details for the file statemachine_elunico-1.0.0-py3-none-any.whl
.
File metadata
- Download URL: statemachine_elunico-1.0.0-py3-none-any.whl
- Upload date:
- Size: 13.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/3.4.1 importlib_metadata/4.5.0 pkginfo/1.7.0 requests/2.25.1 requests-toolbelt/0.9.1 tqdm/4.61.1 CPython/3.9.1
File hashes
Algorithm | Hash digest | |
---|---|---|
SHA256 | b3841ac079aa57d6c11397527769b77b3c971a0dbf7785e63af46582f33899b3 |
|
MD5 | 7fe5a1642c7ab3f88e95da467484c046 |
|
BLAKE2b-256 | 4bbe1b90e44326cf071fe533c8f7b3927c8d7b06b4e64f5b949779b3f711efca |