From fa499ec6a0299d65a2062371e864587704cbdf47 Mon Sep 17 00:00:00 2001 From: Jonathan Tushman Date: Wed, 21 May 2014 16:01:30 -0400 Subject: [PATCH 01/10] first test passing for new syntax -- like where this is going --- state_machine/__init__.py | 20 +++-- state_machine/models/__init__.py | 134 ++++++++++++++++++++++++++++++- tests.py | 61 +++++++++++++- 3 files changed, 206 insertions(+), 9 deletions(-) diff --git a/state_machine/__init__.py b/state_machine/__init__.py index 911d0f3..1272637 100644 --- a/state_machine/__init__.py +++ b/state_machine/__init__.py @@ -1,6 +1,6 @@ import inspect -from state_machine.models import Event, State, InvalidStateTransition +from state_machine.models import Event, State, StateMachine, InvalidStateTransition from state_machine.orm import get_adaptor _temp_callback_cache = None @@ -42,8 +42,16 @@ def wrapper(func): def acts_as_state_machine(original_class): - adaptor = get_adaptor(original_class) - global _temp_callback_cache - modified_class = adaptor.modifed_class(original_class, _temp_callback_cache) - _temp_callback_cache = None - return modified_class + + for member, value in inspect.getmembers(original_class): + + if isinstance(value, StateMachine): + name, machine = member, value + setattr(machine, 'name', name) + + # add extra_class memebers is necessary as such the case for mongo and sqlalchemy + for name in machine.extra_class_members: + setattr(original_class, name, machine.extra_class_members[name]) + + return original_class + diff --git a/state_machine/models/__init__.py b/state_machine/models/__init__.py index e4e70ec..0370021 100644 --- a/state_machine/models/__init__.py +++ b/state_machine/models/__init__.py @@ -3,6 +3,11 @@ except NameError: string_type = str +try: + import mongoengine +except ImportError as e: + mongoengine = None + class InvalidStateTransition(Exception): pass @@ -11,7 +16,7 @@ class State(object): def __init__(self, initial=False, **kwargs): self.initial = initial - def __eq__(self,other): + def __eq__(self, other): if isinstance(other, string_type): return self.name == other elif isinstance(other, State): @@ -26,10 +31,135 @@ def __ne__(self, other): class Event(object): def __init__(self, **kwargs): - self.to_state = kwargs.get('to_state', None) + self.to_state = kwargs['to_state'] self.from_states = tuple() from_state_args = kwargs.get('from_states', tuple()) if isinstance(from_state_args, (tuple, list)): self.from_states = tuple(from_state_args) else: self.from_states = (from_state_args,) + + if self.to_state is None: + raise TypeError("Expected to_state to be set") + + if self.from_states is None or len(self.from_states) == 0: + raise TypeError("Expected From States to be set") + + +class AbstractStateMachine(object): + """ Collection of states and events + """ + + def add_event(self, value): + self.events[value.name] = value + + def add_state(self, state): + self.states[state.name] = state + + # set initial value + if state.initial: + if self.initial_state is not None: + raise ValueError("multiple initial states!") + self.initial_state = state + + # add helper method is_ + # for example is the state is sleeping + # add a method is_sleeping() + def __getattr__(self, item): + if item.startswith('is_'): + state_name = item[3:] + return self.current_state == state_name + elif item in self.events: + return lambda: self.attempt_transition(self.events[item]) + else: + raise AttributeError(item) + + + def attempt_transition(self, event): + if self.current_state not in event.from_states: + raise InvalidStateTransition + + self.update(event.to_state) + + + @property + def current_state(self): + return getattr(self.parent, self.underlying_name) + + def __init__(self, **kwargs): + self.events = self.states = {} + self.initial_state = None + + + # parent refers to the object that this attribute is associted with so ... + # class Fish(): + # + # activity = StateMachine( + # sleeping=State(initial=True), + # swimming=State(), + # + # swim=Event(from_states='sleeping', to_state='swimming'), + # sleep=Event(from_states='swimming', to_state='sleeping') + # ) + # + # parent will refer to the instance of Fish + self.parent = None + + for key in kwargs: + value = kwargs[key] + setattr(value, 'name', key) + if isinstance(value, Event): + self.add_event(value) + elif isinstance(value, State): + self.add_state(value) + else: + raise TypeError("Expecting Events and States, nothing else") + + + + # Descriptor Goodness + # https://docs.python.org/2/howto/descriptor.html + def __get__(self, instance, owner): + if instance is not None: + self.parent = instance + return self + + @property + def name(self): + return self._name + + @name.setter + def name(self, value): + self._name = value + self.underlying_name = "sm_{}".format(value) + + + @property + def extra_class_members(self): + raise NotImplementedError + + def update(self, new_state_name): + raise NotImplementedError + + def __eq__(self, other): + if isinstance(other, basestring): + return self.current_state == other + elif isinstance(other, State): + return self.current_state == other.current_state + + +class StateMachine(AbstractStateMachine): + + @property + def extra_class_members(self): + return {self.underlying_name: self.initial_state} + + def update(self, state_name): + setattr(self.parent, self.underlying_name, state_name) + + +class MongoEngineStateMachine(AbstractStateMachine): + + @property + def extra_class_members(self): + return {self.underlying_name: mongoengine.StringField(default=self.initial_state)} \ No newline at end of file diff --git a/tests.py b/tests.py index 736a4c5..f148f4b 100644 --- a/tests.py +++ b/tests.py @@ -22,7 +22,7 @@ def establish_mongo_connection(): except ImportError: sqlalchemy = None -from state_machine import acts_as_state_machine, before, State, Event, after, InvalidStateTransition +from state_machine import acts_as_state_machine, before, State, Event, after, InvalidStateTransition, StateMachine def requires_mongoengine(func): @@ -111,6 +111,65 @@ class Robot(): assert robot.is_sleeping +def test_multitple_machines_on_same_object(): + + @acts_as_state_machine + class Fish(): + + activity = StateMachine( + sleeping=State(initial=True), + swimming=State(), + + swim=Event(from_states='sleeping', to_state='swimming'), + sleep=Event(from_states='swimming', to_state='sleeping') + ) + + preparation = StateMachine( + raw=State(inital=True), + cooking=State(), + cooked=State(), + + cook=Event(from_states='raw', to_state='cooking'), + serve=Event(from_states='cooking', to_state='cooked'), + ) + + @before('activity.sleep') + def do_one_thing(self): + print("{} is sleepy".format(self.name)) + + @before('activity.sleep') + def do_another_thing(self): + print("{} is REALLY sleepy".format(self.name)) + + @after('activity.sleep') + def snore(self): + print("Zzzzzzzzzzzz") + + @after('activity.sleep') + def snore(self): + print("Zzzzzzzzzzzzzzzzzzzzzz") + + fish = Fish() + eq_(fish.activity, 'sleeping') + assert fish.activity.is_sleeping + assert not fish.activity.is_swimming + fish.activity.swim() + assert fish.activity.is_swimming + fish.activity.sleep() + assert fish.activity.is_sleeping + + # Question if there is not naming conflicts should we support shortcuts: + # Such as: + # + # fish.is_swimming + # fish.swim() + # + # vs. + # + # fish.activity.is_swimming + # fish.activity.swim() + + def test_multiple_machines(): @acts_as_state_machine class Person(object): From a459ea650a4b29c9954901708e6ae0f6c7afb688 Mon Sep 17 00:00:00 2001 From: Jonathan Tushman Date: Thu, 22 May 2014 09:26:18 -0400 Subject: [PATCH 02/10] callbacks working --- state_machine/models/__init__.py | 43 +++++++++++++++++++++++++++++++- tests.py | 37 ++++++++++++++------------- 2 files changed, 61 insertions(+), 19 deletions(-) diff --git a/state_machine/models/__init__.py b/state_machine/models/__init__.py index 0370021..09bab18 100644 --- a/state_machine/models/__init__.py +++ b/state_machine/models/__init__.py @@ -31,6 +31,7 @@ def __ne__(self, other): class Event(object): def __init__(self, **kwargs): + self.name = None self.to_state = kwargs['to_state'] self.from_states = tuple() from_state_args = kwargs.get('from_states', tuple()) @@ -45,6 +46,12 @@ def __init__(self, **kwargs): if self.from_states is None or len(self.from_states) == 0: raise TypeError("Expected From States to be set") + def __eq__(self, other): + if isinstance(other, basestring): + return self.name == other + elif isinstance(other, Event): + return self.name == other.name + class AbstractStateMachine(object): """ Collection of states and events @@ -79,7 +86,26 @@ def attempt_transition(self, event): if self.current_state not in event.from_states: raise InvalidStateTransition - self.update(event.to_state) + # fire before_change events + failed = False + + # TODO: what do we need to overwrite so we can do: + # event in ?? + if event.name in self.before_callbacks: + for callback in self.before_callbacks[event.name]: + result = callback(self) + if result is False: + print("One of the 'before' callbacks returned false, breaking") + failed = True + break + + if not failed: + self.update(event.to_state) + + # fire after change events + if event.name in self.after_callbacks: + for callback in self.after_callbacks[event.name]: + callback(self) @property @@ -90,6 +116,9 @@ def __init__(self, **kwargs): self.events = self.states = {} self.initial_state = None + self.before_callbacks = {} + self.after_callbacks = {} + # parent refers to the object that this attribute is associted with so ... # class Fish(): @@ -141,6 +170,18 @@ def extra_class_members(self): def update(self, new_state_name): raise NotImplementedError + def before(self, before_what): + def wrapper(func): + self.before_callbacks.setdefault(before_what, []).append(func) + return func + return wrapper + + def after(self, after_what): + def wrapper(func): + self.after_callbacks.setdefault(after_what, []).append(func) + return func + return wrapper + def __eq__(self, other): if isinstance(other, basestring): return self.current_state == other diff --git a/tests.py b/tests.py index f148f4b..e4647f4 100644 --- a/tests.py +++ b/tests.py @@ -53,39 +53,40 @@ def test_state_machine(): class Robot(): name = 'R2-D2' - sleeping = State(initial=True) - running = State() - cleaning = State() - - run = Event(from_states=sleeping, to_state=running) - cleanup = Event(from_states=running, to_state=cleaning) - sleep = Event(from_states=(running, cleaning), to_state=sleeping) + status = StateMachine( + sleeping=State(initial=True), + running=State(), + cleaning=State(), + run=Event(from_states='sleeping', to_state='running'), + cleanup=Event(from_states='running', to_state='cleaning'), + sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') + ) - @before('sleep') + @status.before('sleep') def do_one_thing(self): print("{} is sleepy".format(self.name)) - @before('sleep') + @status.before('sleep') def do_another_thing(self): print("{} is REALLY sleepy".format(self.name)) - @after('sleep') + @status.after('sleep') def snore(self): print("Zzzzzzzzzzzz") - @after('sleep') + @status.after('sleep') def snore(self): print("Zzzzzzzzzzzzzzzzzzzzzz") robot = Robot() - eq_(robot.current_state, 'sleeping') - assert robot.is_sleeping - assert not robot.is_running - robot.run() - assert robot.is_running - robot.sleep() - assert robot.is_sleeping + eq_(robot.status, 'sleeping') + assert robot.status.is_sleeping + assert not robot.status.is_running + robot.status.run() + assert robot.status.is_running + robot.status.sleep() + assert robot.status.is_sleeping def test_state_machine_no_callbacks(): From 21ac963d73dba8c762c619c63e1cd87e8954d0f0 Mon Sep 17 00:00:00 2001 From: Jonathan Tushman Date: Thu, 22 May 2014 12:29:01 -0400 Subject: [PATCH 03/10] all mongoengine tests passing --- state_machine/__init__.py | 132 +++++++++++++++-------- state_machine/models/__init__.py | 25 ++++- tests.py | 180 +++++++++++++------------------ 3 files changed, 189 insertions(+), 148 deletions(-) diff --git a/state_machine/__init__.py b/state_machine/__init__.py index 1272637..9488e7e 100644 --- a/state_machine/__init__.py +++ b/state_machine/__init__.py @@ -1,57 +1,101 @@ import inspect -from state_machine.models import Event, State, StateMachine, InvalidStateTransition -from state_machine.orm import get_adaptor - -_temp_callback_cache = None - -def get_callback_cache(): - global _temp_callback_cache - if _temp_callback_cache is None: - _temp_callback_cache = dict() - return _temp_callback_cache - -def get_function_name(frame): - return inspect.getouterframes(frame)[1][3] - -def before(before_what): - def wrapper(func): - frame = inspect.currentframe() - calling_class = get_function_name(frame) - - calling_class_dict = get_callback_cache().setdefault(calling_class, {'before': {}, 'after': {}}) - calling_class_dict['before'].setdefault(before_what, []).append(func) - - return func - - return wrapper - - -def after(after_what): - def wrapper(func): - - frame = inspect.currentframe() - calling_class = get_function_name(frame) - - calling_class_dict = get_callback_cache().setdefault(calling_class, {'before': {}, 'after': {}}) - calling_class_dict['after'].setdefault(after_what, []).append(func) - - return func - - return wrapper - +from state_machine.models import Event, State, StateMachine, InvalidStateTransition, MongoEngineStateMachine, SqlAlchemyStateMachine, AbstractStateMachine +# from state_machine.orm import get_adaptor + +try: + import mongoengine +except ImportError as e: + mongoengine = None + +# _temp_callback_cache = None +# +# def get_callback_cache(): +# global _temp_callback_cache +# if _temp_callback_cache is None: +# _temp_callback_cache = dict() +# return _temp_callback_cache +# +# def get_function_name(frame): +# return inspect.getouterframes(frame)[1][3] +# +# def before(before_what): +# def wrapper(func): +# frame = inspect.currentframe() +# calling_class = get_function_name(frame) +# +# calling_class_dict = get_callback_cache().setdefault(calling_class, {'before': {}, 'after': {}}) +# calling_class_dict['before'].setdefault(before_what, []).append(func) +# +# return func +# +# return wrapper +# +# +# def after(after_what): +# def wrapper(func): +# +# frame = inspect.currentframe() +# calling_class = get_function_name(frame) +# +# calling_class_dict = get_callback_cache().setdefault(calling_class, {'before': {}, 'after': {}}) +# calling_class_dict['after'].setdefault(after_what, []).append(func) +# +# return func +# +# return wrapper + + + +# def _get_potential_state_machine_attributes(clazz): +# if mongoengine is not None: +# # reimplementing inspect.getmembers to swallow ConnectionError +# results = [] +# for key in dir(clazz): +# try: +# value = getattr(clazz, key) +# except (AttributeError, mongoengine.ConnectionError): +# continue +# results.append((key, value)) +# results.sort() +# return results +# else: +# return inspect.getmembers(clazz) + +# def acts_as_state_machine(original_class): +# +# for member, value in _get_potential_state_machine_attributes(original_class): +# +# if isinstance(value, AbstractStateMachine): +# name, machine = member, value +# setattr(machine, 'name', name) +# +# # add extra_class memebers is necessary as such the case for mongo and sqlalchemy +# for name in machine.extra_class_members: +# setattr(original_class, name, machine.extra_class_members[name]) +# +# return original_class def acts_as_state_machine(original_class): - for member, value in inspect.getmembers(original_class): + class_name = original_class.__name__ + class_dict = dict() + class_dict.update(original_class.__dict__) - if isinstance(value, StateMachine): + extra_members = {} + for member in class_dict: + value = class_dict[member] + + if isinstance(value, AbstractStateMachine): name, machine = member, value setattr(machine, 'name', name) # add extra_class memebers is necessary as such the case for mongo and sqlalchemy for name in machine.extra_class_members: - setattr(original_class, name, machine.extra_class_members[name]) + extra_members[name] = machine.extra_class_members[name] + + class_dict.update(extra_members) - return original_class + clazz = type(class_name, original_class.__bases__, class_dict) + return clazz diff --git a/state_machine/models/__init__.py b/state_machine/models/__init__.py index 09bab18..dded63a 100644 --- a/state_machine/models/__init__.py +++ b/state_machine/models/__init__.py @@ -8,6 +8,16 @@ except ImportError as e: mongoengine = None +try: + import sqlalchemy + from sqlalchemy import inspection + from sqlalchemy.orm import instrumentation + from sqlalchemy.orm import Session +except ImportError: + sqlalchemy = None + instrumentation = None + + class InvalidStateTransition(Exception): pass @@ -118,6 +128,7 @@ def __init__(self, **kwargs): self.before_callbacks = {} self.after_callbacks = {} + self.underlying_name = None # parent refers to the object that this attribute is associted with so ... @@ -203,4 +214,16 @@ class MongoEngineStateMachine(AbstractStateMachine): @property def extra_class_members(self): - return {self.underlying_name: mongoengine.StringField(default=self.initial_state)} \ No newline at end of file + return {self.underlying_name: mongoengine.StringField(default=self.initial_state.name)} + + def update(self, state_name): + setattr(self.parent, self.underlying_name, state_name) + + +class SqlAlchemyStateMachine(AbstractStateMachine): + + def extra_class_members(self): + return {self.underlying_name: sqlalchemy.Column(sqlalchemy.String)} + + def update(self, state_name): + setattr(self.parent, self.underlying_name, state_name) diff --git a/tests.py b/tests.py index e4647f4..93c8bb4 100644 --- a/tests.py +++ b/tests.py @@ -22,7 +22,7 @@ def establish_mongo_connection(): except ImportError: sqlalchemy = None -from state_machine import acts_as_state_machine, before, State, Event, after, InvalidStateTransition, StateMachine +from state_machine import acts_as_state_machine, State, Event, InvalidStateTransition, StateMachine, MongoEngineStateMachine def requires_mongoengine(func): @@ -94,22 +94,24 @@ def test_state_machine_no_callbacks(): class Robot(): name = 'R2-D2' - sleeping = State(initial=True) - running = State() - cleaning = State() + status = StateMachine( + sleeping=State(initial=True), + running=State(), + cleaning=State(), - run = Event(from_states=sleeping, to_state=running) - cleanup = Event(from_states=running, to_state=cleaning) - sleep = Event(from_states=(running, cleaning), to_state=sleeping) + run=Event(from_states='sleeping', to_state='running'), + cleanup=Event(from_states='running', to_state='cleaning'), + sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') + ) robot = Robot() - eq_(robot.current_state, 'sleeping') - assert robot.is_sleeping - assert not robot.is_running - robot.run() - assert robot.is_running - robot.sleep() - assert robot.is_sleeping + eq_(robot.status, 'sleeping') + assert robot.status.is_sleeping + assert not robot.status.is_running + robot.status.run() + assert robot.status.is_running + robot.status.sleep() + assert robot.status.is_sleeping def test_multitple_machines_on_same_object(): @@ -134,19 +136,19 @@ class Fish(): serve=Event(from_states='cooking', to_state='cooked'), ) - @before('activity.sleep') + @activity.before('sleep') def do_one_thing(self): print("{} is sleepy".format(self.name)) - @before('activity.sleep') + @activity.before('sleep') def do_another_thing(self): print("{} is REALLY sleepy".format(self.name)) - @after('activity.sleep') + @activity.after('sleep') def snore(self): print("Zzzzzzzzzzzz") - @after('activity.sleep') + @activity.after('sleep') def snore(self): print("Zzzzzzzzzzzzzzzzzzzzzz") @@ -171,43 +173,6 @@ def snore(self): # fish.activity.swim() -def test_multiple_machines(): - @acts_as_state_machine - class Person(object): - sleeping = State(initial=True) - running = State() - cleaning = State() - - run = Event(from_states=sleeping, to_state=running) - cleanup = Event(from_states=running, to_state=cleaning) - sleep = Event(from_states=(running, cleaning), to_state=sleeping) - - @before('run') - def on_run(self): - things_done.append("Person.ran") - - @acts_as_state_machine - class Dog(object): - sleeping = State(initial=True) - running = State() - - run = Event(from_states=sleeping, to_state=running) - sleep = Event(from_states=(running,), to_state=sleeping) - - @before('run') - def on_run(self): - things_done.append("Dog.ran") - - things_done = [] - person = Person() - dog = Dog() - eq_(person.current_state, 'sleeping') - eq_(dog.current_state, 'sleeping') - assert person.is_sleeping - assert dog.is_sleeping - person.run() - eq_(things_done, ["Person.ran"]) - ################################################################################### ## SqlAlchemy Tests ################################################################################### @@ -224,27 +189,29 @@ class Puppy(Base): id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) name = sqlalchemy.Column(sqlalchemy.String) - sleeping = State(initial=True) - running = State() - cleaning = State() + status = StateMachine( + sleeping=State(initial=True), + running=State(), + cleaning=State(), - run = Event(from_states=sleeping, to_state=running) - cleanup = Event(from_states=running, to_state=cleaning) - sleep = Event(from_states=(running, cleaning), to_state=sleeping) + run=Event(from_states='sleeping', to_state='running'), + cleanup=Event(from_states='running', to_state='cleaning'), + sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') + ) - @before('sleep') + @status.before('sleep') def do_one_thing(self): print("{} is sleepy".format(self.name)) - @before('sleep') + @status.before('sleep') def do_another_thing(self): print("{} is REALLY sleepy".format(self.name)) - @after('sleep') + @status.after('sleep') def snore(self): print("Zzzzzzzzzzzz") - @after('sleep') + @status.after('sleep') def snore(self): print("Zzzzzzzzzzzzzzzzzzzzzz") @@ -368,46 +335,49 @@ def test_mongoengine_state_machine(): class Person(mongoengine.Document): name = mongoengine.StringField(default='Billy') - sleeping = State(initial=True) - running = State() - cleaning = State() + status = MongoEngineStateMachine( + sleeping=State(initial=True), + running=State(), + cleaning=State(), - run = Event(from_states=sleeping, to_state=running) - cleanup = Event(from_states=running, to_state=cleaning) - sleep = Event(from_states=(running, cleaning), to_state=sleeping) + run=Event(from_states='sleeping', to_state='running'), + cleanup=Event(from_states='running', to_state='cleaning'), + sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') + ) - @before('sleep') + @status.before('sleep') def do_one_thing(self): print("{} is sleepy".format(self.name)) - @before('sleep') + @status.before('sleep') def do_another_thing(self): print("{} is REALLY sleepy".format(self.name)) - @after('sleep') + @status.after('sleep') def snore(self): print("Zzzzzzzzzzzz") - @after('sleep') + @status.after('sleep') def snore(self): print("Zzzzzzzzzzzzzzzzzzzzzz") establish_mongo_connection() person = Person() + person.save() - eq_(person.current_state, Person.sleeping) - assert person.is_sleeping - assert not person.is_running - person.run() - assert person.is_running - person.sleep() - assert person.is_sleeping - person.run() + eq_(person.status, 'sleeping') + assert person.status.is_sleeping + assert not person.status.is_running + person.status.run() + assert person.status.is_running + person.status.sleep() + assert person.status.is_sleeping + person.status.run() person.save() person2 = Person.objects(id=person.id).first() - assert person2.is_running + assert person2.status.is_running @requires_mongoengine @@ -416,22 +386,24 @@ def test_invalid_state_transition(): class Person(mongoengine.Document): name = mongoengine.StringField(default='Billy') - sleeping = State(initial=True) - running = State() - cleaning = State() + status = MongoEngineStateMachine( + sleeping=State(initial=True), + running=State(), + cleaning=State(), - run = Event(from_states=sleeping, to_state=running) - cleanup = Event(from_states=running, to_state=cleaning) - sleep = Event(from_states=(running, cleaning), to_state=sleeping) + run=Event(from_states='sleeping', to_state='running'), + cleanup=Event(from_states='running', to_state='cleaning'), + sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') + ) establish_mongo_connection() person = Person() person.save() - assert person.is_sleeping + assert person.status.is_sleeping #should raise an invalid state exception with assert_raises(InvalidStateTransition): - person.sleep() + person.status.sleep() @requires_mongoengine @@ -440,26 +412,28 @@ def test_before_callback_blocking_transition(): class Runner(mongoengine.Document): name = mongoengine.StringField(default='Billy') - sleeping = State(initial=True) - running = State() - cleaning = State() + status = MongoEngineStateMachine( + sleeping=State(initial=True), + running=State(), + cleaning=State(), - run = Event(from_states=sleeping, to_state=running) - cleanup = Event(from_states=running, to_state=cleaning) - sleep = Event(from_states=(running, cleaning), to_state=sleeping) + run=Event(from_states='sleeping', to_state='running'), + cleanup=Event(from_states='running', to_state='cleaning'), + sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') + ) - @before('run') + @status.before('run') def check_sneakers(self): return False establish_mongo_connection() runner = Runner() runner.save() - assert runner.is_sleeping - runner.run() + assert runner.status.is_sleeping + runner.status.run() runner.reload() - assert runner.is_sleeping - assert not runner.is_running + assert runner.status.is_sleeping + assert not runner.status.is_running if __name__ == "__main__": From b026fdbb4e3c568f4f000fb6501c009b1a159881 Mon Sep 17 00:00:00 2001 From: Jonathan Tushman Date: Thu, 22 May 2014 14:40:17 -0400 Subject: [PATCH 04/10] all tests pass --- state_machine/__init__.py | 124 ++++++++++++++----------------- state_machine/models/__init__.py | 2 + tests.py | 98 ++++++++++++++++-------- 3 files changed, 123 insertions(+), 101 deletions(-) diff --git a/state_machine/__init__.py b/state_machine/__init__.py index 9488e7e..6a9153f 100644 --- a/state_machine/__init__.py +++ b/state_machine/__init__.py @@ -8,80 +8,20 @@ except ImportError as e: mongoengine = None -# _temp_callback_cache = None -# -# def get_callback_cache(): -# global _temp_callback_cache -# if _temp_callback_cache is None: -# _temp_callback_cache = dict() -# return _temp_callback_cache -# -# def get_function_name(frame): -# return inspect.getouterframes(frame)[1][3] -# -# def before(before_what): -# def wrapper(func): -# frame = inspect.currentframe() -# calling_class = get_function_name(frame) -# -# calling_class_dict = get_callback_cache().setdefault(calling_class, {'before': {}, 'after': {}}) -# calling_class_dict['before'].setdefault(before_what, []).append(func) -# -# return func -# -# return wrapper -# -# -# def after(after_what): -# def wrapper(func): -# -# frame = inspect.currentframe() -# calling_class = get_function_name(frame) -# -# calling_class_dict = get_callback_cache().setdefault(calling_class, {'before': {}, 'after': {}}) -# calling_class_dict['after'].setdefault(after_what, []).append(func) -# -# return func -# -# return wrapper - - - -# def _get_potential_state_machine_attributes(clazz): -# if mongoengine is not None: -# # reimplementing inspect.getmembers to swallow ConnectionError -# results = [] -# for key in dir(clazz): -# try: -# value = getattr(clazz, key) -# except (AttributeError, mongoengine.ConnectionError): -# continue -# results.append((key, value)) -# results.sort() -# return results -# else: -# return inspect.getmembers(clazz) - -# def acts_as_state_machine(original_class): -# -# for member, value in _get_potential_state_machine_attributes(original_class): -# -# if isinstance(value, AbstractStateMachine): -# name, machine = member, value -# setattr(machine, 'name', name) -# -# # add extra_class memebers is necessary as such the case for mongo and sqlalchemy -# for name in machine.extra_class_members: -# setattr(original_class, name, machine.extra_class_members[name]) -# -# return original_class +try: + import sqlalchemy + from sqlalchemy import inspection + from sqlalchemy.orm import instrumentation + from sqlalchemy.orm import Session +except ImportError: + sqlalchemy = None + instrumentation = None -def acts_as_state_machine(original_class): +def modified_class_for_mongoengine(original_class): class_name = original_class.__name__ class_dict = dict() class_dict.update(original_class.__dict__) - extra_members = {} for member in class_dict: value = class_dict[member] @@ -95,7 +35,51 @@ def acts_as_state_machine(original_class): extra_members[name] = machine.extra_class_members[name] class_dict.update(extra_members) - clazz = type(class_name, original_class.__bases__, class_dict) return clazz + +def modified_class_for_sqlalchemy(original_class): + mod_class = modified_class(original_class) + + orig_init = mod_class.__init__ + + def new_init_builder(): + def new_init(self, *args, **kwargs): + orig_init(self, *args, **kwargs) + + for member, value in inspect.getmembers(mod_class): + + if isinstance(value, AbstractStateMachine): + machine = value + setattr(self, machine.underlying_name, machine.initial_state.name) + + return new_init + + mod_class.__init__ = new_init_builder() + return mod_class + + + +def modified_class(original_class): + for member, value in inspect.getmembers(original_class): + + if isinstance(value, AbstractStateMachine): + name, machine = member, value + setattr(machine, 'name', name) + + # add extra_class memebers is necessary as such the case for mongo and sqlalchemy + for name in machine.extra_class_members: + setattr(original_class, name, machine.extra_class_members[name]) + return original_class + + +def acts_as_state_machine(original_class): + + if mongoengine and issubclass(original_class, mongoengine.Document): + return modified_class_for_mongoengine(original_class) + elif sqlalchemy is not None and hasattr(original_class, '_sa_class_manager') and isinstance( + original_class._sa_class_manager, instrumentation.ClassManager): + return modified_class_for_sqlalchemy(original_class) + else: + return modified_class(original_class) diff --git a/state_machine/models/__init__.py b/state_machine/models/__init__.py index dded63a..7a5df93 100644 --- a/state_machine/models/__init__.py +++ b/state_machine/models/__init__.py @@ -18,6 +18,7 @@ instrumentation = None + class InvalidStateTransition(Exception): pass @@ -222,6 +223,7 @@ def update(self, state_name): class SqlAlchemyStateMachine(AbstractStateMachine): + @property def extra_class_members(self): return {self.underlying_name: sqlalchemy.Column(sqlalchemy.String)} diff --git a/tests.py b/tests.py index 93c8bb4..ff1a74d 100644 --- a/tests.py +++ b/tests.py @@ -17,12 +17,13 @@ def establish_mongo_connection(): try: import sqlalchemy - - engine = sqlalchemy.create_engine('sqlite:///:memory:', echo=True) + engine = sqlalchemy.create_engine('sqlite:///:memory:', echo=False) except ImportError: sqlalchemy = None -from state_machine import acts_as_state_machine, State, Event, InvalidStateTransition, StateMachine, MongoEngineStateMachine + + +from state_machine import acts_as_state_machine, State, Event, InvalidStateTransition, StateMachine, MongoEngineStateMachine, SqlAlchemyStateMachine def requires_mongoengine(func): @@ -189,7 +190,7 @@ class Puppy(Base): id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) name = sqlalchemy.Column(sqlalchemy.String) - status = StateMachine( + status = SqlAlchemyStateMachine( sleeping=State(initial=True), running=State(), cleaning=State(), @@ -223,18 +224,18 @@ def snore(self): puppy = Puppy(name='Ralph') - eq_(puppy.current_state, Puppy.sleeping) - assert puppy.is_sleeping - assert not puppy.is_running - puppy.run() - assert puppy.is_running + eq_(puppy.status, 'sleeping') + assert puppy.status.is_sleeping + assert not puppy.status.is_running + puppy.status.run() + assert puppy.status.is_running session.add(puppy) session.commit() puppy2 = session.query(Puppy).filter_by(id=puppy.id)[0] - assert puppy2.is_running + assert puppy2.status.is_running @requires_sqlalchemy @@ -252,14 +253,31 @@ class Kitten(Base): id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) name = sqlalchemy.Column(sqlalchemy.String) - sleeping = State(initial=True) - running = State() - cleaning = State() + status = SqlAlchemyStateMachine( + sleeping=State(initial=True), + running=State(), + cleaning=State(), - run = Event(from_states=sleeping, to_state=running) - cleanup = Event(from_states=running, to_state=cleaning) - sleep = Event(from_states=(running, cleaning), to_state=sleeping) + run=Event(from_states='sleeping', to_state='running'), + cleanup=Event(from_states='running', to_state='cleaning'), + sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') + ) + @status.before('sleep') + def do_one_thing(self): + print("{} is sleepy".format(self.name)) + + @status.before('sleep') + def do_another_thing(self): + print("{} is REALLY sleepy".format(self.name)) + + @status.after('sleep') + def snore(self): + print("Zzzzzzzzzzzz") + + @status.after('sleep') + def snore(self): + print("Zzzzzzzzzzzzzzzzzzzzzz") Base.metadata.create_all(engine) @@ -268,18 +286,18 @@ class Kitten(Base): kitten = Kitten(name='Kit-Kat') - eq_(kitten.current_state, Kitten.sleeping) - assert kitten.is_sleeping - assert not kitten.is_running - kitten.run() - assert kitten.is_running + eq_(kitten.status, 'sleeping') + assert kitten.status.is_sleeping + assert not kitten.status.is_running + kitten.status.run() + assert kitten.status.is_running session.add(kitten) session.commit() kitten2 = session.query(Kitten).filter_by(id=kitten.id)[0] - assert kitten2.is_running + assert kitten2.status.is_running @requires_sqlalchemy @@ -297,13 +315,31 @@ class Penguin(Base): id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) name = sqlalchemy.Column(sqlalchemy.String) - sleeping = State(initial=True) - running = State() - cleaning = State() + status = SqlAlchemyStateMachine( + sleeping=State(initial=True), + running=State(), + cleaning=State(), + + run=Event(from_states='sleeping', to_state='running'), + cleanup=Event(from_states='running', to_state='cleaning'), + sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') + ) + + @status.before('sleep') + def do_one_thing(self): + print("{} is sleepy".format(self.name)) + + @status.before('sleep') + def do_another_thing(self): + print("{} is REALLY sleepy".format(self.name)) + + @status.after('sleep') + def snore(self): + print("Zzzzzzzzzzzz") - run = Event(from_states=sleeping, to_state=running) - cleanup = Event(from_states=running, to_state=cleaning) - sleep = Event(from_states=(running, cleaning), to_state=sleeping) + @status.after('sleep') + def snore(self): + print("Zzzzzzzzzzzzzzzzzzzzzz") Base.metadata.create_all(engine) @@ -313,15 +349,15 @@ class Penguin(Base): # Note: No state transition occurs between the initial state and when it's saved to the database. penguin = Penguin(name='Tux') - eq_(penguin.current_state, Penguin.sleeping) - assert penguin.is_sleeping + eq_(penguin.status, 'sleeping') + assert penguin.status.is_sleeping session.add(penguin) session.commit() penguin2 = session.query(Penguin).filter_by(id=penguin.id)[0] - assert penguin2.is_sleeping + assert penguin2.status.is_sleeping ################################################################################### From c093c5d76eb570ca2056eb9b8453d7f6fe5558d2 Mon Sep 17 00:00:00 2001 From: Jonathan Tushman Date: Thu, 22 May 2014 15:00:11 -0400 Subject: [PATCH 05/10] cleaning up fixing 33 issue --- state_machine/__init__.py | 52 +++++++++------ state_machine/models/__init__.py | 71 ++++++++++++++++---- state_machine/orm/__init__.py | 26 -------- state_machine/orm/base.py | 111 ------------------------------- state_machine/orm/mongoengine.py | 36 ---------- state_machine/orm/sqlalchemy.py | 62 ----------------- 6 files changed, 89 insertions(+), 269 deletions(-) delete mode 100644 state_machine/orm/__init__.py delete mode 100644 state_machine/orm/base.py delete mode 100644 state_machine/orm/mongoengine.py delete mode 100644 state_machine/orm/sqlalchemy.py diff --git a/state_machine/__init__.py b/state_machine/__init__.py index 6a9153f..194929a 100644 --- a/state_machine/__init__.py +++ b/state_machine/__init__.py @@ -18,7 +18,37 @@ instrumentation = None -def modified_class_for_mongoengine(original_class): +def acts_as_state_machine(original_class): + """ Decorates classes that contain StateMachines to update the classes constructors and underlying + structure if needed. + + For example for mongoengine it will add the necessary fields needed, + and for sqlalchemy it updates the constructure to set the default states + """ + + if mongoengine and issubclass(original_class, mongoengine.Document): + return _modified_class_for_mongoengine(original_class) + elif sqlalchemy is not None and hasattr(original_class, '_sa_class_manager') and isinstance( + original_class._sa_class_manager, instrumentation.ClassManager): + return _modified_class_for_sqlalchemy(original_class) + else: + return modified_class(original_class) + + +def modified_class(original_class): + for member, value in inspect.getmembers(original_class): + + if isinstance(value, AbstractStateMachine): + name, machine = member, value + setattr(machine, 'name', name) + + # add extra_class memebers is necessary as such the case for mongo and sqlalchemy + for name in machine.extra_class_members: + setattr(original_class, name, machine.extra_class_members[name]) + return original_class + + +def _modified_class_for_mongoengine(original_class): class_name = original_class.__name__ class_dict = dict() class_dict.update(original_class.__dict__) @@ -39,7 +69,7 @@ def modified_class_for_mongoengine(original_class): return clazz -def modified_class_for_sqlalchemy(original_class): +def _modified_class_for_sqlalchemy(original_class): mod_class = modified_class(original_class) orig_init = mod_class.__init__ @@ -61,25 +91,7 @@ def new_init(self, *args, **kwargs): -def modified_class(original_class): - for member, value in inspect.getmembers(original_class): - if isinstance(value, AbstractStateMachine): - name, machine = member, value - setattr(machine, 'name', name) - # add extra_class memebers is necessary as such the case for mongo and sqlalchemy - for name in machine.extra_class_members: - setattr(original_class, name, machine.extra_class_members[name]) - return original_class -def acts_as_state_machine(original_class): - - if mongoengine and issubclass(original_class, mongoengine.Document): - return modified_class_for_mongoengine(original_class) - elif sqlalchemy is not None and hasattr(original_class, '_sa_class_manager') and isinstance( - original_class._sa_class_manager, instrumentation.ClassManager): - return modified_class_for_sqlalchemy(original_class) - else: - return modified_class(original_class) diff --git a/state_machine/models/__init__.py b/state_machine/models/__init__.py index 7a5df93..1155a72 100644 --- a/state_machine/models/__init__.py +++ b/state_machine/models/__init__.py @@ -18,8 +18,9 @@ instrumentation = None - class InvalidStateTransition(Exception): + """ A statemachine attempted to enter a state that it is not allowed to enter + """ pass @@ -35,16 +36,17 @@ def __eq__(self, other): else: return False - def __ne__(self, other): return not self == other class Event(object): + def __init__(self, **kwargs): self.name = None self.to_state = kwargs['to_state'] self.from_states = tuple() + from_state_args = kwargs.get('from_states', tuple()) if isinstance(from_state_args, (tuple, list)): self.from_states = tuple(from_state_args) @@ -65,7 +67,28 @@ def __eq__(self, other): class AbstractStateMachine(object): - """ Collection of states and events + """ Our StateMachine (well abstract state machine) + + It uses python descriptors to get the context of the parent class. It should be used in conjunction with + `acts_as_state_machine` + + @acts_as_state_machine + class Robot(): + name = 'R2-D2' + + status = StateMachine( + sleeping=State(initial=True), + running=State(), + cleaning=State(), + run=Event(from_states='sleeping', to_state='running'), + cleanup=Event(from_states='running', to_state='cleaning'), + sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') + ) + + You should use one of the implemented StateMachines: + * StateMachine + * MongoEngineStateMachine + * SqlAlchemyStateMachine """ def add_event(self, value): @@ -80,13 +103,16 @@ def add_state(self, state): raise ValueError("multiple initial states!") self.initial_state = state - # add helper method is_ - # for example is the state is sleeping - # add a method is_sleeping() + def __getattr__(self, item): + # add helper method is_ + # for example is the state is sleeping + # add a method is_sleeping() if item.startswith('is_'): state_name = item[3:] return self.current_state == state_name + + # if it is an event, then attempt the transition elif item in self.events: return lambda: self.attempt_transition(self.events[item]) else: @@ -94,6 +120,15 @@ def __getattr__(self, item): def attempt_transition(self, event): + """ attempts the transition + + if there any before callbacks will execute them first + + if any of them fail or return false it will cancel the transition + + upon successful transition it will then call the after callbacks + """ + if self.current_state not in event.from_states: raise InvalidStateTransition @@ -161,6 +196,9 @@ def __init__(self, **kwargs): # Descriptor Goodness # https://docs.python.org/2/howto/descriptor.html def __get__(self, instance, owner): + """ Descriptor Goodness + used to set the parent on to the instance + """ if instance is not None: self.parent = instance return self @@ -175,13 +213,6 @@ def name(self, value): self.underlying_name = "sm_{}".format(value) - @property - def extra_class_members(self): - raise NotImplementedError - - def update(self, new_state_name): - raise NotImplementedError - def before(self, before_what): def wrapper(func): self.before_callbacks.setdefault(before_what, []).append(func) @@ -195,12 +226,24 @@ def wrapper(func): return wrapper def __eq__(self, other): - if isinstance(other, basestring): + if isinstance(other, string_type): return self.current_state == other elif isinstance(other, State): return self.current_state == other.current_state + ############################################################################################### + # Abstract Method Contract + ############################################################################################### + + @property + def extra_class_members(self): + raise NotImplementedError + + def update(self, new_state_name): + raise NotImplementedError + + class StateMachine(AbstractStateMachine): @property diff --git a/state_machine/orm/__init__.py b/state_machine/orm/__init__.py deleted file mode 100644 index 31ef682..0000000 --- a/state_machine/orm/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import absolute_import - -from state_machine.orm.base import BaseAdaptor -from state_machine.orm.mongoengine import get_mongo_adaptor -from state_machine.orm.sqlalchemy import get_sqlalchemy_adaptor - -_adaptors = [get_mongo_adaptor, get_sqlalchemy_adaptor] - - -def get_adaptor(original_class): - # if none, then just keep state in memory - for get_adaptor in _adaptors: - adaptor = get_adaptor(original_class) - if adaptor is not None: - break - else: - adaptor = NullAdaptor(original_class) - return adaptor - - -class NullAdaptor(BaseAdaptor): - def extra_class_members(self, initial_state): - return {"aasm_state": initial_state.name} - - def update(self, document, state_name): - document.aasm_state = state_name diff --git a/state_machine/orm/base.py b/state_machine/orm/base.py deleted file mode 100644 index fafe3c2..0000000 --- a/state_machine/orm/base.py +++ /dev/null @@ -1,111 +0,0 @@ -from __future__ import absolute_import -import inspect -from state_machine.models import Event, State, InvalidStateTransition - - -class BaseAdaptor(object): - - def __init__(self, original_class): - self.original_class = original_class - - def get_potential_state_machine_attributes(self, clazz): - return inspect.getmembers(clazz) - - def process_states(self, original_class): - initial_state = None - is_method_dict = dict() - for member, value in self.get_potential_state_machine_attributes(original_class): - - if isinstance(value, State): - if value.initial: - if initial_state is not None: - raise ValueError("multiple initial states!") - initial_state = value - - #add its name to itself: - setattr(value, 'name', member) - - is_method_string = "is_" + member - - def is_method_builder(member): - def f(self): - return self.aasm_state == str(member) - - return property(f) - - is_method_dict[is_method_string] = is_method_builder(member) - - return is_method_dict, initial_state - - def process_events(self, original_class): - _adaptor = self - event_method_dict = dict() - for member, value in self.get_potential_state_machine_attributes(original_class): - if isinstance(value, Event): - # Create event methods - - def event_meta_method(event_name, event_description): - def f(self): - #assert current state - if self.current_state not in event_description.from_states: - raise InvalidStateTransition - - # fire before_change - failed = False - if self.__class__.callback_cache and \ - event_name in self.__class__.callback_cache[_adaptor.original_class.__name__]['before']: - for callback in self.__class__.callback_cache[_adaptor.original_class.__name__]['before'][event_name]: - result = callback(self) - if result is False: - print("One of the 'before' callbacks returned false, breaking") - failed = True - break - - #change state - if not failed: - _adaptor.update(self, event_description.to_state.name) - - #fire after_change - if self.__class__.callback_cache and \ - event_name in self.__class__.callback_cache[_adaptor.original_class.__name__]['after']: - for callback in self.__class__.callback_cache[_adaptor.original_class.__name__]['after'][event_name]: - callback(self) - - return f - - event_method_dict[member] = event_meta_method(member, value) - return event_method_dict - - def modifed_class(self, original_class, callback_cache): - - class_name = original_class.__name__ - class_dict = dict() - - class_dict['callback_cache'] = callback_cache - - def current_state_method(): - def f(self): - return self.aasm_state - return property(f) - - class_dict['current_state'] = current_state_method() - - class_dict.update(original_class.__dict__) - - # Get states - state_method_dict, initial_state = self.process_states(original_class) - class_dict.update(self.extra_class_members(initial_state)) - class_dict.update(state_method_dict) - - # Get events - event_method_dict = self.process_events(original_class) - class_dict.update(event_method_dict) - - clazz = type(class_name, original_class.__bases__, class_dict) - return clazz - - def extra_class_members(self, initial_state): - raise NotImplementedError - - def update(self, document, state_name): - raise NotImplementedError diff --git a/state_machine/orm/mongoengine.py b/state_machine/orm/mongoengine.py deleted file mode 100644 index dfbacae..0000000 --- a/state_machine/orm/mongoengine.py +++ /dev/null @@ -1,36 +0,0 @@ -from __future__ import absolute_import - -try: - import mongoengine -except ImportError as e: - mongoengine = None - -from state_machine.orm.base import BaseAdaptor - - -class MongoAdaptor(BaseAdaptor): - - def get_potential_state_machine_attributes(self, clazz): - # reimplementing inspect.getmembers to swallow ConnectionError - results = [] - for key in dir(clazz): - try: - value = getattr(clazz, key) - except (AttributeError, mongoengine.ConnectionError): - continue - results.append((key, value)) - results.sort() - return results - - - def extra_class_members(self, initial_state): - return {'aasm_state': mongoengine.StringField(default=initial_state.name)} - - def update(self, document, state_name): - document.aasm_state = state_name - - -def get_mongo_adaptor(original_class): - if mongoengine is not None and issubclass(original_class, mongoengine.Document): - return MongoAdaptor(original_class) - return None diff --git a/state_machine/orm/sqlalchemy.py b/state_machine/orm/sqlalchemy.py deleted file mode 100644 index bc1401c..0000000 --- a/state_machine/orm/sqlalchemy.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import absolute_import - -try: - import sqlalchemy - from sqlalchemy import inspection - from sqlalchemy.orm import instrumentation - from sqlalchemy.orm import Session -except ImportError: - sqlalchemy = None - instrumentation = None - -from state_machine.orm.base import BaseAdaptor - - -class SqlAlchemyAdaptor(BaseAdaptor): - def extra_class_members(self, initial_state): - return {'aasm_state': sqlalchemy.Column(sqlalchemy.String)} - - def update(self, document, state_name): - document.aasm_state = state_name - - def modifed_class(self, original_class, callback_cache): - class_dict = dict() - - class_dict['callback_cache'] = callback_cache - - def current_state_method(): - def f(self): - return self.aasm_state - - return property(f) - - class_dict['current_state'] = current_state_method() - - # Get states - state_method_dict, initial_state = self.process_states(original_class) - class_dict.update(self.extra_class_members(initial_state)) - class_dict.update(state_method_dict) - - orig_init = original_class.__init__ - - def new_init(self, *args, **kwargs): - orig_init(self, *args, **kwargs) - self.aasm_state = initial_state.name - - class_dict['__init__'] = new_init - - # Get events - event_method_dict = self.process_events(original_class) - class_dict.update(event_method_dict) - - for key in class_dict: - setattr(original_class, key, class_dict[key]) - - return original_class - - -def get_sqlalchemy_adaptor(original_class): - if sqlalchemy is not None and hasattr(original_class, '_sa_class_manager') and isinstance( - original_class._sa_class_manager, instrumentation.ClassManager): - return SqlAlchemyAdaptor(original_class) - return None From 64b88de8c170961346ba435485db2281d5e7c4be Mon Sep 17 00:00:00 2001 From: Jonathan Tushman Date: Fri, 23 May 2014 08:29:06 -0400 Subject: [PATCH 06/10] adding on callback wrapper --- CHANGES | 7 +++ README.rst | 46 +++++++++--------- setup.py | 2 +- state_machine/__init__.py | 4 +- state_machine/models/__init__.py | 43 +++++++++++++---- tests.py | 82 ++++++++++++++++++++++++++++++-- 6 files changed, 146 insertions(+), 38 deletions(-) create mode 100644 CHANGES diff --git a/CHANGES b/CHANGES new file mode 100644 index 0000000..1c1b01c --- /dev/null +++ b/CHANGES @@ -0,0 +1,7 @@ +Release 0.3.0 +############# + +Not backwards compatible with 0.2 version +Now supports multiple machines per class + + diff --git a/README.rst b/README.rst index 0542220..c32c7a7 100644 --- a/README.rst +++ b/README.rst @@ -32,44 +32,46 @@ Basic Usage class Person(): name = 'Billy' - sleeping = State(initial=True) - running = State() - cleaning = State() + status = MongoEngineStateMachine( + sleeping=State(initial=True), + running=State(), + cleaning=State(), - run = Event(from_states=sleeping, to_state=running) - cleanup = Event(from_states=running, to_state=cleaning) - sleep = Event(from_states=(running, cleaning), to_state=sleeping) + run=Event(from_states='sleeping', to_state='running'), + cleanup=Event(from_states='running', to_state='cleaning'), + sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') + ) - @before('sleep') + @status.before('sleep') def do_one_thing(self): - print "{} is sleepy".format(self.name) + print("{} is sleepy".format(self.name)) - @before('sleep') + @status.before('sleep') def do_another_thing(self): - print "{} is REALLY sleepy".format(self.name) + print("{} is REALLY sleepy".format(self.name)) - @after('sleep') + @status.after('sleep') def snore(self): - print "Zzzzzzzzzzzz" + print("Zzzzzzzzzzzz") - @after('sleep') - def big_snore(self): - print "Zzzzzzzzzzzzzzzzzzzzzz" + @status.after('sleep') + def snore(self): + print("Zzzzzzzzzzzzzzzzzzzzzz") person = Person() - print person.current_state == Person.sleeping # True - print person.is_sleeping # True - print person.is_running # False - person.run() - print person.is_running # True - person.sleep() + print person.status == Person.status.sleeping # True + print person.status.is_sleeping # True + print person.status.is_running # False + person.status.run() + print person.status.is_running # True + person.status.sleep() # Billy is sleepy # Billy is REALLY sleepy # Zzzzzzzzzzzz # Zzzzzzzzzzzzzzzzzzzzzz - print person.is_sleeping # True + print person.status.is_sleeping # True Features -------- diff --git a/setup.py b/setup.py index 62f38ab..8926726 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ def get_packages(): required_modules = [] setup(name='state_machine', - version='0.2.9', + version='0.3.0', description='Python State Machines for Humans', url='http://github.com/jtushman/state_machine', author='Jonathan Tushman', diff --git a/state_machine/__init__.py b/state_machine/__init__.py index 194929a..9e568fc 100644 --- a/state_machine/__init__.py +++ b/state_machine/__init__.py @@ -1,7 +1,7 @@ import inspect -from state_machine.models import Event, State, StateMachine, InvalidStateTransition, MongoEngineStateMachine, SqlAlchemyStateMachine, AbstractStateMachine -# from state_machine.orm import get_adaptor +from state_machine.models import Event, State, StateMachine, InvalidStateTransition, MongoEngineStateMachine, \ + SqlAlchemyStateMachine, AbstractStateMachine, StateTransitionFailure try: import mongoengine diff --git a/state_machine/models/__init__.py b/state_machine/models/__init__.py index 1155a72..d7d1a58 100644 --- a/state_machine/models/__init__.py +++ b/state_machine/models/__init__.py @@ -24,6 +24,12 @@ class InvalidStateTransition(Exception): pass +class StateTransitionFailure(Exception): + """ The method that was triggering the transition failed (for instance with the `on` callbacks) + """ + + + class State(object): def __init__(self, initial=False, **kwargs): self.initial = initial @@ -65,6 +71,9 @@ def __eq__(self, other): elif isinstance(other, Event): return self.name == other.name + def __hash__(self): + return self.name.__hash__() + class AbstractStateMachine(object): """ Our StateMachine (well abstract state machine) @@ -119,7 +128,7 @@ def __getattr__(self, item): raise AttributeError(item) - def attempt_transition(self, event): + def attempt_transition(self, event, on_function=None): """ attempts the transition if there any before callbacks will execute them first @@ -136,22 +145,30 @@ def attempt_transition(self, event): failed = False # TODO: what do we need to overwrite so we can do: - # event in ?? - if event.name in self.before_callbacks: + if event in self.before_callbacks: for callback in self.before_callbacks[event.name]: result = callback(self) if result is False: + #TODO: Should be a log print("One of the 'before' callbacks returned false, breaking") failed = True break - if not failed: - self.update(event.to_state) + if failed: + return - # fire after change events - if event.name in self.after_callbacks: - for callback in self.after_callbacks[event.name]: - callback(self) + if on_function: + function, args, kwargs = on_function + on_function_result = function(*args, **kwargs) + if on_function_result is False: + raise StateTransitionFailure("\"{}\" transition failed in 'on' callback".format(event.name)) + + self.update(event.to_state) + + # fire after change events + if event.name in self.after_callbacks: + for callback in self.after_callbacks[event.name]: + callback(self) @property @@ -212,7 +229,6 @@ def name(self, value): self._name = value self.underlying_name = "sm_{}".format(value) - def before(self, before_what): def wrapper(func): self.before_callbacks.setdefault(before_what, []).append(func) @@ -225,6 +241,13 @@ def wrapper(func): return func return wrapper + def on(self, on_what): + def decorate(func): + def call(*args, **kwargs): + return self.attempt_transition(self.events[on_what], (func, args, kwargs)) + return call + return decorate + def __eq__(self, other): if isinstance(other, string_type): return self.current_state == other diff --git a/tests.py b/tests.py index ff1a74d..74df837 100644 --- a/tests.py +++ b/tests.py @@ -21,9 +21,8 @@ def establish_mongo_connection(): except ImportError: sqlalchemy = None - - -from state_machine import acts_as_state_machine, State, Event, InvalidStateTransition, StateMachine, MongoEngineStateMachine, SqlAlchemyStateMachine +from state_machine import acts_as_state_machine, State, Event, InvalidStateTransition, \ + StateMachine, MongoEngineStateMachine, SqlAlchemyStateMachine, StateTransitionFailure def requires_mongoengine(func): @@ -174,6 +173,83 @@ def snore(self): # fish.activity.swim() +def test_state_machine_event_wrappers(): + @acts_as_state_machine + class Robot(): + name = 'R2-D2' + + status = StateMachine( + sleeping=State(initial=True), + running=State(), + cleaning=State(), + run=Event(from_states='sleeping', to_state='running'), + cleanup=Event(from_states='running', to_state='cleaning'), + sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') + ) + + + # This if sucessful will move from running or cleaning to sleeping + # will raise invalid state if not a valid transition + @status.on('run') + def run(self): + print("I'm running") + + @status.on('sleep') + def sleep(self): + print("I'm going back to sleep") + + + robot = Robot() + eq_(robot.status, 'sleeping') + assert robot.status.is_sleeping + assert not robot.status.is_running + robot.run() + assert robot.status.is_running + robot.sleep() + assert robot.status.is_sleeping + + + +def test_state_machine_event_wrappers_block(): + @acts_as_state_machine + class Robot(): + name = 'R2-D2' + + status = StateMachine( + sleeping=State(initial=True), + running=State(), + cleaning=State(), + run=Event(from_states='sleeping', to_state='running'), + cleanup=Event(from_states='running', to_state='cleaning'), + sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') + ) + + + # This if sucessful will move from running or cleaning to sleeping + # will raise invalid state if not a valid transition + @status.on('run') + def try_running(self): + print "I'm running" + return False + + @status.on('sleep') + def sleep(self): + print "I'm going back to sleep" + + + robot = Robot() + eq_(robot.status, 'sleeping') + assert robot.status.is_sleeping + assert not robot.status.is_running + + #should raise an invalid state exception + with assert_raises(StateTransitionFailure): + robot.try_running() + + assert robot.status.is_sleeping + + + ################################################################################### ## SqlAlchemy Tests ################################################################################### From 3364c34b1b24d632c46be29d526139bcdd525f33 Mon Sep 17 00:00:00 2001 From: Jonathan Tushman Date: Fri, 23 May 2014 08:41:44 -0400 Subject: [PATCH 07/10] breaking up tests into data store specific files --- tests/__init__.py | 4 + tests/test_core.py | 206 +++++++++++++++++++++++++++++++++++++ tests/test_mongoengine.py | 136 +++++++++++++++++++++++++ tests/test_sqlalchemy.py | 208 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 554 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/test_core.py create mode 100644 tests/test_mongoengine.py create mode 100644 tests/test_sqlalchemy.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d69e79c --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,4 @@ +import nose + +if __name__ == "__main__": + nose.run() diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..e23a22e --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,206 @@ + +from nose.tools import * +from nose.tools import assert_raises + +from state_machine import acts_as_state_machine, State, Event, StateMachine +from state_machine import InvalidStateTransition, StateTransitionFailure + +def test_state_machine(): + @acts_as_state_machine + class Robot(): + name = 'R2-D2' + + status = StateMachine( + sleeping=State(initial=True), + running=State(), + cleaning=State(), + run=Event(from_states='sleeping', to_state='running'), + cleanup=Event(from_states='running', to_state='cleaning'), + sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') + ) + + @status.before('sleep') + def do_one_thing(self): + print("{} is sleepy".format(self.name)) + + @status.before('sleep') + def do_another_thing(self): + print("{} is REALLY sleepy".format(self.name)) + + @status.after('sleep') + def snore(self): + print("Zzzzzzzzzzzz") + + @status.after('sleep') + def snore(self): + print("Zzzzzzzzzzzzzzzzzzzzzz") + + + robot = Robot() + eq_(robot.status, 'sleeping') + assert robot.status.is_sleeping + assert not robot.status.is_running + robot.status.run() + assert robot.status.is_running + robot.status.sleep() + assert robot.status.is_sleeping + + +def test_state_machine_no_callbacks(): + @acts_as_state_machine + class Robot(): + name = 'R2-D2' + + status = StateMachine( + sleeping=State(initial=True), + running=State(), + cleaning=State(), + + run=Event(from_states='sleeping', to_state='running'), + cleanup=Event(from_states='running', to_state='cleaning'), + sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') + ) + + robot = Robot() + eq_(robot.status, 'sleeping') + assert robot.status.is_sleeping + assert not robot.status.is_running + robot.status.run() + assert robot.status.is_running + robot.status.sleep() + assert robot.status.is_sleeping + + +def test_multitple_machines_on_same_object(): + + @acts_as_state_machine + class Fish(): + + activity = StateMachine( + sleeping=State(initial=True), + swimming=State(), + + swim=Event(from_states='sleeping', to_state='swimming'), + sleep=Event(from_states='swimming', to_state='sleeping') + ) + + preparation = StateMachine( + raw=State(inital=True), + cooking=State(), + cooked=State(), + + cook=Event(from_states='raw', to_state='cooking'), + serve=Event(from_states='cooking', to_state='cooked'), + ) + + @activity.before('sleep') + def do_one_thing(self): + print("{} is sleepy".format(self.name)) + + @activity.before('sleep') + def do_another_thing(self): + print("{} is REALLY sleepy".format(self.name)) + + @activity.after('sleep') + def snore(self): + print("Zzzzzzzzzzzz") + + @activity.after('sleep') + def snore(self): + print("Zzzzzzzzzzzzzzzzzzzzzz") + + fish = Fish() + eq_(fish.activity, 'sleeping') + assert fish.activity.is_sleeping + assert not fish.activity.is_swimming + fish.activity.swim() + assert fish.activity.is_swimming + fish.activity.sleep() + assert fish.activity.is_sleeping + + # Question if there is not naming conflicts should we support shortcuts: + # Such as: + # + # fish.is_swimming + # fish.swim() + # + # vs. + # + # fish.activity.is_swimming + # fish.activity.swim() + + +def test_state_machine_event_wrappers(): + @acts_as_state_machine + class Robot(): + name = 'R2-D2' + + status = StateMachine( + sleeping=State(initial=True), + running=State(), + cleaning=State(), + run=Event(from_states='sleeping', to_state='running'), + cleanup=Event(from_states='running', to_state='cleaning'), + sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') + ) + + + # This if sucessful will move from running or cleaning to sleeping + # will raise invalid state if not a valid transition + @status.on('run') + def run(self): + print("I'm running") + + @status.on('sleep') + def sleep(self): + print("I'm going back to sleep") + + + robot = Robot() + eq_(robot.status, 'sleeping') + assert robot.status.is_sleeping + assert not robot.status.is_running + robot.run() + assert robot.status.is_running + robot.sleep() + assert robot.status.is_sleeping + + + +def test_state_machine_event_wrappers_block(): + @acts_as_state_machine + class Robot(): + name = 'R2-D2' + + status = StateMachine( + sleeping=State(initial=True), + running=State(), + cleaning=State(), + run=Event(from_states='sleeping', to_state='running'), + cleanup=Event(from_states='running', to_state='cleaning'), + sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') + ) + + + # This if sucessful will move from running or cleaning to sleeping + # will raise invalid state if not a valid transition + @status.on('run') + def try_running(self): + print "I'm running" + return False + + @status.on('sleep') + def sleep(self): + print "I'm going back to sleep" + + + robot = Robot() + eq_(robot.status, 'sleeping') + assert robot.status.is_sleeping + assert not robot.status.is_running + + #should raise an invalid state exception + with assert_raises(StateTransitionFailure): + robot.try_running() + + assert robot.status.is_sleeping \ No newline at end of file diff --git a/tests/test_mongoengine.py b/tests/test_mongoengine.py new file mode 100644 index 0000000..070621a --- /dev/null +++ b/tests/test_mongoengine.py @@ -0,0 +1,136 @@ +import functools +import os +from nose.plugins.skip import SkipTest +from nose.tools import * +from nose.tools import assert_raises + +try: + import mongoengine +except ImportError: + mongoengine = None + +from state_machine import acts_as_state_machine, State, Event, MongoEngineStateMachine +from state_machine import InvalidStateTransition, StateTransitionFailure + +def establish_mongo_connection(): + mongo_name = os.environ.get('AASM_MONGO_DB_NAME', 'test_acts_as_state_machine') + mongo_port = int(os.environ.get('AASM_MONGO_DB_PORT', 27017)) + mongoengine.connect(mongo_name, port=mongo_port) + + +def requires_mongoengine(func): + @functools.wraps(func) + def wrapper(*args, **kw): + if mongoengine is None: + raise SkipTest("mongoengine is not installed") + return func(*args, **kw) + + return wrapper + + +@requires_mongoengine +def test_mongoengine_state_machine(): + + @acts_as_state_machine + class Person(mongoengine.Document): + name = mongoengine.StringField(default='Billy') + + status = MongoEngineStateMachine( + sleeping=State(initial=True), + running=State(), + cleaning=State(), + + run=Event(from_states='sleeping', to_state='running'), + cleanup=Event(from_states='running', to_state='cleaning'), + sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') + ) + + @status.before('sleep') + def do_one_thing(self): + print("{} is sleepy".format(self.name)) + + @status.before('sleep') + def do_another_thing(self): + print("{} is REALLY sleepy".format(self.name)) + + @status.after('sleep') + def snore(self): + print("Zzzzzzzzzzzz") + + @status.after('sleep') + def snore(self): + print("Zzzzzzzzzzzzzzzzzzzzzz") + + establish_mongo_connection() + + person = Person() + + person.save() + eq_(person.status, 'sleeping') + assert person.status.is_sleeping + assert not person.status.is_running + person.status.run() + assert person.status.is_running + person.status.sleep() + assert person.status.is_sleeping + person.status.run() + person.save() + + person2 = Person.objects(id=person.id).first() + assert person2.status.is_running + + +@requires_mongoengine +def test_invalid_state_transition(): + @acts_as_state_machine + class Person(mongoengine.Document): + name = mongoengine.StringField(default='Billy') + + status = MongoEngineStateMachine( + sleeping=State(initial=True), + running=State(), + cleaning=State(), + + run=Event(from_states='sleeping', to_state='running'), + cleanup=Event(from_states='running', to_state='cleaning'), + sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') + ) + + establish_mongo_connection() + person = Person() + person.save() + assert person.status.is_sleeping + + #should raise an invalid state exception + with assert_raises(InvalidStateTransition): + person.status.sleep() + + +@requires_mongoengine +def test_before_callback_blocking_transition(): + @acts_as_state_machine + class Runner(mongoengine.Document): + name = mongoengine.StringField(default='Billy') + + status = MongoEngineStateMachine( + sleeping=State(initial=True), + running=State(), + cleaning=State(), + + run=Event(from_states='sleeping', to_state='running'), + cleanup=Event(from_states='running', to_state='cleaning'), + sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') + ) + + @status.before('run') + def check_sneakers(self): + return False + + establish_mongo_connection() + runner = Runner() + runner.save() + assert runner.status.is_sleeping + runner.status.run() + runner.reload() + assert runner.status.is_sleeping + assert not runner.status.is_running \ No newline at end of file diff --git a/tests/test_sqlalchemy.py b/tests/test_sqlalchemy.py new file mode 100644 index 0000000..39e79d8 --- /dev/null +++ b/tests/test_sqlalchemy.py @@ -0,0 +1,208 @@ +import functools + +from nose.plugins.skip import SkipTest +from nose.tools import * +from nose.tools import assert_raises + +try: + import sqlalchemy + engine = sqlalchemy.create_engine('sqlite:///:memory:', echo=False) +except ImportError: + sqlalchemy = None + +from state_machine import acts_as_state_machine, State, Event, SqlAlchemyStateMachine +from state_machine import InvalidStateTransition, StateTransitionFailure + +def requires_sqlalchemy(func): + @functools.wraps(func) + def wrapper(*args, **kw): + if sqlalchemy is None: + raise SkipTest("sqlalchemy is not installed") + return func(*args, **kw) + + return wrapper + +@requires_sqlalchemy +def test_sqlalchemy_state_machine(): + from sqlalchemy.ext.declarative import declarative_base + from sqlalchemy.orm import sessionmaker + + Base = declarative_base() + + @acts_as_state_machine + class Puppy(Base): + __tablename__ = 'puppies' + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) + name = sqlalchemy.Column(sqlalchemy.String) + + status = SqlAlchemyStateMachine( + sleeping=State(initial=True), + running=State(), + cleaning=State(), + + run=Event(from_states='sleeping', to_state='running'), + cleanup=Event(from_states='running', to_state='cleaning'), + sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') + ) + + @status.before('sleep') + def do_one_thing(self): + print("{} is sleepy".format(self.name)) + + @status.before('sleep') + def do_another_thing(self): + print("{} is REALLY sleepy".format(self.name)) + + @status.after('sleep') + def snore(self): + print("Zzzzzzzzzzzz") + + @status.after('sleep') + def snore(self): + print("Zzzzzzzzzzzzzzzzzzzzzz") + + + Base.metadata.create_all(engine) + + Session = sessionmaker(bind=engine) + session = Session() + + puppy = Puppy(name='Ralph') + + eq_(puppy.status, 'sleeping') + assert puppy.status.is_sleeping + assert not puppy.status.is_running + puppy.status.run() + assert puppy.status.is_running + + session.add(puppy) + session.commit() + + puppy2 = session.query(Puppy).filter_by(id=puppy.id)[0] + + assert puppy2.status.is_running + + +@requires_sqlalchemy +def test_sqlalchemy_state_machine_no_callbacks(): + ''' This is to make sure that the state change will still work even if no callbacks are registered. + ''' + from sqlalchemy.ext.declarative import declarative_base + from sqlalchemy.orm import sessionmaker + + Base = declarative_base() + + @acts_as_state_machine + class Kitten(Base): + __tablename__ = 'kittens' + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) + name = sqlalchemy.Column(sqlalchemy.String) + + status = SqlAlchemyStateMachine( + sleeping=State(initial=True), + running=State(), + cleaning=State(), + + run=Event(from_states='sleeping', to_state='running'), + cleanup=Event(from_states='running', to_state='cleaning'), + sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') + ) + + @status.before('sleep') + def do_one_thing(self): + print("{} is sleepy".format(self.name)) + + @status.before('sleep') + def do_another_thing(self): + print("{} is REALLY sleepy".format(self.name)) + + @status.after('sleep') + def snore(self): + print("Zzzzzzzzzzzz") + + @status.after('sleep') + def snore(self): + print("Zzzzzzzzzzzzzzzzzzzzzz") + + Base.metadata.create_all(engine) + + Session = sessionmaker(bind=engine) + session = Session() + + kitten = Kitten(name='Kit-Kat') + + eq_(kitten.status, 'sleeping') + assert kitten.status.is_sleeping + assert not kitten.status.is_running + kitten.status.run() + assert kitten.status.is_running + + session.add(kitten) + session.commit() + + kitten2 = session.query(Kitten).filter_by(id=kitten.id)[0] + + assert kitten2.status.is_running + + +@requires_sqlalchemy +def test_sqlalchemy_state_machine_using_initial_state(): + ''' This is to make sure that the database will save the object with the initial state. + ''' + from sqlalchemy.ext.declarative import declarative_base + from sqlalchemy.orm import sessionmaker + + Base = declarative_base() + + @acts_as_state_machine + class Penguin(Base): + __tablename__ = 'penguins' + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) + name = sqlalchemy.Column(sqlalchemy.String) + + status = SqlAlchemyStateMachine( + sleeping=State(initial=True), + running=State(), + cleaning=State(), + + run=Event(from_states='sleeping', to_state='running'), + cleanup=Event(from_states='running', to_state='cleaning'), + sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') + ) + + @status.before('sleep') + def do_one_thing(self): + print("{} is sleepy".format(self.name)) + + @status.before('sleep') + def do_another_thing(self): + print("{} is REALLY sleepy".format(self.name)) + + @status.after('sleep') + def snore(self): + print("Zzzzzzzzzzzz") + + @status.after('sleep') + def snore(self): + print("Zzzzzzzzzzzzzzzzzzzzzz") + + + Base.metadata.create_all(engine) + + Session = sessionmaker(bind=engine) + session = Session() + + # Note: No state transition occurs between the initial state and when it's saved to the database. + penguin = Penguin(name='Tux') + eq_(penguin.status, 'sleeping') + assert penguin.status.is_sleeping + + session.add(penguin) + session.commit() + + penguin2 = session.query(Penguin).filter_by(id=penguin.id)[0] + + assert penguin2.status.is_sleeping + + + ######################### \ No newline at end of file From 439880bd347f0f6f0f424c105dfd6d061fb0936f Mon Sep 17 00:00:00 2001 From: Jonathan Tushman Date: Fri, 23 May 2014 08:51:05 -0400 Subject: [PATCH 08/10] test cleanup --- tests/test_core.py | 63 +++++++++++---------------------------- tests/test_mongoengine.py | 45 +++++++++++----------------- 2 files changed, 34 insertions(+), 74 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index e23a22e..63d3ee5 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -5,19 +5,24 @@ from state_machine import acts_as_state_machine, State, Event, StateMachine from state_machine import InvalidStateTransition, StateTransitionFailure +def sleep_run_machine(): + """ This was repeated a lot in the tests so I pulled it out --keep it DRY + """ + return StateMachine( + sleeping=State(initial=True), + running=State(), + cleaning=State(), + run=Event(from_states='sleeping', to_state='running'), + cleanup=Event(from_states='running', to_state='cleaning'), + sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') + ) + def test_state_machine(): @acts_as_state_machine class Robot(): name = 'R2-D2' - status = StateMachine( - sleeping=State(initial=True), - running=State(), - cleaning=State(), - run=Event(from_states='sleeping', to_state='running'), - cleanup=Event(from_states='running', to_state='cleaning'), - sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') - ) + status = sleep_run_machine() @status.before('sleep') def do_one_thing(self): @@ -51,15 +56,7 @@ def test_state_machine_no_callbacks(): class Robot(): name = 'R2-D2' - status = StateMachine( - sleeping=State(initial=True), - running=State(), - cleaning=State(), - - run=Event(from_states='sleeping', to_state='running'), - cleanup=Event(from_states='running', to_state='cleaning'), - sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') - ) + status = sleep_run_machine() robot = Robot() eq_(robot.status, 'sleeping') @@ -91,7 +88,7 @@ class Fish(): cook=Event(from_states='raw', to_state='cooking'), serve=Event(from_states='cooking', to_state='cooked'), - ) + ) @activity.before('sleep') def do_one_thing(self): @@ -118,16 +115,6 @@ def snore(self): fish.activity.sleep() assert fish.activity.is_sleeping - # Question if there is not naming conflicts should we support shortcuts: - # Such as: - # - # fish.is_swimming - # fish.swim() - # - # vs. - # - # fish.activity.is_swimming - # fish.activity.swim() def test_state_machine_event_wrappers(): @@ -135,15 +122,7 @@ def test_state_machine_event_wrappers(): class Robot(): name = 'R2-D2' - status = StateMachine( - sleeping=State(initial=True), - running=State(), - cleaning=State(), - run=Event(from_states='sleeping', to_state='running'), - cleanup=Event(from_states='running', to_state='cleaning'), - sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') - ) - + status = sleep_run_machine() # This if sucessful will move from running or cleaning to sleeping # will raise invalid state if not a valid transition @@ -172,15 +151,7 @@ def test_state_machine_event_wrappers_block(): class Robot(): name = 'R2-D2' - status = StateMachine( - sleeping=State(initial=True), - running=State(), - cleaning=State(), - run=Event(from_states='sleeping', to_state='running'), - cleanup=Event(from_states='running', to_state='cleaning'), - sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') - ) - + status = sleep_run_machine() # This if sucessful will move from running or cleaning to sleeping # will raise invalid state if not a valid transition diff --git a/tests/test_mongoengine.py b/tests/test_mongoengine.py index 070621a..9d1a34a 100644 --- a/tests/test_mongoengine.py +++ b/tests/test_mongoengine.py @@ -9,7 +9,7 @@ except ImportError: mongoengine = None -from state_machine import acts_as_state_machine, State, Event, MongoEngineStateMachine +from state_machine import acts_as_state_machine, State, Event, MongoEngineStateMachine as StateMachine from state_machine import InvalidStateTransition, StateTransitionFailure def establish_mongo_connection(): @@ -28,6 +28,19 @@ def wrapper(*args, **kw): return wrapper +def sleep_run_machine(): + """ This was repeated a lot in the tests so I pulled it out --keep it DRY + """ + return StateMachine( + sleeping=State(initial=True), + running=State(), + cleaning=State(), + run=Event(from_states='sleeping', to_state='running'), + cleanup=Event(from_states='running', to_state='cleaning'), + sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') + ) + + @requires_mongoengine def test_mongoengine_state_machine(): @@ -35,15 +48,7 @@ def test_mongoengine_state_machine(): class Person(mongoengine.Document): name = mongoengine.StringField(default='Billy') - status = MongoEngineStateMachine( - sleeping=State(initial=True), - running=State(), - cleaning=State(), - - run=Event(from_states='sleeping', to_state='running'), - cleanup=Event(from_states='running', to_state='cleaning'), - sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') - ) + status = sleep_run_machine() @status.before('sleep') def do_one_thing(self): @@ -86,15 +91,7 @@ def test_invalid_state_transition(): class Person(mongoengine.Document): name = mongoengine.StringField(default='Billy') - status = MongoEngineStateMachine( - sleeping=State(initial=True), - running=State(), - cleaning=State(), - - run=Event(from_states='sleeping', to_state='running'), - cleanup=Event(from_states='running', to_state='cleaning'), - sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') - ) + status = sleep_run_machine() establish_mongo_connection() person = Person() @@ -112,15 +109,7 @@ def test_before_callback_blocking_transition(): class Runner(mongoengine.Document): name = mongoengine.StringField(default='Billy') - status = MongoEngineStateMachine( - sleeping=State(initial=True), - running=State(), - cleaning=State(), - - run=Event(from_states='sleeping', to_state='running'), - cleanup=Event(from_states='running', to_state='cleaning'), - sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') - ) + status = sleep_run_machine() @status.before('run') def check_sneakers(self): From bf67977e9d73254e7b65292066ac95d2f7c70f9e Mon Sep 17 00:00:00 2001 From: Jonathan Tushman Date: Fri, 23 May 2014 10:26:38 -0400 Subject: [PATCH 09/10] fixing 33 compatability and supporting blank from states --- state_machine/models/__init__.py | 23 +- tests.py | 552 ------------------------------- tests/test_core.py | 36 +- 3 files changed, 40 insertions(+), 571 deletions(-) delete mode 100644 tests.py diff --git a/state_machine/models/__init__.py b/state_machine/models/__init__.py index d7d1a58..c896e8b 100644 --- a/state_machine/models/__init__.py +++ b/state_machine/models/__init__.py @@ -51,22 +51,24 @@ class Event(object): def __init__(self, **kwargs): self.name = None self.to_state = kwargs['to_state'] - self.from_states = tuple() - from_state_args = kwargs.get('from_states', tuple()) - if isinstance(from_state_args, (tuple, list)): - self.from_states = tuple(from_state_args) - else: - self.from_states = (from_state_args,) + # If from states are empty that mean all states + self.from_states = None + + from_state_args = kwargs.get('from_states', None) + if from_state_args: + if isinstance(from_state_args, (tuple, list)): + self.from_states = tuple(from_state_args) + else: + self.from_states = (from_state_args,) if self.to_state is None: raise TypeError("Expected to_state to be set") - if self.from_states is None or len(self.from_states) == 0: - raise TypeError("Expected From States to be set") + def __eq__(self, other): - if isinstance(other, basestring): + if isinstance(other, string_type): return self.name == other elif isinstance(other, Event): return self.name == other.name @@ -137,8 +139,7 @@ def attempt_transition(self, event, on_function=None): upon successful transition it will then call the after callbacks """ - - if self.current_state not in event.from_states: + if event.from_states is not None and self.current_state not in event.from_states: raise InvalidStateTransition # fire before_change events diff --git a/tests.py b/tests.py deleted file mode 100644 index 74df837..0000000 --- a/tests.py +++ /dev/null @@ -1,552 +0,0 @@ -import functools -import os -import nose -from nose.plugins.skip import SkipTest -from nose.tools import * -from nose.tools import assert_raises - -try: - import mongoengine -except ImportError: - mongoengine = None - -def establish_mongo_connection(): - mongo_name = os.environ.get('AASM_MONGO_DB_NAME', 'test_acts_as_state_machine') - mongo_port = int(os.environ.get('AASM_MONGO_DB_PORT', 27017)) - mongoengine.connect(mongo_name, port=mongo_port) - -try: - import sqlalchemy - engine = sqlalchemy.create_engine('sqlite:///:memory:', echo=False) -except ImportError: - sqlalchemy = None - -from state_machine import acts_as_state_machine, State, Event, InvalidStateTransition, \ - StateMachine, MongoEngineStateMachine, SqlAlchemyStateMachine, StateTransitionFailure - - -def requires_mongoengine(func): - @functools.wraps(func) - def wrapper(*args, **kw): - if mongoengine is None: - raise SkipTest("mongoengine is not installed") - return func(*args, **kw) - - return wrapper - - -def requires_sqlalchemy(func): - @functools.wraps(func) - def wrapper(*args, **kw): - if sqlalchemy is None: - raise SkipTest("sqlalchemy is not installed") - return func(*args, **kw) - - return wrapper - -################################################################################### -## Plain Old In Memory Tests -################################################################################### - -def test_state_machine(): - @acts_as_state_machine - class Robot(): - name = 'R2-D2' - - status = StateMachine( - sleeping=State(initial=True), - running=State(), - cleaning=State(), - run=Event(from_states='sleeping', to_state='running'), - cleanup=Event(from_states='running', to_state='cleaning'), - sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') - ) - - @status.before('sleep') - def do_one_thing(self): - print("{} is sleepy".format(self.name)) - - @status.before('sleep') - def do_another_thing(self): - print("{} is REALLY sleepy".format(self.name)) - - @status.after('sleep') - def snore(self): - print("Zzzzzzzzzzzz") - - @status.after('sleep') - def snore(self): - print("Zzzzzzzzzzzzzzzzzzzzzz") - - - robot = Robot() - eq_(robot.status, 'sleeping') - assert robot.status.is_sleeping - assert not robot.status.is_running - robot.status.run() - assert robot.status.is_running - robot.status.sleep() - assert robot.status.is_sleeping - - -def test_state_machine_no_callbacks(): - @acts_as_state_machine - class Robot(): - name = 'R2-D2' - - status = StateMachine( - sleeping=State(initial=True), - running=State(), - cleaning=State(), - - run=Event(from_states='sleeping', to_state='running'), - cleanup=Event(from_states='running', to_state='cleaning'), - sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') - ) - - robot = Robot() - eq_(robot.status, 'sleeping') - assert robot.status.is_sleeping - assert not robot.status.is_running - robot.status.run() - assert robot.status.is_running - robot.status.sleep() - assert robot.status.is_sleeping - - -def test_multitple_machines_on_same_object(): - - @acts_as_state_machine - class Fish(): - - activity = StateMachine( - sleeping=State(initial=True), - swimming=State(), - - swim=Event(from_states='sleeping', to_state='swimming'), - sleep=Event(from_states='swimming', to_state='sleeping') - ) - - preparation = StateMachine( - raw=State(inital=True), - cooking=State(), - cooked=State(), - - cook=Event(from_states='raw', to_state='cooking'), - serve=Event(from_states='cooking', to_state='cooked'), - ) - - @activity.before('sleep') - def do_one_thing(self): - print("{} is sleepy".format(self.name)) - - @activity.before('sleep') - def do_another_thing(self): - print("{} is REALLY sleepy".format(self.name)) - - @activity.after('sleep') - def snore(self): - print("Zzzzzzzzzzzz") - - @activity.after('sleep') - def snore(self): - print("Zzzzzzzzzzzzzzzzzzzzzz") - - fish = Fish() - eq_(fish.activity, 'sleeping') - assert fish.activity.is_sleeping - assert not fish.activity.is_swimming - fish.activity.swim() - assert fish.activity.is_swimming - fish.activity.sleep() - assert fish.activity.is_sleeping - - # Question if there is not naming conflicts should we support shortcuts: - # Such as: - # - # fish.is_swimming - # fish.swim() - # - # vs. - # - # fish.activity.is_swimming - # fish.activity.swim() - - -def test_state_machine_event_wrappers(): - @acts_as_state_machine - class Robot(): - name = 'R2-D2' - - status = StateMachine( - sleeping=State(initial=True), - running=State(), - cleaning=State(), - run=Event(from_states='sleeping', to_state='running'), - cleanup=Event(from_states='running', to_state='cleaning'), - sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') - ) - - - # This if sucessful will move from running or cleaning to sleeping - # will raise invalid state if not a valid transition - @status.on('run') - def run(self): - print("I'm running") - - @status.on('sleep') - def sleep(self): - print("I'm going back to sleep") - - - robot = Robot() - eq_(robot.status, 'sleeping') - assert robot.status.is_sleeping - assert not robot.status.is_running - robot.run() - assert robot.status.is_running - robot.sleep() - assert robot.status.is_sleeping - - - -def test_state_machine_event_wrappers_block(): - @acts_as_state_machine - class Robot(): - name = 'R2-D2' - - status = StateMachine( - sleeping=State(initial=True), - running=State(), - cleaning=State(), - run=Event(from_states='sleeping', to_state='running'), - cleanup=Event(from_states='running', to_state='cleaning'), - sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') - ) - - - # This if sucessful will move from running or cleaning to sleeping - # will raise invalid state if not a valid transition - @status.on('run') - def try_running(self): - print "I'm running" - return False - - @status.on('sleep') - def sleep(self): - print "I'm going back to sleep" - - - robot = Robot() - eq_(robot.status, 'sleeping') - assert robot.status.is_sleeping - assert not robot.status.is_running - - #should raise an invalid state exception - with assert_raises(StateTransitionFailure): - robot.try_running() - - assert robot.status.is_sleeping - - - -################################################################################### -## SqlAlchemy Tests -################################################################################### -@requires_sqlalchemy -def test_sqlalchemy_state_machine(): - from sqlalchemy.ext.declarative import declarative_base - from sqlalchemy.orm import sessionmaker - - Base = declarative_base() - - @acts_as_state_machine - class Puppy(Base): - __tablename__ = 'puppies' - id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) - name = sqlalchemy.Column(sqlalchemy.String) - - status = SqlAlchemyStateMachine( - sleeping=State(initial=True), - running=State(), - cleaning=State(), - - run=Event(from_states='sleeping', to_state='running'), - cleanup=Event(from_states='running', to_state='cleaning'), - sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') - ) - - @status.before('sleep') - def do_one_thing(self): - print("{} is sleepy".format(self.name)) - - @status.before('sleep') - def do_another_thing(self): - print("{} is REALLY sleepy".format(self.name)) - - @status.after('sleep') - def snore(self): - print("Zzzzzzzzzzzz") - - @status.after('sleep') - def snore(self): - print("Zzzzzzzzzzzzzzzzzzzzzz") - - - Base.metadata.create_all(engine) - - Session = sessionmaker(bind=engine) - session = Session() - - puppy = Puppy(name='Ralph') - - eq_(puppy.status, 'sleeping') - assert puppy.status.is_sleeping - assert not puppy.status.is_running - puppy.status.run() - assert puppy.status.is_running - - session.add(puppy) - session.commit() - - puppy2 = session.query(Puppy).filter_by(id=puppy.id)[0] - - assert puppy2.status.is_running - - -@requires_sqlalchemy -def test_sqlalchemy_state_machine_no_callbacks(): - ''' This is to make sure that the state change will still work even if no callbacks are registered. - ''' - from sqlalchemy.ext.declarative import declarative_base - from sqlalchemy.orm import sessionmaker - - Base = declarative_base() - - @acts_as_state_machine - class Kitten(Base): - __tablename__ = 'kittens' - id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) - name = sqlalchemy.Column(sqlalchemy.String) - - status = SqlAlchemyStateMachine( - sleeping=State(initial=True), - running=State(), - cleaning=State(), - - run=Event(from_states='sleeping', to_state='running'), - cleanup=Event(from_states='running', to_state='cleaning'), - sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') - ) - - @status.before('sleep') - def do_one_thing(self): - print("{} is sleepy".format(self.name)) - - @status.before('sleep') - def do_another_thing(self): - print("{} is REALLY sleepy".format(self.name)) - - @status.after('sleep') - def snore(self): - print("Zzzzzzzzzzzz") - - @status.after('sleep') - def snore(self): - print("Zzzzzzzzzzzzzzzzzzzzzz") - - Base.metadata.create_all(engine) - - Session = sessionmaker(bind=engine) - session = Session() - - kitten = Kitten(name='Kit-Kat') - - eq_(kitten.status, 'sleeping') - assert kitten.status.is_sleeping - assert not kitten.status.is_running - kitten.status.run() - assert kitten.status.is_running - - session.add(kitten) - session.commit() - - kitten2 = session.query(Kitten).filter_by(id=kitten.id)[0] - - assert kitten2.status.is_running - - -@requires_sqlalchemy -def test_sqlalchemy_state_machine_using_initial_state(): - ''' This is to make sure that the database will save the object with the initial state. - ''' - from sqlalchemy.ext.declarative import declarative_base - from sqlalchemy.orm import sessionmaker - - Base = declarative_base() - - @acts_as_state_machine - class Penguin(Base): - __tablename__ = 'penguins' - id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) - name = sqlalchemy.Column(sqlalchemy.String) - - status = SqlAlchemyStateMachine( - sleeping=State(initial=True), - running=State(), - cleaning=State(), - - run=Event(from_states='sleeping', to_state='running'), - cleanup=Event(from_states='running', to_state='cleaning'), - sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') - ) - - @status.before('sleep') - def do_one_thing(self): - print("{} is sleepy".format(self.name)) - - @status.before('sleep') - def do_another_thing(self): - print("{} is REALLY sleepy".format(self.name)) - - @status.after('sleep') - def snore(self): - print("Zzzzzzzzzzzz") - - @status.after('sleep') - def snore(self): - print("Zzzzzzzzzzzzzzzzzzzzzz") - - - Base.metadata.create_all(engine) - - Session = sessionmaker(bind=engine) - session = Session() - - # Note: No state transition occurs between the initial state and when it's saved to the database. - penguin = Penguin(name='Tux') - eq_(penguin.status, 'sleeping') - assert penguin.status.is_sleeping - - session.add(penguin) - session.commit() - - penguin2 = session.query(Penguin).filter_by(id=penguin.id)[0] - - assert penguin2.status.is_sleeping - - -################################################################################### -## Mongo Engine Tests -################################################################################### - -@requires_mongoengine -def test_mongoengine_state_machine(): - - @acts_as_state_machine - class Person(mongoengine.Document): - name = mongoengine.StringField(default='Billy') - - status = MongoEngineStateMachine( - sleeping=State(initial=True), - running=State(), - cleaning=State(), - - run=Event(from_states='sleeping', to_state='running'), - cleanup=Event(from_states='running', to_state='cleaning'), - sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') - ) - - @status.before('sleep') - def do_one_thing(self): - print("{} is sleepy".format(self.name)) - - @status.before('sleep') - def do_another_thing(self): - print("{} is REALLY sleepy".format(self.name)) - - @status.after('sleep') - def snore(self): - print("Zzzzzzzzzzzz") - - @status.after('sleep') - def snore(self): - print("Zzzzzzzzzzzzzzzzzzzzzz") - - establish_mongo_connection() - - person = Person() - - person.save() - eq_(person.status, 'sleeping') - assert person.status.is_sleeping - assert not person.status.is_running - person.status.run() - assert person.status.is_running - person.status.sleep() - assert person.status.is_sleeping - person.status.run() - person.save() - - person2 = Person.objects(id=person.id).first() - assert person2.status.is_running - - -@requires_mongoengine -def test_invalid_state_transition(): - @acts_as_state_machine - class Person(mongoengine.Document): - name = mongoengine.StringField(default='Billy') - - status = MongoEngineStateMachine( - sleeping=State(initial=True), - running=State(), - cleaning=State(), - - run=Event(from_states='sleeping', to_state='running'), - cleanup=Event(from_states='running', to_state='cleaning'), - sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') - ) - - establish_mongo_connection() - person = Person() - person.save() - assert person.status.is_sleeping - - #should raise an invalid state exception - with assert_raises(InvalidStateTransition): - person.status.sleep() - - -@requires_mongoengine -def test_before_callback_blocking_transition(): - @acts_as_state_machine - class Runner(mongoengine.Document): - name = mongoengine.StringField(default='Billy') - - status = MongoEngineStateMachine( - sleeping=State(initial=True), - running=State(), - cleaning=State(), - - run=Event(from_states='sleeping', to_state='running'), - cleanup=Event(from_states='running', to_state='cleaning'), - sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') - ) - - @status.before('run') - def check_sneakers(self): - return False - - establish_mongo_connection() - runner = Runner() - runner.save() - assert runner.status.is_sleeping - runner.status.run() - runner.reload() - assert runner.status.is_sleeping - assert not runner.status.is_running - - -if __name__ == "__main__": - nose.run() diff --git a/tests/test_core.py b/tests/test_core.py index 63d3ee5..9454b51 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,9 +1,9 @@ - from nose.tools import * from nose.tools import assert_raises from state_machine import acts_as_state_machine, State, Event, StateMachine -from state_machine import InvalidStateTransition, StateTransitionFailure +from state_machine import StateTransitionFailure + def sleep_run_machine(): """ This was repeated a lot in the tests so I pulled it out --keep it DRY @@ -17,6 +17,7 @@ def sleep_run_machine(): sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') ) + def test_state_machine(): @acts_as_state_machine class Robot(): @@ -40,7 +41,6 @@ def snore(self): def snore(self): print("Zzzzzzzzzzzzzzzzzzzzzz") - robot = Robot() eq_(robot.status, 'sleeping') assert robot.status.is_sleeping @@ -116,7 +116,6 @@ def snore(self): assert fish.activity.is_sleeping - def test_state_machine_event_wrappers(): @acts_as_state_machine class Robot(): @@ -134,7 +133,6 @@ def run(self): def sleep(self): print("I'm going back to sleep") - robot = Robot() eq_(robot.status, 'sleeping') assert robot.status.is_sleeping @@ -145,7 +143,6 @@ def sleep(self): assert robot.status.is_sleeping - def test_state_machine_event_wrappers_block(): @acts_as_state_machine class Robot(): @@ -164,7 +161,6 @@ def try_running(self): def sleep(self): print "I'm going back to sleep" - robot = Robot() eq_(robot.status, 'sleeping') assert robot.status.is_sleeping @@ -174,4 +170,28 @@ def sleep(self): with assert_raises(StateTransitionFailure): robot.try_running() - assert robot.status.is_sleeping \ No newline at end of file + assert robot.status.is_sleeping + + +def test_empty_from_states(): + + @acts_as_state_machine + class Fax(): + + status = StateMachine( + ready=State(initial=True), + sending=State(), + failure=State(), + + send=Event(from_states='ready', to_state='sending'), + fail=Event(to_state='failure'), + ) + + + my_fax_machine = Fax() + + eq_(my_fax_machine.status, 'ready') + my_fax_machine.status.fail() + eq_(my_fax_machine.status, 'failure') + + From d220336762f42fa457fb38c0bb291c0f0d7ed880 Mon Sep 17 00:00:00 2001 From: Jonathan Tushman Date: Fri, 23 May 2014 11:30:29 -0400 Subject: [PATCH 10/10] adding state_machine constants like eq_(robot.status, robot.status.sleeping) --- state_machine/models/__init__.py | 10 ++++++++-- tests/test_core.py | 23 ++++++++++++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/state_machine/models/__init__.py b/state_machine/models/__init__.py index c896e8b..70f4571 100644 --- a/state_machine/models/__init__.py +++ b/state_machine/models/__init__.py @@ -45,6 +45,9 @@ def __eq__(self, other): def __ne__(self, other): return not self == other + def __hash__(self): + return self.name.__hash__() + class Event(object): @@ -126,6 +129,8 @@ def __getattr__(self, item): # if it is an event, then attempt the transition elif item in self.events: return lambda: self.attempt_transition(self.events[item]) + elif item in self.states: + return self.states[item] else: raise AttributeError(item) @@ -177,7 +182,8 @@ def current_state(self): return getattr(self.parent, self.underlying_name) def __init__(self, **kwargs): - self.events = self.states = {} + self.events = {} + self.states = {} self.initial_state = None self.before_callbacks = {} @@ -253,7 +259,7 @@ def __eq__(self, other): if isinstance(other, string_type): return self.current_state == other elif isinstance(other, State): - return self.current_state == other.current_state + return self.current_state == other.name ############################################################################################### diff --git a/tests/test_core.py b/tests/test_core.py index 9454b51..493a9cf 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -154,12 +154,12 @@ class Robot(): # will raise invalid state if not a valid transition @status.on('run') def try_running(self): - print "I'm running" + print("I'm running") return False @status.on('sleep') def sleep(self): - print "I'm going back to sleep" + print("I'm going back to sleep") robot = Robot() eq_(robot.status, 'sleeping') @@ -187,7 +187,6 @@ class Fax(): fail=Event(to_state='failure'), ) - my_fax_machine = Fax() eq_(my_fax_machine.status, 'ready') @@ -195,3 +194,21 @@ class Fax(): eq_(my_fax_machine.status, 'failure') +def test_state_constants(): + + @acts_as_state_machine + class Robot(): + + status = StateMachine( + sleeping=State(initial=True), + running=State(), + cleaning=State(), + run=Event(from_states='sleeping', to_state='running'), + cleanup=Event(from_states='running', to_state='cleaning'), + sleep=Event(from_states=('running', 'cleaning'), to_state='sleeping') + ) + + robot = Robot() + eq_(robot.status, robot.status.sleeping) + +