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 911d0f3..9e568fc 100644 --- a/state_machine/__init__.py +++ b/state_machine/__init__.py @@ -1,49 +1,97 @@ import inspect -from state_machine.models import Event, State, InvalidStateTransition -from state_machine.orm import get_adaptor +from state_machine.models import Event, State, StateMachine, InvalidStateTransition, MongoEngineStateMachine, \ + SqlAlchemyStateMachine, AbstractStateMachine, StateTransitionFailure -_temp_callback_cache = None +try: + import mongoengine +except ImportError as e: + mongoengine = None -def get_callback_cache(): - global _temp_callback_cache - if _temp_callback_cache is None: - _temp_callback_cache = dict() - return _temp_callback_cache +try: + import sqlalchemy + from sqlalchemy import inspection + from sqlalchemy.orm import instrumentation + from sqlalchemy.orm import Session +except ImportError: + sqlalchemy = None + instrumentation = None -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) +def acts_as_state_machine(original_class): + """ Decorates classes that contain StateMachines to update the classes constructors and underlying + structure if needed. - calling_class_dict = get_callback_cache().setdefault(calling_class, {'before': {}, 'after': {}}) - calling_class_dict['before'].setdefault(before_what, []).append(func) + For example for mongoengine it will add the necessary fields needed, + and for sqlalchemy it updates the constructure to set the default states + """ - return func + 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) - return wrapper +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__) + 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: + 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 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 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 diff --git a/state_machine/models/__init__.py b/state_machine/models/__init__.py index e4e70ec..70f4571 100644 --- a/state_machine/models/__init__.py +++ b/state_machine/models/__init__.py @@ -3,15 +3,38 @@ except NameError: string_type = str +try: + import mongoengine +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): + """ A statemachine attempted to enter a state that it is not allowed to enter + """ 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 - def __eq__(self,other): + def __eq__(self, other): if isinstance(other, string_type): return self.name == other elif isinstance(other, State): @@ -19,17 +42,263 @@ def __eq__(self,other): else: return False - def __ne__(self, other): return not self == other + def __hash__(self): + return self.name.__hash__() + class Event(object): + def __init__(self, **kwargs): - self.to_state = kwargs.get('to_state', None) - 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) + self.name = None + self.to_state = kwargs['to_state'] + + # 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") + + + + def __eq__(self, other): + if isinstance(other, string_type): + return self.name == 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) + + 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): + 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 + + + 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]) + elif item in self.states: + return self.states[item] else: - self.from_states = (from_state_args,) + raise AttributeError(item) + + + def attempt_transition(self, event, on_function=None): + """ 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 event.from_states is not None and self.current_state not in event.from_states: + raise InvalidStateTransition + + # fire before_change events + failed = False + + # TODO: what do we need to overwrite so we can do: + 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 failed: + return + + 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 + def current_state(self): + return getattr(self.parent, self.underlying_name) + + def __init__(self, **kwargs): + self.events = {} + self.states = {} + self.initial_state = None + + self.before_callbacks = {} + self.after_callbacks = {} + self.underlying_name = 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): + """ Descriptor Goodness + used to set the parent on to the instance + """ + 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) + + 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 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 + elif isinstance(other, State): + return self.current_state == other.name + + + ############################################################################################### + # Abstract Method Contract + ############################################################################################### + + @property + def extra_class_members(self): + raise NotImplementedError + + def update(self, new_state_name): + raise NotImplementedError + + +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.name)} + + def update(self, state_name): + setattr(self.parent, self.underlying_name, state_name) + + +class SqlAlchemyStateMachine(AbstractStateMachine): + + @property + 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/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 diff --git a/tests.py b/tests.py deleted file mode 100644 index 736a4c5..0000000 --- a/tests.py +++ /dev/null @@ -1,406 +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=True) -except ImportError: - sqlalchemy = None - -from state_machine import acts_as_state_machine, before, State, Event, after, InvalidStateTransition - - -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' - - 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') - def do_one_thing(self): - print("{} is sleepy".format(self.name)) - - @before('sleep') - def do_another_thing(self): - print("{} is REALLY sleepy".format(self.name)) - - @after('sleep') - def snore(self): - print("Zzzzzzzzzzzz") - - @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 - - -def test_state_machine_no_callbacks(): - @acts_as_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) - - 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 - - -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 -################################################################################### -@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) - - 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') - def do_one_thing(self): - print("{} is sleepy".format(self.name)) - - @before('sleep') - def do_another_thing(self): - print("{} is REALLY sleepy".format(self.name)) - - @after('sleep') - def snore(self): - print("Zzzzzzzzzzzz") - - @after('sleep') - def snore(self): - print("Zzzzzzzzzzzzzzzzzzzzzz") - - - Base.metadata.create_all(engine) - - Session = sessionmaker(bind=engine) - session = Session() - - 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 - - session.add(puppy) - session.commit() - - puppy2 = session.query(Puppy).filter_by(id=puppy.id)[0] - - assert puppy2.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) - - 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) - - - Base.metadata.create_all(engine) - - Session = sessionmaker(bind=engine) - session = Session() - - 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 - - session.add(kitten) - session.commit() - - kitten2 = session.query(Kitten).filter_by(id=kitten.id)[0] - - assert kitten2.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) - - 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) - - - 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.current_state, Penguin.sleeping) - assert penguin.is_sleeping - - session.add(penguin) - session.commit() - - penguin2 = session.query(Penguin).filter_by(id=penguin.id)[0] - - assert penguin2.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') - - 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') - def do_one_thing(self): - print("{} is sleepy".format(self.name)) - - @before('sleep') - def do_another_thing(self): - print("{} is REALLY sleepy".format(self.name)) - - @after('sleep') - def snore(self): - print("Zzzzzzzzzzzz") - - @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() - person.save() - - person2 = Person.objects(id=person.id).first() - assert person2.is_running - - -@requires_mongoengine -def test_invalid_state_transition(): - @acts_as_state_machine - class Person(mongoengine.Document): - name = mongoengine.StringField(default='Billy') - - 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.is_sleeping - - #should raise an invalid state exception - with assert_raises(InvalidStateTransition): - person.sleep() - - -@requires_mongoengine -def test_before_callback_blocking_transition(): - @acts_as_state_machine - class Runner(mongoengine.Document): - name = mongoengine.StringField(default='Billy') - - 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 check_sneakers(self): - return False - - establish_mongo_connection() - runner = Runner() - runner.save() - assert runner.is_sleeping - runner.run() - runner.reload() - assert runner.is_sleeping - assert not runner.is_running - - -if __name__ == "__main__": - nose.run() 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..493a9cf --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,214 @@ +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 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 = sleep_run_machine() + + @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 = sleep_run_machine() + + 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 + + +def test_state_machine_event_wrappers(): + @acts_as_state_machine + class Robot(): + name = 'R2-D2' + + status = sleep_run_machine() + + # 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 = sleep_run_machine() + + # 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 + + +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') + + +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) + + diff --git a/tests/test_mongoengine.py b/tests/test_mongoengine.py new file mode 100644 index 0000000..9d1a34a --- /dev/null +++ b/tests/test_mongoengine.py @@ -0,0 +1,125 @@ +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 as StateMachine +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 + + +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(): + + @acts_as_state_machine + class Person(mongoengine.Document): + name = mongoengine.StringField(default='Billy') + + status = sleep_run_machine() + + @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 = sleep_run_machine() + + 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 = sleep_run_machine() + + @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