From fecebd1be3254161c1816fc68bda60e4e8d906e6 Mon Sep 17 00:00:00 2001 From: Ilja Orlovs Date: Wed, 31 Jan 2018 17:08:52 +0000 Subject: [PATCH 1/2] Objective transation API model --- .travis.yml | 9 +++ README.md | 36 +++++----- setup.py | 6 +- sqlalchemy_fsm/__init__.py | 5 -- sqlalchemy_fsm/bound.py | 114 ++++++++++++++++++++++--------- sqlalchemy_fsm/func.py | 20 ------ sqlalchemy_fsm/meta.py | 13 ++-- sqlalchemy_fsm/transition.py | 112 +++++++++++++++++++++--------- sqlalchemy_fsm/util.py | 17 ----- tests/test_basic.py | 77 +++++++++++---------- tests/test_conditionals.py | 16 ++--- tests/test_events.py | 24 +++---- tests/test_invalids.py | 20 +++--- tests/test_multi_source.py | 40 +++++------ tests/test_transition_classes.py | 26 +++---- 15 files changed, 299 insertions(+), 236 deletions(-) delete mode 100644 sqlalchemy_fsm/func.py diff --git a/.travis.yml b/.travis.yml index 68aa210c..aaa77829 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,8 +2,17 @@ language: python python: - "2.7" - "3.6" +env: + - USE_MIN_PACKAGE_VERSIONS=no + - USE_MIN_PACKAGE_VERSIONS=yes install: - pip install -r requirements/develop.txt + - | + if [ "${USE_MIN_PACKAGE_VERSIONS}" == "yes" ]; + then + pip install "SQLAlchemy==1.0.0" "six==1.10.0"; + fi + - pip freeze # Print versions of all installed packages for logging purposes script: - py.test after_success: diff --git a/README.md b/README.md index 33f81985..a0071336 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ [![Build Status](https://travis-ci.org/VRGhost/sqlalchemy-fsm.svg?branch=master)](https://travis-ci.org/VRGhost/sqlalchemy-fsm) [![Coverage Status](https://coveralls.io/repos/github/VRGhost/sqlalchemy-fsm/badge.svg?branch=master)](https://coveralls.io/github/VRGhost/sqlalchemy-fsm?branch=master) -Finite state machine field for sqlalchemy (based on django-fsm) +Finite state machine field for sqlalchemy ============================================================== sqlalchemy-fsm adds declarative states management for sqlalchemy models. @@ -26,7 +26,7 @@ Add FSMState field to you model Use the `transition` decorator to annotate model methods @transition(source='new', target='published') - def publish(self): + def published(self): """ This function may contain side-effects, like updating caches, notifying users, etc. @@ -36,6 +36,12 @@ Use the `transition` decorator to annotate model methods `source` parameter accepts a list of states, or an individual state. You can use `*` for source, to allow switching to `target` from any state. +`@transition`- annotated methods have the following API: +1. `.method()` - returns an SqlAlchemy filter condition that can be used for querying the database (e.g. `session.query(BlogPost).filter(BlogPost.published())`) +1. `.method()` - returns boolean value that tells if this particular record is in the target state for that method() (e.g. `if not blog.published():`) +1. `.method.set(*args, **kwargs)` - changes the state of the record object to the transitions' target state (or raises an exception if it is not able to do so) +1. `.method.can_proceed(*args, **kwargs)` - returns `True` if calling `.method.set(*args, **kwargs)` (with same `*args, **kwargs`) should succeed. + You can also use `None` as source state for (e.g. in case when the state column in nullable). However, it is _not possible_ to create transition with `None` as target state due to religious reasons. @@ -56,14 +62,14 @@ for same target state. class BlogPost(db.Model): ... - publish = PublishHandler + published = PublishHandler -The transition is still to be invoked by calling the model's publish() method. +The transition is still to be invoked by calling the model's `published.set()` method. An alternative inline class syntax is supported too: @transition(target='published') - class publish(object): + class published(object): @transition(source='new') def do_one(self, instance, value): @@ -73,34 +79,32 @@ An alternative inline class syntax is supported too: def do_two(self, instance, value): instance.side_effect = "published from draft" -If calling publish() succeeds without raising an exception, the state field +If calling `published.set()` succeeds without raising an exception, the state field will be changed, but not written to the database. - from sqlalchemy_fsm import can_proceed - def publish_view(request, post_id): post = get_object__or_404(BlogPost, pk=post_id) - if not can_proceed(post.publish): + if not post.published.can_proceed(): raise Http404; - post.publish() + post.published.set() post.save() return redirect('/') If your given function requires arguments to validate, you need to include them -when calling can_proceed as well as including them when you call the function -normally. Say publish() required a date for some reason: +when calling `can_proceed` as well as including them when you call the function +normally. Say `publish.set()` required a date for some reason: - if not can_proceed(post.publish, the_date): + if not post.published.can_proceed(the_date): raise Http404 else: post.publish(the_date) -If your code needs to know the state model is currently in, you can call -the is_current() function. +If your code needs to know the state model is currently in, you can just call +the main function function. - if is_current(post.delete): + if post.deleted(): raise Http404 If you require some conditions to be met before changing state, use the diff --git a/setup.py b/setup.py index a1632a11..58237dc3 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ def get_readme(): py_modules=['sqlalchemy_fsm'], description='Finite state machine field for sqlalchemy', long_description=get_readme(), - author='Peter & Ilja', + author='Ilja & Peter', author_email='ilja@wise.fish', license='MIT', classifiers=[ @@ -41,8 +41,8 @@ def get_readme(): version='1.1.7', url='https://github.com/VRGhost/sqlalchemy-fsm', install_requires=[ - 'SQLAlchemy>=1.1.5', - 'six>=1.0.0', + 'SQLAlchemy>=1.0.0', + 'six>=1.10.0', ], setup_requires=['pytest-runner'], tests_require=['pytest'] diff --git a/sqlalchemy_fsm/__init__.py b/sqlalchemy_fsm/__init__.py index 9545187e..537f9841 100644 --- a/sqlalchemy_fsm/__init__.py +++ b/sqlalchemy_fsm/__init__.py @@ -7,9 +7,4 @@ from .transition import transition -from .func import ( - can_proceed, - is_current, -) - __version__ = '1.1.7' \ No newline at end of file diff --git a/sqlalchemy_fsm/bound.py b/sqlalchemy_fsm/bound.py index 281b8eee..27343130 100644 --- a/sqlalchemy_fsm/bound.py +++ b/sqlalchemy_fsm/bound.py @@ -5,23 +5,46 @@ import warnings import inspect as py_inspect -from functools import partial +from sqlalchemy import inspect as sqla_inspect from . import exc, util, meta, events +from .sqltypes import FSMField +class SqlAlchemyHandle(object): -class BoundFSMFunction(object): + table_class = record = fsm_column = dispatch = None - meta = instance = state_field = internal_handler = None + def __init__(self, table_class, table_record_instance=None): + self.table_class = table_class + self.record = table_record_instance + self.fsm_column = self.get_fsm_column(table_class) - def __init__(self, meta, instance, internal_handler): + if table_record_instance: + self.dispatch = events.BoundFSMDispatcher(table_record_instance) + + def get_fsm_column(self, table_class): + fsm_fields = [ + col + for col in sqla_inspect(table_class).columns + if isinstance(col.type, FSMField) + ] + + if len(fsm_fields) == 0: + raise exc.SetupError('No FSMField found in model') + elif len(fsm_fields) > 1: + raise exc.SetupError( + 'More than one FSMField found in model ({})'.format(fsm_fields)) + return fsm_fields[0] + + +class BoundFSMBase(object): + + meta = sqla_handle = None + + def __init__(self, meta, sqla_handle): self.meta = meta - self.instance = instance - self.internal_handler = internal_handler - # Get the state field - self.state_field = util.get_fsm_column(type(instance)) - self.dispatch = events.BoundFSMDispatcher(instance) + self.sqla_handle = sqla_handle @property def target_state(self): @@ -29,7 +52,7 @@ def target_state(self): @property def current_state(self): - return getattr(self.instance, self.state_field.name) + return getattr(self.sqla_handle.record, self.sqla_handle.fsm_column.name) def transition_possible(self): return ( @@ -38,8 +61,17 @@ def transition_possible(self): '*' in self.meta.sources ) +class BoundFSMFunction(BoundFSMBase): + + set_func = None + + def __init__(self, meta, sqla_handle, set_func): + super(BoundFSMFunction, self).__init__(meta, sqla_handle) + self.set_func = set_func + + def conditions_met(self, args, kwargs): - args = self.meta.extra_call_args + (self.instance, ) + tuple(args) + args = self.meta.extra_call_args + (self.sqla_handle.record, ) + tuple(args) kwargs = dict(kwargs) out = True @@ -58,7 +90,7 @@ def conditions_met(self, args, kwargs): if out: # Check that the function itself can be called with these args try: - py_inspect.getcallargs(self.internal_handler, *args, **kwargs) + py_inspect.getcallargs(self.set_func, *args, **kwargs) except TypeError as err: warnings.warn( "Failure to validate handler call args: {}".format(err)) @@ -68,7 +100,7 @@ def conditions_met(self, args, kwargs): raise exc.SetupError( "Mismatch beteen args accepted by preconditons " "({!r}) & handler ({!r})".format( - self.meta.conditions, self.internal_handler + self.meta.conditions, self.set_func ) ) return out @@ -77,34 +109,39 @@ def to_next_state(self, args, kwargs): old_state = self.current_state new_state = self.target_state - args = self.meta.extra_call_args + (self.instance, ) + tuple(args) + sqla_target = self.sqla_handle.record - self.dispatch.before_state_change( + args = self.meta.extra_call_args + (sqla_target, ) + tuple(args) + + self.sqla_handle.dispatch.before_state_change( source=old_state, target=new_state ) - self.internal_handler(*args, **kwargs) - setattr(self.instance, self.state_field.name, new_state) - self.dispatch.after_state_change( + self.set_func(*args, **kwargs) + setattr( + sqla_target, + self.sqla_handle.fsm_column.name, + new_state + ) + self.sqla_handle.dispatch.after_state_change( source=old_state, target=new_state ) def __repr__(self): return "<{} meta={!r} instance={!r}>".format( - self.__class__.__name__, self.meta, self.instance) + self.__class__.__name__, self.meta, self.sqla_handle) -class BoundFSMObject(BoundFSMFunction): +class BoundFSMObject(BoundFSMBase): - def __init__(self, *args, **kwargs): - super(BoundFSMObject, self).__init__(*args, **kwargs) + def __init__(self, meta, sqlalchemy_handle, child_object): + super(BoundFSMObject, self).__init__(meta, sqlalchemy_handle) # Collect sub-handlers sub_handlers = [] - sub_instance = self.internal_handler - for name in dir(sub_instance): + for name in dir(child_object): try: - attr = getattr(sub_instance, name) - meta = attr._sa_fsm + attr = getattr(child_object, name) + meta = attr._sa_fsm_bound_meta except AttributeError: # Skip non-fsm methods continue @@ -143,7 +180,7 @@ def to_next_state(self, args, kwargs): return can_transition_with[0].to_next_state(args, kwargs) def mk_restricted_bound_sub_metas(self): - instance = self.instance + sqla_handle = self.sqla_handle my_sources = self.meta.sources my_target = self.meta.target my_conditions = self.meta.conditions @@ -169,8 +206,9 @@ def target_intersection(sub_meta_target): out = [] for sub_handler in self.sub_handlers: - handler_self = sub_handler.__self__ - sub_meta = sub_handler._sa_fsm + handler_fn = sub_handler._sa_fsm_transition_fn + handler_self = sub_handler._sa_fsm_self + sub_meta = sub_handler._sa_fsm_meta sub_sources = source_intersection(sub_meta.sources) if not sub_sources: @@ -192,16 +230,24 @@ def target_intersection(sub_meta_target): sub_args = (handler_self, ) + my_args + sub_meta.extra_call_args sub_meta = meta.FSMMeta( - sub_meta.payload, sub_sources, sub_target, + sub_sources, sub_target, sub_conditions, sub_args, sub_meta.bound_cls ) - out.append(sub_meta.get_bound(instance)) + out.append(sub_meta.get_bound(sqla_handle, handler_fn)) return out class BoundFSMClass(BoundFSMObject): - def __init__(self, meta, instance, internal_handler): - bound_object = internal_handler() - super(BoundFSMClass, self).__init__(meta, instance, bound_object) + def __init__(self, meta, sqlalchemy_handle, child_cls): + child_cls_with_bound_sqla = type( + '{}::sqlalchemy_handle::{}'.format(child_cls.__name__, id(sqlalchemy_handle)), + (child_cls, ), + { + '_sa_fsm_sqlalchemy_handle': sqlalchemy_handle, + } + ) + + bound_object = child_cls_with_bound_sqla() + super(BoundFSMClass, self).__init__(meta, sqlalchemy_handle, bound_object) diff --git a/sqlalchemy_fsm/func.py b/sqlalchemy_fsm/func.py deleted file mode 100644 index 53aafa94..00000000 --- a/sqlalchemy_fsm/func.py +++ /dev/null @@ -1,20 +0,0 @@ -"""API functions.""" - - -def _get_bound_meta(bound_method): - try: - meta = bound_method._sa_fsm - except AttributeError: - raise NotImplementedError('This is not transition handler') - return meta.get_bound(bound_method.__self__) - - -def can_proceed(bound_method, *args, **kwargs): - bound_meta = _get_bound_meta(bound_method) - return bound_meta.transition_possible() and bound_meta.conditions_met( - args, kwargs) - - -def is_current(bound_method): - bound_meta = _get_bound_meta(bound_method) - return bound_meta.target_state == bound_meta.current_state diff --git a/sqlalchemy_fsm/meta.py b/sqlalchemy_fsm/meta.py index a7f2a8af..94b24803 100644 --- a/sqlalchemy_fsm/meta.py +++ b/sqlalchemy_fsm/meta.py @@ -7,15 +7,14 @@ class FSMMeta(object): - payload = transitions = conditions = sources = bound_cls = None + transitions = conditions = sources = bound_cls = None extra_call_args = () def __init__( - self, payload, source, target, + self, source, target, conditions, extra_args, bound_cls ): self.bound_cls = bound_cls - self.payload = payload self.conditions = tuple(conditions) self.extra_call_args = tuple(extra_args) @@ -41,12 +40,12 @@ def __init__( self.sources = frozenset(all_sources) - def get_bound(self, instance): - return self.bound_cls(self, instance, self.payload) + def get_bound(self, sqlalchemy_handle, set_func): + return self.bound_cls(self, sqlalchemy_handle, set_func) def __repr__(self): return "<{} sources={!r} target={!r} conditions={!r} " \ - "extra call args={!r} payload={!r}>".format( + "extra call args={!r}>".format( self.__class__.__name__, self.sources, self.target, - self.conditions, self.extra_call_args, self.payload, + self.conditions, self.extra_call_args, ) diff --git a/sqlalchemy_fsm/transition.py b/sqlalchemy_fsm/transition.py index b183cd42..748a7768 100644 --- a/sqlalchemy_fsm/transition.py +++ b/sqlalchemy_fsm/transition.py @@ -2,49 +2,97 @@ import inspect as py_inspect from functools import wraps -from sqlalchemy.ext.hybrid import hybrid_method + +from sqlalchemy.orm.interfaces import InspectionAttrInfo +from sqlalchemy.ext.hybrid import HYBRID_METHOD from . import bound, util, exc from .meta import FSMMeta +class ClassBoundFsmTransition(object): + + def __init__(self, meta, sqla_handle, ownerCls): + self._sa_fsm_meta = meta + self._sa_fsm_owner_cls = ownerCls + self._sa_fsm_sqla_handle = sqla_handle -def transition(source='*', target=None, conditions=()): + def __call__(self): + """Return a SQLAlchemy filter for this particular state.""" + column = self._sa_fsm_sqla_handle.fsm_column + target = self._sa_fsm_meta.target + assert target, "Target must be defined at this level." + return column == target - def inner_transition(func): +class InstanceBoundFsmTransition(object): - if py_inspect.isfunction(func): - meta = FSMMeta( - func, source, target, conditions, (), bound.BoundFSMFunction) - elif py_inspect.isclass(func): - # Assume a class with multiple handles for various source states - meta = FSMMeta( - func, source, target, conditions, (), bound.BoundFSMClass) - else: - raise NotImplementedError("Do not know how to {!r}".format(func)) - - @wraps(func, updated=()) - def _change_fsm_state(instance, *args, **kwargs): - bound_meta = _change_fsm_state._sa_fsm.get_bound(instance) - if not bound_meta.transition_possible(): - raise exc.InvalidSourceStateError( - 'Unable to switch from {} using method {}'.format( - bound_meta.current_state, func.__name__ - ) + def __init__(self, meta, sqla_handle, transition_fn, ownerCls, instance): + self._sa_fsm_meta = meta + self._sa_fsm_transition_fn = transition_fn + self._sa_fsm_owner_cls = ownerCls + self._sa_fsm_self = instance + self._sa_fsm_bound_meta = meta.get_bound(sqla_handle, transition_fn) + + def __call__(self, *args, **kwargs): + """Check if this is the current state of the object.""" + bound_meta = self._sa_fsm_bound_meta + return bound_meta.target_state == bound_meta.current_state + + def set(self, *args, **kwargs): + """Transition the FSM to this new state.""" + bound_meta = self._sa_fsm_bound_meta + func = self._sa_fsm_transition_fn + + if not bound_meta.transition_possible(): + raise exc.InvalidSourceStateError( + 'Unable to switch from {} using method {}'.format( + bound_meta.current_state, func.__name__ ) - if not bound_meta.conditions_met(args, kwargs): - raise exc.PreconditionError("Preconditions are not satisfied.") - return bound_meta.to_next_state(args, kwargs) + ) + if not bound_meta.conditions_met(args, kwargs): + raise exc.PreconditionError("Preconditions are not satisfied.") + return bound_meta.to_next_state(args, kwargs) + + + def can_proceed(self, *args, **kwargs): + bound_meta = self._sa_fsm_bound_meta + return bound_meta.transition_possible() and bound_meta.conditions_met( + args, kwargs) + - def _query_fsm_state(cls): - column = util.get_fsm_column(cls) - target = _change_fsm_state._sa_fsm.target - assert target, "Target must be defined at this level." - return column == target +class FsmTransition(InspectionAttrInfo): - _change_fsm_state.__name__ = "fsm::{}".format(func.__name__) + is_attribute = True + extension_type = HYBRID_METHOD - _change_fsm_state._sa_fsm = meta + def __init__(self, meta, set_function): + self.meta = meta + self.set_fn = set_function + + def __get__(self, instance, owner): + try: + sql_alchemy_handle = owner._sa_fsm_sqlalchemy_handle + except AttributeError: + # Owner class is not bound to sqlalchemy handle object + sql_alchemy_handle = bound.SqlAlchemyHandle(owner, instance) + + if instance is None: + return ClassBoundFsmTransition(self.meta, sql_alchemy_handle, owner) + else: + return InstanceBoundFsmTransition( + self.meta, sql_alchemy_handle, self.set_fn, owner, instance) + +def transition(source='*', target=None, conditions=()): + + def inner_transition(subject): + + if py_inspect.isfunction(subject): + meta = FSMMeta(source, target, conditions, (), bound.BoundFSMFunction) + elif py_inspect.isclass(subject): + # Assume a class with multiple handles for various source states + meta = FSMMeta(source, target, conditions, (), bound.BoundFSMClass) + else: + raise NotImplementedError("Do not know how to {!r}".format(subject)) - return hybrid_method(_change_fsm_state, _query_fsm_state) + return FsmTransition(meta, subject) return inner_transition diff --git a/sqlalchemy_fsm/util.py b/sqlalchemy_fsm/util.py index 0c23242f..b4f2ab9c 100644 --- a/sqlalchemy_fsm/util.py +++ b/sqlalchemy_fsm/util.py @@ -1,10 +1,7 @@ """Utility functions and consts.""" from six import string_types -from sqlalchemy import inspect - from . import exc -from .sqltypes import FSMField def is_valid_fsm_state(value): @@ -19,17 +16,3 @@ def is_valid_source_state(value): """ return (value == '*') or (value is None) or is_valid_fsm_state(value) - -def get_fsm_column(table_class): - fsm_fields = [ - col - for col in inspect(table_class).columns - if isinstance(col.type, FSMField) - ] - - if len(fsm_fields) == 0: - raise exc.SetupError('No FSMField found in model') - elif len(fsm_fields) > 1: - raise exc.SetupError( - 'More than one FSMField found in model ({})'.format(fsm_fields)) - return fsm_fields[0] diff --git a/tests/test_basic.py b/tests/test_basic.py index f89a5e67..9a46618e 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,7 +1,7 @@ import pytest import sqlalchemy -from sqlalchemy_fsm import FSMField, transition, can_proceed, is_current +from sqlalchemy_fsm import FSMField, transition from sqlalchemy_fsm.exc import SetupError, PreconditionError, InvalidSourceStateError from tests.conftest import Base @@ -16,23 +16,23 @@ def __init__(self, *args, **kwargs): super(BlogPost, self).__init__(*args, **kwargs) @transition(source='new', target='published') - def publish(self): + def published(self): pass @transition(source='published', target='hidden') - def hide(self): + def hidden(self): pass @transition(source='new', target='removed') - def remove(self): + def removed(self): raise Exception('No rights to delete %s' % self) @transition(source=['published','hidden'], target='stolen') - def steal(self): + def stolen(self): pass @transition(source='*', target='moderated') - def moderate(self): + def moderated(self): pass class TestFSMField(object): @@ -45,45 +45,48 @@ def test_initial_state_instatiated(self, model): assert model.state == 'new' def test_meta_attached(self, model): - assert model.publish._sa_fsm - assert 'FSMMeta' in repr(model.publish._sa_fsm) + assert model.published._sa_fsm_meta + assert 'FSMMeta' in repr(model.published._sa_fsm_meta) def test_known_transition_should_succeed(self, model): - assert can_proceed(model.publish) - model.publish() + assert not model.published() # Model is not publish-ed yet + assert model.published.can_proceed() + model.published.set() assert model.state == 'published' + # model is publish-ed now + assert model.published() - assert can_proceed(model.hide) - model.hide() + assert model.hidden.can_proceed() + model.hidden.set() assert model.state == 'hidden' def test_unknow_transition_fails(self, model): - assert not can_proceed(model.hide) + assert not model.hidden.can_proceed() with pytest.raises(NotImplementedError) as err: - model.hide() + model.hidden.set() assert 'Unable to switch from' in str(err) def test_state_non_changed_after_fail(self, model): with pytest.raises(Exception) as err: - model.remove() + model.removed.set() assert 'No rights to delete' in str(err) - assert can_proceed(model.remove) + assert model.removed.can_proceed() assert model.state == 'new' def test_mutiple_source_support_path_1_works(self, model): - model.publish() - model.steal() + model.published.set() + model.stolen.set() assert model.state == 'stolen' def test_mutiple_source_support_path_2_works(self, model): - model.publish() - model.hide() - model.steal() + model.published.set() + model.hidden.set() + model.stolen.set() assert model.state == 'stolen' def test_star_shortcut_succeed(self, model): - assert can_proceed(model.moderate) - model.moderate() + assert model.moderated.can_proceed() + model.moderated.set() assert model.state == 'moderated' @@ -92,8 +95,8 @@ def test_query_filter(self, session): model2 = BlogPost() model3 = BlogPost() model4 = BlogPost() - model3.publish() - model4.publish() + model3.published.set() + model4.published.set() session.add_all([model1, model2, model3, model4]) session.commit() @@ -102,7 +105,7 @@ def test_query_filter(self, session): # Check that one can query by fsm handler query_results = session.query(BlogPost).filter( - BlogPost.publish(), + BlogPost.published(), BlogPost.id.in_(ids), ).all() assert len(query_results) == 2, query_results @@ -110,7 +113,7 @@ def test_query_filter(self, session): assert model4 in query_results negated_query_results = session.query(BlogPost).filter( - ~BlogPost.publish(), + ~BlogPost.published(), BlogPost.id.in_(ids), ).all() assert len(negated_query_results) == 2, query_results @@ -130,14 +133,14 @@ def __init__(self, *args, **kwargs): super(InvalidModel, self).__init__(*args, **kwargs) @transition(source='new', target='no') - def validate(self): + def validated(self): pass class TestInvalidModel(object): def test_two_fsmfields_in_one_model_not_allowed(self): model = InvalidModel() with pytest.raises(SetupError) as err: - model.validate() + model.validated() assert 'More than one FSMField found' in str(err) @@ -151,14 +154,14 @@ def __init__(self, *args, **kwargs): super(Document, self).__init__(*args, **kwargs) @transition(source='new', target='published') - def publish(self): + def published(self): pass class TestDocument(object): def test_any_state_field_name_allowed(self): model = Document() - model.publish() + model.published.set() assert model.status == 'published' @@ -192,21 +195,21 @@ def model(self): def test_null_to_end(self, model): assert model.status is None - model.endFromAll() + model.endFromAll.set() assert model.status == 'end' def test_null_pub_end(self, model): assert model.status is None - model.pubFromNone() + model.pubFromNone.set() assert model.status == 'published' - model.endFromAll() + model.endFromAll.set() assert model.status == 'end' def test_null_new_pub_end(self, model): assert model.status is None - model.newFromNone() + model.newFromNone.set() assert model.status == 'new' - model.pubFromEither() + model.pubFromEither.set() assert model.status == 'published' - model.endFromAll() + model.endFromAll.set() assert model.status == 'end' \ No newline at end of file diff --git a/tests/test_conditionals.py b/tests/test_conditionals.py index 97a40625..3a52fd66 100644 --- a/tests/test_conditionals.py +++ b/tests/test_conditionals.py @@ -2,7 +2,7 @@ import sqlalchemy -from sqlalchemy_fsm import FSMField, transition, can_proceed, is_current +from sqlalchemy_fsm import FSMField, transition from sqlalchemy_fsm.exc import SetupError, PreconditionError, InvalidSourceStateError from tests.conftest import Base @@ -27,11 +27,11 @@ def unmet_condition(self): return False @transition(source='new', target='published', conditions=[condition_func, model_condition]) - def publish(self): + def published(self): pass @transition(source='published', target='destroyed', conditions=[condition_func, unmet_condition]) - def destroy(self): + def destroyed(self): pass @@ -43,13 +43,13 @@ def test_initial_staet(self): self.assertEqual(self.model.state, 'new') def test_known_transition_should_succeed(self): - self.assertTrue(can_proceed(self.model.publish)) - self.model.publish() + self.assertTrue(self.model.published.can_proceed()) + self.model.published.set() self.assertEqual(self.model.state, 'published') def test_unmet_condition(self): - self.model.publish() + self.model.published.set() self.assertEqual(self.model.state, 'published') - self.assertFalse(can_proceed(self.model.destroy)) - self.assertRaises(PreconditionError, self.model.destroy) + self.assertFalse(self.model.destroyed.can_proceed()) + self.assertRaises(PreconditionError, self.model.destroyed.set) self.assertEqual(self.model.state, 'published') \ No newline at end of file diff --git a/tests/test_events.py b/tests/test_events.py index a21ce0aa..53389ba2 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -53,14 +53,14 @@ def on_update(instance, source, target): handle = model.stateA else: handle = model.stateB - handle() + handle.set() assert listener_result == expected_result # Remove the listener & check that it had an effect sqlalchemy.event.remove(EventModel, event_name, on_update) # Call the state handle & ensure that listener had not been called. - model.stateA() + model.stateA.set() assert listener_result == expected_result def test_standard_sqlalchemy_events_still_work(self, model, session): @@ -78,11 +78,11 @@ def before_insert(mapper, connection, target): assert not state_log assert not insert_log - model.stateA() + model.stateA.set() assert len(state_log) == 1 assert len(insert_log) == 0 - model.stateB() + model.stateB.set() assert len(state_log) == 2 assert len(insert_log) == 0 @@ -92,7 +92,7 @@ def before_insert(mapper, connection, target): assert len(state_log) == 2 assert len(insert_log) == 1 - model.stateB() + model.stateB.set() assert len(state_log) == 3 assert len(insert_log) == 1 @@ -154,9 +154,9 @@ def on_update(instance, source, target): handle = model.stateA else: handle = model.stateB - handle() + handle.set() assert listener_result == expected_result - model.stateClass() + model.stateClass.set() if handle_name == 'state_a': expected_side = 'from_a' @@ -172,7 +172,7 @@ def on_update(instance, source, target): # Remove the listener & check that it had an effect sqlalchemy.event.remove(TransitionClassEventModel, event_name, on_update) # Call the state handle & ensure that listener had not been called. - model.stateA() + model.stateA.set() assert listener_result == expected_result class TestEventsLeakage(object): @@ -208,22 +208,22 @@ def on_both_update(instance, source, target): assert len(tr_cls_result) == 0 assert len(joint_result) == 0 - event_model.stateA() + event_model.stateA.set() assert len(event_result) == 1 assert len(tr_cls_result) == 0 assert len(joint_result) == 1 - event_model.stateB() + event_model.stateB.set() assert len(event_result) == 2 assert len(tr_cls_result) == 0 assert len(joint_result) == 2 - tr_cls_model.stateA() + tr_cls_model.stateA.set() assert len(event_result) == 2 assert len(tr_cls_result) == 1 assert len(joint_result) == 3 - tr_cls_model.stateA() + tr_cls_model.stateA.set() assert len(event_result) == 2 assert len(tr_cls_result) == 2 assert len(joint_result) == 4 \ No newline at end of file diff --git a/tests/test_invalids.py b/tests/test_invalids.py index 686af4d1..6dc0d220 100644 --- a/tests/test_invalids.py +++ b/tests/test_invalids.py @@ -1,7 +1,7 @@ import pytest import sqlalchemy -from sqlalchemy_fsm import FSMField, transition, exc, can_proceed, is_current +from sqlalchemy_fsm import FSMField, transition, exc from tests.conftest import Base @@ -24,13 +24,9 @@ def test_not_fsm(): assert 'No FSMField found in model' in str(err) def test_not_transition(): - with pytest.raises(NotImplementedError) as err: - can_proceed(NotFsm.not_transition) - assert 'This is not transition handler' in str(err) + with pytest.raises(AttributeError): + NotFsm.not_transition.can_proceed() - with pytest.raises(NotImplementedError) as err: - is_current(NotFsm.not_transition) - assert 'This is not transition handler' in str(err) class TooMuchFsm(Base): __tablename__ = 'TooMuchFsm' @@ -156,23 +152,23 @@ def model(self): def test_misconfigured_transitions(self, model): with pytest.raises(exc.SetupError) as err: with pytest.warns(UserWarning): - model.change_state(42) + model.change_state.set(42) assert 'Mismatch beteen args accepted' in str(err) def test_multi_transition_handlers(self, model): with pytest.raises(exc.SetupError) as err: - model.multi_handler_transition() + model.multi_handler_transition.set() assert "Can transition with multiple handlers" in str(err) def test_incompatible_targets(self, model): with pytest.raises(exc.SetupError) as err: - model.incompatible_targets() + model.incompatible_targets.set() assert 'are not compatable' in str(err) def test_incompatable_sources(self, model): with pytest.raises(exc.SetupError) as err: - model.incompatible_sources() + model.incompatible_sources.set() assert 'are not compatable' in str(err) def test_no_conflict_due_to_precondition_arg_count(self, model): - assert can_proceed(model.no_conflict_due_to_precondition_arg_count) \ No newline at end of file + assert model.no_conflict_due_to_precondition_arg_count.can_proceed() \ No newline at end of file diff --git a/tests/test_multi_source.py b/tests/test_multi_source.py index 5a2416ab..281fe243 100644 --- a/tests/test_multi_source.py +++ b/tests/test_multi_source.py @@ -2,7 +2,7 @@ import sqlalchemy -from sqlalchemy_fsm import FSMField, transition, can_proceed, is_current +from sqlalchemy_fsm import FSMField, transition from sqlalchemy_fsm.exc import SetupError, PreconditionError, InvalidSourceStateError from tests.conftest import Base @@ -91,72 +91,72 @@ def model(self): return MultiSourceBlogPost() def test_transition_one(self, model): - assert can_proceed(model.publish, 1) + assert model.publish.can_proceed(1) - model.publish(1) + model.publish.set(1) assert model.state == 'published' assert model.side_effect == 'did_one' def test_transition_two(self, model): - assert can_proceed(model.publish, 2) + assert model.publish.can_proceed(2) - model.publish(2) + model.publish.set(2) assert model.state == 'published' assert model.side_effect == 'did_two' def test_three_arg_transition_mk1(self, model): - assert can_proceed(model.noPreFilterPublish, 1, 2, 3) - model.noPreFilterPublish(1, 2, 3) + assert model.noPreFilterPublish.can_proceed(1, 2, 3) + model.noPreFilterPublish.set(1, 2, 3) assert model.state == 'published' assert model.side_effect == 'did_three_arg_mk1::[1, 2, 3]' def test_three_arg_transition_mk2(self, model): - assert can_proceed(model.noPreFilterPublish, 'str', -1, 42) - model.noPreFilterPublish('str', -1, 42) + assert model.noPreFilterPublish.can_proceed('str', -1, 42) + model.noPreFilterPublish.set('str', -1, 42) assert model.state == 'published' assert model.side_effect == "did_three_arg_mk2::['str', -1, 42]" def unable_to_proceed_with_invalid_kwargs(self, model): - assert not can_proceed(model.noPreFilterPublish, 'str', -1, tomato='potato') + assert not model.noPreFilterPublish.can_proceed('str', -1, tomato='potato') def test_transition_two_incorrect_arg(self, model): # Transition should be rejected because of top-level `val_contains_condition([1,2])` constraint with pytest.raises(PreconditionError) as err: - model.publish(42) + model.publish.set(42) assert 'Preconditions are not satisfied' in str(err) assert model.state == 'new' assert model.side_effect == 'default' # Verify that the exception can still be avoided with can_proceed() call - assert not can_proceed(model.publish, 42) - assert not can_proceed(model.publish, 4242) + assert not model.publish.can_proceed(42) + assert not model.publish.can_proceed(4242) def test_hide(self, model): - model.hide() + model.hide.set() assert model.state == 'hidden' assert model.side_effect == 'did_hide' - model.publish(2) + model.publish.set(2) assert model.state == 'published' assert model.side_effect == 'did_unhide: 2' def test_publish_loop(self, model): - model.publish(1) + model.publish.set(1) assert model.state == 'published' assert model.side_effect == 'did_one' for arg in (1, 2, 1, 1, 2): - model.publish(arg) + model.publish.set(arg) assert model.state == 'published' assert model.side_effect == 'do_publish_loop: {}'.format(arg) def test_delete_new(self, model): - model.delete() + model.delete.set() assert model.state == 'deleted' # Can not switch from deleted to published - assert not can_proceed(model.publish, 2) + assert not model.publish.can_proceed(2) with pytest.raises(InvalidSourceStateError) as err: - model.publish(2) + model.publish.set(2) assert 'Unable to switch' in str(err) assert model.state == 'deleted' \ No newline at end of file diff --git a/tests/test_transition_classes.py b/tests/test_transition_classes.py index 85f84801..7f913d7c 100644 --- a/tests/test_transition_classes.py +++ b/tests/test_transition_classes.py @@ -2,7 +2,7 @@ import sqlalchemy import pytest -from sqlalchemy_fsm import FSMField, transition, can_proceed, is_current +from sqlalchemy_fsm import FSMField, transition from sqlalchemy_fsm.exc import SetupError, PreconditionError, InvalidSourceStateError from tests.conftest import Base @@ -56,29 +56,29 @@ def model(self): return AltSyntaxBlogPost() def test_pre_decorated_publish(self, model): - model.pre_decorated_publish() + model.pre_decorated_publish.set() assert model.state == 'pre_decorated_publish' assert model.side_effect == 'SeparatePublishHandler::did_one' def test_pre_decorated_publish_from_hidden(self, model): - model.hide() + model.hide.set() assert model.state == 'hidden' - assert is_current(model.hide) - assert not is_current(model.pre_decorated_publish) - model.pre_decorated_publish() + assert model.hide() + assert not model.pre_decorated_publish() + model.pre_decorated_publish.set() assert model.state == 'pre_decorated_publish' - assert is_current(model.pre_decorated_publish) + assert model.pre_decorated_publish() assert model.side_effect == 'SeparatePublishHandler::did_two' def test_post_decorated_from_hidden(self, model): - model.post_decorated_publish() + model.post_decorated_publish.set() assert model.state == 'post_decorated_publish' assert model.side_effect == 'SeparatePublishHandler::did_one' def test_post_decorated_publish_from_hidden(self, model): - model.hide() + model.hide.set() assert model.state == 'hidden' - model.post_decorated_publish() + model.post_decorated_publish.set() assert model.state == 'post_decorated_publish' assert model.side_effect == 'SeparatePublishHandler::did_two' @@ -88,9 +88,9 @@ def test_class_query(self, session, model): pre_decorated_published = records[3:5] post_decorated_published = records[-2:] - [el.hide() for el in hidden_records] - [el.pre_decorated_publish() for el in pre_decorated_published] - [el.post_decorated_publish() for el in post_decorated_published] + [el.hide.set() for el in hidden_records] + [el.pre_decorated_publish.set() for el in pre_decorated_published] + [el.post_decorated_publish.set() for el in post_decorated_published] session.add_all(records) session.commit() From 344d08ecd40385f5145457494cc5acc9dc0176c9 Mon Sep 17 00:00:00 2001 From: Ilja Orlovs Date: Wed, 31 Jan 2018 17:11:35 +0000 Subject: [PATCH 2/2] New release on 2018-01-31 17:11:34.862814: 2.0.0 --- .travis.yml | 9 +++ README.md | 36 +++++----- setup.cfg | 2 +- setup.py | 8 +-- sqlalchemy_fsm/__init__.py | 7 +- sqlalchemy_fsm/bound.py | 114 ++++++++++++++++++++++--------- sqlalchemy_fsm/func.py | 20 ------ sqlalchemy_fsm/meta.py | 13 ++-- sqlalchemy_fsm/transition.py | 112 +++++++++++++++++++++--------- sqlalchemy_fsm/util.py | 17 ----- tests/test_basic.py | 77 +++++++++++---------- tests/test_conditionals.py | 16 ++--- tests/test_events.py | 24 +++---- tests/test_invalids.py | 20 +++--- tests/test_multi_source.py | 40 +++++------ tests/test_transition_classes.py | 26 +++---- 16 files changed, 302 insertions(+), 239 deletions(-) delete mode 100644 sqlalchemy_fsm/func.py diff --git a/.travis.yml b/.travis.yml index 68aa210c..aaa77829 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,8 +2,17 @@ language: python python: - "2.7" - "3.6" +env: + - USE_MIN_PACKAGE_VERSIONS=no + - USE_MIN_PACKAGE_VERSIONS=yes install: - pip install -r requirements/develop.txt + - | + if [ "${USE_MIN_PACKAGE_VERSIONS}" == "yes" ]; + then + pip install "SQLAlchemy==1.0.0" "six==1.10.0"; + fi + - pip freeze # Print versions of all installed packages for logging purposes script: - py.test after_success: diff --git a/README.md b/README.md index 33f81985..a0071336 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ [![Build Status](https://travis-ci.org/VRGhost/sqlalchemy-fsm.svg?branch=master)](https://travis-ci.org/VRGhost/sqlalchemy-fsm) [![Coverage Status](https://coveralls.io/repos/github/VRGhost/sqlalchemy-fsm/badge.svg?branch=master)](https://coveralls.io/github/VRGhost/sqlalchemy-fsm?branch=master) -Finite state machine field for sqlalchemy (based on django-fsm) +Finite state machine field for sqlalchemy ============================================================== sqlalchemy-fsm adds declarative states management for sqlalchemy models. @@ -26,7 +26,7 @@ Add FSMState field to you model Use the `transition` decorator to annotate model methods @transition(source='new', target='published') - def publish(self): + def published(self): """ This function may contain side-effects, like updating caches, notifying users, etc. @@ -36,6 +36,12 @@ Use the `transition` decorator to annotate model methods `source` parameter accepts a list of states, or an individual state. You can use `*` for source, to allow switching to `target` from any state. +`@transition`- annotated methods have the following API: +1. `.method()` - returns an SqlAlchemy filter condition that can be used for querying the database (e.g. `session.query(BlogPost).filter(BlogPost.published())`) +1. `.method()` - returns boolean value that tells if this particular record is in the target state for that method() (e.g. `if not blog.published():`) +1. `.method.set(*args, **kwargs)` - changes the state of the record object to the transitions' target state (or raises an exception if it is not able to do so) +1. `.method.can_proceed(*args, **kwargs)` - returns `True` if calling `.method.set(*args, **kwargs)` (with same `*args, **kwargs`) should succeed. + You can also use `None` as source state for (e.g. in case when the state column in nullable). However, it is _not possible_ to create transition with `None` as target state due to religious reasons. @@ -56,14 +62,14 @@ for same target state. class BlogPost(db.Model): ... - publish = PublishHandler + published = PublishHandler -The transition is still to be invoked by calling the model's publish() method. +The transition is still to be invoked by calling the model's `published.set()` method. An alternative inline class syntax is supported too: @transition(target='published') - class publish(object): + class published(object): @transition(source='new') def do_one(self, instance, value): @@ -73,34 +79,32 @@ An alternative inline class syntax is supported too: def do_two(self, instance, value): instance.side_effect = "published from draft" -If calling publish() succeeds without raising an exception, the state field +If calling `published.set()` succeeds without raising an exception, the state field will be changed, but not written to the database. - from sqlalchemy_fsm import can_proceed - def publish_view(request, post_id): post = get_object__or_404(BlogPost, pk=post_id) - if not can_proceed(post.publish): + if not post.published.can_proceed(): raise Http404; - post.publish() + post.published.set() post.save() return redirect('/') If your given function requires arguments to validate, you need to include them -when calling can_proceed as well as including them when you call the function -normally. Say publish() required a date for some reason: +when calling `can_proceed` as well as including them when you call the function +normally. Say `publish.set()` required a date for some reason: - if not can_proceed(post.publish, the_date): + if not post.published.can_proceed(the_date): raise Http404 else: post.publish(the_date) -If your code needs to know the state model is currently in, you can call -the is_current() function. +If your code needs to know the state model is currently in, you can just call +the main function function. - if is_current(post.delete): + if post.deleted(): raise Http404 If you require some conditions to be met before changing state, use the diff --git a/setup.cfg b/setup.cfg index 464f9009..c0a254da 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.1.7 +current_version = 2.0.0 commit = True tag = True diff --git a/setup.py b/setup.py index a1632a11..4e26a9bd 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ def get_readme(): py_modules=['sqlalchemy_fsm'], description='Finite state machine field for sqlalchemy', long_description=get_readme(), - author='Peter & Ilja', + author='Ilja & Peter', author_email='ilja@wise.fish', license='MIT', classifiers=[ @@ -38,11 +38,11 @@ def get_readme(): 'Topic :: Database', ], keywords='sqlalchemy finite state machine fsm', - version='1.1.7', + version='2.0.0', url='https://github.com/VRGhost/sqlalchemy-fsm', install_requires=[ - 'SQLAlchemy>=1.1.5', - 'six>=1.0.0', + 'SQLAlchemy>=1.0.0', + 'six>=1.10.0', ], setup_requires=['pytest-runner'], tests_require=['pytest'] diff --git a/sqlalchemy_fsm/__init__.py b/sqlalchemy_fsm/__init__.py index 9545187e..b5f1309c 100644 --- a/sqlalchemy_fsm/__init__.py +++ b/sqlalchemy_fsm/__init__.py @@ -7,9 +7,4 @@ from .transition import transition -from .func import ( - can_proceed, - is_current, -) - -__version__ = '1.1.7' \ No newline at end of file +__version__ = '2.0.0' \ No newline at end of file diff --git a/sqlalchemy_fsm/bound.py b/sqlalchemy_fsm/bound.py index 281b8eee..27343130 100644 --- a/sqlalchemy_fsm/bound.py +++ b/sqlalchemy_fsm/bound.py @@ -5,23 +5,46 @@ import warnings import inspect as py_inspect -from functools import partial +from sqlalchemy import inspect as sqla_inspect from . import exc, util, meta, events +from .sqltypes import FSMField +class SqlAlchemyHandle(object): -class BoundFSMFunction(object): + table_class = record = fsm_column = dispatch = None - meta = instance = state_field = internal_handler = None + def __init__(self, table_class, table_record_instance=None): + self.table_class = table_class + self.record = table_record_instance + self.fsm_column = self.get_fsm_column(table_class) - def __init__(self, meta, instance, internal_handler): + if table_record_instance: + self.dispatch = events.BoundFSMDispatcher(table_record_instance) + + def get_fsm_column(self, table_class): + fsm_fields = [ + col + for col in sqla_inspect(table_class).columns + if isinstance(col.type, FSMField) + ] + + if len(fsm_fields) == 0: + raise exc.SetupError('No FSMField found in model') + elif len(fsm_fields) > 1: + raise exc.SetupError( + 'More than one FSMField found in model ({})'.format(fsm_fields)) + return fsm_fields[0] + + +class BoundFSMBase(object): + + meta = sqla_handle = None + + def __init__(self, meta, sqla_handle): self.meta = meta - self.instance = instance - self.internal_handler = internal_handler - # Get the state field - self.state_field = util.get_fsm_column(type(instance)) - self.dispatch = events.BoundFSMDispatcher(instance) + self.sqla_handle = sqla_handle @property def target_state(self): @@ -29,7 +52,7 @@ def target_state(self): @property def current_state(self): - return getattr(self.instance, self.state_field.name) + return getattr(self.sqla_handle.record, self.sqla_handle.fsm_column.name) def transition_possible(self): return ( @@ -38,8 +61,17 @@ def transition_possible(self): '*' in self.meta.sources ) +class BoundFSMFunction(BoundFSMBase): + + set_func = None + + def __init__(self, meta, sqla_handle, set_func): + super(BoundFSMFunction, self).__init__(meta, sqla_handle) + self.set_func = set_func + + def conditions_met(self, args, kwargs): - args = self.meta.extra_call_args + (self.instance, ) + tuple(args) + args = self.meta.extra_call_args + (self.sqla_handle.record, ) + tuple(args) kwargs = dict(kwargs) out = True @@ -58,7 +90,7 @@ def conditions_met(self, args, kwargs): if out: # Check that the function itself can be called with these args try: - py_inspect.getcallargs(self.internal_handler, *args, **kwargs) + py_inspect.getcallargs(self.set_func, *args, **kwargs) except TypeError as err: warnings.warn( "Failure to validate handler call args: {}".format(err)) @@ -68,7 +100,7 @@ def conditions_met(self, args, kwargs): raise exc.SetupError( "Mismatch beteen args accepted by preconditons " "({!r}) & handler ({!r})".format( - self.meta.conditions, self.internal_handler + self.meta.conditions, self.set_func ) ) return out @@ -77,34 +109,39 @@ def to_next_state(self, args, kwargs): old_state = self.current_state new_state = self.target_state - args = self.meta.extra_call_args + (self.instance, ) + tuple(args) + sqla_target = self.sqla_handle.record - self.dispatch.before_state_change( + args = self.meta.extra_call_args + (sqla_target, ) + tuple(args) + + self.sqla_handle.dispatch.before_state_change( source=old_state, target=new_state ) - self.internal_handler(*args, **kwargs) - setattr(self.instance, self.state_field.name, new_state) - self.dispatch.after_state_change( + self.set_func(*args, **kwargs) + setattr( + sqla_target, + self.sqla_handle.fsm_column.name, + new_state + ) + self.sqla_handle.dispatch.after_state_change( source=old_state, target=new_state ) def __repr__(self): return "<{} meta={!r} instance={!r}>".format( - self.__class__.__name__, self.meta, self.instance) + self.__class__.__name__, self.meta, self.sqla_handle) -class BoundFSMObject(BoundFSMFunction): +class BoundFSMObject(BoundFSMBase): - def __init__(self, *args, **kwargs): - super(BoundFSMObject, self).__init__(*args, **kwargs) + def __init__(self, meta, sqlalchemy_handle, child_object): + super(BoundFSMObject, self).__init__(meta, sqlalchemy_handle) # Collect sub-handlers sub_handlers = [] - sub_instance = self.internal_handler - for name in dir(sub_instance): + for name in dir(child_object): try: - attr = getattr(sub_instance, name) - meta = attr._sa_fsm + attr = getattr(child_object, name) + meta = attr._sa_fsm_bound_meta except AttributeError: # Skip non-fsm methods continue @@ -143,7 +180,7 @@ def to_next_state(self, args, kwargs): return can_transition_with[0].to_next_state(args, kwargs) def mk_restricted_bound_sub_metas(self): - instance = self.instance + sqla_handle = self.sqla_handle my_sources = self.meta.sources my_target = self.meta.target my_conditions = self.meta.conditions @@ -169,8 +206,9 @@ def target_intersection(sub_meta_target): out = [] for sub_handler in self.sub_handlers: - handler_self = sub_handler.__self__ - sub_meta = sub_handler._sa_fsm + handler_fn = sub_handler._sa_fsm_transition_fn + handler_self = sub_handler._sa_fsm_self + sub_meta = sub_handler._sa_fsm_meta sub_sources = source_intersection(sub_meta.sources) if not sub_sources: @@ -192,16 +230,24 @@ def target_intersection(sub_meta_target): sub_args = (handler_self, ) + my_args + sub_meta.extra_call_args sub_meta = meta.FSMMeta( - sub_meta.payload, sub_sources, sub_target, + sub_sources, sub_target, sub_conditions, sub_args, sub_meta.bound_cls ) - out.append(sub_meta.get_bound(instance)) + out.append(sub_meta.get_bound(sqla_handle, handler_fn)) return out class BoundFSMClass(BoundFSMObject): - def __init__(self, meta, instance, internal_handler): - bound_object = internal_handler() - super(BoundFSMClass, self).__init__(meta, instance, bound_object) + def __init__(self, meta, sqlalchemy_handle, child_cls): + child_cls_with_bound_sqla = type( + '{}::sqlalchemy_handle::{}'.format(child_cls.__name__, id(sqlalchemy_handle)), + (child_cls, ), + { + '_sa_fsm_sqlalchemy_handle': sqlalchemy_handle, + } + ) + + bound_object = child_cls_with_bound_sqla() + super(BoundFSMClass, self).__init__(meta, sqlalchemy_handle, bound_object) diff --git a/sqlalchemy_fsm/func.py b/sqlalchemy_fsm/func.py deleted file mode 100644 index 53aafa94..00000000 --- a/sqlalchemy_fsm/func.py +++ /dev/null @@ -1,20 +0,0 @@ -"""API functions.""" - - -def _get_bound_meta(bound_method): - try: - meta = bound_method._sa_fsm - except AttributeError: - raise NotImplementedError('This is not transition handler') - return meta.get_bound(bound_method.__self__) - - -def can_proceed(bound_method, *args, **kwargs): - bound_meta = _get_bound_meta(bound_method) - return bound_meta.transition_possible() and bound_meta.conditions_met( - args, kwargs) - - -def is_current(bound_method): - bound_meta = _get_bound_meta(bound_method) - return bound_meta.target_state == bound_meta.current_state diff --git a/sqlalchemy_fsm/meta.py b/sqlalchemy_fsm/meta.py index a7f2a8af..94b24803 100644 --- a/sqlalchemy_fsm/meta.py +++ b/sqlalchemy_fsm/meta.py @@ -7,15 +7,14 @@ class FSMMeta(object): - payload = transitions = conditions = sources = bound_cls = None + transitions = conditions = sources = bound_cls = None extra_call_args = () def __init__( - self, payload, source, target, + self, source, target, conditions, extra_args, bound_cls ): self.bound_cls = bound_cls - self.payload = payload self.conditions = tuple(conditions) self.extra_call_args = tuple(extra_args) @@ -41,12 +40,12 @@ def __init__( self.sources = frozenset(all_sources) - def get_bound(self, instance): - return self.bound_cls(self, instance, self.payload) + def get_bound(self, sqlalchemy_handle, set_func): + return self.bound_cls(self, sqlalchemy_handle, set_func) def __repr__(self): return "<{} sources={!r} target={!r} conditions={!r} " \ - "extra call args={!r} payload={!r}>".format( + "extra call args={!r}>".format( self.__class__.__name__, self.sources, self.target, - self.conditions, self.extra_call_args, self.payload, + self.conditions, self.extra_call_args, ) diff --git a/sqlalchemy_fsm/transition.py b/sqlalchemy_fsm/transition.py index b183cd42..748a7768 100644 --- a/sqlalchemy_fsm/transition.py +++ b/sqlalchemy_fsm/transition.py @@ -2,49 +2,97 @@ import inspect as py_inspect from functools import wraps -from sqlalchemy.ext.hybrid import hybrid_method + +from sqlalchemy.orm.interfaces import InspectionAttrInfo +from sqlalchemy.ext.hybrid import HYBRID_METHOD from . import bound, util, exc from .meta import FSMMeta +class ClassBoundFsmTransition(object): + + def __init__(self, meta, sqla_handle, ownerCls): + self._sa_fsm_meta = meta + self._sa_fsm_owner_cls = ownerCls + self._sa_fsm_sqla_handle = sqla_handle -def transition(source='*', target=None, conditions=()): + def __call__(self): + """Return a SQLAlchemy filter for this particular state.""" + column = self._sa_fsm_sqla_handle.fsm_column + target = self._sa_fsm_meta.target + assert target, "Target must be defined at this level." + return column == target - def inner_transition(func): +class InstanceBoundFsmTransition(object): - if py_inspect.isfunction(func): - meta = FSMMeta( - func, source, target, conditions, (), bound.BoundFSMFunction) - elif py_inspect.isclass(func): - # Assume a class with multiple handles for various source states - meta = FSMMeta( - func, source, target, conditions, (), bound.BoundFSMClass) - else: - raise NotImplementedError("Do not know how to {!r}".format(func)) - - @wraps(func, updated=()) - def _change_fsm_state(instance, *args, **kwargs): - bound_meta = _change_fsm_state._sa_fsm.get_bound(instance) - if not bound_meta.transition_possible(): - raise exc.InvalidSourceStateError( - 'Unable to switch from {} using method {}'.format( - bound_meta.current_state, func.__name__ - ) + def __init__(self, meta, sqla_handle, transition_fn, ownerCls, instance): + self._sa_fsm_meta = meta + self._sa_fsm_transition_fn = transition_fn + self._sa_fsm_owner_cls = ownerCls + self._sa_fsm_self = instance + self._sa_fsm_bound_meta = meta.get_bound(sqla_handle, transition_fn) + + def __call__(self, *args, **kwargs): + """Check if this is the current state of the object.""" + bound_meta = self._sa_fsm_bound_meta + return bound_meta.target_state == bound_meta.current_state + + def set(self, *args, **kwargs): + """Transition the FSM to this new state.""" + bound_meta = self._sa_fsm_bound_meta + func = self._sa_fsm_transition_fn + + if not bound_meta.transition_possible(): + raise exc.InvalidSourceStateError( + 'Unable to switch from {} using method {}'.format( + bound_meta.current_state, func.__name__ ) - if not bound_meta.conditions_met(args, kwargs): - raise exc.PreconditionError("Preconditions are not satisfied.") - return bound_meta.to_next_state(args, kwargs) + ) + if not bound_meta.conditions_met(args, kwargs): + raise exc.PreconditionError("Preconditions are not satisfied.") + return bound_meta.to_next_state(args, kwargs) + + + def can_proceed(self, *args, **kwargs): + bound_meta = self._sa_fsm_bound_meta + return bound_meta.transition_possible() and bound_meta.conditions_met( + args, kwargs) + - def _query_fsm_state(cls): - column = util.get_fsm_column(cls) - target = _change_fsm_state._sa_fsm.target - assert target, "Target must be defined at this level." - return column == target +class FsmTransition(InspectionAttrInfo): - _change_fsm_state.__name__ = "fsm::{}".format(func.__name__) + is_attribute = True + extension_type = HYBRID_METHOD - _change_fsm_state._sa_fsm = meta + def __init__(self, meta, set_function): + self.meta = meta + self.set_fn = set_function + + def __get__(self, instance, owner): + try: + sql_alchemy_handle = owner._sa_fsm_sqlalchemy_handle + except AttributeError: + # Owner class is not bound to sqlalchemy handle object + sql_alchemy_handle = bound.SqlAlchemyHandle(owner, instance) + + if instance is None: + return ClassBoundFsmTransition(self.meta, sql_alchemy_handle, owner) + else: + return InstanceBoundFsmTransition( + self.meta, sql_alchemy_handle, self.set_fn, owner, instance) + +def transition(source='*', target=None, conditions=()): + + def inner_transition(subject): + + if py_inspect.isfunction(subject): + meta = FSMMeta(source, target, conditions, (), bound.BoundFSMFunction) + elif py_inspect.isclass(subject): + # Assume a class with multiple handles for various source states + meta = FSMMeta(source, target, conditions, (), bound.BoundFSMClass) + else: + raise NotImplementedError("Do not know how to {!r}".format(subject)) - return hybrid_method(_change_fsm_state, _query_fsm_state) + return FsmTransition(meta, subject) return inner_transition diff --git a/sqlalchemy_fsm/util.py b/sqlalchemy_fsm/util.py index 0c23242f..b4f2ab9c 100644 --- a/sqlalchemy_fsm/util.py +++ b/sqlalchemy_fsm/util.py @@ -1,10 +1,7 @@ """Utility functions and consts.""" from six import string_types -from sqlalchemy import inspect - from . import exc -from .sqltypes import FSMField def is_valid_fsm_state(value): @@ -19,17 +16,3 @@ def is_valid_source_state(value): """ return (value == '*') or (value is None) or is_valid_fsm_state(value) - -def get_fsm_column(table_class): - fsm_fields = [ - col - for col in inspect(table_class).columns - if isinstance(col.type, FSMField) - ] - - if len(fsm_fields) == 0: - raise exc.SetupError('No FSMField found in model') - elif len(fsm_fields) > 1: - raise exc.SetupError( - 'More than one FSMField found in model ({})'.format(fsm_fields)) - return fsm_fields[0] diff --git a/tests/test_basic.py b/tests/test_basic.py index f89a5e67..9a46618e 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,7 +1,7 @@ import pytest import sqlalchemy -from sqlalchemy_fsm import FSMField, transition, can_proceed, is_current +from sqlalchemy_fsm import FSMField, transition from sqlalchemy_fsm.exc import SetupError, PreconditionError, InvalidSourceStateError from tests.conftest import Base @@ -16,23 +16,23 @@ def __init__(self, *args, **kwargs): super(BlogPost, self).__init__(*args, **kwargs) @transition(source='new', target='published') - def publish(self): + def published(self): pass @transition(source='published', target='hidden') - def hide(self): + def hidden(self): pass @transition(source='new', target='removed') - def remove(self): + def removed(self): raise Exception('No rights to delete %s' % self) @transition(source=['published','hidden'], target='stolen') - def steal(self): + def stolen(self): pass @transition(source='*', target='moderated') - def moderate(self): + def moderated(self): pass class TestFSMField(object): @@ -45,45 +45,48 @@ def test_initial_state_instatiated(self, model): assert model.state == 'new' def test_meta_attached(self, model): - assert model.publish._sa_fsm - assert 'FSMMeta' in repr(model.publish._sa_fsm) + assert model.published._sa_fsm_meta + assert 'FSMMeta' in repr(model.published._sa_fsm_meta) def test_known_transition_should_succeed(self, model): - assert can_proceed(model.publish) - model.publish() + assert not model.published() # Model is not publish-ed yet + assert model.published.can_proceed() + model.published.set() assert model.state == 'published' + # model is publish-ed now + assert model.published() - assert can_proceed(model.hide) - model.hide() + assert model.hidden.can_proceed() + model.hidden.set() assert model.state == 'hidden' def test_unknow_transition_fails(self, model): - assert not can_proceed(model.hide) + assert not model.hidden.can_proceed() with pytest.raises(NotImplementedError) as err: - model.hide() + model.hidden.set() assert 'Unable to switch from' in str(err) def test_state_non_changed_after_fail(self, model): with pytest.raises(Exception) as err: - model.remove() + model.removed.set() assert 'No rights to delete' in str(err) - assert can_proceed(model.remove) + assert model.removed.can_proceed() assert model.state == 'new' def test_mutiple_source_support_path_1_works(self, model): - model.publish() - model.steal() + model.published.set() + model.stolen.set() assert model.state == 'stolen' def test_mutiple_source_support_path_2_works(self, model): - model.publish() - model.hide() - model.steal() + model.published.set() + model.hidden.set() + model.stolen.set() assert model.state == 'stolen' def test_star_shortcut_succeed(self, model): - assert can_proceed(model.moderate) - model.moderate() + assert model.moderated.can_proceed() + model.moderated.set() assert model.state == 'moderated' @@ -92,8 +95,8 @@ def test_query_filter(self, session): model2 = BlogPost() model3 = BlogPost() model4 = BlogPost() - model3.publish() - model4.publish() + model3.published.set() + model4.published.set() session.add_all([model1, model2, model3, model4]) session.commit() @@ -102,7 +105,7 @@ def test_query_filter(self, session): # Check that one can query by fsm handler query_results = session.query(BlogPost).filter( - BlogPost.publish(), + BlogPost.published(), BlogPost.id.in_(ids), ).all() assert len(query_results) == 2, query_results @@ -110,7 +113,7 @@ def test_query_filter(self, session): assert model4 in query_results negated_query_results = session.query(BlogPost).filter( - ~BlogPost.publish(), + ~BlogPost.published(), BlogPost.id.in_(ids), ).all() assert len(negated_query_results) == 2, query_results @@ -130,14 +133,14 @@ def __init__(self, *args, **kwargs): super(InvalidModel, self).__init__(*args, **kwargs) @transition(source='new', target='no') - def validate(self): + def validated(self): pass class TestInvalidModel(object): def test_two_fsmfields_in_one_model_not_allowed(self): model = InvalidModel() with pytest.raises(SetupError) as err: - model.validate() + model.validated() assert 'More than one FSMField found' in str(err) @@ -151,14 +154,14 @@ def __init__(self, *args, **kwargs): super(Document, self).__init__(*args, **kwargs) @transition(source='new', target='published') - def publish(self): + def published(self): pass class TestDocument(object): def test_any_state_field_name_allowed(self): model = Document() - model.publish() + model.published.set() assert model.status == 'published' @@ -192,21 +195,21 @@ def model(self): def test_null_to_end(self, model): assert model.status is None - model.endFromAll() + model.endFromAll.set() assert model.status == 'end' def test_null_pub_end(self, model): assert model.status is None - model.pubFromNone() + model.pubFromNone.set() assert model.status == 'published' - model.endFromAll() + model.endFromAll.set() assert model.status == 'end' def test_null_new_pub_end(self, model): assert model.status is None - model.newFromNone() + model.newFromNone.set() assert model.status == 'new' - model.pubFromEither() + model.pubFromEither.set() assert model.status == 'published' - model.endFromAll() + model.endFromAll.set() assert model.status == 'end' \ No newline at end of file diff --git a/tests/test_conditionals.py b/tests/test_conditionals.py index 97a40625..3a52fd66 100644 --- a/tests/test_conditionals.py +++ b/tests/test_conditionals.py @@ -2,7 +2,7 @@ import sqlalchemy -from sqlalchemy_fsm import FSMField, transition, can_proceed, is_current +from sqlalchemy_fsm import FSMField, transition from sqlalchemy_fsm.exc import SetupError, PreconditionError, InvalidSourceStateError from tests.conftest import Base @@ -27,11 +27,11 @@ def unmet_condition(self): return False @transition(source='new', target='published', conditions=[condition_func, model_condition]) - def publish(self): + def published(self): pass @transition(source='published', target='destroyed', conditions=[condition_func, unmet_condition]) - def destroy(self): + def destroyed(self): pass @@ -43,13 +43,13 @@ def test_initial_staet(self): self.assertEqual(self.model.state, 'new') def test_known_transition_should_succeed(self): - self.assertTrue(can_proceed(self.model.publish)) - self.model.publish() + self.assertTrue(self.model.published.can_proceed()) + self.model.published.set() self.assertEqual(self.model.state, 'published') def test_unmet_condition(self): - self.model.publish() + self.model.published.set() self.assertEqual(self.model.state, 'published') - self.assertFalse(can_proceed(self.model.destroy)) - self.assertRaises(PreconditionError, self.model.destroy) + self.assertFalse(self.model.destroyed.can_proceed()) + self.assertRaises(PreconditionError, self.model.destroyed.set) self.assertEqual(self.model.state, 'published') \ No newline at end of file diff --git a/tests/test_events.py b/tests/test_events.py index a21ce0aa..53389ba2 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -53,14 +53,14 @@ def on_update(instance, source, target): handle = model.stateA else: handle = model.stateB - handle() + handle.set() assert listener_result == expected_result # Remove the listener & check that it had an effect sqlalchemy.event.remove(EventModel, event_name, on_update) # Call the state handle & ensure that listener had not been called. - model.stateA() + model.stateA.set() assert listener_result == expected_result def test_standard_sqlalchemy_events_still_work(self, model, session): @@ -78,11 +78,11 @@ def before_insert(mapper, connection, target): assert not state_log assert not insert_log - model.stateA() + model.stateA.set() assert len(state_log) == 1 assert len(insert_log) == 0 - model.stateB() + model.stateB.set() assert len(state_log) == 2 assert len(insert_log) == 0 @@ -92,7 +92,7 @@ def before_insert(mapper, connection, target): assert len(state_log) == 2 assert len(insert_log) == 1 - model.stateB() + model.stateB.set() assert len(state_log) == 3 assert len(insert_log) == 1 @@ -154,9 +154,9 @@ def on_update(instance, source, target): handle = model.stateA else: handle = model.stateB - handle() + handle.set() assert listener_result == expected_result - model.stateClass() + model.stateClass.set() if handle_name == 'state_a': expected_side = 'from_a' @@ -172,7 +172,7 @@ def on_update(instance, source, target): # Remove the listener & check that it had an effect sqlalchemy.event.remove(TransitionClassEventModel, event_name, on_update) # Call the state handle & ensure that listener had not been called. - model.stateA() + model.stateA.set() assert listener_result == expected_result class TestEventsLeakage(object): @@ -208,22 +208,22 @@ def on_both_update(instance, source, target): assert len(tr_cls_result) == 0 assert len(joint_result) == 0 - event_model.stateA() + event_model.stateA.set() assert len(event_result) == 1 assert len(tr_cls_result) == 0 assert len(joint_result) == 1 - event_model.stateB() + event_model.stateB.set() assert len(event_result) == 2 assert len(tr_cls_result) == 0 assert len(joint_result) == 2 - tr_cls_model.stateA() + tr_cls_model.stateA.set() assert len(event_result) == 2 assert len(tr_cls_result) == 1 assert len(joint_result) == 3 - tr_cls_model.stateA() + tr_cls_model.stateA.set() assert len(event_result) == 2 assert len(tr_cls_result) == 2 assert len(joint_result) == 4 \ No newline at end of file diff --git a/tests/test_invalids.py b/tests/test_invalids.py index 686af4d1..6dc0d220 100644 --- a/tests/test_invalids.py +++ b/tests/test_invalids.py @@ -1,7 +1,7 @@ import pytest import sqlalchemy -from sqlalchemy_fsm import FSMField, transition, exc, can_proceed, is_current +from sqlalchemy_fsm import FSMField, transition, exc from tests.conftest import Base @@ -24,13 +24,9 @@ def test_not_fsm(): assert 'No FSMField found in model' in str(err) def test_not_transition(): - with pytest.raises(NotImplementedError) as err: - can_proceed(NotFsm.not_transition) - assert 'This is not transition handler' in str(err) + with pytest.raises(AttributeError): + NotFsm.not_transition.can_proceed() - with pytest.raises(NotImplementedError) as err: - is_current(NotFsm.not_transition) - assert 'This is not transition handler' in str(err) class TooMuchFsm(Base): __tablename__ = 'TooMuchFsm' @@ -156,23 +152,23 @@ def model(self): def test_misconfigured_transitions(self, model): with pytest.raises(exc.SetupError) as err: with pytest.warns(UserWarning): - model.change_state(42) + model.change_state.set(42) assert 'Mismatch beteen args accepted' in str(err) def test_multi_transition_handlers(self, model): with pytest.raises(exc.SetupError) as err: - model.multi_handler_transition() + model.multi_handler_transition.set() assert "Can transition with multiple handlers" in str(err) def test_incompatible_targets(self, model): with pytest.raises(exc.SetupError) as err: - model.incompatible_targets() + model.incompatible_targets.set() assert 'are not compatable' in str(err) def test_incompatable_sources(self, model): with pytest.raises(exc.SetupError) as err: - model.incompatible_sources() + model.incompatible_sources.set() assert 'are not compatable' in str(err) def test_no_conflict_due_to_precondition_arg_count(self, model): - assert can_proceed(model.no_conflict_due_to_precondition_arg_count) \ No newline at end of file + assert model.no_conflict_due_to_precondition_arg_count.can_proceed() \ No newline at end of file diff --git a/tests/test_multi_source.py b/tests/test_multi_source.py index 5a2416ab..281fe243 100644 --- a/tests/test_multi_source.py +++ b/tests/test_multi_source.py @@ -2,7 +2,7 @@ import sqlalchemy -from sqlalchemy_fsm import FSMField, transition, can_proceed, is_current +from sqlalchemy_fsm import FSMField, transition from sqlalchemy_fsm.exc import SetupError, PreconditionError, InvalidSourceStateError from tests.conftest import Base @@ -91,72 +91,72 @@ def model(self): return MultiSourceBlogPost() def test_transition_one(self, model): - assert can_proceed(model.publish, 1) + assert model.publish.can_proceed(1) - model.publish(1) + model.publish.set(1) assert model.state == 'published' assert model.side_effect == 'did_one' def test_transition_two(self, model): - assert can_proceed(model.publish, 2) + assert model.publish.can_proceed(2) - model.publish(2) + model.publish.set(2) assert model.state == 'published' assert model.side_effect == 'did_two' def test_three_arg_transition_mk1(self, model): - assert can_proceed(model.noPreFilterPublish, 1, 2, 3) - model.noPreFilterPublish(1, 2, 3) + assert model.noPreFilterPublish.can_proceed(1, 2, 3) + model.noPreFilterPublish.set(1, 2, 3) assert model.state == 'published' assert model.side_effect == 'did_three_arg_mk1::[1, 2, 3]' def test_three_arg_transition_mk2(self, model): - assert can_proceed(model.noPreFilterPublish, 'str', -1, 42) - model.noPreFilterPublish('str', -1, 42) + assert model.noPreFilterPublish.can_proceed('str', -1, 42) + model.noPreFilterPublish.set('str', -1, 42) assert model.state == 'published' assert model.side_effect == "did_three_arg_mk2::['str', -1, 42]" def unable_to_proceed_with_invalid_kwargs(self, model): - assert not can_proceed(model.noPreFilterPublish, 'str', -1, tomato='potato') + assert not model.noPreFilterPublish.can_proceed('str', -1, tomato='potato') def test_transition_two_incorrect_arg(self, model): # Transition should be rejected because of top-level `val_contains_condition([1,2])` constraint with pytest.raises(PreconditionError) as err: - model.publish(42) + model.publish.set(42) assert 'Preconditions are not satisfied' in str(err) assert model.state == 'new' assert model.side_effect == 'default' # Verify that the exception can still be avoided with can_proceed() call - assert not can_proceed(model.publish, 42) - assert not can_proceed(model.publish, 4242) + assert not model.publish.can_proceed(42) + assert not model.publish.can_proceed(4242) def test_hide(self, model): - model.hide() + model.hide.set() assert model.state == 'hidden' assert model.side_effect == 'did_hide' - model.publish(2) + model.publish.set(2) assert model.state == 'published' assert model.side_effect == 'did_unhide: 2' def test_publish_loop(self, model): - model.publish(1) + model.publish.set(1) assert model.state == 'published' assert model.side_effect == 'did_one' for arg in (1, 2, 1, 1, 2): - model.publish(arg) + model.publish.set(arg) assert model.state == 'published' assert model.side_effect == 'do_publish_loop: {}'.format(arg) def test_delete_new(self, model): - model.delete() + model.delete.set() assert model.state == 'deleted' # Can not switch from deleted to published - assert not can_proceed(model.publish, 2) + assert not model.publish.can_proceed(2) with pytest.raises(InvalidSourceStateError) as err: - model.publish(2) + model.publish.set(2) assert 'Unable to switch' in str(err) assert model.state == 'deleted' \ No newline at end of file diff --git a/tests/test_transition_classes.py b/tests/test_transition_classes.py index 85f84801..7f913d7c 100644 --- a/tests/test_transition_classes.py +++ b/tests/test_transition_classes.py @@ -2,7 +2,7 @@ import sqlalchemy import pytest -from sqlalchemy_fsm import FSMField, transition, can_proceed, is_current +from sqlalchemy_fsm import FSMField, transition from sqlalchemy_fsm.exc import SetupError, PreconditionError, InvalidSourceStateError from tests.conftest import Base @@ -56,29 +56,29 @@ def model(self): return AltSyntaxBlogPost() def test_pre_decorated_publish(self, model): - model.pre_decorated_publish() + model.pre_decorated_publish.set() assert model.state == 'pre_decorated_publish' assert model.side_effect == 'SeparatePublishHandler::did_one' def test_pre_decorated_publish_from_hidden(self, model): - model.hide() + model.hide.set() assert model.state == 'hidden' - assert is_current(model.hide) - assert not is_current(model.pre_decorated_publish) - model.pre_decorated_publish() + assert model.hide() + assert not model.pre_decorated_publish() + model.pre_decorated_publish.set() assert model.state == 'pre_decorated_publish' - assert is_current(model.pre_decorated_publish) + assert model.pre_decorated_publish() assert model.side_effect == 'SeparatePublishHandler::did_two' def test_post_decorated_from_hidden(self, model): - model.post_decorated_publish() + model.post_decorated_publish.set() assert model.state == 'post_decorated_publish' assert model.side_effect == 'SeparatePublishHandler::did_one' def test_post_decorated_publish_from_hidden(self, model): - model.hide() + model.hide.set() assert model.state == 'hidden' - model.post_decorated_publish() + model.post_decorated_publish.set() assert model.state == 'post_decorated_publish' assert model.side_effect == 'SeparatePublishHandler::did_two' @@ -88,9 +88,9 @@ def test_class_query(self, session, model): pre_decorated_published = records[3:5] post_decorated_published = records[-2:] - [el.hide() for el in hidden_records] - [el.pre_decorated_publish() for el in pre_decorated_published] - [el.post_decorated_publish() for el in post_decorated_published] + [el.hide.set() for el in hidden_records] + [el.pre_decorated_publish.set() for el in pre_decorated_published] + [el.post_decorated_publish.set() for el in post_decorated_published] session.add_all(records) session.commit()