diff --git a/README.rst b/README.rst index 0e6ae94f84..65b63f9015 100644 --- a/README.rst +++ b/README.rst @@ -2,6 +2,10 @@ Revived ======= +.. image:: https://readthedocs.org/projects/revived/badge/?version=latest + :target: http://revived.readthedocs.io/en/latest/?badge=latest + :alt: Documentation Status + --------------------------------------------------------------------- A predictable state container for python *heavily* inspired by Redux_ --------------------------------------------------------------------- @@ -14,10 +18,17 @@ do pretty much the same job in the most pythonic way possible. Contents -------- +* Documentation_ * Installation_ * Examples_ * Contribute_ -* Documentation_ + +Documentation +------------- + +Currently the documentation is not buliding into ReadTheDocs (see +`issue #11 `_). You can +build the documentation locally. Check out Contribute_ section. Installation ------------ @@ -71,11 +82,6 @@ Contribute #. Create a pull request. #. Profit :) -Documentation -------------- - -More detailed documentation is **coming soon**. - .. _Redux: http://redux.js.org/ .. _`Redux API`: Redux_ .. _virtualenv: https://virtualenv.pypa.io/en/stable/ diff --git a/docs/api/action.rst b/docs/api/action.rst index 7bbf34f907..35d13bd814 100644 --- a/docs/api/action.rst +++ b/docs/api/action.rst @@ -1,6 +1,5 @@ -======================================= -``revived.action`` module documentation -======================================= +Action (``revived.action``) module documentation +================================================ .. automodule:: revived.action :members: diff --git a/docs/api/reducer.rst b/docs/api/reducer.rst index a116ab7b17..a8df0a9ac3 100644 --- a/docs/api/reducer.rst +++ b/docs/api/reducer.rst @@ -1,6 +1,5 @@ -======================================== -``revived.reducer`` module documentation -======================================== +Reducer (``revived.reducer``) module documentation +================================================== .. automodule:: revived.reducer :members: diff --git a/docs/api/store.rst b/docs/api/store.rst index be68306c0f..d9d3b77d0b 100644 --- a/docs/api/store.rst +++ b/docs/api/store.rst @@ -1,6 +1,5 @@ -====================================== -``revived.store`` module documentation -====================================== +Store (``revived.store``) module documentation +============================================== .. automodule:: revived.store :members: diff --git a/docs/conf.py b/docs/conf.py index af9d9387cb..72feee5dc3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -33,8 +33,11 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ['sphinx.ext.autodoc', - 'sphinx.ext.viewcode'] +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.viewcode', + 'sphinx_autodoc_typehints' +] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] diff --git a/requirements.txt b/requirements.txt index dbffee2bff..701ad94728 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ mypy pytest -sphinx-autobuild Sphinx -sphinx_rtd_theme \ No newline at end of file +sphinx_rtd_theme +sphinx-autobuild +sphinx-autodoc-typehints \ No newline at end of file diff --git a/revived/action.py b/revived/action.py index 02b1c180a3..61f99f4d32 100644 --- a/revived/action.py +++ b/revived/action.py @@ -1,8 +1,59 @@ -"""Action module. - +""" This module implements helper functions and classes that can be used to define -actions and action creators, in the same fashion of redux ones, but using -decorators instead of anonymous functions. +``actions`` and ``action creators``, in the same fashion of redux ones, but +using decorators instead of anonymous functions. + +Actions and action creators +=========================== + +Actions are payloads of information that send data from your application to your +``store``. They are the only source of information for the ``store``. You send +them to the store using :any:`revived.store.Store.dispatch`. + +Actions are instances of :any:`revived.action.Action`. They have a type +property. Types should be defined in an enum inheriting +:any:`revived.action.ActionType`. Once your app is large enough, you may want to +move them into a separate module. + +Action creators are exactly that: functions that create ``actions``. It's easy +to conflate the terms ``action`` and ``action creator``, so do your best to use +the *proper term*. + +Define action types +=================== + +While you are free to define the action type enum as he prefers, it is +**strongly suggested** to write them down in this way: + +.. code:: python + + from revived.actions import ActionType as BaseActionType + + # custom action types enum + class ActionType(BaseActionType): + AN_ACTION_TYPE = 'an_action_type' + + +Define action creators +====================== + +While it is possible to explicitly build :any:`revived.action.Action` instances +directly, it is **strongly suggested** to create ``actions`` using ``action +creators``. + +Assuming you are in the same module of the ``action types`` defined previously, +you can define ``action creators`` in this way: + +.. code:: python + + # define the action creator that takes two arguments and returns a + # dictionary with those arguments in an arbitrary way. + @action(ActionTypes.AN_ACTION_TYPE) + def an_action_type_with_parameters(param1, param2): + return {'1': param1, '2': param2} + + # create the action object + action_obj = an_action_type_with_parameters(1, 2) """ from enum import Enum from enum import unique @@ -21,7 +72,10 @@ class ActionType(str, Enum): for each module. Usually there would be no need for such a feature-less class, but it is pretty handy while using type hints. """ - pass + # FIXME: this method is added to avoid sphinx_autodoc_typehints errors: + # see https://github.com/agronholm/sphinx-autodoc-typehints/issues/12 + def __init__(*args, **kwargs): + pass class Action(dict): @@ -29,36 +83,33 @@ class Action(dict): Redux actions are plain objects - ie: python dicts - but having a specific class here helps for type hinting. The rationale behind this is that we - store the type as *metadata* instead of part of the action data itself. - """ + store the type as ``metadata`` instead of part of the action data itself. - def __init__(self, action_type: ActionType, data: Optional[Dict[str, Any]]=None) -> None: - """Constructor. + While ``action_type`` is going to be stored as ``metadata``, the + :any:`revived.action.Action` instance itself is going to behave exactly as a + dict, with all the action data inside. - Builds an action using the specified action type and optional data. - While action_type is going to be stored as *metadata*, the Action object - itself is going to behave exactly as a dict, with all the action data - inside. + :param action_type: The type of the action. + :param data: An optional dict containing data. No restriction on depth + and members type, as long as the keys are strings. + """ - :param action_type: The type of the action. - :param data: An optional dict containing data. No restriction on depth - and members type, as long as the keys are strings. - """ + def __init__(self, action_type: ActionType, data: Optional[Dict[str, Any]]=None) -> None: super().__init__(**(data or {})) self.type = action_type def action(action_type: ActionType) -> Callable[[Callable], Callable]: - """Decorator function to use as an *action creator* factory. + """Decorator function to use as an ``action creator`` factory. This helper function is used to create action creators. The idea behind this - is that we just want to define the relevant data as a dict, instead of - complex objects. This decorator will take care of simple-dict-returing - functions preparing the proper Action object that is needed by the revived - API. + is that we just want to define the relevant data as a ``dict``, instead of + complex objects. This decorator will take care of simple-dict-returning + functions preparing the proper :any:`revived.action.Action` instance that + is needed by the revived API. :param action_type: The type of the action. - :returns: The action creator. + :returns: The ``action creator``. """ def wrap(f: Callable[..., Dict]) -> Callable[..., Action]: @wraps(f) diff --git a/revived/reducer.py b/revived/reducer.py index 92893c5a43..aece0309e0 100644 --- a/revived/reducer.py +++ b/revived/reducer.py @@ -1,8 +1,169 @@ -"""Reducer module. - +""" This module implements helper functions and classes that can be used to define reducers in the same fashion of redux ones, but using decorators instead of anonymous functions. + +Things you **should never do** inside a reducer: + +* Mutate its arguments; +* Perform side effects like API calls and routing transitions; +* Call **non-pure** functions. + +**Given the same arguments, it should calculate the next state and return it. No +surprises. No side effects. No API calls. No mutations. Just a calculation.** + +Create a reducer +================ + +A reducer is a function that looks like this: + +.. code:: python + + def dummy(prev, action): + next = prev + if action.type == ActionType.DUMMY_ACTION_TYPE: + # Do something + return next + +In order to decrease the amount of required boilerplate ``revived`` makes use of +a lot of python goodies, especially **decorators**. + +While every function can be used as ``reducer`` (as long as it takes the proper +parameters), the easiest way to create a ``reducer`` that handles a specific +type of ``actions`` is to use the :any:`revived.reducer.reducer` decorator. + +.. code:: python + + @reducer(ActionType.DUMMY_ACTION_TYPE) + def dummy(prev, action): + next = prev + # Do something + return next + + +Combine reducers +================ + +You can naively combine several ``reducers`` in this way: + +.. code:: python + + def dummy(prev, action): + next = prev + if action.type == ActionType.DUMMY_ACTION_TYPE1: + # Do something + return next + elif action.type == ActionType.DUMMY_ACTION_TYPE2: + # Do something different + return next + else: + return next + +but this is going to make your ``reducer`` function huge and barely readable. +:any:`revived.reducer` contains utility functions that allows you to create much +more readable ``reducers``. + +Reducers can (*and should*) be combined. You can easily do this combination +using :any:`revived.reducer.combine_reducers`. + +The following example will produce a ``combined reducer`` where both the +``reducers`` will handle the whole subtree passed to it: exactly the same result of +the previous snippet of code! + +.. code:: python + + @reducer(ActionType.DUMMY_ACTION_TYPE1) + def dummy1(prev, action): + next = prev + # Do something + return next + + @reducer(ActionType.DUMMY_ACTION_TYPE2) + def dummy2(prev, action): + next = prev + # Do something + return next + + combined_reducer = combine_reducers(dummy1, dummy2) + +**Note**: a ``combined reducer`` is a ``reducer`` and can be combined again with +other reducers allowing you to creare every structure you will ever need in your +app. + +Pass a subtree of the state +--------------------------- + +If you want it is possible to pass to a reducer only a subtree of the state +passed to the ``combined reducer``. To do this you should use keyword arguments +in this way: + +.. code:: python + + @reducer(ActionType.DUMMY_ACTION_TYPE1) + def dummy1(prev, action): + next = prev + # Do something + return next + + @reducer(ActionType.DUMMY_ACTION_TYPE2) + def dummy2(prev, action): + next = prev + # Do something + return next + + combined_reducer = combine_reducers(dummy1, dummy_subtree=dummy2) + +In this example ``dummy1`` will receive the whole subtree passed to the +``combined_reducer`` while ``dummy2`` will only receive the ``dummy_subtree`` +subtree. + +Create a reducer module +======================= + +A ``reducer module`` is an utility object that behave exactly like a single +``reducer``, but permits to register more ``reducers`` into it. You will use it +to define a bunch of ``reducers`` that are all handling the same subtree of the +``state``. + +Note that this is *only a helper construct*, because the following snippet of +code: + +.. code:: python + + mod = Module() + + @mod.reduder(ActionType.DUMMY_ACTION_TYPE1) + def dummy1(prev, action): + next = prev + # Do something + return next + + @mod.reduder(ActionType.DUMMY_ACTION_TYPE2) + def dummy2(prev, action): + next = prev + # Do something + return next + +has exactly the same result of: + +.. code:: python + + @reducer(ActionType.DUMMY_ACTION_TYPE1) + def dummy1(prev, action): + next = prev + # Do something + return next + + @reducer(ActionType.DUMMY_ACTION_TYPE2) + def dummy2(prev, action): + next = prev + # Do something + return next + + module_reducer = combine_reducers(dummy1, dummy2) + +And of course **you can combine** a ``reducer module`` with other ``reducers`` +and ``reducer modules``. """ from .action import Action from .action import ActionType @@ -23,52 +184,10 @@ class Module: reducer decorator and then combining all the defined reducers as top-level reducers. The module instance will work exactly as a reducer function, but will call all the registered reducers. The call order is not guaranteed. - - This snippet: - - .. code:: python - - module_reducer = Module() - - @mod.reduder(ActionType.DUMMY_ACTION_TYPE1) - def dummy1(prev, action): - next = prev - # Do something - return next - - @mod.reduder(ActionType.DUMMY_ACTION_TYPE2) - def dummy2(prev, action): - next = prev - # Do something - return next - - has exactly the same result of: - - .. code:: python - - @reducer(ActionType.DUMMY_ACTION_TYPE1) - def dummy1(prev, action): - next = prev - # Do something - return next - - @reducer(ActionType.DUMMY_ACTION_TYPE2) - def dummy2(prev, action): - next = prev - # Do something - return next - - module_reducer = combine_reducers(dummy1, dummy2) """ def __init__(self) -> None: - """Constructor. - - Creates a module - ie. an aggregate of reducers - that works like a - single reducer on a specific state. - """ - self._reducers = [] # type: List - ReducerList + self._reducers = [] # type: ReducerList def __call__(self, prev: Any, action: Action): """Lets the module work like a reducer. diff --git a/revived/store.py b/revived/store.py index 5fb76c000e..d566b2a5c2 100644 --- a/revived/store.py +++ b/revived/store.py @@ -1,7 +1,88 @@ -"""Store module. - -This module implements the global state store, and the init action and +""" +This module implements the **global state store**, and the ``INIT`` action and action_creator. This is the entry point of the revived module. + +Rationale behind the ``Store`` +============================== + +:any:`revived.store.Store` is the object that brings ``actions`` and +``reducers``. The store has the following responsibilities: + +* Holds application state; +* Allows access to state via :any:`revived.store.Store.get_state`; +* Allows state to be updated via :any:`revived.store.Store.dispatch`; +* Registers listeners via :any:`revived.store.Store.subscribe` or + :any:`revived.store.Store.subscriber` decorator; +* Handles unregistering of listeners via the function returned by + :any:`revived.store.Store.subscribe` or via the property + :any:`revived.store.Subscriber.unsubscribe` of the + :any:`revived.store.Subscriber` instance. + +It's important to note that you'll only have a single store in your application. +When you want to split your data handling logic, you'll use ``reducer`` +composition instead of many stores. + +Dispatch actions +================ + +To dispatch actions the :any:`revived.store.Store.dispatch` method should be +used, passing as parameter the result of an action_creator. See more in +:any:`revived.action.action` and :any:`revived.action.Action`. + +.. code:: python + + # create the store object + store = Store(root_reducer) + + # register subscribers + # ... + + # dispatch an action using the action_creator + store.dispatch(an_action_creator(a_parameter, another_parameter)) + +Subscribe and unsubscribe to state changes +========================================== + +There are two ways to **subscribe** and **usubscribe** to store changes: using +the :any:`revived.store.Store.subscribe` method or the +:any:`revived.store.Store.subscriber` decorator. Both approaches are equivalent +and the choice should be just made based on your taste. + +Subscribe using :any:`revived.store.Store.subscribe` +---------------------------------------------------- + +.. code:: python + + # create the store object + store = Store(root_reducer) + + # define the function + def a_subscriber(): + # do something! + pass + + # subscribe the function + unsubscribe = store.subscribe(a_subscriber) + + # unsubscribe the function + unsubscribe() + +Subscribe using :any:`revived.store.Store.subscriber` +----------------------------------------------------- + +.. code:: python + + # create the store object + store = Store(root_reducer) + + # define and subscribe the function + @store.subscriber + def a_subscriber(): + # do something! + pass + + # unsubscribe the function + a_subscriber.unsubscribe() """ from .action import action from .action import Action @@ -18,7 +99,7 @@ class ActionType(BaseActionType): """Action types for the store module. - Basically the only type here is the **INIT** one. Reducers should wait for + Basically the only type here is the ``INIT`` one. Reducers should wait for this action to create the initial state for the state subpath they are responsible of. """ @@ -27,98 +108,71 @@ class ActionType(BaseActionType): @action(ActionType.INIT) def init(): - """Action creator for the init aciton. + """Action creator for the init action. """ pass class DispatchInReducerError(Exception): - """Exception raised when a dispatch is called inside a reducer. + """Raised when :any:`revived.store.Store.dispatch` is called in a reducer. """ - pass + # FIXME: this method is added to avoid sphinx_autodoc_typehints errors: + # see https://github.com/agronholm/sphinx-autodoc-typehints/issues/12 + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) class Subscriber: - """Wrapper around a subscriber function with unsubscribe property. + """Wrapper around a subscriber function with the ``unsubscribe`` property. - Creating a subscriber via decorator it is not possible to return the - unsubscribe function. So a Subscriber is created around the callback, that - contains the unsubscribe function to be used to properly unregister the - subscriber. - """ - def __init__(self, callback: Callable[[], None], unsubscribe: Callable[[], None]) -> None: - """Constructor. + While creating a subscriber using the decorator it is not possible to return + the ``unsubscribe`` function. So a :any:`revived.store.Subscriber` is + created wrapping the callback, that contains the + :any:`revived.store.Subscriber.unsubscribe` function to be used to properly + unregister the subscriber. - Creates a subscriber wrapper around a callback, with the provided - unsubscribe function. + :param callback: The callback to be wrapped into the subscriber. + :param unsubscribe: The unsubscribe function for the subscriber. + """ - :param callback: The callback to be wrapped into the subscriber. - :param unsubscribe: The unsubscribe function for the subscriber. - """ + def __init__(self, callback: Callable[[], None], unsubscribe: Callable[[], None]) -> None: self.callback = callback - self.unsubscribe = unsubscribe + self._unsubscribe = unsubscribe def __call__(self) -> None: """Calls the wrapped subscriber. """ self.callback() + @property + def unsubscribe(self) -> Callable[[], None]: + """Property containing the ``unsubscribe`` function. + + :returns: The ``unsubscribe`` function for the subscriber. + """ + return self._unsubscribe + class Store: - """Container object of the global state. + """Container object for the global state. This object is responsible of the global state. Its main responsibilities are: - * Keeping track of all the subscribers, and call them on state changes. - * Keeping reference to the reducer to be used and call it to proprly handle - state changes. - - There are two ways to subscribe and usubscribe to store changes: - - #. using the ``Store.subscribe`` method: - - .. code:: python - # create the store object - store = Store(root_reducer) + * Keeping track of all the *subscribers*, and call them on **state + changes**. + * Keeping reference to the *reducer* to be used and call it to properly + handle **state changes**. - # define the function - def a_subscriber(): - # do something! - pass + Creates the store, using the given function as ``reducer``. At the + beginning no callback is subscribed to *store changes*. It is possible + to add subscribers later, while there is no way - *at the moment* - to + replace the reducer. - # subscribe the function - unsubscribe = store.subscribe(a_subscriber) - - # unsubscribe the function - unsubscribe() - - #. using the ``Store.subscriber`` decorator: - - .. code:: python - - # create the store object - store = Store(root_reducer) - - # define and subscribe the function - @store.subscriber - def a_subscriber(): - # do something! - pass - - # unsubscribe the function - a_subscriber.unsubscribe() + :param reducer: The root reducer. """ - def __init__(self, reducer: Union[Reducer, Module]) -> None: - """Constructor. - - Creates the store, using the given function as reducer. At the beginning - no callback is subscribed to store changes. It is possible to add - subscribers later, while there is no way - at the moment - to replace - the reducer. - :param reducer: The root reducer. - """ + def __init__(self, reducer: Union[Reducer, Module]) -> None: self._reducer = reducer self._state = None # type: Any @@ -128,32 +182,34 @@ def __init__(self, reducer: Union[Reducer, Module]) -> None: self.dispatch(init()) def subscribe(self, callback: Callable[[], None]) -> Callable[[], None]: - """Subscribes a callback to state changes. + """Subscribes a callback to *state changes*. Every time the state changes, the callback is called. No parameters are - passed to the callback. It is responsibility of the store handler to - actually connect the store with the caller. The returned function can be - called without arguments to unsubscribe the callback. + passed to the callback. It is responsibility of the callback to actually + connect the store with the caller. The returned function can be called + without arguments to unsubscribe the callback. :param callback: The callback to be subscribed. - :returns: The unsubscribe functions. + :returns: The unsubscribe function. """ key = uuid.uuid1() self._subscribers[key] = callback - def unsubscribe(): + def unsubscribe() -> None: self._subscribers.pop(key, None) return unsubscribe def subscriber(self, callback: Callable[[], None]) -> Subscriber: - """Decorator function to subscribe a function to store changes. + """Decorator function to subscribe a function to *store changes*. The subscribed function will be called every time the internal state of the store changes. + **NOTE: The decorator function will return the function itself**. To - unsubscribe the callback the user should use the *unsubscribe* function - attached into the callback. + unsubscribe the callback the user should use the + :any:`revived.store.Subscriber.unsubscribe` function attached into the + callback. :param callback: The callback to be subscribed. :returns: The callback itself. @@ -164,14 +220,16 @@ def subscriber(self, callback: Callable[[], None]) -> Subscriber: return s def dispatch(self, action: Action) -> None: - """Dispatches an action. + """Dispatches an ``action``. + + This is the only piece of code responsible of *dispatching actions*. + When an ``action`` is dispatched, the state is changed according to the + defined root reducer and all the subscribers are called. - This is the only piece of code responsible of dispatching actions. When - an action is dispatched, the state is changed according to the defined - root reducer and all the subscribers are called. **The calling order is - not guaranteed**. + **The calling order is not guaranteed**. - :param action: The action that should be dispatched. + :param action: The ``action`` that should be dispatched. + :raises: :class:`revived.store.DispatchInReducerError` """ if self._is_reducing: raise DispatchInReducerError