├── .gitignore ├── .travis.yml ├── AUTHORS ├── ChangeLog ├── LICENSE ├── README.rst ├── saga ├── __init__.py ├── saga.py └── saga_test.py ├── setup.cfg └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.4" 4 | - "3.5" 5 | - "3.6" 6 | - "pypy3" 7 | 8 | install: 9 | - pip install coverage coveralls 10 | 11 | script: 12 | - coverage run --branch -m unittest discover -s saga -p "*_test.py" 13 | 14 | after_success: 15 | - coveralls 16 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Florian Plattner 2 | Florian Plattner 3 | -------------------------------------------------------------------------------- /ChangeLog: -------------------------------------------------------------------------------- 1 | CHANGES 2 | ======= 3 | 4 | 1.0 5 | --- 6 | 7 | * fat: adds python packaging information 8 | * docs: switch readme from markdown to restructuredText and add a few code examples 9 | * fix: final polishing 10 | * feat: run coveralls after test 11 | * fix: who needs python 2 anyways 12 | * fix: change nonlocal keyword to global in tests to get python 3.2 and 2.x working 13 | * fix: syntax error in travis.yml 14 | * docs: adds travis and coveralls badges to README.md 15 | * feat: initial commit 16 | * Initial commit 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Florian Plattner 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | 2 | saga_py 3 | ======= 4 | 5 | Create a series of dependent actions and roll everything back when one of them fails. 6 | 7 | 8 | Install 9 | ------- 10 | 11 | .. code-block:: bash 12 | 13 | $ pip install saga_py 14 | 15 | 16 | Usage 17 | ----- 18 | 19 | 20 | Simple example 21 | ^^^^^^^^^^^^^^ 22 | 23 | .. code-block:: python 24 | 25 | from saga import SagaBuilder 26 | 27 | counter1 = 0 28 | counter2 = 0 29 | 30 | def incr_counter1(amount): 31 | global counter1 32 | counter1 += amount 33 | 34 | def incr_counter2(amount): 35 | global counter2 36 | counter2 += amount 37 | 38 | def decr_counter1(amount): 39 | global counter1 40 | counter1 -= amount 41 | 42 | def decr_counter2(amount): 43 | global counter2 44 | counter2 -= amount 45 | 46 | SagaBuilder \ 47 | .create() \ 48 | .action(lambda: incr_counter1(15), lambda: decr_counter1(15)) \ 49 | .action(lambda: incr_counter2(1), lambda: decr_counter2(1)) \ 50 | .build() \ 51 | .execute() 52 | 53 | # if every action succeeds, the effects of all actions are applied 54 | print(counter1) # 15 55 | print(counter2) # 1 56 | 57 | 58 | An action fails example 59 | ^^^^^^^^^^^^^^^^^^^^^^^ 60 | 61 | If one action fails, the compensations for all already executed actions are run and a SagaError is raised that wraps 62 | all Exceptions encountered during the run. 63 | 64 | .. code-block:: python 65 | 66 | from saga import SagaBuilder, SagaError 67 | 68 | counter1 = 0 69 | counter2 = 0 70 | 71 | def incr_counter1(amount): 72 | global counter1 73 | counter1 += amount 74 | 75 | def incr_counter2(amount): 76 | global counter2 77 | counter2 += amount 78 | raise BaseException('some error happened') 79 | 80 | def decr_counter1(amount): 81 | global counter1 82 | counter1 -= amount 83 | 84 | def decr_counter2(amount): 85 | global counter2 86 | counter2 -= amount 87 | 88 | try: 89 | SagaBuilder \ 90 | .create() \ 91 | .action(lambda: incr_counter1(15), lambda: decr_counter1(15)) \ 92 | .action(lambda: incr_counter2(1), lambda: decr_counter2(1)) \ 93 | .build() \ 94 | .execute() 95 | except SagaError as e: 96 | print(e) # wraps the BaseException('some error happened') 97 | 98 | print(counter1) # 0 99 | print(counter2) # 0 100 | 101 | 102 | An action and a compensation fail example 103 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 104 | 105 | Since the compensation for action2 fails, the compensation effect is undefined from the framework's perspective, 106 | all other compensations are run regardless. 107 | 108 | .. code-block:: python 109 | 110 | from saga import SagaBuilder, SagaError 111 | 112 | counter1 = 0 113 | counter2 = 0 114 | 115 | def incr_counter1(amount): 116 | global counter1 117 | counter1 += amount 118 | 119 | def incr_counter2(amount): 120 | global counter2 121 | counter2 += amount 122 | raise BaseException('some error happened') 123 | 124 | def decr_counter1(amount): 125 | global counter1 126 | counter1 -= amount 127 | 128 | def decr_counter2(amount): 129 | global counter2 130 | raise BaseException('compensation also fails') 131 | 132 | try: 133 | SagaBuilder \ 134 | .create() \ 135 | .action(lambda: incr_counter1(15), lambda: decr_counter1(15)) \ 136 | .action(lambda: incr_counter2(1), lambda: decr_counter2(1)) \ 137 | .build() \ 138 | .execute() 139 | except SagaError as e: 140 | print(e) # 141 | 142 | print(counter1) # 0 143 | print(counter2) # 1 144 | 145 | 146 | Passing values from one action to the next 147 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 148 | 149 | An action can return a dict of return values. 150 | The dict is then passed as keyword arguments to the next action and it's corresponding compensation. 151 | No values can be passed between compensations. 152 | 153 | .. code-block:: python 154 | 155 | from saga import SagaBuilder, SagaError 156 | 157 | counter1 = 0 158 | counter2 = 0 159 | 160 | def incr_counter1(amount): 161 | global counter1 162 | counter1 += amount 163 | return {'counter1_value': counter1} 164 | 165 | def incr_counter2(counter1_value): 166 | global counter2 167 | counter2 += amount 168 | 169 | def decr_counter1(amount): 170 | global counter1 171 | counter1 -= amount 172 | 173 | def decr_counter2(counter1_value): 174 | global counter2 175 | counter2 -= amount 176 | 177 | SagaBuilder \ 178 | .create() \ 179 | .action(lambda: incr_counter1(15), lambda: decr_counter1(15)) \ 180 | .action(incr_counter2, decr_counter2) \ 181 | .build() \ 182 | .execute() 183 | 184 | print(counter1) # 15 185 | print(counter2) # 15 186 | -------------------------------------------------------------------------------- /saga/__init__.py: -------------------------------------------------------------------------------- 1 | from saga.saga import Action, Saga, SagaBuilder, SagaError 2 | 3 | __all__ = [Action, Saga, SagaBuilder, SagaError] 4 | -------------------------------------------------------------------------------- /saga/saga.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class SagaError(BaseException): 4 | """ 5 | Raised when an action failed and at least one compensation also failed. 6 | """ 7 | 8 | def __init__(self, exception, compensation_exceptions): 9 | """ 10 | :param exception: BaseException the exception that caused this SagaException 11 | :param compensation_exceptions: list[BaseException] all exceptions that happened while executing compensations 12 | """ 13 | self.action = exception 14 | self.compensations = compensation_exceptions 15 | 16 | 17 | class Action(object): 18 | """ 19 | Groups an action with its corresponding compensation. For internal use. 20 | """ 21 | def __init__(self, action, compensation): 22 | """ 23 | 24 | :param action: Callable a function executed as the action 25 | :param compensation: Callable a function that reverses the effects of action 26 | """ 27 | self.__kwargs = None 28 | self.__action = action 29 | self.__compensation = compensation 30 | 31 | def act(self, **kwargs): 32 | """ 33 | Execute this action 34 | 35 | :param kwargs: dict If there was an action executed successfully before this action, then kwargs contains the 36 | return values of the previous action 37 | :return: dict optional return value of this action 38 | """ 39 | self.__kwargs = kwargs 40 | return self.__action(**kwargs) 41 | 42 | def compensate(self): 43 | """ 44 | Execute the compensation. 45 | :return: None 46 | """ 47 | if self.__kwargs: 48 | self.__compensation(**self.__kwargs) 49 | else: 50 | self.__compensation() 51 | 52 | 53 | class Saga(object): 54 | """ 55 | Executes a series of Actions. 56 | If one of the actions raises, the compensation for the failed action and for all previous actions 57 | are executed in reverse order. 58 | Each action can return a dict, that is then passed as kwargs to the next action. 59 | While executing compensations possible Exceptions are recorded and raised wrapped in a SagaException once all 60 | compensations have been executed. 61 | """ 62 | def __init__(self, actions): 63 | """ 64 | :param actions: list[Action] 65 | """ 66 | self.actions = actions 67 | 68 | def execute(self): 69 | """ 70 | Execute this Saga. 71 | :return: None 72 | """ 73 | kwargs = {} 74 | for action_index in range(len(self.actions)): 75 | try: 76 | kwargs = self.__get_action(action_index).act(**kwargs) or {} 77 | except BaseException as e: 78 | compensation_exceptions = self.__run_compensations(action_index) 79 | raise SagaError(e, compensation_exceptions) 80 | 81 | if type(kwargs) is not dict: 82 | raise TypeError('action return type should be dict or None but is {}'.format(type(kwargs))) 83 | 84 | def __get_action(self, index): 85 | """ 86 | Returns an action by index. 87 | 88 | :param index: int 89 | :return: Action 90 | """ 91 | return self.actions[index] 92 | 93 | def __run_compensations(self, last_action_index): 94 | """ 95 | :param last_action_index: int 96 | :return: None 97 | """ 98 | compensation_exceptions = [] 99 | for compensation_index in range(last_action_index, -1, -1): 100 | try: 101 | self.__get_action(compensation_index).compensate() 102 | except BaseException as ex: 103 | compensation_exceptions.append(ex) 104 | return compensation_exceptions 105 | 106 | 107 | class SagaBuilder(object): 108 | """ 109 | Build a Saga. 110 | """ 111 | def __init__(self): 112 | self.actions = [] 113 | 114 | @staticmethod 115 | def create(): 116 | return SagaBuilder() 117 | 118 | def action(self, action, compensation): 119 | """ 120 | Add an action and a corresponding compensation. 121 | 122 | :param action: Callable an action to be executed 123 | :param compensation: Callable an action that reverses the effects of action 124 | :return: SagaBuilder 125 | """ 126 | action = Action(action, compensation) 127 | self.actions.append(action) 128 | return self 129 | 130 | def build(self): 131 | """ 132 | Returns a new Saga ready to execute all actions passed to the builder. 133 | :return: Saga 134 | """ 135 | return Saga(self.actions) 136 | -------------------------------------------------------------------------------- /saga/saga_test.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import Mock 3 | from . import Saga, Action, SagaError, SagaBuilder 4 | 5 | 6 | class SagaTest(TestCase): 7 | def test_run_single_action(self): 8 | action_call_count = 0 9 | 10 | def action(): 11 | nonlocal action_call_count 12 | action_call_count += 1 13 | return None 14 | 15 | action1 = Mock(spec=Action) 16 | action1.act = action 17 | 18 | (Saga([action1])).execute() 19 | 20 | self.assertEqual(action_call_count, 1) 21 | self.assertEqual(action1.compensate.call_count, 0) 22 | 23 | def test_run_multiple_actions(self): 24 | action_call_count = 0 25 | def action(): 26 | nonlocal action_call_count 27 | action_call_count += 1 28 | action1 = Mock(spec=Action) 29 | action1.act = action 30 | action2 = Mock(spec=Action) 31 | action2.act = action 32 | action3 = Mock(spec=Action) 33 | action3.act = action 34 | action4 = Mock(spec=Action) 35 | action4.act = action 36 | 37 | (Saga([action1, action2, action3, action4])).execute() 38 | 39 | self.assertEqual(action_call_count, 4) 40 | self.assertEqual(action1.compensate.call_count, 0) 41 | self.assertEqual(action2.compensate.call_count, 0) 42 | self.assertEqual(action3.compensate.call_count, 0) 43 | self.assertEqual(action4.compensate.call_count, 0) 44 | 45 | def test_single_and_only_action_fails(self): 46 | def ex(**kwargs): 47 | raise BaseException('test_random_action_of_multiple_fails') 48 | action = Mock(spec=Action) 49 | action.act = ex 50 | 51 | with self.assertRaises(BaseException): 52 | (Saga([action])).execute() 53 | 54 | self.assertEqual(action.compensate.call_count, 1) 55 | 56 | def test_random_action_of_multiple_fails(self): 57 | action_call_count = 0 58 | 59 | def action(): 60 | nonlocal action_call_count 61 | action_call_count += 1 62 | 63 | def action_raise(**kwargs): 64 | raise BaseException('test_random_action_of_multiple_fails') 65 | 66 | action1 = Mock(spec=Action) 67 | action1.act = action 68 | action2 = Mock(spec=Action) 69 | action2.act = action 70 | action3 = Mock(spec=Action) 71 | action3.act = action_raise 72 | action4 = Mock(spec=Action) 73 | 74 | with self.assertRaises(BaseException): 75 | (Saga([action1, action2, action3, action4])).execute() 76 | 77 | self.assertEqual(action_call_count, 2) 78 | self.assertEqual(action1.compensate.call_count, 1) 79 | self.assertEqual(action2.compensate.call_count, 1) 80 | # can't assert execute as it's not a mock, 81 | # but then the compensations are triggered by this execute 82 | self.assertEqual(action3.compensate.call_count, 1) 83 | self.assertEqual(action4.act.call_count, 0) 84 | self.assertEqual(action4.compensate.call_count, 0) 85 | 86 | def test_single_compensation_fails(self): 87 | def ex(**kwargs): 88 | raise BaseException('test_single_compensation_fails_action') 89 | 90 | def com_ex(): 91 | raise BaseException('test_single_compensation_fails_compensation') 92 | 93 | action = Mock(spec=Action) 94 | action.act = ex 95 | action.compensate = com_ex 96 | 97 | with self.assertRaises(SagaError) as context: 98 | (Saga([action])).execute() 99 | 100 | self.assertIsInstance(context.exception.action, BaseException) 101 | self.assertEqual(len(context.exception.compensations), 1) 102 | self.assertIsInstance(context.exception.compensations[0], BaseException) 103 | 104 | def test_raise_original_exception_if_only_one_action_failed(self): 105 | def action(): 106 | raise ValueError('test_single_compensation_fails_action') 107 | action1 = Mock(spec=Action) 108 | action1.act = action 109 | 110 | with self.assertRaises(SagaError): 111 | (Saga([action1])).execute() 112 | 113 | def test_random_action_of_multiple_fails_and_random_compensation_fails(self): 114 | action_call_count = 0 115 | 116 | def action(): 117 | nonlocal action_call_count 118 | action_call_count += 1 119 | 120 | def ex_comp(): 121 | raise BaseException('test_random_action_of_multiple_fails_compensation') 122 | 123 | def ex(**kwargs): 124 | raise BaseException('test_random_action_of_multiple_fails_action') 125 | 126 | action1 = Mock(spec=Action) 127 | action1.act = action 128 | action2 = Mock(spec=Action) 129 | action2.act = action 130 | action2.compensate = ex_comp 131 | action3 = Mock(spec=Action) 132 | action3.act = ex 133 | action4 = Mock(spec=Action) 134 | 135 | with self.assertRaises(SagaError) as context: 136 | (Saga([action1, action2, action3, action4])).execute() 137 | 138 | self.assertEqual(action_call_count, 2) 139 | self.assertEqual(action1.compensate.call_count, 1) 140 | self.assertEqual(action3.compensate.call_count, 1) 141 | self.assertEqual(action4.act.call_count, 0) 142 | self.assertEqual(action4.compensate.call_count, 0) 143 | self.assertIsInstance(context.exception.action, BaseException) 144 | self.assertEqual(len(context.exception.compensations), 1) 145 | self.assertIsInstance(context.exception.compensations[0], BaseException) 146 | 147 | def test_random_action_of_multiple_fails_and_all_compensations_fail(self): 148 | action_call_count = 0 149 | 150 | def action(): 151 | nonlocal action_call_count 152 | action_call_count += 1 153 | 154 | def ex_comp(): 155 | raise BaseException('test_random_action_of_multiple_fails_compensation') 156 | 157 | def action_raises(**kwargs): 158 | raise BaseException('test_random_action_of_multiple_fails_action') 159 | 160 | action1 = Mock(spec=Action) 161 | action1.act = action 162 | action1.compensate = ex_comp 163 | action2 = Mock(spec=Action) 164 | action2.act = action 165 | action2.compensate = ex_comp 166 | action3 = Mock(spec=Action) 167 | action3.act = action_raises 168 | action3.compensate = ex_comp 169 | action4 = Mock(spec=Action) 170 | 171 | with self.assertRaises(SagaError) as context: 172 | (Saga([action1, action2, action3, action4])).execute() 173 | 174 | self.assertEqual(action_call_count, 2) 175 | self.assertEqual(action4.act.call_count, 0) 176 | self.assertEqual(action4.compensate.call_count, 0) 177 | self.assertIsInstance(context.exception.action, BaseException) 178 | self.assertEqual(len(context.exception.compensations), 3) 179 | self.assertIsInstance(context.exception.compensations[0], BaseException) 180 | self.assertIsInstance(context.exception.compensations[1], BaseException) 181 | self.assertIsInstance(context.exception.compensations[2], BaseException) 182 | 183 | def test_action_return_value_is_not_dict(self): 184 | action = Mock(spec=Action) 185 | 186 | with self.assertRaises(TypeError): 187 | (Saga([action])).execute() 188 | 189 | self.assertEqual(action.act.call_count, 1) 190 | self.assertEqual(action.compensate.call_count, 0) 191 | 192 | 193 | class SagaBuilderTest(TestCase): 194 | def test_execute_and_compensate(self): 195 | action_count = 0 196 | compensation_count = 0 197 | 198 | def action(): 199 | nonlocal action_count 200 | action_count += 1 201 | raise BaseException('test_build_saga_action') 202 | 203 | def compensation(): 204 | nonlocal compensation_count 205 | compensation_count += 1 206 | 207 | with self.assertRaises(BaseException): 208 | SagaBuilder \ 209 | .create() \ 210 | .action(action, compensation) \ 211 | .build() \ 212 | .execute() 213 | self.assertEqual(action_count, 1) 214 | self.assertEqual(compensation_count, 1) 215 | 216 | def test_pass_return_value_to_next_action(self): 217 | action1_return_value = {'return_value': 'some result'} 218 | action2_argument = None 219 | 220 | def action1(**kwargs): 221 | return action1_return_value 222 | 223 | def action2(**kwargs): 224 | nonlocal action2_argument 225 | action2_argument = kwargs 226 | return None 227 | 228 | compensation = Mock() 229 | saga = SagaBuilder \ 230 | .create() \ 231 | .action(action1, compensation) \ 232 | .action(action2, compensation) \ 233 | .build() 234 | saga.execute() 235 | self.assertDictEqual(action1_return_value, action2_argument) 236 | 237 | def test_pass_return_value_to_next_compensation(self): 238 | action1_return_value = {'return_value': 'some result'} 239 | compensation2_argument = None 240 | 241 | def action1(**kwargs): 242 | return action1_return_value 243 | 244 | def action2(**kwargs): 245 | raise BaseException('fail test action2') 246 | 247 | def compensation2(**kwargs): 248 | nonlocal compensation2_argument 249 | compensation2_argument = kwargs 250 | 251 | compensation = Mock() 252 | try: 253 | saga = SagaBuilder \ 254 | .create() \ 255 | .action(action1, compensation) \ 256 | .action(action2, compensation2) \ 257 | .build() 258 | saga.execute() 259 | except SagaError: 260 | pass 261 | 262 | self.assertDictEqual(action1_return_value, compensation2_argument) 263 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | author = Florian Plattner 3 | author-email = me@florianplattner.de 4 | maintainer = Florian Plattner 5 | maintainer-email = me@florianplattner.de 6 | name = saga_py 7 | summary = create a series of dependent actions and roll everything back when one of them fails 8 | description-file = README.rst 9 | home-page = https://github.com/flowpl/dbit 10 | license = MIT 11 | classifier = 12 | Development Status :: 5 - Production/Stable 13 | Environment :: Other Environment 14 | Intended Audience :: Developers 15 | Intended Audience :: Information Technology 16 | License :: OSI Approved :: MIT License 17 | Operating System :: OS Independent 18 | Programming Language :: Python :: 3.5 19 | Programming Language :: Python :: 3.6 20 | Topic :: Software Development :: Libraries :: Python Modules 21 | 22 | keywords = 23 | saga 24 | dependent actions 25 | rollback 26 | compensate 27 | distributed transaction 28 | 29 | 30 | [files] 31 | packages=saga 32 | 33 | [bdist_wheel] 34 | universal = 1 35 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | setup_requires=['pbr>=3.1.1'], 5 | pbr=True, 6 | packages=find_packages('.') 7 | ) 8 | --------------------------------------------------------------------------------