diff --git a/dash/_utils.py b/dash/_utils.py index dcd83b1c26..73b07d22a0 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -116,3 +116,30 @@ def create_callback_id(output): return '{}.{}'.format( output.component_id, output.component_property ) + + +class RedisDict: + + def __init__(self, redis_kwargs): + import redis + import dill + self.serializer = dill + self.r = redis.Redis(**redis_kwargs) + + def __iter__(self): + + for k in self.r.keys(): + yield k.decode('utf-8') + + def __getitem__(self, k): + + return self.serializer.loads(self.r.get(k)) + + def __setitem__(self, k, v): + + self.r.set(k, self.serializer.dumps(v)) + + def items(self): + + for k in self.r.keys(): + yield k.decode('utf-8'), self[k] diff --git a/dash/dash.py b/dash/dash.py index deb28d76fc..0cb6bf8024 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -34,6 +34,7 @@ from . import _watch from ._utils import get_asset_path as _get_asset_path from ._utils import create_callback_id as _create_callback_id +from ._utils import RedisDict from ._configs import (get_combined_config, pathname_configs) from .version import __version__ @@ -224,6 +225,7 @@ def __init__( suppress_callback_exceptions=None, show_undo_redo=False, plugins=None, + callback_redis=None, **obsolete): for key in obsolete: @@ -293,7 +295,10 @@ def __init__( ) # list of dependencies - self.callback_map = {} + if callback_redis is None: + self.callback_map = {} + else: + self.callback_map = RedisDict(callback_redis) # index_string has special setter so can't go in config self._index_string = '' @@ -951,7 +956,7 @@ def duplicate_check(): else: def duplicate_check(): return callback_id in callbacks - if duplicate_check(): + if duplicate_check() and isinstance(self.callback_map, dict): if is_multi: msg = ''' Multi output {} contains an `Output` object @@ -974,110 +979,6 @@ def duplicate_check(): ).replace(' ', '') raise exceptions.DuplicateCallbackOutput(msg) - @staticmethod - def _validate_callback_output(output_value, output): - valid = [str, dict, int, float, type(None), Component] - - def _raise_invalid(bad_val, outer_val, path, index=None, - toplevel=False): - bad_type = type(bad_val).__name__ - outer_id = "(id={:s})".format(outer_val.id) \ - if getattr(outer_val, 'id', False) else '' - outer_type = type(outer_val).__name__ - raise exceptions.InvalidCallbackReturnValue(''' - The callback for `{output:s}` - returned a {object:s} having type `{type:s}` - which is not JSON serializable. - - {location_header:s}{location:s} - and has string representation - `{bad_val}` - - In general, Dash properties can only be - dash components, strings, dictionaries, numbers, None, - or lists of those. - '''.format( - output=repr(output), - object='tree with one value' if not toplevel else 'value', - type=bad_type, - location_header=( - 'The value in question is located at' - if not toplevel else - '''The value in question is either the only value returned, - or is in the top level of the returned list,''' - ), - location=( - "\n" + - ("[{:d}] {:s} {:s}".format(index, outer_type, outer_id) - if index is not None - else ('[*] ' + outer_type + ' ' + outer_id)) - + "\n" + path + "\n" - ) if not toplevel else '', - bad_val=bad_val - ).replace(' ', '')) - - def _value_is_valid(val): - return ( - # pylint: disable=unused-variable - any([isinstance(val, x) for x in valid]) or - type(val).__name__ == 'unicode' - ) - - def _validate_value(val, index=None): - # val is a Component - if isinstance(val, Component): - # pylint: disable=protected-access - for p, j in val._traverse_with_paths(): - # check each component value in the tree - if not _value_is_valid(j): - _raise_invalid( - bad_val=j, - outer_val=val, - path=p, - index=index - ) - - # Children that are not of type Component or - # list/tuple not returned by traverse - child = getattr(j, 'children', None) - if not isinstance(child, (tuple, - collections.MutableSequence)): - if child and not _value_is_valid(child): - _raise_invalid( - bad_val=child, - outer_val=val, - path=p + "\n" + "[*] " + type(child).__name__, - index=index - ) - - # Also check the child of val, as it will not be returned - child = getattr(val, 'children', None) - if not isinstance(child, (tuple, collections.MutableSequence)): - if child and not _value_is_valid(child): - _raise_invalid( - bad_val=child, - outer_val=val, - path=type(child).__name__, - index=index - ) - - # val is not a Component, but is at the top level of tree - else: - if not _value_is_valid(val): - _raise_invalid( - bad_val=val, - outer_val=type(val).__name__, - path='', - index=index, - toplevel=True - ) - - if isinstance(output_value, list): - for i, val in enumerate(output_value): - _validate_value(val, index=i) - else: - _validate_value(output_value) - # pylint: disable=dangerous-default-value def clientside_callback( self, clientside_function, output, inputs=[], state=[]): @@ -1158,17 +1059,6 @@ def callback(self, output, inputs=[], state=[]): callback_id = _create_callback_id(output) multi = isinstance(output, (list, tuple)) - self.callback_map[callback_id] = { - 'inputs': [ - {'id': c.component_id, 'property': c.component_property} - for c in inputs - ], - 'state': [ - {'id': c.component_id, 'property': c.component_property} - for c in state - ], - } - def wrap_func(func): @wraps(func) def add_context(*args, **kwargs): @@ -1227,7 +1117,7 @@ def add_context(*args, **kwargs): cls=plotly.utils.PlotlyJSONEncoder ) except TypeError: - self._validate_callback_output(output_value, output) + validate_callback_output(output_value, output) raise exceptions.InvalidCallbackReturnValue(''' The callback for property `{property:s}` of component `{id:s}` returned a value @@ -1243,7 +1133,17 @@ def add_context(*args, **kwargs): return jsonResponse - self.callback_map[callback_id]['callback'] = add_context + self.callback_map[callback_id] = { + 'inputs': [ + {'id': c.component_id, 'property': c.component_property} + for c in inputs + ], + 'state': [ + {'id': c.component_id, 'property': c.component_property} + for c in state + ], + 'callback': add_context + } return add_context @@ -1692,3 +1592,107 @@ def run_server( self.logger.info('Debugger PIN: %s', debugger_pin) self.server.run(port=port, debug=debug, **flask_run_options) + + +def validate_callback_output(output_value, output): + valid = [str, dict, int, float, type(None), Component] + + def _raise_invalid(bad_val, outer_val, path, index=None, + toplevel=False): + bad_type = type(bad_val).__name__ + outer_id = "(id={:s})".format(outer_val.id) \ + if getattr(outer_val, 'id', False) else '' + outer_type = type(outer_val).__name__ + raise exceptions.InvalidCallbackReturnValue(''' + The callback for `{output:s}` + returned a {object:s} having type `{type:s}` + which is not JSON serializable. + + {location_header:s}{location:s} + and has string representation + `{bad_val}` + + In general, Dash properties can only be + dash components, strings, dictionaries, numbers, None, + or lists of those. + '''.format( + output=repr(output), + object='tree with one value' if not toplevel else 'value', + type=bad_type, + location_header=( + 'The value in question is located at' + if not toplevel else + '''The value in question is either the only value returned, + or is in the top level of the returned list,''' + ), + location=( + "\n" + + ("[{:d}] {:s} {:s}".format(index, outer_type, outer_id) + if index is not None + else ('[*] ' + outer_type + ' ' + outer_id)) + + "\n" + path + "\n" + ) if not toplevel else '', + bad_val=bad_val + ).replace(' ', '')) + + def _value_is_valid(val): + return ( + # pylint: disable=unused-variable + any([isinstance(val, x) for x in valid]) or + type(val).__name__ == 'unicode' + ) + + def _validate_value(val, index=None): + # val is a Component + if isinstance(val, Component): + # pylint: disable=protected-access + for p, j in val._traverse_with_paths(): + # check each component value in the tree + if not _value_is_valid(j): + _raise_invalid( + bad_val=j, + outer_val=val, + path=p, + index=index + ) + + # Children that are not of type Component or + # list/tuple not returned by traverse + child = getattr(j, 'children', None) + if not isinstance(child, (tuple, + collections.MutableSequence)): + if child and not _value_is_valid(child): + _raise_invalid( + bad_val=child, + outer_val=val, + path=p + "\n" + "[*] " + type(child).__name__, + index=index + ) + + # Also check the child of val, as it will not be returned + child = getattr(val, 'children', None) + if not isinstance(child, (tuple, collections.MutableSequence)): + if child and not _value_is_valid(child): + _raise_invalid( + bad_val=child, + outer_val=val, + path=type(child).__name__, + index=index + ) + + # val is not a Component, but is at the top level of tree + else: + if not _value_is_valid(val): + _raise_invalid( + bad_val=val, + outer_val=type(val).__name__, + path='', + index=index, + toplevel=True + ) + + if isinstance(output_value, list): + for i, val in enumerate(output_value): + _validate_value(val, index=i) + else: + _validate_value(output_value) diff --git a/requires-install.txt b/requires-install.txt index 61b19f0cf0..0a3f5eea3d 100644 --- a/requires-install.txt +++ b/requires-install.txt @@ -15,4 +15,6 @@ selenium percy requests[security] beautifulsoup4 -waitress \ No newline at end of file +waitress +redis +dill \ No newline at end of file diff --git a/tests/integration/callbacks/test_basic_callback.py b/tests/integration/callbacks/test_basic_callback.py index fffe0701ab..27a1890a6d 100644 --- a/tests/integration/callbacks/test_basic_callback.py +++ b/tests/integration/callbacks/test_basic_callback.py @@ -1,11 +1,10 @@ from multiprocessing import Value - -from bs4 import BeautifulSoup +import time import dash_core_components as dcc import dash_html_components as html import dash -from dash.dependencies import Input, Output +from dash.dependencies import Input, Output, State def test_cbsc001_simple_callback(dash_duo): @@ -24,15 +23,12 @@ def update_output(value): return value dash_duo.start_server(app) - assert dash_duo.find_element("#output-1").text == "initial value" dash_duo.percy_snapshot(name="simple-callback-initial") input_ = dash_duo.find_element("#input") dash_duo.clear_input(input_) - input_.send_keys("hello world") - assert dash_duo.find_element("#output-1").text == "hello world" dash_duo.percy_snapshot(name="simple-callback-hello-world") @@ -140,3 +136,158 @@ def update_input(value): dash_duo.percy_snapshot(name="callback-generating-function-2") assert dash_duo.get_logs() == [], "console is clean" + + +def test_redis_cbsc001_simple_callback(dash_duo): + import redis + redis_kwargs = {'host': 'localhost', + 'port': 6379} + r = redis.Redis(**redis_kwargs) + r.flushdb() + + app = dash.Dash(__name__, callback_redis=redis_kwargs) + app.layout = html.Div( + [ + dcc.Input(id="input", value="initial value"), + html.Div(html.Div([1.5, None, "string", html.Div(id="output-1"), + html.Div(0, id="cb-count")])), + ] + ) + + @app.callback([Output("output-1", "children"), + Output("cb-count", "children")], + [Input("input", "value")], + [State("cb-count", "children")]) + def update_output(value, count): + count += 1 + return value, count + + dash_duo.start_server(app) + assert dash_duo.find_element("#output-1").text == "initial value" + dash_duo.percy_snapshot(name="simple-callback-initial") + + input_ = dash_duo.find_element("#input") + dash_duo.clear_input(input_) + for c in "hello world": + input_.send_keys(c) + time.sleep(1/30) # redis is slightly slower, but still quite fast + assert dash_duo.find_element("#output-1").text == "hello world" + dash_duo.percy_snapshot(name="simple-callback-hello-world") + + assert int(dash_duo.find_element("#cb-count").text) == 2 + len( + "hello world" + ), "initial count + each key stroke" + + rqs = dash_duo.redux_state_rqs + assert len(rqs) == 1 + + assert dash_duo.get_logs() == [] + r.flushdb() + + +def test_redis_cbsc002_callbacks_generating_children(dash_duo): + """ Modify the DOM tree by adding new components in the callbacks""" + + # some components don't exist in the initial render + import redis + redis_kwargs = {'host': 'localhost', + 'port': 6379} + r = redis.Redis(**redis_kwargs) + r.flushdb() + app = dash.Dash(__name__, suppress_callback_exceptions=True, + callback_redis=redis_kwargs) + app.layout = html.Div( + [dcc.Input(id="input", value="initial value"), html.Div(id="output"), + html.Div(0, id="cb-count")] + ) + + @app.callback(Output("output", "children"), + [Input("input", "value")]) + def pad_output(input): + return html.Div( + [ + dcc.Input(id="sub-input-1", value="sub input initial value"), + html.Div(id="sub-output-1"), + ] + ) + + @app.callback( + [Output("sub-output-1", "children"), + Output("cb-count", "children")], + [Input("sub-input-1", "value")], + [State("cb-count", "children")] + ) + def update_input(value, count): + count += 1 + return value, count + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#sub-output-1", "sub input initial value") + + assert int(dash_duo.find_element("#cb-count").text) == 1, \ + "called once at initial stage" + + pad_input, pad_div = dash_duo.dash_innerhtml_dom.select_one( + "#output > div" + ).contents + + assert ( + pad_input.attrs["value"] == "sub input initial value" + and pad_input.attrs["id"] == "sub-input-1" + ) + assert pad_input.name == "input" + + assert ( + pad_div.text == pad_input.attrs["value"] + and pad_div.get("id") == "sub-output-1" + ), "the sub-output-1 content reflects to sub-input-1 value" + + dash_duo.percy_snapshot(name="callback-generating-function-1") + + assert dash_duo.redux_state_paths == { + "cb-count": ["props", "children", 2], + "input": ["props", "children", 0], + "output": ["props", "children", 1], + "sub-input-1": [ + "props", + "children", + 1, + "props", + "children", + "props", + "children", + 0, + ], + "sub-output-1": [ + "props", + "children", + 1, + "props", + "children", + "props", + "children", + 1, + ], + }, "the paths should include these new output IDs" + + # editing the input should modify the sub output + dash_duo.find_element("#sub-input-1").send_keys("deadbeef") + + assert ( + dash_duo.find_element("#sub-output-1").text + == pad_input.attrs["value"] + "deadbeef" + ), "deadbeef is added" + + # the total updates is initial one + the text input changes + dash_duo.wait_for_text_to_equal( + "#sub-output-1", pad_input.attrs["value"] + "deadbeef" + ) + + rqs = dash_duo.redux_state_rqs + assert rqs, "request queue is not empty" + assert all((rq["status"] == 200 and not rq["rejected"] for rq in rqs)) + + dash_duo.percy_snapshot(name="callback-generating-function-2") + assert dash_duo.get_logs() == [], "console is clean" + r.flushdb() diff --git a/tests/unit/dash/test_utils.py b/tests/unit/dash/test_utils.py index 70e3c74e05..ebb58fafa7 100644 --- a/tests/unit/dash/test_utils.py +++ b/tests/unit/dash/test_utils.py @@ -61,3 +61,37 @@ def test_ddut001_attribute_dict(): 'Object is final: No new keys may be added.', 'x' ) assert 'x' not in a + + +def test_redis_dict(): + + redis_kwargs = {'host': 'localhost', + 'port': 6379} + import redis + r = redis.Redis(**redis_kwargs) + r.flushdb() + rd = utils.RedisDict(redis_kwargs) + + test_d = {'A.figure': {'in': [None, 2, 3], + 'out': ['boo', 'fing']}, + 'B.id': {'c': 'woo'}} + for k, v in test_d.items(): + rd[k] = v + + for k, v in rd.items(): + assert k in test_d.keys() + for (k2, v2), (k3, v3) in zip(test_d[k].items(), + v.items()): + assert k2 == k3 + for e1, e2 in zip(v2, v3): + assert e1 == e2 + + def f(x): + return x + 2 + + test_d['F'] = f + + assert f(10) == test_d['F'](10) + + for k in rd: + assert k in test_d.keys()