A State Machine which can simultaneously track many items in all states and manage transitions.
- Python 2.7
- The basic concepts in TSM are:
- TrackingStateMachine: The manager object which contains all state objects and initiates transitions.
- TrackingState: A state in the TSM, whose minimal responsibility is to store any objects which are in this state.
- TrackingItem: A base class for items which will be stored in a state (or states).
First we must describe the items we will be storing in each state:
class Friend(TrackingItem): def __init__(self, name, reason): super(self.__class__, self).__init__() self.name = name self.reason
Here we define a simple item which does nothing but store a name, and a reason why we are friends with them.
Next we define a state:
class FriendshipState(TrackingState): def __init__(self, name): super(self.__class__, self).__init__(name, Friend) self.items = [] def _track(self, item): yield TransitionValidationResult(True, None) self.items.append(item)
This is a a trivial state which stores a list of Friend
s which currently exist in it.
The _track()
method tells the TSM what to do with items transitioning into this state.
An important note to make is the yield
statement in _track()
:
yield TransitionValidationResult(True, None)
This exposes some of the design of TSM, specifically that the function of tracking items and performing transitions are implementing using generators.
As just mentioned, transitions and item tracking are implemented using generators. TrackingState implementations need to conform to this protocol by:
- Performing
yield TransitionValidationResult(True, None)
to tell TSM that they are ready to commit to the transition. - If they decide that a transition will break one of their invariants, they can perform:
yield TransitionValidationResult(False, "DO NOT WANT!")
to tell TSM that the transition should abort.
A more explicit example, say we never want to track items whose name is "Jonathan", this can be achieved by:
def _track(self, item): if item.name is "Jonathan": yield TransitionValidationResult(False, "I don't track Jonathans") # I'm happy to accept all other names at this point however yield TransitionValidationResult(True, None) self.items.append(item)
Checking the name on each track event is a little bit tedious, therefore TSM provides TrackingItem validations too:
class Friend(TrackingItem): def __init__(self, name, reason): super(self.__class__, self).__init__() self.name = name self.reason = reason self.validations.extend([ (lambda item: item.name is not "Jonathan"), ])
TrackingItem.validations
is a list of lambdas which are applied to the item, if any of them are False, the item is
deemed as invalid.
This validation mechanism is used by TSM automatically, any items which are tracked (explicitly, or implicitly via a transition) are subject to these validations. Failures will result in transitions being aborted.
At this point you may wonder which form of validation to use when?
Item validations are useful for:
- Sanitising your items (correct types, presence of values, bounds checks etc.)
Transition validations are useful for:
- Checking for state internal invariants
Say we modify our example and create a "No Jonathans rule", e.g. one Jonathan is fine, two is not:
class Friend(TrackingItem): def __init__(self, name, reason): super(self.__class__, self).__init__() self.name = name self.reason = reason self.validations.extend([ (lambda item: isinstance(item.name, str)), ]) class FriendshipState(TrackingState): def __init__(self, name): super(self.__class__, self).__init__(name, Friend) self.items = [] def __know_person(self, name): # Return index of person if we know them, otherwise None for i, person in enumerate(self.items): if person.name == name: return i return None def _track(self, item): if self.__know_person("Jonathan"): yield TransitionValidationResult(False, "I already know one Jonathan") # I'm happy to accept all other names at this point however yield TransitionValidationResult(True, None) self.items.append(item)
Here we see the guidelines in practise, an item ensures the name is actually a string, but in and of itself, it has no capacity to check if there exists another item also called Jonathan.
The invariant (only one Jonathan) is enforced in the transition validation.
Now that we've defined our state and item, we can describe our state machine.
Let's say we are quite fickle and fall in and out of friendships often:
tsm = TrackingStateMachine() tsm.add_state(FriendshipState("Friend")) tsm.add_state(FriendshipState("Enemy"))
To describe how people move between being our Friend and Enemy, we add transitions:
tsm.add_transition("falling_out", "Friend", "Enemy") tsm.add_transition("resolve_differences", "Enemy", "Friend")
However we haven't yet defined in our FriendshipState
how to have a falling out or how to resolve differences.
In general, we say:
tsm.add_transition(TRANSITION_NAME, FROM_STATE, TO_STATE)
To define our transitions, we must create methods in the state with the same name as that registered with the TSM:
class FriendshipState(TrackingState): def __know_person(self, name): # Return index of person if we know them, otherwise None for i, person in enumerate(self.items): if person.name == name: return i return None def __remove_name(self, name): known = self.__know_person(name) if not known: yield TransitionValidationResult(False, "Person {0} is not known to us".format(name)) # We've made sure person exists and is in this state yield TransitionValidationResult(True, None) self.items.pop(known) def falling_out(self, item): return self.__remove_name(item.name) def resolve_differences(self, item): return self.__remove_name(item.name)
As with all transitions, they must yield a successful transition validation.
Notice, these two transitions are fundamentally identical -- removing the person from the state's internal list of items. The transition names are simply semantic.
With the above TSM configuration we can now make friends and enemies!:
# Declare some people as friends friends = [Friend("Jonathan", "I love myself"), Friend("Chris", "Cool dude"), Friend("James", "Nice guy")] # We 'track' each friend in the relevant state for friend in friends: tsm.state("Friends").track(friend) # Jonathan annoyed us, he's now an enemy tsm.transition("falling_out", Friend("Jonathan", None), Friend("Jonathan", "I hate myself"))
So the way we perform transitions is of the form:
transition(TRANSITION_NAME, FROM_STATE_ITEM, TO_STATE_ITEM)
When we un-friended Jonathan above, we had to re-create a Friend
object to specify him to each state,
the first time we didn't bother giving a reason because we knew that FriendshipState
isn't interested in the
reason for removing a person.
Performing some transitions immediately exposes some annoyances:
Friend
items are exposed outside of the TSM.- We must create
Friend
items and know which parameters are useful in which context. e.g. When can I set the Friendship reason toNone
?
To address these two issues, TSM allows dictionary items to be used when performing transitions:
# Previously, to un-friend Jonathan tsm.transition("falling_out", Friend("Jonathan", None), Friend("Jonathan", "I hate myself")) # Now with dictionary items tsm.transition("falling_out", {"name": "Jonathan"}, {"name": "Jonathan", "reason": "I hate myself"})
However to enable this, we need to change how we init our TrackingItem
, in this case Friend
:
class Friend(TrackingItem): def __init__(self, properties): super(self.__class__, self).__init__() self.name = properties.get("name") self.reason = properties.get("reason") self.validations.extend([ (lambda item: item.name is not None), ])
Notice we don't validate the reason
, this is because reason
s presence is optional. We actually only care if a
reason is supplied at one point -- when tracking a new Friend
, i.e.:
class FriendshipState(TrackingState): def _track(self, item): if not item.reason: yield TransitionValidationResult(False, "You must supply a reason") if "Jonathan" in self.items: yield TransitionValidationResult(False, "I already have one Jonathan") # I'm happy to accept all other names at this point however yield TransitionValidationResult(True, None) self.items.append(item)
Dictionary based items didn't solve one problem:
- We still need to mention Jonathan twice in our transition.
If we accidentally mis-typed the name the second time, we could risk never getting our Friend back!
TSM provides a mechanism for the -from- state to communicate paramaters to the -to- state via
TransitionParamater
objects, which can be inserted into dictionary items as follows:
tsm.transition("falling_out", {"name": "Jonathan"}, {"name": TransitionParameter("name"), "reason": "I hate myself"})
What we want to achieve here is to have the -from- state fill in the name for us. This requires one small tweak in how our state transitions:
class FriendshipState(TrackingState): def __remove_name(self, name): known = self.__know_person(name) if not known: yield TransitionValidationResult(False, "Person {0} is not known to us".format(name)) # We've made sure person exists and is in this state success = TransitionValidationResult(True, None) success.add_parameter("name", name) yield success self.items.pop(known) def falling_out(self, item): return self.__remove_name(item.name) def resolve_differences(self, item): return self.__remove_name(item.name)
A state communicates which, after transition validation succeeds, a list of parameters which may be useful to the next state.
It is also possible to provide a default value in the case where the -from- state fails to provide us with a paramater:
{"name": TransitionParameter("name"), "foo": TransitionParamater("foo", value="Default Foo")}
Sometimes we want to perform some action on the items tracked in a state. Since these can be quite varied, state actions are fairly free to do what they like.
Like transitions, we must register them with the TSM and create methods with corresponding names on the state:
tsm = TrackingStateMachine() tsm.add_state(FriendshipState("Friend")) tsm.add_state(FriendshipState("Enemy")) tsm.add_action("upper_case", "Friend")
And the action definition:
class FriendshipState(TrackingState): def upper_case(self, args): initial = args.get("initial", None) if not initial: raise TransitionActionError("Must provide the intial of the person to upper case") for person in self.items: if person.name.upper().startswith(initial.upper()): person.name = person.name.upper()
To issue the action we simply:
tsm.action("upper_case", {"initial": 'J'})
Note, that this action, although technically defined on the Enemy
state, isn't registered with the TSM because
when we registered the action, we specified that the action was on the Friend
state.
TrackingState
provides a get
method, we can make use of it and are free to scope its functionality as we
please:
class Friend(TrackingState): def _get(self, initial=None): """Return all known persons with the given initial, otherwise return all persons""" matches = [] for person in self.items: if not initial: matches.append(person) elif person.name.upper().startswith(initial.upper()): matches.append(person) # A bit contrived, but shows all our possible return forms if not matches: return None elif len(matches) == 1: return matches[0] else: return mactches
When implementing _get
(note the underscore), you are free to return either:
None
-- if your criteria matched nothing- An instance of
TrackingItem
- A list of
TrackingItem
Items retrieved using get
are always exported as dicts:
> tsm.state("Friend").get(initial='J') {"name": "Jonathan", "reason": "I love myself"} > tsm.state("Friend").get(initial='Z') None > tsm.state("Friend").get() [{"name": "Jonathan", "reason": "I love myself"}, {...}, {...}, etc ]