├── test ├── helpers │ ├── __init__.py │ ├── action_types.py │ ├── middleware.py │ ├── action_creators.py │ └── reducers.py ├── __init__.py ├── test_apply_middleware.py ├── test_compose.py ├── test_bind_action_creators.py ├── test_combine_reducers.py └── test_create_store.py ├── python_redux ├── utils │ ├── __init__.py │ └── warning.py ├── __init__.py ├── compose.py ├── apply_middleware.py ├── bind_action_creators.py ├── combine_reducers.py └── create_store.py ├── .gitignore ├── tests.py ├── README.md └── LICENSE.md /test/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /python_redux/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .vscode 3 | __pycache__ -------------------------------------------------------------------------------- /test/helpers/action_types.py: -------------------------------------------------------------------------------- 1 | ADD_TODO = 'ADD_TODO' 2 | DISPATCH_IN_MIDDLE = 'DISPATCH_IN_MIDDLE' 3 | THROW_ERROR = 'THROW_ERROR' 4 | UNKNOWN_ACTION = 'UNKNOWN_ACTION' -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | all_tests = unittest.defaultTestLoader.discover('./test') 4 | results = unittest.TestResult() 5 | all_tests.run(results) 6 | 7 | print(results) -------------------------------------------------------------------------------- /python_redux/utils/warning.py: -------------------------------------------------------------------------------- 1 | import logging 2 | """ 3 | * Prints a warning in the console if it exists. 4 | * 5 | * @param {String} message The warning message. 6 | * @returns {void} 7 | """ 8 | def warning(message): 9 | logging.warning(message) 10 | -------------------------------------------------------------------------------- /test/helpers/middleware.py: -------------------------------------------------------------------------------- 1 | def thunk(store): 2 | dispatch = store['dispatch'] 3 | get_state = store['get_state'] 4 | 5 | def apply_middleware(next): 6 | def apply_action(action): 7 | if hasattr(action, '__call__'): 8 | action(dispatch, get_state) 9 | else: 10 | next(action) 11 | return apply_action 12 | return apply_middleware -------------------------------------------------------------------------------- /python_redux/__init__.py: -------------------------------------------------------------------------------- 1 | from .apply_middleware import apply_middleware 2 | from .bind_action_creators import bind_action_creators 3 | from .combine_reducers import combine_reducers 4 | from .compose import compose 5 | from .create_store import create_store 6 | 7 | __all__ = ['apply_middleware', 'bind_action_creators', 'combine_reducers', 'compose', 'create_store'] -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | from .test_apply_middleware import TestApplyMiddleware 2 | from .test_bind_action_creators import TestBindActionCreators 3 | from .test_combine_reducers import TestCombineReducers 4 | from .test_compose import TestComposeMethod 5 | from .test_create_store import TestCreateStoreMethod 6 | 7 | __all__ = ['TestApplyMiddleware', 'TestBindActionCreators', 'TestCombineReducers', 'TestComposeMethod', 'TestCreateStoreMethod'] -------------------------------------------------------------------------------- /test/helpers/action_creators.py: -------------------------------------------------------------------------------- 1 | from test.helpers.action_types import ADD_TODO, DISPATCH_IN_MIDDLE, THROW_ERROR, UNKNOWN_ACTION 2 | 3 | def add_todo(text): 4 | return {'type': ADD_TODO, 'text': text} 5 | 6 | def add_todo_if_empty(text): 7 | def anon(dispatch, get_state): 8 | if len(get_state()) == 0: 9 | add_todo(text) 10 | return anon 11 | 12 | def dispatch_in_middle(bound_dispatch_fn): 13 | return { 14 | 'type': DISPATCH_IN_MIDDLE, 15 | 'bound_dispatch_fn': bound_dispatch_fn 16 | } 17 | 18 | def throw_error(): 19 | return { 20 | 'type': THROW_ERROR 21 | } 22 | 23 | def unknown_action(): 24 | return { 25 | 'type': UNKNOWN_ACTION 26 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Python Redux 2 | This is a port from the popular state management library [redux](http://redux.js.org/) but written entirely in Python. All functionality (with the exception of the async testing done with redux-thunk and the Symbol Obversable stuff) have been converted into python. This includes all relevant unit tests as well. 3 | 4 | NOTE: Only works with python 3.4.3 or greater 5 | 6 | ### Usage 7 | Include the `python_redux` folder in your application (Not yet a `pip` package) 8 | ```python 9 | from python_redux import create_store, combine_reducers 10 | from reducers import todos, filter 11 | 12 | store = create_store(combine_reducers({ 13 | 'todos': todos, 14 | 'filter': filter 15 | })) 16 | 17 | store['dispatch'](dict(type='ADD_TODO', text='Hello')) 18 | 19 | print(store['get_state']().get('todos')) # ['Hello'] 20 | ``` 21 | 22 | ### Tests 23 | Run `python tests.py` 24 | 25 | -------------------------------------------------------------------------------- /python_redux/compose.py: -------------------------------------------------------------------------------- 1 | """ 2 | * Composes single-argument functions from right to left. The rightmost 3 | * function can take multiple arguments as it provides the signature for 4 | * the resulting composite function. 5 | * 6 | * @param {...Function} funcs The functions to compose. 7 | * @returns {Function} A function obtained by composing the argument functions 8 | * from right to left. For example, compose(f, g, h) is identical to doing 9 | * lambda *args: f(g(h(*args))) 10 | """ 11 | def compose(*funcs): 12 | if len(funcs) == 0: 13 | return lambda *args: args[0] if args else None 14 | if len(funcs) == 1: 15 | return funcs[0] 16 | 17 | # reverse array so we can reduce from left to right 18 | funcs = list(reversed(funcs)) 19 | last = funcs[0] 20 | rest = funcs[1:] 21 | 22 | def composition(*args): 23 | composed = last(*args) 24 | for f in rest: 25 | composed = f(composed) 26 | return composed 27 | return composition 28 | 29 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-present Dan Abramov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /test/test_apply_middleware.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import unittest.mock as mock 3 | from python_redux import create_store, apply_middleware 4 | from test.helpers.reducers import reducers 5 | from test.helpers.action_creators import add_todo, add_todo_if_empty 6 | from test.helpers.middleware import thunk 7 | 8 | class TestApplyMiddleware(unittest.TestCase): 9 | def test_wraps_dispatch_method_with_middleware_once(self): 10 | def test(spy_on_methods): 11 | def apply(methods): 12 | spy_on_methods(methods) 13 | return lambda next: lambda action: next(action) 14 | return apply 15 | 16 | spy = mock.MagicMock() 17 | store = apply_middleware(test(spy), thunk)(create_store)(reducers['todos']) 18 | 19 | store['dispatch'](add_todo('Use Redux')) 20 | store['dispatch'](add_todo('Flux FTW!')) 21 | 22 | self.assertEqual(spy.call_count, 1) 23 | args, kwargs = spy.call_args 24 | self.assertEqual(sorted(list(args[0].keys())), sorted(['get_state', 'dispatch'])) 25 | 26 | self.assertEqual(store['get_state'](), [dict(id=1, text='Use Redux'), dict(id=2, text='Flux FTW!')]) 27 | 28 | if __name__ == '__main__': 29 | unittest.main() 30 | 31 | -------------------------------------------------------------------------------- /test/helpers/reducers.py: -------------------------------------------------------------------------------- 1 | from test.helpers.action_types import ADD_TODO, DISPATCH_IN_MIDDLE, THROW_ERROR 2 | 3 | def id(state = []): 4 | id = 0 5 | for item in state: 6 | id = item.get('id') if item.get('id') > id else id 7 | return id + 1 8 | 9 | def todos(state=None, action={}): 10 | if state is None: 11 | state = [] 12 | if action.get('type') == ADD_TODO: 13 | return list(state) + [{ 14 | 'id': id(state), 15 | 'text': action['text'] 16 | }] 17 | else: 18 | return state 19 | 20 | def todos_reverse(state=None, action={}): 21 | if state is None: 22 | state = [] 23 | if action.get('type') == ADD_TODO: 24 | return [{ 25 | 'id': id(state), 26 | 'text': action.get('text') 27 | }] + list(state) 28 | else: 29 | return state 30 | 31 | def dispatch_in_middle_of_reducer(state=None, action={}): 32 | if state is None: 33 | state = [] 34 | if action.get('type') == DISPATCH_IN_MIDDLE: 35 | action.get('bound_dispatch_fn')() 36 | return state 37 | else: 38 | return state 39 | 40 | def error_throwing_reducer(state=None, action={}): 41 | if state is None: 42 | state = [] 43 | if action.get('type') == THROW_ERROR: 44 | raise Exception() 45 | else: 46 | return state 47 | 48 | reducers = { 49 | 'todos': todos, 50 | 'todos_reverse': todos_reverse, 51 | 'dispatch_in_middle_of_reducer': dispatch_in_middle_of_reducer, 52 | 'error_throwing_reducer': error_throwing_reducer 53 | } -------------------------------------------------------------------------------- /test/test_compose.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from python_redux import compose 3 | 4 | class TestComposeMethod(unittest.TestCase): 5 | 6 | def test_composes_from_right_to_left(self): 7 | double = lambda x: x * 2 8 | square = lambda x: x * x 9 | self.assertEqual(compose(square)(5), 25) 10 | self.assertEqual(compose(square, double)(5), 100) 11 | self.assertEqual(compose(double, square, double)(5), 200) 12 | 13 | def test_composes_functions_from_right_to_left(self): 14 | a = lambda next: lambda x: next(x + 'a') 15 | b = lambda next: lambda x: next(x + 'b') 16 | c = lambda next: lambda x: next(x + 'c') 17 | final = lambda x: x 18 | 19 | self.assertEqual(compose(a, b, c)(final)(''), 'abc') 20 | self.assertEqual(compose(b, c, a)(final)(''), 'bca') 21 | self.assertEqual(compose(c, a, b)(final)(''), 'cab') 22 | 23 | def test_can_be_seeded_with_multiple_arguments(self): 24 | square = lambda x: x * x 25 | add = lambda x, y: x + y 26 | 27 | self.assertEqual(compose(square, add)(1, 2), 9) 28 | 29 | def test_returns_first_given_argument_if_no_given_functions(self): 30 | self.assertEqual(compose()(1,2), 1) 31 | self.assertEqual(compose()(3), 3) 32 | self.assertEqual(compose()(), None) 33 | 34 | def test_returns_first_function_if_only_one(self): 35 | func = lambda: {} 36 | self.assertEqual(compose(func), func) 37 | 38 | if __name__ == '__main__': 39 | unittest.main() 40 | -------------------------------------------------------------------------------- /python_redux/apply_middleware.py: -------------------------------------------------------------------------------- 1 | from .compose import compose 2 | def apply_middleware(*middlewares): 3 | """Creates a store enhancer that applies middleware to the dispatch method 4 | of the Redux store. This is handy for a variety of tasks, such as expressing 5 | asynchronous actions in a concise manner, or logging every action payload. 6 | 7 | See `redux-thunk` package as an example of the Redux middleware. 8 | 9 | Because middleware is potentially asynchronous, this should be the first 10 | store enhancer in the composition chain. 11 | 12 | Note that each middleware will be given the `dispatch` and `getState` functions 13 | as named arguments. 14 | 15 | @param {*Function} middlewares The middleware chain to be applied. 16 | @returns {Function} A store enhancer applying the middleware. 17 | """ 18 | def chain(create_store): 19 | def inner(reducer, preloaded_state=None, enhancer=None): 20 | store = create_store(reducer, preloaded_state, enhancer) 21 | dispatch = store.get('dispatch') 22 | chain = [] 23 | 24 | middleware_api = { 25 | 'get_state': store.get('get_state'), 26 | 'dispatch': lambda action: dispatch(action) 27 | } 28 | chain = [middleware(middleware_api) for middleware in middlewares] 29 | dispatch = compose(*chain)(store.get('dispatch')) 30 | 31 | store_to_return = store.copy() 32 | store_to_return['dispatch'] = dispatch 33 | return store_to_return 34 | return inner 35 | return chain 36 | -------------------------------------------------------------------------------- /python_redux/bind_action_creators.py: -------------------------------------------------------------------------------- 1 | def bind_action_creator(action_creator, dispatch): 2 | return lambda *args: dispatch(action_creator(*args)) 3 | 4 | def bind_action_creators(action_creators=None, dispatch=None): 5 | """ 6 | Turns an object whose values are action creators, into an object with the 7 | same keys, but with every function wrapped into a `dispatch` call so they 8 | may be invoked directly. This is just a convenience method, as you can call 9 | `store['dispatch'](MyActionCreators['doSomething']())` yourself just fine. 10 | 11 | For convenience, you can also pass a single function as the first argument, 12 | and get a function in return. 13 | 14 | @param {Function|Object} actionCreators An object whose values are action 15 | creator functions. 16 | You may also pass a single function. 17 | 18 | @param {Function} dispatch The `dispatch` function available on your Redux 19 | store. 20 | 21 | @returns {Function|Object} The object mimicking the original object, but with 22 | every action creator wrapped into the `dispatch` call. If you passed a 23 | function as `actionCreators`, the return value will also be a single 24 | function. 25 | """ 26 | if hasattr(action_creators, '__call__'): 27 | return bind_action_creator(action_creators, dispatch) 28 | if type(action_creators) != dict or action_creators == None: 29 | raise Exception('bind_action_creators expected an object or a function, instead received {}.'.format('None' if action_creators == None else type(action_creators))) 30 | 31 | bound_action_creators = {} 32 | for key in action_creators: 33 | action_creator = action_creators[key] 34 | if hasattr(action_creator, '__call__'): 35 | bound_action_creators[key] = bind_action_creator(action_creator, dispatch) 36 | return bound_action_creators -------------------------------------------------------------------------------- /test/test_bind_action_creators.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import unittest.mock as mock 3 | import re 4 | from python_redux import bind_action_creators, create_store 5 | from test.helpers.reducers import reducers 6 | from test.helpers.action_creators import add_todo, add_todo_if_empty, dispatch_in_middle, unknown_action 7 | 8 | todos = reducers['todos'] 9 | action_creators = dict( 10 | add_todo=add_todo, 11 | add_todo_if_empty=add_todo_if_empty, 12 | dispatch_in_middle=dispatch_in_middle, 13 | unknown_action=unknown_action 14 | ) 15 | 16 | class TestBindActionCreators(unittest.TestCase): 17 | store = None 18 | action_creator_functions = None 19 | def setUp(self): 20 | self.store = create_store(todos) 21 | self.action_creator_functions = dict(action_creators) 22 | for key in self.action_creator_functions: 23 | if not hasattr(self.action_creator_functions[key], '__call__'): 24 | del self.action_creator_functions[key] 25 | 26 | def test_wraps_action_creators_with_dispatch_function(self): 27 | bound_action_creators = bind_action_creators(action_creators, self.store['dispatch']) 28 | self.assertEqual(bound_action_creators.keys(), self.action_creator_functions.keys()) 29 | 30 | action = bound_action_creators['add_todo']('Hello') 31 | self.assertEqual(action, action_creators['add_todo']('Hello')) 32 | 33 | self.assertEqual(self.store['get_state'](), [dict(id=1, text='Hello')]) 34 | 35 | def test_skips_non_function_values_in_the_passed_object(self): 36 | bound_action_creators = bind_action_creators(dict( 37 | foo=42, 38 | bar='baz', 39 | wow=None, 40 | much={}, 41 | **action_creators 42 | ), self.store['dispatch']) 43 | 44 | self.assertEqual(bound_action_creators.keys(), self.action_creator_functions.keys()) 45 | 46 | def test_supports_wrapping_single_function_only(self): 47 | action_creator = action_creators['add_todo'] 48 | bound_action_creator = bind_action_creators(action_creator, self.store['dispatch']) 49 | 50 | action = bound_action_creator('Hello') 51 | self.assertEqual(action, action_creator('Hello')) 52 | self.assertEqual(self.store['get_state'](), [dict(id=1, text='Hello')]) 53 | 54 | def test_throws_for_undefined_action_creator(self): 55 | with self.assertRaises(Exception) as e: 56 | bind_action_creators(None, self.store['dispatch']) 57 | self.assertTrue(re.search(r'bind_action_creators expected an object or a function, instead received None', str(e.exception))) 58 | 59 | def test_throws_for_a_primative_action_creator(self): 60 | with self.assertRaises(Exception) as e: 61 | bind_action_creators('string', self.store['dispatch']) 62 | self.assertTrue(re.search('bind_action_creators expected an object or a function, instead received ', str(e.exception))) 63 | if __name__ == '__main__': 64 | unittest.main() -------------------------------------------------------------------------------- /python_redux/combine_reducers.py: -------------------------------------------------------------------------------- 1 | from .utils.warning import warning 2 | from random import choice 3 | 4 | ACTION_TYPES = { 5 | 'INIT': '@@redux/INIT' 6 | } 7 | 8 | def get_undefined_state_error_message(key, action): 9 | action_type = action and action['type'] 10 | action_name = action_type and str(action_type) or 'an action' 11 | return 'Given action "{}", reducer "{}" returned None. To ignore an action you must return the previous state'.format(action_name, key) 12 | 13 | def get_unexpected_state_shape_warning_message(input_state, reducers, action, unexpected_key_cache): 14 | reducer_keys = reducers.keys() 15 | argument_name = 'preloaded_state argument passed to create_store' if action and type(action) == dict and action.get('type') == ACTION_TYPES['INIT'] else 'previous state recieved by reducer' 16 | 17 | if len(reducer_keys) == 0: 18 | return 'Store does not have a valid reducer. Make sure the argument passed to combine_reducers is an object whose values are reducers.' 19 | 20 | if not type(input_state) == dict: 21 | return 'The {} has an unexpected type of {}. Expected argument to be an object with the following keys: "{}"'.format( 22 | argument_name, 23 | str(type(input_state)).replace('\'', '"'), 24 | '", "'.join(reducer_keys) 25 | ) 26 | 27 | unexpected_keys = [key for key in input_state.keys() if not reducers.get(key) and not unexpected_key_cache.get(key)] 28 | for key in unexpected_keys: 29 | unexpected_key_cache[key] = True 30 | 31 | if len(unexpected_keys) > 0: 32 | return 'Unexpected {} "{}" found in {}. Expected to find one of the known reducer keys instead: "{}". Unexpected keys will be ignored.'.format( 33 | 'keys' if len(unexpected_keys) > 1 else 'key', 34 | '", "'.join(unexpected_keys), 35 | argument_name, 36 | '", "'.join(reducer_keys) 37 | ) 38 | 39 | def assert_reducer_sanity(reducers): 40 | for key in reducers.keys(): 41 | reducer = reducers[key] 42 | initial_state = reducer(None, { 'type': ACTION_TYPES['INIT'] }) 43 | 44 | if initial_state is None: 45 | raise Exception('Reducer "{}" returned undefined during initialization. If the state passed to the reducer is undefined, you must explicitly return the initial state. The initial state may not be undefined.'.format(key)) 46 | ty = '@@redux/PROBE_UNKNOWN_ACTION_{}'.format('.'.join(choice('0123456789ABCDEFGHIJKLM') for i in range(20))) 47 | if reducer(None, { 'type': ty }) is None: 48 | msg = 'Reducer "{}" returned undefined when probed with a random type. Don\'t try to handle {} or other actions in "redux/*" \namespace. They are considered private. Instead, you must return the current state for any unknown actions, unless it is undefined, in which case you must return initial state, regardless of the action type. The initial state may not be undefined.'.format(key, ACTION_TYPES['INIT']) 49 | raise Exception(msg) 50 | 51 | """ 52 | * Turns an object whose values are different reducer functions, into a single 53 | * reducer function. It will call every child reducer, and gather their results 54 | * into a single state object, whose keys correspond to the keys of the passed 55 | * reducer functions. 56 | * 57 | * @param {Object} reducers An object whose values correspond to different 58 | * reducer functions that need to be combined into one. One handy way to obtain 59 | * it is to use ES6 `import * as reducers` syntax. The reducers may never return 60 | * undefined for any action. Instead, they should return their initial state 61 | * if the state passed to them was undefined, and the current state for any 62 | * unrecognized action. 63 | * 64 | * @returns {Function} A reducer function that invokes every reducer inside the 65 | * passed object, and builds a state object with the same shape. 66 | """ 67 | def combine_reducers(reducers): 68 | reducer_keys = reducers.keys() 69 | final_reducers = {} 70 | for key in reducer_keys: 71 | if hasattr(reducers[key], '__call__'): 72 | final_reducers[key] = reducers[key] 73 | 74 | final_reducer_keys = final_reducers.keys() 75 | sanity_error = None 76 | unexpected_key_cache = {} 77 | 78 | try: 79 | assert_reducer_sanity(final_reducers) 80 | except Exception as e: 81 | sanity_error = e 82 | 83 | def combination(state=None, action = None): 84 | nonlocal sanity_error 85 | if state is None: 86 | state = {} 87 | if sanity_error: 88 | raise sanity_error 89 | warning_message = get_unexpected_state_shape_warning_message(state, final_reducers, action, unexpected_key_cache) 90 | if warning_message: 91 | warning(warning_message) 92 | 93 | has_changed = False 94 | next_state = {} 95 | for key in final_reducer_keys: 96 | reducer = final_reducers.get(key) 97 | previous_state_for_key = state.get(key) if type(state) == dict else state 98 | next_state_for_key = reducer(previous_state_for_key, action) 99 | if next_state_for_key is None: 100 | error_message = get_undefined_state_error_message(key, action) 101 | raise Exception(error_message) 102 | next_state[key] = next_state_for_key 103 | has_changed = has_changed or next_state_for_key != previous_state_for_key 104 | return next_state if has_changed else state 105 | return combination 106 | -------------------------------------------------------------------------------- /python_redux/create_store.py: -------------------------------------------------------------------------------- 1 | ACTION_TYPES = { 2 | 'INIT': '@@redux/INIT' 3 | } 4 | 5 | """ 6 | * Creates a Redux store that holds the state tree. 7 | * The only way to change the data in the store is to call `dispatch()` on it. 8 | * 9 | * There should only be a single store in your app. To specify how different 10 | * parts of the state tree respond to actions, you may combine several reducers 11 | * into a single reducer function by using `combineReducers`. 12 | * 13 | * @param {Function} reducer A function that returns the next state tree, given 14 | * the current state tree and the action to handle. 15 | * 16 | * @param {any} [preloadedState] The initial state. You may optionally specify it 17 | * to hydrate the state from the server in universal apps, or to restore a 18 | * previously serialized user session. 19 | * If you use `combineReducers` to produce the root reducer function, this must be 20 | * an object with the same shape as `combineReducers` keys. 21 | * 22 | * @param {Function} enhancer The store enhancer. You may optionally specify it 23 | * to enhance the store with third-party capabilities such as middleware, 24 | * time travel, persistence, etc. The only store enhancer that ships with Redux 25 | * is `applyMiddleware()`. 26 | * 27 | * @returns {Store} A Redux store that lets you read the state, dispatch actions 28 | * and subscribe to changes. 29 | """ 30 | def create_store(reducer=None, preloaded_state=None, enhancer=None): 31 | if hasattr(preloaded_state, '__call__') and enhancer is None: 32 | enhancer = preloaded_state 33 | preloaded_state = None 34 | 35 | if enhancer is not None: 36 | if not hasattr(enhancer, '__call__'): 37 | raise Exception('Expected the enhancer to be a function') 38 | return enhancer(create_store)(reducer, preloaded_state) 39 | 40 | if not hasattr(reducer, '__call__'): 41 | raise Exception('Expected the reducer to be a function') 42 | 43 | current_reducer = reducer 44 | current_state = preloaded_state 45 | current_listeners = [] 46 | next_listeners = current_listeners 47 | is_dispatching = False 48 | 49 | def ensure_can_mutate_next_listeners(): 50 | nonlocal next_listeners, current_listeners 51 | if next_listeners == current_listeners: 52 | next_listeners = [c for c in current_listeners] 53 | 54 | """ 55 | * Reads the state tree managed by the store. 56 | * 57 | * @returns {any} The current state tree of your application. 58 | """ 59 | def get_state(): 60 | nonlocal current_state 61 | return current_state 62 | 63 | """ 64 | * Adds a change listener. It will be called any time an action is dispatched, 65 | * and some part of the state tree may potentially have changed. You may then 66 | * call `getState()` to read the current state tree inside the callback. 67 | * 68 | * You may call `dispatch()` from a change listener, with the following 69 | * caveats: 70 | * 71 | * 1. The subscriptions are snapshotted just before every `dispatch()` call. 72 | * If you subscribe or unsubscribe while the listeners are being invoked, this 73 | * will not have any effect on the `dispatch()` that is currently in progress. 74 | * However, the next `dispatch()` call, whether nested or not, will use a more 75 | * recent snapshot of the subscription list. 76 | * 77 | * 2. The listener should not expect to see all state changes, as the state 78 | * might have been updated multiple times during a nested `dispatch()` before 79 | * the listener is called. It is, however, guaranteed that all subscribers 80 | * registered before the `dispatch()` started will be called with the latest 81 | * state by the time it exits. 82 | * 83 | * @param {Function} listener A callback to be invoked on every dispatch. 84 | * @returns {Function} A function to remove this change listener. 85 | """ 86 | def subscribe(listener=None): 87 | nonlocal next_listeners 88 | if not hasattr(listener, '__call__'): 89 | raise Exception('Expected listener to be a function') 90 | 91 | is_subscribed = True 92 | ensure_can_mutate_next_listeners() 93 | next_listeners.append(listener) 94 | 95 | def unsubscribe(): 96 | nonlocal is_subscribed 97 | if not is_subscribed: 98 | return 99 | is_subscribed = False 100 | ensure_can_mutate_next_listeners() 101 | index = next_listeners.index(listener) 102 | del next_listeners[index] 103 | 104 | return unsubscribe 105 | 106 | """ 107 | * Dispatches an action. It is the only way to trigger a state change. 108 | * 109 | * The `reducer` function, used to create the store, will be called with the 110 | * current state tree and the given `action`. Its return value will 111 | * be considered the **next** state of the tree, and the change listeners 112 | * will be notified. 113 | * 114 | * The base implementation only supports plain object actions. If you want to 115 | * dispatch a Promise, an Observable, a thunk, or something else, you need to 116 | * wrap your store creating function into the corresponding middleware. For 117 | * example, see the documentation for the `redux-thunk` package. Even the 118 | * middleware will eventually dispatch plain object actions using this method. 119 | * 120 | * @param {Object} action A plain object representing what changed. It is 121 | * a good idea to keep actions serializable so you can record and replay user 122 | * sessions, or use the time travelling `redux-devtools`. An action must have 123 | * a `type` property which may not be `undefined`. It is a good idea to use 124 | * string constants for action types. 125 | * 126 | * @returns {Object} For convenience, the same action object you dispatched. 127 | * 128 | * Note that, if you use a custom middleware, it may wrap `dispatch()` to 129 | * return something else (for example, a Promise you can await). 130 | """ 131 | def dispatch(action=None): 132 | nonlocal is_dispatching, current_state, current_listeners, next_listeners 133 | if not type(action) == dict: 134 | raise Exception('Actions must be plain dictionaries. Consider adding middleware to change this') 135 | if action.get('type') is None: 136 | raise Exception('Actions may not have an undefined "type" property.\n Have you misspelled a constants?') 137 | if is_dispatching: 138 | raise Exception('Reducers may not dispatch actions') 139 | 140 | try: 141 | is_dispatching = True 142 | current_state = current_reducer(current_state, action) 143 | finally: 144 | is_dispatching = False 145 | 146 | listeners = current_listeners = next_listeners 147 | for l in listeners: 148 | l() 149 | return action 150 | 151 | """ 152 | * Replaces the reducer currently used by the store to calculate the state. 153 | * 154 | * You might need this if your app implements code splitting and you want to 155 | * load some of the reducers dynamically. You might also need this if you 156 | * implement a hot reloading mechanism for Redux. 157 | * 158 | * @param {Function} nextReducer The reducer for the store to use instead. 159 | * @returns {void} 160 | """ 161 | def replace_reducer(next_reducer=None): 162 | nonlocal current_reducer 163 | if not hasattr(next_reducer, '__call__'): 164 | raise Exception('Expected next_reducer to be a function') 165 | current_reducer = next_reducer 166 | dispatch({ 'type': ACTION_TYPES['INIT'] }) 167 | 168 | # TODO: Figure out how to add the observables 169 | 170 | # When a store is created, an "INIT" action is dispatched so that every 171 | # reducer returns their initial state. This effectively populates 172 | # the initial state tree. 173 | dispatch({ 'type': ACTION_TYPES['INIT'] }) 174 | 175 | return { 176 | 'dispatch': dispatch, 177 | 'subscribe': subscribe, 178 | 'get_state': get_state, 179 | 'replace_reducer': replace_reducer 180 | } -------------------------------------------------------------------------------- /test/test_combine_reducers.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import unittest.mock as mock 3 | import re 4 | from python_redux import combine_reducers, create_store 5 | 6 | ACTION_TYPES = { 7 | 'INIT': '@@redux/INIT' 8 | } 9 | 10 | class TestCombineReducers(unittest.TestCase): 11 | def test_returns_reducer_that_maps_state_keys_to_given_reducers(self): 12 | def counter(state=None, action={}): 13 | if state is None: 14 | state = 0 15 | if action.get('type') == 'increment': 16 | return state + 1 17 | return state 18 | 19 | def stack(state=None, action={}): 20 | if state is None: state = [] 21 | if action.get('type') == 'push': 22 | return list(state) + [action.get('value')] 23 | return state 24 | 25 | reducer = combine_reducers({ 26 | 'counter': counter, 27 | 'stack': stack 28 | }) 29 | s1 = reducer({}, { 'type': 'increment' }) 30 | self.assertEqual(s1, { 'counter': 1, 'stack': []}) 31 | s2 = reducer(s1, { 'type': 'push', 'value': 'a' }) 32 | self.assertEqual(s2, { 'counter': 1, 'stack': [ 'a' ] }) 33 | 34 | def test_ignores_all_props_which_are_not_functions(self): 35 | reducer = combine_reducers({ 36 | 'fake': True, 37 | 'broken': 'string', 38 | 'another': { 'nested': 'object' }, 39 | 'stack': lambda state, action: state if state is not None else [] 40 | }) 41 | 42 | self.assertEqual(list(reducer({}, { 'type': 'push' }).keys()), [ 'stack' ]) 43 | 44 | def test_throws_if_reducer_returns_undefined_handling_an_action(self): 45 | def counter(state=None, action=None): 46 | state = 0 if state is None else state 47 | action = {} if action is None else action 48 | if action.get('type') == 'increment': 49 | return state + 1 50 | if action.get('type') == 'decrement': 51 | return state - 1 52 | if action.get('type') in ['decrement', 'whatever', None]: 53 | return None 54 | return state 55 | 56 | reducer = combine_reducers({ 'counter': counter }) 57 | 58 | with self.assertRaises(Exception) as e: 59 | reducer({ 'counter': 0}, { 'type': 'whatever' }) 60 | e = str(e.exception) 61 | self.assertTrue('"whatever"' in e and "counter" in e) 62 | 63 | with self.assertRaises(Exception) as e: 64 | reducer({ 'counter': 0 }, None) 65 | e = str(e.exception) 66 | self.assertTrue('"counter"' in e and 'an action' in e) 67 | 68 | with self.assertRaises(Exception) as e: 69 | reducer({ 'counter': 0 }, {}) 70 | e = str(e.exception) 71 | self.assertTrue('"counter"' in e and 'an action' in e) 72 | 73 | def test_throws_error_on_first_call_if_reducer_returns_undefined_initializing(self): 74 | def counter(state=None, action=None): 75 | if action.get('type') == 'increment': 76 | return state + 1 77 | if action.get('type') == 'decrement': 78 | return state - 1 79 | return state 80 | 81 | reducer = combine_reducers({ 'counter': counter }) 82 | with self.assertRaises(Exception) as e: 83 | reducer({}) 84 | e = str(e.exception) 85 | self.assertTrue('"counter"' in e and 'initialization' in e) 86 | 87 | def test_catches_error_thrown_in_reducer_when_initializing_and_re_throw(self): 88 | def throwing_reducer(state=None, action=None): 89 | raise Exception('Error in reducer') 90 | reducer = combine_reducers({ 91 | 'throwing_reducer': throwing_reducer 92 | }) 93 | 94 | with self.assertRaises(Exception) as e: 95 | reducer({}) 96 | self.assertTrue('Error in reducer' in str(e.exception)) 97 | 98 | def test_maintains_referential_equality_if_reducers_it_combines_does(self): 99 | def child_1(state=None, action=None): 100 | state = {} if state is None else state 101 | return state 102 | def child_2(state=None, action=None): 103 | state = {} if state is None else state 104 | return state 105 | def child_3(state=None, action=None): 106 | state = {} if state is None else state 107 | return state 108 | reducer = combine_reducers({ 109 | 'child_1': child_1, 110 | 'child_2': child_2, 111 | 'child_3': child_3 112 | }) 113 | 114 | initial_state = reducer(None, '@@INIT') 115 | self.assertEqual(reducer(initial_state, { 'type': 'FOO' }), initial_state) 116 | 117 | def test_does_not_have_referential_equality_if_one_reducer_changes_something(self): 118 | def child_1(state=None, action=None): 119 | if state is None: 120 | state = {} 121 | return state 122 | 123 | def child_2(state=None, action=None): 124 | if action is None: 125 | action = {} 126 | if state is None: 127 | state = { 'count': 0 } 128 | if action.get('type') == 'increment': 129 | return { 'count': state['count'] + 1} 130 | return state 131 | 132 | def child_3(state=None, action=None): 133 | if state is None: 134 | state = {} 135 | return state 136 | 137 | reducer = combine_reducers({ 138 | 'child_1': child_1, 139 | 'child_2': child_2, 140 | 'child_3': child_3 141 | }) 142 | 143 | initial_state = reducer(None) 144 | self.assertNotEqual(reducer(initial_state, { 'type': 'increment' }), initial_state) 145 | 146 | def test_throws_error_if_reducer_attempts_to_handle_a_private_action(self): 147 | def counter(state=None, action=None): 148 | if action is None: 149 | action = {} 150 | if state is None: 151 | state = 0 152 | if action.get('type') == 'increment': 153 | return state + 1 154 | if action.get('type') == 'decrement': 155 | return state - 1 156 | # Never do this 157 | if action.get('type') == ACTION_TYPES['INIT']: 158 | return 0 159 | return None 160 | 161 | reducer = combine_reducers({ 'counter': counter }) 162 | with self.assertRaises(Exception) as e: 163 | reducer() 164 | self.assertTrue('counter' in str(e.exception) and 'private' in str(e.exception)) 165 | 166 | @mock.patch('logging.warning', new_callable=mock.MagicMock()) 167 | def test_warns_if_no_reducers_passed_to_combine_reducers(self, logging): 168 | def foo(state=None, action=None): 169 | if state is None: 170 | state = { 'bar': 1} 171 | return state 172 | def baz(state=None, action=None): 173 | if state is None: 174 | state = { 'qux': 3 } 175 | return state 176 | 177 | reducer = combine_reducers({ 178 | 'foo': foo, 179 | 'baz': baz 180 | }) 181 | reducer() 182 | self.assertEqual(len(logging.call_args_list), 0) 183 | 184 | reducer({ 'foo': { 'bar': 2 }}) 185 | self.assertEqual(len(logging.call_args_list), 0) 186 | 187 | reducer({ 188 | 'foo': { 'bar': 2 }, 189 | 'baz': { 'qux': 4 } 190 | }) 191 | self.assertEqual(len(logging.call_args_list), 0) 192 | 193 | create_store(reducer, { 'bar': 2 }) 194 | m = str(logging.call_args_list[0]) 195 | self.assertTrue(re.search(r'Unexpected key "bar".*create_store.*instead: ("foo"|"baz"), ("foo"|"baz")', m)) 196 | 197 | create_store(reducer, { 'bar': 2, 'qux': 4, 'thud': 5 }) 198 | m = str(logging.call_args_list[1]) 199 | self.assertTrue(re.search(r'Unexpected keys ("qux"|"thud"), ("qux"|"thud").*create_store.*instead: ("foo"|"baz"), ("foo"|"baz")', m)) 200 | 201 | create_store(reducer, 1) 202 | m = str(logging.call_args_list[2]) 203 | self.assertTrue(re.search(r'create_store has an unexpected type of .*keys: ("foo"|"baz"), ("foo"|"baz")', m)) 204 | 205 | reducer({ 'corge': 2 }) 206 | m = str(logging.call_args_list[3]) 207 | self.assertTrue(re.search(r'Unexpected key "corge".*reducer.*instead: ("foo"|"baz"), ("foo"|"baz")', m)) 208 | 209 | reducer({ 'rick': 2, 'morty': 4 }) 210 | m = str(logging.call_args_list[4]) 211 | self.assertTrue(re.search(r'Unexpected keys ("rick"|"morty"), ("rick"|"morty").*reducer.*instead: ("foo"|"baz"), ("foo"|"baz")', m)) 212 | 213 | reducer(1) 214 | m = str(logging.call_args_list[5]) 215 | self.assertTrue(re.search(r'reducer has an unexpected type of ', m)) 216 | 217 | @mock.patch('logging.warning', new_callable=mock.MagicMock()) 218 | def test_only_warns_for_unexpected_keys_once(self, logging): 219 | def foo(state=None, action=None): 220 | return { 'foo': 1 } 221 | def bar(state=None, action=None): 222 | return { 'bar': 2 } 223 | 224 | self.assertEqual(len(logging.call_args_list), 0) 225 | reducer = combine_reducers(dict(foo=foo, bar=bar)) 226 | state = dict(foo=1, bar=2, qux=3) 227 | reducer(state, {}) 228 | reducer(state, {}) 229 | reducer(state, {}) 230 | reducer(state, {}) 231 | self.assertEqual(len(logging.call_args_list), 1) 232 | reducer(dict(baz=5, **state), {}) 233 | reducer(dict(baz=5, **state), {}) 234 | reducer(dict(baz=5, **state), {}) 235 | reducer(dict(baz=5, **state), {}) 236 | self.assertEqual(len(logging.call_args_list), 2) 237 | 238 | if __name__ == '__main__': 239 | unittest.main() 240 | 241 | 242 | -------------------------------------------------------------------------------- /test/test_create_store.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import unittest.mock as mock 3 | 4 | from python_redux import create_store, combine_reducers 5 | from test.helpers.action_creators import add_todo, dispatch_in_middle, throw_error, unknown_action 6 | from test.helpers.reducers import reducers 7 | 8 | class TestCreateStoreMethod(unittest.TestCase): 9 | def test_exposes_public_API(self): 10 | store = create_store(combine_reducers(reducers)) 11 | methods = store.keys() 12 | 13 | self.assertEqual(len(methods), 4) 14 | self.assertTrue('subscribe' in methods) 15 | self.assertTrue('dispatch' in methods) 16 | self.assertTrue('get_state' in methods) 17 | self.assertTrue('replace_reducer' in methods) 18 | 19 | def test_throws_if_reducer_is_not_a_function(self): 20 | with self.assertRaises(Exception): 21 | create_store(combine_reducers) 22 | with self.assertRaises(Exception): 23 | create_store('test') 24 | with self.assertRaises(Exception): 25 | create_store({}) 26 | try: 27 | create_store(lambda *x: {}) 28 | except Exception as e: 29 | self.fail('create_store(lambda: {}) should not have failed') 30 | 31 | def test_passes_initial_action_and_initial_state(self): 32 | store = create_store(reducers['todos'], [ 33 | { 34 | 'id': 1, 35 | 'text': 'Hello' 36 | } 37 | ]) 38 | self.assertEqual(store['get_state'](), [{ 'id': 1, 'text': 'Hello' }]) 39 | 40 | def test_applies_reducer_to_previous_state(self): 41 | store = create_store(reducers['todos']) 42 | self.assertEqual(store['get_state'](), []) 43 | 44 | store['dispatch'](unknown_action()) 45 | self.assertEqual(store['get_state'](), []) 46 | 47 | store['dispatch'](add_todo('Hello')) 48 | self.assertEqual(store['get_state'](), [ 49 | { 50 | 'id': 1, 51 | 'text': 'Hello' 52 | } 53 | ]) 54 | 55 | store['dispatch'](add_todo('World')) 56 | self.assertEqual(store['get_state'](), [ 57 | { 58 | 'id': 1, 59 | 'text': 'Hello' 60 | }, 61 | { 62 | 'id': 2, 63 | 'text': 'World' 64 | } 65 | ]) 66 | 67 | def test_applied_reducer_to_initial_state(self): 68 | store = create_store(reducers['todos'], [ 69 | { 70 | 'id': 1, 71 | 'text': 'Hello' 72 | } 73 | ]) 74 | self.assertEqual(store['get_state'](), [ 75 | { 76 | 'id': 1, 77 | 'text': 'Hello' 78 | } 79 | ]) 80 | 81 | store['dispatch'](unknown_action()) 82 | self.assertEqual(store['get_state'](), [ 83 | { 84 | 'id': 1, 85 | 'text': 'Hello' 86 | } 87 | ]) 88 | 89 | store['dispatch'](add_todo('World')) 90 | self.assertEqual(store['get_state'](), [ 91 | { 92 | 'id': 1, 93 | 'text': 'Hello' 94 | }, 95 | { 96 | 'id': 2, 97 | 'text': 'World' 98 | } 99 | ]) 100 | 101 | def test_preserves_state_when_replacing_reducer(self): 102 | store = create_store(reducers['todos']) 103 | store['dispatch'](add_todo('Hello')) 104 | store['dispatch'](add_todo('World')) 105 | self.assertEqual(store['get_state'](), [ 106 | { 107 | 'id': 1, 108 | 'text': 'Hello' 109 | }, 110 | { 111 | 'id': 2, 112 | 'text': 'World' 113 | } 114 | ]) 115 | 116 | store['replace_reducer'](reducers['todos_reverse']) 117 | self.assertEqual(store['get_state'](), [ 118 | { 119 | 'id': 1, 120 | 'text': 'Hello' 121 | }, 122 | { 123 | 'id': 2, 124 | 'text': 'World' 125 | } 126 | ]) 127 | 128 | store['dispatch'](add_todo('Perhaps')) 129 | self.assertEqual(store['get_state'](), [ 130 | { 131 | 'id': 3, 132 | 'text': 'Perhaps' 133 | }, 134 | { 135 | 'id': 1, 136 | 'text': 'Hello' 137 | }, 138 | { 139 | 'id': 2, 140 | 'text': 'World' 141 | } 142 | ]) 143 | 144 | store['replace_reducer'](reducers['todos']) 145 | self.assertEqual(store['get_state'](), [ 146 | { 147 | 'id': 3, 148 | 'text': 'Perhaps' 149 | }, 150 | { 151 | 'id': 1, 152 | 'text': 'Hello' 153 | }, 154 | { 155 | 'id': 2, 156 | 'text': 'World' 157 | } 158 | ]) 159 | 160 | store['dispatch'](add_todo('Surely')) 161 | self.assertEqual(store['get_state'](), [ 162 | { 163 | 'id': 3, 164 | 'text': 'Perhaps' 165 | }, 166 | { 167 | 'id': 1, 168 | 'text': 'Hello' 169 | }, 170 | { 171 | 'id': 2, 172 | 'text': 'World' 173 | }, 174 | { 175 | 'id': 4, 176 | 'text': 'Surely' 177 | } 178 | ]) 179 | 180 | def test_supports_multiple_subscriptions(self): 181 | store = create_store(reducers['todos']) 182 | listener_a = mock.MagicMock() 183 | listener_b = mock.MagicMock() 184 | 185 | unsubscribe_a = store['subscribe'](listener_a) 186 | store['dispatch'](unknown_action()) 187 | self.assertEqual(len(listener_a.call_args_list), 1) 188 | self.assertEqual(len(listener_b.call_args_list), 0) 189 | 190 | store['dispatch'](unknown_action()) 191 | self.assertEqual(len(listener_a.call_args_list), 2) 192 | self.assertEqual(len(listener_b.call_args_list), 0) 193 | 194 | unsubscribe_b = store['subscribe'](listener_b) 195 | self.assertEqual(len(listener_a.call_args_list), 2) 196 | self.assertEqual(len(listener_b.call_args_list), 0) 197 | 198 | store['dispatch'](unknown_action()) 199 | self.assertEqual(len(listener_a.call_args_list), 3) 200 | self.assertEqual(len(listener_b.call_args_list), 1) 201 | 202 | unsubscribe_a() 203 | self.assertEqual(len(listener_a.call_args_list), 3) 204 | self.assertEqual(len(listener_b.call_args_list), 1) 205 | 206 | store['dispatch'](unknown_action()) 207 | self.assertEqual(len(listener_a.call_args_list), 3) 208 | self.assertEqual(len(listener_b.call_args_list), 2) 209 | 210 | unsubscribe_b() 211 | self.assertEqual(len(listener_a.call_args_list), 3) 212 | self.assertEqual(len(listener_b.call_args_list), 2) 213 | 214 | store['dispatch'](unknown_action()) 215 | self.assertEqual(len(listener_a.call_args_list), 3) 216 | self.assertEqual(len(listener_b.call_args_list), 2) 217 | 218 | unsubscribe_a = store['subscribe'](listener_a) 219 | self.assertEqual(len(listener_a.call_args_list), 3) 220 | self.assertEqual(len(listener_b.call_args_list), 2) 221 | 222 | store['dispatch'](unknown_action()) 223 | self.assertEqual(len(listener_a.call_args_list), 4) 224 | self.assertEqual(len(listener_b.call_args_list), 2) 225 | 226 | def test_only_removes_listener_once_when_unsubscribe_is_called(self): 227 | store = create_store(reducers['todos']) 228 | listener_a = mock.MagicMock() 229 | listener_b = mock.MagicMock() 230 | 231 | unsubscribe_a = store['subscribe'](listener_a) 232 | store['subscribe'](listener_b) 233 | 234 | unsubscribe_a() 235 | unsubscribe_a() 236 | 237 | store['dispatch'](unknown_action()) 238 | self.assertEqual(len(listener_a.call_args_list), 0) 239 | self.assertEqual(len(listener_b.call_args_list), 1) 240 | 241 | def test_only_removes_relevant_listener_when_unsubscribe_is_called(self): 242 | store = create_store(reducers['todos']) 243 | listener = mock.MagicMock() 244 | 245 | store['subscribe'](listener) 246 | unsubscribe_second = store['subscribe'](listener) 247 | 248 | unsubscribe_second() 249 | unsubscribe_second() 250 | 251 | store['dispatch'](unknown_action()) 252 | self.assertEqual(len(listener.call_args_list), 1) 253 | 254 | def test_supports_removing_a_subscription_within_a_subscription(self): 255 | store = create_store(reducers['todos']) 256 | listener_a = mock.MagicMock() 257 | listener_b = mock.MagicMock() 258 | listener_c = mock.MagicMock() 259 | 260 | store['subscribe'](listener_a) 261 | unsub_b = store['subscribe'](lambda: [listener_b(), unsub_b()]) 262 | store['subscribe'](listener_c) 263 | 264 | store['dispatch'](unknown_action()) 265 | store['dispatch'](unknown_action()) 266 | 267 | self.assertEqual(len(listener_a.call_args_list), 2) 268 | self.assertEqual(len(listener_b.call_args_list), 1) 269 | self.assertEqual(len(listener_c.call_args_list), 2) 270 | 271 | def test_delays_unsubscribe_until_the_end_of_current_dispatch(self): 272 | store = create_store(reducers['todos']) 273 | 274 | unsubscribe_handles = [] 275 | def do_unsubscribe_all(): 276 | for unsubscribe in unsubscribe_handles: 277 | unsubscribe() 278 | 279 | listener_1 = mock.MagicMock() 280 | listener_2 = mock.MagicMock() 281 | listener_3 = mock.MagicMock() 282 | 283 | unsubscribe_handles.append(store['subscribe'](lambda: listener_1())) 284 | unsubscribe_handles.append(store['subscribe'](lambda: [listener_2(), do_unsubscribe_all()])) 285 | unsubscribe_handles.append(store['subscribe'](lambda: listener_3())) 286 | 287 | store['dispatch'](unknown_action()) 288 | self.assertEqual(len(listener_1.call_args_list), 1) 289 | self.assertEqual(len(listener_2.call_args_list), 1) 290 | self.assertEqual(len(listener_3.call_args_list), 1) 291 | 292 | store['dispatch'](unknown_action()) 293 | self.assertEqual(len(listener_1.call_args_list), 1) 294 | self.assertEqual(len(listener_2.call_args_list), 1) 295 | self.assertEqual(len(listener_3.call_args_list), 1) 296 | 297 | def test_delays_subscribe_until_the_end_of_current_dispatch(self): 298 | store = create_store(reducers['todos']) 299 | 300 | listener_1 = mock.MagicMock() 301 | listener_2 = mock.MagicMock() 302 | listener_3 = mock.MagicMock() 303 | 304 | listener_3_added = False 305 | def maybe_add_third_listener(): 306 | nonlocal listener_3_added 307 | if not listener_3_added: 308 | listener_3_added = True 309 | store['subscribe'](lambda: listener_3()) 310 | 311 | store['subscribe'](lambda: listener_1()) 312 | store['subscribe'](lambda: [listener_2(), maybe_add_third_listener()]) 313 | 314 | store['dispatch'](unknown_action()) 315 | self.assertEqual(len(listener_1.call_args_list), 1) 316 | self.assertEqual(len(listener_2.call_args_list), 1) 317 | self.assertEqual(len(listener_3.call_args_list), 0) 318 | 319 | store['dispatch'](unknown_action()) 320 | self.assertEqual(len(listener_1.call_args_list), 2) 321 | self.assertEqual(len(listener_2.call_args_list), 2) 322 | self.assertEqual(len(listener_3.call_args_list), 1) 323 | 324 | def test_uses_last_snapshot_of_subscribers_during_nested_dispatch(self): 325 | store = create_store(reducers['todos']) 326 | 327 | listener_1 = mock.MagicMock() 328 | listener_2 = mock.MagicMock() 329 | listener_3 = mock.MagicMock() 330 | listener_4 = mock.MagicMock() 331 | 332 | unsubscribe_4 = None 333 | unsubscribe_1 = None 334 | def callback_for_listener_1(): 335 | nonlocal unsubscribe_1, unsubscribe_4 336 | listener_1() 337 | self.assertEqual(len(listener_1.call_args_list), 1) 338 | self.assertEqual(len(listener_2.call_args_list), 0) 339 | self.assertEqual(len(listener_3.call_args_list), 0) 340 | self.assertEqual(len(listener_4.call_args_list), 0) 341 | 342 | unsubscribe_1() 343 | unsubscribe_4 = store['subscribe'](listener_4) 344 | store['dispatch'](unknown_action()) 345 | 346 | self.assertEqual(len(listener_1.call_args_list), 1) 347 | self.assertEqual(len(listener_2.call_args_list), 1) 348 | self.assertEqual(len(listener_3.call_args_list), 1) 349 | self.assertEqual(len(listener_4.call_args_list), 1) 350 | 351 | unsubscribe_1 = store['subscribe'](callback_for_listener_1) 352 | store['subscribe'](listener_2) 353 | store['subscribe'](listener_3) 354 | 355 | store['dispatch'](unknown_action()) 356 | self.assertEqual(len(listener_1.call_args_list), 1) 357 | self.assertEqual(len(listener_2.call_args_list), 2) 358 | self.assertEqual(len(listener_3.call_args_list), 2) 359 | self.assertEqual(len(listener_4.call_args_list), 1) 360 | 361 | unsubscribe_4() 362 | store['dispatch'](unknown_action()) 363 | self.assertEqual(len(listener_1.call_args_list), 1) 364 | self.assertEqual(len(listener_2.call_args_list), 3) 365 | self.assertEqual(len(listener_3.call_args_list), 3) 366 | self.assertEqual(len(listener_4.call_args_list), 1) 367 | 368 | def test_provides_up_to_date_state_when_subscriber_is_notified(self): 369 | store = create_store(reducers['todos']) 370 | def callback(): 371 | state = store['get_state']() 372 | self.assertEqual(state, [ 373 | { 374 | 'id': 1, 375 | 'text': 'Hello' 376 | } 377 | ]) 378 | store['dispatch'](add_todo('Hello')) 379 | 380 | def test_only_accepts_plain_objects(self): 381 | store = create_store(reducers['todos']) 382 | 383 | try: 384 | store['dispatch'](unknown_action()) 385 | except Exception: 386 | self.fail('Should not have thrown exception') 387 | 388 | class AwesomeMap: 389 | def __init__(self): 390 | self.x = 1 391 | 392 | for non_object in [None, 42, 'hey', AwesomeMap()]: 393 | with self.assertRaises(Exception): 394 | store['dispatch'](non_object) 395 | 396 | def test_handles_nested_dispatches_gracefully(self): 397 | def foo(state, action={}): 398 | if state is None: 399 | state = 0 400 | if action.get('type') == 'foo': 401 | return 1 402 | return state 403 | 404 | def bar(state, action={}): 405 | if state is None: 406 | state = 0 407 | if action.get('type') == 'bar': 408 | return 2 409 | else: 410 | return state 411 | 412 | store = create_store(combine_reducers({ 'foo': foo, 'bar': bar })) 413 | def kinda_component_did_update(): 414 | state = store['get_state']() 415 | if state.get('bar') == 0: 416 | store['dispatch']({ 'type': 'bar' }) 417 | store['subscribe'](kinda_component_did_update) 418 | store['dispatch']({ 'type': 'foo' }) 419 | 420 | self.assertEqual(store['get_state'](), { 421 | 'foo': 1, 422 | 'bar': 2 423 | }) 424 | 425 | def test_does_not_allow_dispatch_from_within_reducer(self): 426 | store = create_store(reducers['dispatch_in_middle_of_reducer']) 427 | with self.assertRaises(Exception) as e: 428 | store['dispatch'](dispatch_in_middle(lambda: store['dispatch'](unknown_action()))) 429 | self.assertTrue('may not dispatch' in str(e.exception)) 430 | 431 | def test_throws_if_action_type_is_none(self): 432 | store = create_store(reducers['todos']) 433 | 434 | with self.assertRaises(Exception) as e: 435 | store['dispatch']({ 'type': None }) 436 | self.assertTrue('Actions may not have an undefined "type"' in str(e.exception)) 437 | 438 | def test_does_not_throw_if_action_type_is_falsy(self): 439 | store = create_store(reducers['todos']) 440 | try: 441 | store['dispatch']({ 'type': False }) 442 | store['dispatch']({ 'type': 0 }) 443 | store['dispatch']({ 'type': '' }) 444 | except Exception: 445 | self.fail('These should not have raised an exception') 446 | 447 | def test_accepts_enhancer_as_third_argument(self): 448 | empty_array = [] 449 | def spy_enhancer(vanilla_create_store): 450 | def enhancer(*args): 451 | self.assertEqual(args[0], reducers['todos']) 452 | self.assertEqual(args[1], empty_array) 453 | self.assertEqual(len(args), 2) 454 | vanilla_store = vanilla_create_store(*args) 455 | vanilla_store['dispatch'] = mock.MagicMock(side_effect=vanilla_store['dispatch']) 456 | return vanilla_store 457 | return enhancer 458 | 459 | store = create_store(reducers['todos'], empty_array, spy_enhancer) 460 | action = add_todo('Hello') 461 | store['dispatch'](action) 462 | self.assertEqual(store['dispatch'].call_args_list, [mock.call(action)]) 463 | self.assertEqual(store['get_state'](), [{ 464 | 'id': 1, 465 | 'text': 'Hello' 466 | }]) 467 | 468 | def test_accepts_enhancer_as_second_argument_if_no_initial_state(self): 469 | def spy_enhancer(vanilla_create_store): 470 | def enhancer(*args): 471 | self.assertEqual(args[0], reducers['todos']) 472 | self.assertEqual(args[1], None) 473 | self.assertEqual(len(args), 2) 474 | vanilla_store = vanilla_create_store(*args) 475 | vanilla_store['dispatch'] = mock.MagicMock(side_effect=vanilla_store['dispatch']) 476 | return vanilla_store 477 | return enhancer 478 | 479 | store = create_store(reducers['todos'], spy_enhancer) 480 | action = add_todo('Hello') 481 | store['dispatch'](action) 482 | self.assertEqual(store['dispatch'].call_args_list, [mock.call(action)]) 483 | self.assertEqual(store['get_state'](), [{ 484 | 'id': 1, 485 | 'text': 'Hello' 486 | }]) 487 | 488 | def test_throws_if_enhancer_is_neither_undefined_or_a_function(self): 489 | with self.assertRaises(Exception): 490 | create_store(reducers['todos'], None, {}) 491 | with self.assertRaises(Exception): 492 | create_store(reducers['todos'], None, []) 493 | with self.assertRaises(Exception): 494 | create_store(reducers['todos'], None, False) 495 | try: 496 | create_store(reducers['todos'], None, None) 497 | create_store(reducers['todos'], None, lambda x: x) 498 | create_store(reducers['todos'], lambda x: x) 499 | create_store(reducers['todos'], []) 500 | create_store(reducers['todos'], {}) 501 | except Exception: 502 | self.fail('Should not have thrown an exception') 503 | 504 | def test_throws_if_next_reducer_is_not_a_function(self): 505 | store = create_store(reducers['todos']) 506 | with self.assertRaises(Exception) as e: 507 | store['replace_reducer']() 508 | self.assertTrue('Expected next_reducer to be a function' in str(e.exception)) 509 | 510 | try: 511 | store['replace_reducer'](lambda *x: x) 512 | except Exception: 513 | self.fail('Should not have raised an exception') 514 | 515 | def test_throws_if_listener_is_not_a_function(self): 516 | store = create_store(reducers['todos']) 517 | 518 | with self.assertRaises(Exception): 519 | store['subscribe']() 520 | with self.assertRaises(Exception): 521 | store['subscribe']('') 522 | with self.assertRaises(Exception): 523 | store['subscribe'](None) 524 | 525 | 526 | 527 | if __name__ == '__main__': 528 | unittest.main() 529 | --------------------------------------------------------------------------------