├── .gitignore ├── LICENSE ├── README.md ├── sqlalchemy_fsm.py └── tests.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.pyc 3 | .coverage 4 | .cache 5 | .ve 6 | reports 7 | distribute-*.tar.gz 8 | nosetests.xml 9 | coverage.xml 10 | pylint.out 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | copyright (c) 2010 Mikhail Podgurskiy 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Finite state machine field for sqlalchemy (based on django-fsm) 2 | ============================================================== 3 | 4 | sqlalchemy-fsm adds declarative states management for sqlalchemy models. 5 | Instead of adding some state field to a model, and manage its 6 | values by hand, you could use FSMState field and mark model methods 7 | with the `transition` decorator. Your method will contain the side-effects 8 | of the state change. 9 | 10 | The decorator also takes a list of conditions, all of which must be met 11 | before a transition is allowed. 12 | 13 | Usage 14 | ----- 15 | 16 | Add FSMState field to you model 17 | from sqlalchemy_fsm import FSMField, transition 18 | 19 | class BlogPost(db.Model): 20 | state = db.Column(FSMField, nullable = False) 21 | 22 | 23 | Use the `transition` decorator to annotate model methods 24 | 25 | @transition(source='new', target='published') 26 | def publish(self): 27 | """ 28 | This function may contain side-effects, 29 | like updating caches, notifying users, etc. 30 | The return value will be discarded. 31 | """ 32 | 33 | `source` parameter accepts a list of states, or an individual state. 34 | You can use `*` for source, to allow switching to `target` from any state. 35 | 36 | If calling publish() succeeds without raising an exception, the state field 37 | will be changed, but not written to the database. 38 | 39 | from sqlalchemy_fsm import can_proceed 40 | 41 | def publish_view(request, post_id): 42 | post = get_object__or_404(BlogPost, pk=post_id) 43 | if not can_proceed(post.publish): 44 | raise Http404; 45 | 46 | post.publish() 47 | post.save() 48 | return redirect('/') 49 | 50 | 51 | If your given function requires arguments to validate, you need to include them 52 | when calling can_proceed as well as including them when you call the function 53 | normally. Say publish() required a date for some reason: 54 | 55 | if not can_proceed(post.publish, the_date): 56 | raise Http404 57 | else: 58 | post.publish(the_date) 59 | 60 | If you require some conditions to be met before changing state, use the 61 | `conditions` argument to `transition`. `conditions` must be a list of functions 62 | that take one argument, the model instance. The function must return either 63 | `True` or `False` or a value that evaluates to `True` or `False`. If all 64 | functions return `True`, all conditions are considered to be met and transition 65 | is allowed to happen. If one of the functions return `False`, the transition 66 | will not happen. These functions should not have any side effects. 67 | 68 | You can use ordinary functions 69 | 70 | def can_publish(instance): 71 | # No publishing after 17 hours 72 | if datetime.datetime.now().hour > 17: 73 | return False 74 | return True 75 | 76 | Or model methods 77 | 78 | def can_destroy(self): 79 | return self.is_under_investigation() 80 | 81 | Use the conditions like this: 82 | 83 | @transition(source='new', target='published', conditions=[can_publish]) 84 | def publish(self): 85 | """ 86 | Side effects galore 87 | """ 88 | 89 | @transition(source='*', target='destroyed', conditions=[can_destroy]) 90 | def destroy(self): 91 | """ 92 | Side effects galore 93 | """ 94 | 95 | 96 | How does sqlalchemy-fsm diverge from django-fsm? 97 | ------------------------------------------------ 98 | 99 | * Can't commit data from within transition-decorated functions 100 | 101 | * No pre/post signals 102 | 103 | * Does support arguments to conditions functions 104 | -------------------------------------------------------------------------------- /sqlalchemy_fsm.py: -------------------------------------------------------------------------------- 1 | import collections 2 | from functools import wraps 3 | from sqlalchemy import types as SAtypes 4 | from sqlalchemy import inspect 5 | 6 | class FSMMeta(object): 7 | def __init__(self): 8 | self.transitions = collections.defaultdict() 9 | self.conditions = collections.defaultdict() 10 | 11 | @staticmethod 12 | def _get_state_field(instance): 13 | fsm_fields = [c for c in inspect(type(instance)).columns if isinstance(c.type, FSMField)] 14 | if len(fsm_fields) == 0: 15 | raise TypeError('No FSMField found in model') 16 | if len(fsm_fields) > 1: 17 | raise TypeError('More than one FSMField found in model') 18 | else: 19 | return fsm_fields[0] 20 | 21 | @staticmethod 22 | def current_state(instance): 23 | field_name = FSMMeta._get_state_field(instance).name 24 | return getattr(instance, field_name) 25 | 26 | def has_transition(self, instance): 27 | return self.transitions.has_key(FSMMeta.current_state(instance)) or\ 28 | self.transitions.has_key('*') 29 | 30 | def conditions_met(self, instance, *args, **kwargs): 31 | current_state = FSMMeta.current_state(instance) 32 | next_state = self.transitions.has_key(current_state) and\ 33 | self.transitions[current_state] or self.transitions['*'] 34 | return all(map(lambda f: f(instance, *args, **kwargs), 35 | self.conditions[next_state])) 36 | 37 | def to_next_state(self, instance): 38 | field_name = FSMMeta._get_state_field(instance).name 39 | current_state = getattr(instance, field_name) 40 | next_state = None 41 | try: 42 | next_state = self.transitions[current_state] 43 | except KeyError: 44 | next_state = self.transitions['*'] 45 | setattr(instance, field_name, next_state) 46 | 47 | def transition(source = '*', target = None, conditions = ()): 48 | def inner_transition(func): 49 | if not hasattr(func, '_sa_fsm'): 50 | setattr(func, '_sa_fsm', FSMMeta()) 51 | if isinstance(source, collections.Sequence) and not\ 52 | isinstance(source, basestring): 53 | for state in source: 54 | func._sa_fsm.transitions[state] = target 55 | else: 56 | func._sa_fsm.transitions[source] = target 57 | func._sa_fsm.conditions[target] = conditions 58 | 59 | @wraps(func) 60 | def _change_state(instance, *args, **kwargs): 61 | meta = func._sa_fsm 62 | if not meta.has_transition(instance): 63 | raise NotImplementedError('Cant switch from %s using method %s'\ 64 | % (FSMMeta.current_state(instance), func.func_name)) 65 | for condition in conditions: 66 | if not condition(instance, *args, **kwargs): 67 | return False 68 | func(instance, *args, **kwargs) 69 | meta.to_next_state(instance) 70 | return _change_state 71 | if not target: 72 | raise ValueError('Result state not specified') 73 | return inner_transition 74 | 75 | def can_proceed(bound_method, *args, **kwargs): 76 | if not hasattr(bound_method, '_sa_fsm'): 77 | raise NotImplementedError('%s method is not transition' %\ 78 | bound_method.im_func.__name__) 79 | meta = bound_method._sa_fsm 80 | return meta.has_transition(bound_method.im_self) and\ 81 | meta.conditions_met(bound_method.im_self, *args, **kwargs) 82 | 83 | class FSMField(SAtypes.String): 84 | pass 85 | 86 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | import unittest, sqlalchemy 2 | from sqlalchemy.ext.declarative import declarative_base 3 | from sqlalchemy.orm import sessionmaker 4 | from sqlalchemy_fsm import FSMField, transition, can_proceed 5 | 6 | engine = sqlalchemy.create_engine('sqlite:///:memory:', echo = True) 7 | session = sessionmaker(bind = engine) 8 | Base = declarative_base() 9 | 10 | class BlogPost(Base): 11 | __tablename__ = 'blogpost' 12 | id = sqlalchemy.Column(sqlalchemy.Integer, primary_key = True) 13 | state = sqlalchemy.Column(FSMField) 14 | 15 | def __init__(self, *args, **kwargs): 16 | self.state = 'new' 17 | super(BlogPost, self).__init__(*args, **kwargs) 18 | 19 | @transition(source='new', target='published') 20 | def publish(self): 21 | pass 22 | 23 | @transition(source='published', target='hidden') 24 | def hide(self): 25 | pass 26 | 27 | @transition(source='new', target='removed') 28 | def remove(self): 29 | raise Exception('No rights to delete %s' % self) 30 | 31 | @transition(source=['published','hidden'], target='stolen') 32 | def steal(self): 33 | pass 34 | 35 | @transition(source='*', target='moderated') 36 | def moderate(self): 37 | pass 38 | 39 | class FSMFieldTest(unittest.TestCase): 40 | def setUp(self): 41 | self.model = BlogPost() 42 | 43 | def test_initial_state_instatiated(self): 44 | self.assertEqual(self.model.state, 'new') 45 | 46 | def test_known_transition_should_succeed(self): 47 | self.assertTrue(can_proceed(self.model.publish)) 48 | self.model.publish() 49 | self.assertEqual(self.model.state, 'published') 50 | 51 | self.assertTrue(can_proceed(self.model.hide)) 52 | self.model.hide() 53 | self.assertEqual(self.model.state, 'hidden') 54 | 55 | def test_unknow_transition_fails(self): 56 | self.assertFalse(can_proceed(self.model.hide)) 57 | self.assertRaises(NotImplementedError, self.model.hide) 58 | 59 | def test_state_non_changed_after_fail(self): 60 | self.assertRaises(Exception, self.model.remove) 61 | self.assertTrue(can_proceed(self.model.remove)) 62 | self.assertEqual(self.model.state, 'new') 63 | 64 | def test_mutiple_source_support_path_1_works(self): 65 | self.model.publish() 66 | self.model.steal() 67 | self.assertEqual(self.model.state, 'stolen') 68 | 69 | def test_mutiple_source_support_path_2_works(self): 70 | self.model.publish() 71 | self.model.hide() 72 | self.model.steal() 73 | self.assertEqual(self.model.state, 'stolen') 74 | 75 | def test_star_shortcut_succeed(self): 76 | self.assertTrue(can_proceed(self.model.moderate)) 77 | self.model.moderate() 78 | self.assertEqual(self.model.state, 'moderated') 79 | 80 | 81 | class InvalidModel(Base): 82 | __tablename__ = 'invalidmodel' 83 | id = sqlalchemy.Column(sqlalchemy.Integer, primary_key = True) 84 | state = sqlalchemy.Column(FSMField) 85 | action = sqlalchemy.Column(FSMField) 86 | 87 | def __init__(self, *args, **kwargs): 88 | self.state = 'new' 89 | self.action = 'no' 90 | super(InvalidModel, self).__init__(*args, **kwargs) 91 | 92 | @transition(source='new', target='no') 93 | def validate(self): 94 | pass 95 | 96 | class InvalidModelTest(unittest.TestCase): 97 | def test_two_fsmfields_in_one_model_not_allowed(self): 98 | model = InvalidModel() 99 | self.assertRaises(TypeError, model.validate) 100 | 101 | 102 | class Document(Base): 103 | __tablename__ = 'document' 104 | id = sqlalchemy.Column(sqlalchemy.Integer, primary_key = True) 105 | status = sqlalchemy.Column(FSMField) 106 | 107 | def __init__(self, *args, **kwargs): 108 | self.status = 'new' 109 | super(Document, self).__init__(*args, **kwargs) 110 | 111 | @transition(source='new', target='published') 112 | def publish(self): 113 | pass 114 | 115 | 116 | class DocumentTest(unittest.TestCase): 117 | def test_any_state_field_name_allowed(self): 118 | model = Document() 119 | model.publish() 120 | self.assertEqual(model.status, 'published') 121 | 122 | def condition_func(instance): 123 | return True 124 | 125 | 126 | class BlogPostWithConditions(Base): 127 | __tablename__ = 'BlogPostWithConditions' 128 | id = sqlalchemy.Column(sqlalchemy.Integer, primary_key = True) 129 | state = sqlalchemy.Column(FSMField) 130 | 131 | def __init__(self, *args, **kwargs): 132 | self.state = 'new' 133 | super(BlogPostWithConditions, self).__init__(*args, **kwargs) 134 | 135 | def model_condition(self): 136 | return True 137 | 138 | def unmet_condition(self): 139 | return False 140 | 141 | @transition(source='new', target='published', conditions=[condition_func, model_condition]) 142 | def publish(self): 143 | pass 144 | 145 | @transition(source='published', target='destroyed', conditions=[condition_func, unmet_condition]) 146 | def destroy(self): 147 | pass 148 | 149 | 150 | class ConditionalTest(unittest.TestCase): 151 | def setUp(self): 152 | self.model = BlogPostWithConditions() 153 | 154 | def test_initial_staet(self): 155 | self.assertEqual(self.model.state, 'new') 156 | 157 | def test_known_transition_should_succeed(self): 158 | self.assertTrue(can_proceed(self.model.publish)) 159 | self.model.publish() 160 | self.assertEqual(self.model.state, 'published') 161 | 162 | def test_unmet_condition(self): 163 | self.model.publish() 164 | self.assertEqual(self.model.state, 'published') 165 | self.assertFalse(can_proceed(self.model.destroy)) 166 | self.model.destroy() 167 | self.assertEqual(self.model.state, 'published') 168 | 169 | if __name__ == '__main__': 170 | unittest.main() 171 | --------------------------------------------------------------------------------