diff --git a/demo/demo/plotly_apps.py b/demo/demo/plotly_apps.py index 876ff9d3..4e880990 100644 --- a/demo/demo/plotly_apps.py +++ b/demo/demo/plotly_apps.py @@ -34,6 +34,7 @@ from django.core.cache import cache import dash +from dash.dependencies import MATCH, ALL import dash_core_components as dcc import dash_html_components as html @@ -436,3 +437,26 @@ def exp_callback_standard(button_clicks): [dash.dependencies.Input('button', 'n_clicks')]) def exp_callback_dash_app_id(button_clicks, dash_app_id): return dash_app_id + + +pattern_state_callbacks = DjangoDash("PatternStateCallbacks") + +pattern_state_callbacks.layout = html.Div([ + html.Div(id={"_id": "output-one", "_type": "divo"}), + html.Div(id={"_id": "output-two", "_type": "div"}), + html.Div(id={"_id": "output-three", "_type": "div"}) +]) + + +@pattern_state_callbacks.callback( + dash.dependencies.Output({"_type": "div", "_id": MATCH}, 'children'), + [dash.dependencies.Input({"_type": "div", "_id": MATCH}, 'n_clicks')]) +def pattern_match(values): + return str(values) + + +@pattern_state_callbacks.callback( + dash.dependencies.Output({"_type": "divo", "_id": "output-one"}, 'children'), + [dash.dependencies.Input({"_type": "div", "_id": ALL}, 'children')]) +def pattern_all(values): + return str(values) diff --git a/django_plotly_dash/dash_wrapper.py b/django_plotly_dash/dash_wrapper.py index ec7c3c0b..fd59aad9 100644 --- a/django_plotly_dash/dash_wrapper.py +++ b/django_plotly_dash/dash_wrapper.py @@ -103,7 +103,6 @@ def triggered(self): def add_usable_app(name, app): 'Add app to local registry by name' - name = slugify(name) global usable_apps # pylint: disable=global-statement usable_apps[name] = app return name @@ -121,8 +120,6 @@ def get_local_stateless_by_name(name): ''' Locate a registered dash app by name, and return a DjangoDash instance encapsulating the app. ''' - name = slugify(name) - sa = usable_apps.get(name, None) if not sa: @@ -398,12 +395,14 @@ def register_blueprint(self, *args, **kwargs): pass -def compare(id_python, id_dash): - """Compare an id of a dash component as a python object with an id of a component - in dash syntax. It handles both id as str or as dict (pattern-matching)""" - if isinstance(id_python, dict): - return "{" in id_dash and id_python == json.loads(id_dash) - return id_python == id_dash +def wid2str(wid): + """Convert an python id (str or dict) into its Dash representation. + + see https://github.com/plotly/dash/blob/c5ba38f0ae7b7f8c173bda10b4a8ddd035f1d867/dash-renderer/src/actions/dependencies.js#L114""" + if isinstance(wid, str): + return wid + data = ",".join(f"{json.dumps(k)}:{json.dumps(v)}" for k, v in sorted(wid.items())) + return f"{{{data}}}" class WrappedDash(Dash): @@ -455,10 +454,7 @@ def augment_initial_layout(self, base_response, initial_arguments=None): baseData = json.loads(baseDataInBytes.decode('utf-8')) # Also add in any initial arguments - if initial_arguments: - if isinstance(initial_arguments, str): - initial_arguments = json.loads(initial_arguments) - else: + if not initial_arguments: initial_arguments = {} # Define overrides as self._replacements updated with initial_arguments @@ -477,10 +473,11 @@ def augment_initial_layout(self, base_response, initial_arguments=None): def walk_tree_and_extract(self, data, target): 'Walk tree of properties and extract identifiers and associated values' if isinstance(data, dict): - for key in ['children', 'props',]: + for key in ['children', 'props']: self.walk_tree_and_extract(data.get(key, None), target) ident = data.get('id', None) if ident is not None: + ident = wid2str(ident) idVals = target.get(ident, {}) for key, value in data.items(): if key not in ['props', 'options', 'children', 'id']: @@ -503,8 +500,9 @@ def walk_tree_and_replace(self, data, overrides): thisID = data.get('id', None) if isinstance(thisID, dict): # handle case of thisID being a dict (pattern) => linear search in overrides dict + thisID = wid2str(thisID) for k, v in overrides.items(): - if compare(id_python=thisID, id_dash=k): + if thisID == k: replacements = v break elif thisID is not None: @@ -607,12 +605,6 @@ def clientside_callback(self, clientside_function, output, inputs=[], state=[]): [self._fix_callback_item(x) for x in inputs], [self._fix_callback_item(x) for x in state]) - def dispatch(self): - 'Perform dispatch, using request embedded within flask global state' - import flask - body = flask.request.get_json() - return self.dispatch_with_args(body, argMap=dict()) - #pylint: disable=too-many-locals def dispatch_with_args(self, body, argMap): 'Perform callback dispatching, with enhanced arguments and recording of response' @@ -651,27 +643,26 @@ def dispatch_with_args(self, body, argMap): # multiple outputs in a list (the list could contain a single item) outputs = output[2:-2].split('...') - args = [] da = argMap.get('dash_app', None) callback_info = self.callback_map[output] - for component_registration in callback_info['inputs']: - for c in inputs: - if c['property'] == component_registration['property'] and compare(id_python=c['id'],id_dash=component_registration['id']): - v = c.get('value', None) - args.append(v) - if da: - da.update_current_state(c['id'], c['property'], v) - - for component_registration in callback_info['state']: - for c in states: - if c['property'] == component_registration['property'] and compare(id_python=c['id'],id_dash=component_registration['id']): - v = c.get('value', None) - args.append(v) - if da: - da.update_current_state(c['id'], c['property'], v) + args = [] + + for c in inputs + states: + if isinstance(c, list): # ALL, ALLSMALLER + v = [ci.get("value") for ci in c] + if da: + for ci, vi in zip(c, v): + da.update_current_state(ci['id'], ci['property'], vi) + else: + v = c.get("value") + if da: + da.update_current_state(c['id'], c['property'], v) + + args.append(v) + # Dash 1.11 introduces a set of outputs outputs_list = body.get('outputs') or split_callback_id(output) diff --git a/django_plotly_dash/migrations/0002_add_examples.py b/django_plotly_dash/migrations/0002_add_examples.py index 551dc471..98413f8f 100644 --- a/django_plotly_dash/migrations/0002_add_examples.py +++ b/django_plotly_dash/migrations/0002_add_examples.py @@ -83,6 +83,17 @@ def addExamples(apps, schema_editor): da5.save() + sa6 = StatelessApp(app_name="PatternStateCallbacks", + slug="pattern-state-callback") + + sa6.save() + + da6 = DashApp(stateless_app=sa6, + instance_name="Pattern and State saving Example", + slug="pattern-state-callback") + + da6.save() + def remExamples(apps, schema_editor): diff --git a/django_plotly_dash/models.py b/django_plotly_dash/models.py index 23523211..d25dc217 100644 --- a/django_plotly_dash/models.py +++ b/django_plotly_dash/models.py @@ -30,8 +30,7 @@ from django.utils.text import slugify from django.shortcuts import get_object_or_404 -from .dash_wrapper import get_local_stateless_by_name, get_local_stateless_list - +from .dash_wrapper import get_local_stateless_by_name, get_local_stateless_list, wid2str logger = logging.getLogger(__name__) @@ -158,7 +157,7 @@ def handle_current_state(self): def have_current_state_entry(self, wid, key): 'Return True if there is a cached current state for this app' cscoll = self.current_state() - c_state = cscoll.get(wid, {}) + c_state = cscoll.get(wid2str(wid), {}) return key in c_state def update_current_state(self, wid, key, value): @@ -168,7 +167,7 @@ def update_current_state(self, wid, key, value): If the key does not represent an existing entry, then ignore it ''' cscoll = self.current_state() - c_state = cscoll.get(wid, {}) + c_state = cscoll.get(wid2str(wid), {}) if key in c_state: current_value = c_state.get(key, None) if current_value != value: diff --git a/django_plotly_dash/tests.py b/django_plotly_dash/tests.py index 76905817..43c44153 100644 --- a/django_plotly_dash/tests.py +++ b/django_plotly_dash/tests.py @@ -27,14 +27,18 @@ ''' -import pytest import json from unittest.mock import patch -#pylint: disable=bare-except +import pytest +# pylint: disable=bare-except from dash.dependencies import Input +from django.urls import reverse from django_plotly_dash import DjangoDash +from django_plotly_dash.dash_wrapper import get_local_stateless_list, get_local_stateless_by_name +from django_plotly_dash.models import DashApp, find_stateless_by_name +from django_plotly_dash.tests_dash_contract import fill_in_test_app, dash_contract_data def test_dash_app(): @@ -48,6 +52,156 @@ def test_dash_app(): assert str(stateless_a) == stateless_a.app_name +@pytest.mark.django_db +def test_dash_stateful_app_client_contract(client): + 'Test the state management of a DashApp as well as the contract between the client and the Dash app' + + from django_plotly_dash.models import StatelessApp + + # create a DjangoDash, StatelessApp and DashApp + ddash = DjangoDash(name="DDash") + fill_in_test_app(ddash, write=False) + + stateless_a = StatelessApp(app_name="DDash") + stateless_a.save() + stateful_a = DashApp(stateless_app=stateless_a, + instance_name="Some name", + slug="my-app", save_on_change=True) + stateful_a.save() + + # check app can be found back + assert "DDash" in get_local_stateless_list() + assert get_local_stateless_by_name("DDash") == ddash + assert find_stateless_by_name("DDash") == ddash + + # check the current_state is empty + assert stateful_a.current_state() == {} + + # set the initial expected state + expected_state = {'inp1': {'n_clicks': 0, 'n_clicks_timestamp': 1611733453854}, + 'inp2': {'n_clicks': 5, 'n_clicks_timestamp': 1611733454354}, + 'out1-0': {'n_clicks': 1, 'n_clicks_timestamp': 1611733453954}, + 'out1-1': {'n_clicks': 2, 'n_clicks_timestamp': 1611733454054}, + 'out1-2': {'n_clicks': 3, 'n_clicks_timestamp': 1611733454154}, + 'out1-3': {'n_clicks': 4, 'n_clicks_timestamp': 1611733454254}, + 'out2-0': {'n_clicks': 6, 'n_clicks_timestamp': 1611733454454}, + 'out3': {'n_clicks': 10, 'n_clicks_timestamp': 1611733454854}, + 'out4': {'n_clicks': 14, 'n_clicks_timestamp': 1611733455254}, + 'out5': {'n_clicks': 18, 'n_clicks_timestamp': 1611733455654}, + '{"_id":"inp-0","_type":"btn3"}': {'n_clicks': 7, + 'n_clicks_timestamp': 1611733454554}, + '{"_id":"inp-0","_type":"btn4"}': {'n_clicks': 11, + 'n_clicks_timestamp': 1611733454954}, + '{"_id":"inp-0","_type":"btn5"}': {'n_clicks': 15, + 'n_clicks_timestamp': 1611733455354}, + '{"_id":"inp-1","_type":"btn3"}': {'n_clicks': 8, + 'n_clicks_timestamp': 1611733454654}, + '{"_id":"inp-1","_type":"btn4"}': {'n_clicks': 12, + 'n_clicks_timestamp': 1611733455054}, + '{"_id":"inp-1","_type":"btn5"}': {'n_clicks': 16, + 'n_clicks_timestamp': 1611733455454}, + '{"_id":"inp-2","_type":"btn3"}': {'n_clicks': 9, + 'n_clicks_timestamp': 1611733454754}, + '{"_id":"inp-2","_type":"btn4"}': {'n_clicks': 13, + 'n_clicks_timestamp': 1611733455154}, + '{"_id":"inp-2","_type":"btn5"}': {'n_clicks': 17, + 'n_clicks_timestamp': 1611733455554}} + + ########## test state management of the app and conversion of components ids + # search for state values in dash layout + stateful_a.populate_values() + assert stateful_a.current_state() == expected_state + assert stateful_a.have_current_state_entry("inp1", "n_clicks") + assert stateful_a.have_current_state_entry({"_type": "btn3", "_id": "inp-0"}, "n_clicks_timestamp") + assert stateful_a.have_current_state_entry('{"_id":"inp-0","_type":"btn3"}', "n_clicks_timestamp") + assert not stateful_a.have_current_state_entry("checklist", "other-prop") + + # update a non existent state => no effect on current_state + stateful_a.update_current_state("foo", "value", "random") + assert stateful_a.current_state() == expected_state + + # update an existent state => update current_state + stateful_a.update_current_state('{"_id":"inp-2","_type":"btn5"}', "n_clicks", 100) + expected_state['{"_id":"inp-2","_type":"btn5"}'] = {'n_clicks': 100, 'n_clicks_timestamp': 1611733455554} + assert stateful_a.current_state() == expected_state + + assert DashApp.objects.get(instance_name="Some name").current_state() == {} + + stateful_a.handle_current_state() + + assert DashApp.objects.get(instance_name="Some name").current_state() == expected_state + + # check initial layout serve has the correct values injected + dash_instance = stateful_a.as_dash_instance() + resp = dash_instance.serve_layout() + + # initialise layout with app state + layout, mimetype = dash_instance.augment_initial_layout(resp, {}) + assert '"n_clicks": 100' in layout + + # initialise layout with initial arguments + layout, mimetype = dash_instance.augment_initial_layout(resp, { + '{"_id":"inp-2","_type":"btn5"}': {"n_clicks": 200}}) + assert '"n_clicks": 100' not in layout + assert '"n_clicks": 200' in layout + + ########### test contract between client and app by replaying interactions recorded in tests_dash_contract.json + # get update component route + url = reverse('the_django_plotly_dash:update-component', kwargs={'ident': 'my-app'}) + + # for all interactions in the tests_dash_contract.json + for scenario in json.load(dash_contract_data.open("r")): + body = scenario["body"] + + response = client.post(url, json.dumps(body), content_type="application/json") + + assert response.status_code == 200 + + response = json.loads(response.content) + + # compare first item in response with first result + result = scenario["result"] + if isinstance(result, list): + result = result[0] + content = response["response"].popitem()[1].popitem()[1] + assert content == result + + # handle state + stateful_a.handle_current_state() + + # check final state has been changed accordingly + final_state = {'inp1': {'n_clicks': 1, 'n_clicks_timestamp': 1611736145932}, + 'inp2': {'n_clicks': 6, 'n_clicks_timestamp': 1611736146875}, + 'out1-0': {'n_clicks': 1, 'n_clicks_timestamp': 1611733453954}, + 'out1-1': {'n_clicks': 2, 'n_clicks_timestamp': 1611733454054}, + 'out1-2': {'n_clicks': 3, 'n_clicks_timestamp': 1611733454154}, + 'out1-3': {'n_clicks': 4, 'n_clicks_timestamp': 1611733454254}, + 'out2-0': {'n_clicks': 6, 'n_clicks_timestamp': 1611733454454}, + 'out3': {'n_clicks': 10, 'n_clicks_timestamp': 1611733454854}, + 'out4': {'n_clicks': 14, 'n_clicks_timestamp': 1611733455254}, + 'out5': {'n_clicks': 18, 'n_clicks_timestamp': 1611733455654}, + '{"_id":"inp-0","_type":"btn3"}': {'n_clicks': 8, + 'n_clicks_timestamp': 1611736147644}, + '{"_id":"inp-0","_type":"btn4"}': {'n_clicks': 12, + 'n_clicks_timestamp': 1611733454954}, + '{"_id":"inp-0","_type":"btn5"}': {'n_clicks': 16, + 'n_clicks_timestamp': 1611733455354}, + '{"_id":"inp-1","_type":"btn3"}': {'n_clicks': 9, + 'n_clicks_timestamp': 1611736148172}, + '{"_id":"inp-1","_type":"btn4"}': {'n_clicks': 13, + 'n_clicks_timestamp': 1611733455054}, + '{"_id":"inp-1","_type":"btn5"}': {'n_clicks': 18, + 'n_clicks_timestamp': 1611733455454}, + '{"_id":"inp-2","_type":"btn3"}': {'n_clicks': 10, + 'n_clicks_timestamp': 1611736149140}, + '{"_id":"inp-2","_type":"btn4"}': {'n_clicks': 13, + 'n_clicks_timestamp': 1611733455154}, + '{"_id":"inp-2","_type":"btn5"}': {'n_clicks': 19, + 'n_clicks_timestamp': 1611733455554}} + + assert DashApp.objects.get(instance_name="Some name").current_state() == final_state + + def test_util_error_cases(settings): 'Test handling of missing settings' @@ -258,6 +412,7 @@ def test_flexible_expanded_callbacks(client): resp = json.loads(response.content.decode('utf-8')) assert resp["response"]=={"output-three": {"children": "flexible_expanded_callbacks"}} + @pytest.mark.django_db def test_injection_updating(client): 'Check updating of an app using demo test data' diff --git a/django_plotly_dash/tests_dash_contract.json b/django_plotly_dash/tests_dash_contract.json new file mode 100644 index 00000000..58c3a643 --- /dev/null +++ b/django_plotly_dash/tests_dash_contract.json @@ -0,0 +1,1337 @@ +[ + { + "body": { + "output": "out1-0.children", + "outputs": { + "id": "out1-0", + "property": "children" + }, + "inputs": [ + { + "id": "inp1", + "property": "n_clicks_timestamp", + "value": 1611733453854 + } + ], + "changedPropIds": [], + "state": [ + { + "id": "inp1", + "property": "n_clicks", + "value": 0 + } + ] + }, + "args": [ + 1611733453854, + 0 + ], + "kwargs": {}, + "result": "single - (1611733453854, 0)" + }, + { + "body": { + "output": "..out1-1.children..", + "outputs": [ + { + "id": "out1-1", + "property": "children" + } + ], + "inputs": [ + { + "id": "inp1", + "property": "n_clicks_timestamp", + "value": 1611733453854 + } + ], + "changedPropIds": [], + "state": [ + { + "id": "inp1", + "property": "n_clicks", + "value": 0 + } + ] + }, + "args": [ + 1611733453854, + 0 + ], + "kwargs": {}, + "result": [ + "single in list - (1611733453854, 0)" + ] + }, + { + "body": { + "output": "..out1-2.children...out1-3.children..", + "outputs": [ + { + "id": "out1-2", + "property": "children" + }, + { + "id": "out1-3", + "property": "children" + } + ], + "inputs": [ + { + "id": "inp1", + "property": "n_clicks_timestamp", + "value": 1611733453854 + } + ], + "changedPropIds": [], + "state": [ + { + "id": "inp1", + "property": "n_clicks", + "value": 0 + } + ] + }, + "args": [ + 1611733453854, + 0 + ], + "kwargs": {}, + "result": [ + "multi in list - (1611733453854, 0)", + "multi in list - (1611733453854, 0)" + ] + }, + { + "body": { + "output": "out2-0.children", + "outputs": { + "id": "out2-0", + "property": "children" + }, + "inputs": [ + { + "id": "inp2", + "property": "n_clicks_timestamp", + "value": 1611733454354 + }, + { + "id": "inp2", + "property": "n_clicks", + "value": 5 + } + ], + "changedPropIds": [] + }, + "args": [ + 1611733454354, + 5 + ], + "kwargs": {}, + "result": "multi triggered - (1611733454354, 5) - []" + }, + { + "body": { + "output": "out3.children", + "outputs": { + "id": "out3", + "property": "children" + }, + "inputs": [ + [ + { + "id": { + "_id": "inp-0", + "_type": "btn3" + }, + "property": "n_clicks_timestamp", + "value": 1611733454554 + }, + { + "id": { + "_id": "inp-1", + "_type": "btn3" + }, + "property": "n_clicks_timestamp", + "value": 1611733454654 + }, + { + "id": { + "_id": "inp-2", + "_type": "btn3" + }, + "property": "n_clicks_timestamp", + "value": 1611733454754 + } + ] + ], + "changedPropIds": [], + "state": [ + [ + { + "id": { + "_id": "inp-0", + "_type": "btn3" + }, + "property": "n_clicks", + "value": 7 + }, + { + "id": { + "_id": "inp-1", + "_type": "btn3" + }, + "property": "n_clicks", + "value": 8 + }, + { + "id": { + "_id": "inp-2", + "_type": "btn3" + }, + "property": "n_clicks", + "value": 9 + } + ] + ] + }, + "args": [ + [ + 1611733454554, + 1611733454654, + 1611733454754 + ], + [ + 7, + 8, + 9 + ] + ], + "kwargs": {}, + "result": "pattern ALL - ([1611733454554, 1611733454654, 1611733454754], [7, 8, 9])" + }, + { + "body": { + "output": "{\"_id\":[\"MATCH\"],\"_type\":\"btn4\"}.children", + "outputs": { + "id": { + "_id": "inp-0", + "_type": "btn4" + }, + "property": "children" + }, + "inputs": [ + [] + ], + "changedPropIds": [], + "state": [ + [] + ] + }, + "args": [ + [], + [] + ], + "kwargs": {}, + "result": "pattern ALLSMALLER - ([], [])" + }, + { + "body": { + "output": "{\"_id\":[\"MATCH\"],\"_type\":\"btn4\"}.children", + "outputs": { + "id": { + "_id": "inp-1", + "_type": "btn4" + }, + "property": "children" + }, + "inputs": [ + [ + { + "id": { + "_id": "inp-0", + "_type": "btn4" + }, + "property": "n_clicks", + "value": 11 + } + ] + ], + "changedPropIds": [], + "state": [ + [ + { + "id": { + "_id": "inp-0", + "_type": "btn4" + }, + "property": "id", + "value": { + "_id": "inp-0", + "_type": "btn4" + } + } + ] + ] + }, + "args": [ + [ + 11 + ], + [ + { + "_id": "inp-0", + "_type": "btn4" + } + ] + ], + "kwargs": {}, + "result": "pattern ALLSMALLER - ([11], [{'_id': 'inp-0', '_type': 'btn4'}])" + }, + { + "body": { + "output": "{\"_id\":[\"MATCH\"],\"_type\":\"btn4\"}.children", + "outputs": { + "id": { + "_id": "inp-2", + "_type": "btn4" + }, + "property": "children" + }, + "inputs": [ + [ + { + "id": { + "_id": "inp-0", + "_type": "btn4" + }, + "property": "n_clicks", + "value": 11 + }, + { + "id": { + "_id": "inp-1", + "_type": "btn4" + }, + "property": "n_clicks", + "value": 12 + } + ] + ], + "changedPropIds": [], + "state": [ + [ + { + "id": { + "_id": "inp-0", + "_type": "btn4" + }, + "property": "id", + "value": { + "_id": "inp-0", + "_type": "btn4" + } + }, + { + "id": { + "_id": "inp-1", + "_type": "btn4" + }, + "property": "id", + "value": { + "_id": "inp-1", + "_type": "btn4" + } + } + ] + ] + }, + "args": [ + [ + 11, + 12 + ], + [ + { + "_id": "inp-0", + "_type": "btn4" + }, + { + "_id": "inp-1", + "_type": "btn4" + } + ] + ], + "kwargs": {}, + "result": "pattern ALLSMALLER - ([11, 12], [{'_id': 'inp-0', '_type': 'btn4'}, {'_id': 'inp-1', '_type': 'btn4'}])" + }, + { + "body": { + "output": "{\"_id\":[\"MATCH\"],\"_type\":\"btn5\"}.children", + "outputs": { + "id": { + "_id": "inp-0", + "_type": "btn5" + }, + "property": "children" + }, + "inputs": [ + { + "id": { + "_id": "inp-0", + "_type": "btn5" + }, + "property": "n_clicks", + "value": 15 + } + ], + "changedPropIds": [], + "state": [ + { + "id": { + "_id": "inp-0", + "_type": "btn5" + }, + "property": "id", + "value": { + "_id": "inp-0", + "_type": "btn5" + } + } + ] + }, + "args": [ + 15, + { + "_id": "inp-0", + "_type": "btn5" + } + ], + "kwargs": {}, + "result": "pattern MATCH - (15, {'_id': 'inp-0', '_type': 'btn5'})" + }, + { + "body": { + "output": "{\"_id\":[\"MATCH\"],\"_type\":\"btn5\"}.children", + "outputs": { + "id": { + "_id": "inp-1", + "_type": "btn5" + }, + "property": "children" + }, + "inputs": [ + { + "id": { + "_id": "inp-1", + "_type": "btn5" + }, + "property": "n_clicks", + "value": 16 + } + ], + "changedPropIds": [], + "state": [ + { + "id": { + "_id": "inp-1", + "_type": "btn5" + }, + "property": "id", + "value": { + "_id": "inp-1", + "_type": "btn5" + } + } + ] + }, + "args": [ + 16, + { + "_id": "inp-1", + "_type": "btn5" + } + ], + "kwargs": {}, + "result": "pattern MATCH - (16, {'_id': 'inp-1', '_type': 'btn5'})" + }, + { + "body": { + "output": "{\"_id\":[\"MATCH\"],\"_type\":\"btn5\"}.children", + "outputs": { + "id": { + "_id": "inp-2", + "_type": "btn5" + }, + "property": "children" + }, + "inputs": [ + { + "id": { + "_id": "inp-2", + "_type": "btn5" + }, + "property": "n_clicks", + "value": 17 + } + ], + "changedPropIds": [], + "state": [ + { + "id": { + "_id": "inp-2", + "_type": "btn5" + }, + "property": "id", + "value": { + "_id": "inp-2", + "_type": "btn5" + } + } + ] + }, + "args": [ + 17, + { + "_id": "inp-2", + "_type": "btn5" + } + ], + "kwargs": {}, + "result": "pattern MATCH - (17, {'_id': 'inp-2', '_type': 'btn5'})" + }, + { + "body": { + "output": "out1-0.children", + "outputs": { + "id": "out1-0", + "property": "children" + }, + "inputs": [ + { + "id": "inp1", + "property": "n_clicks_timestamp", + "value": 1611736145932 + } + ], + "changedPropIds": [ + "inp1.n_clicks_timestamp" + ], + "state": [ + { + "id": "inp1", + "property": "n_clicks", + "value": 1 + } + ] + }, + "args": [ + 1611736145932, + 1 + ], + "kwargs": {}, + "result": "single - (1611736145932, 1)" + }, + { + "body": { + "output": "..out1-1.children..", + "outputs": [ + { + "id": "out1-1", + "property": "children" + } + ], + "inputs": [ + { + "id": "inp1", + "property": "n_clicks_timestamp", + "value": 1611736145932 + } + ], + "changedPropIds": [ + "inp1.n_clicks_timestamp" + ], + "state": [ + { + "id": "inp1", + "property": "n_clicks", + "value": 1 + } + ] + }, + "args": [ + 1611736145932, + 1 + ], + "kwargs": {}, + "result": [ + "single in list - (1611736145932, 1)" + ] + }, + { + "body": { + "output": "..out1-2.children...out1-3.children..", + "outputs": [ + { + "id": "out1-2", + "property": "children" + }, + { + "id": "out1-3", + "property": "children" + } + ], + "inputs": [ + { + "id": "inp1", + "property": "n_clicks_timestamp", + "value": 1611736145932 + } + ], + "changedPropIds": [ + "inp1.n_clicks_timestamp" + ], + "state": [ + { + "id": "inp1", + "property": "n_clicks", + "value": 1 + } + ] + }, + "args": [ + 1611736145932, + 1 + ], + "kwargs": {}, + "result": [ + "multi in list - (1611736145932, 1)", + "multi in list - (1611736145932, 1)" + ] + }, + { + "body": { + "output": "out2-0.children", + "outputs": { + "id": "out2-0", + "property": "children" + }, + "inputs": [ + { + "id": "inp2", + "property": "n_clicks_timestamp", + "value": 1611736146875 + }, + { + "id": "inp2", + "property": "n_clicks", + "value": 6 + } + ], + "changedPropIds": [ + "inp2.n_clicks", + "inp2.n_clicks_timestamp" + ] + }, + "args": [ + 1611736146875, + 6 + ], + "kwargs": {}, + "result": "multi triggered - (1611736146875, 6) - [{'prop_id': 'inp2.n_clicks', 'value': 6}, {'prop_id': 'inp2.n_clicks_timestamp', 'value': 1611736146875}]" + }, + { + "body": { + "output": "out3.children", + "outputs": { + "id": "out3", + "property": "children" + }, + "inputs": [ + [ + { + "id": { + "_id": "inp-0", + "_type": "btn3" + }, + "property": "n_clicks_timestamp", + "value": 1611736147644 + }, + { + "id": { + "_id": "inp-1", + "_type": "btn3" + }, + "property": "n_clicks_timestamp", + "value": 1611733454654 + }, + { + "id": { + "_id": "inp-2", + "_type": "btn3" + }, + "property": "n_clicks_timestamp", + "value": 1611733454754 + } + ] + ], + "changedPropIds": [ + "{\"_id\":\"inp-0\",\"_type\":\"btn3\"}.n_clicks_timestamp" + ], + "state": [ + [ + { + "id": { + "_id": "inp-0", + "_type": "btn3" + }, + "property": "n_clicks", + "value": 8 + }, + { + "id": { + "_id": "inp-1", + "_type": "btn3" + }, + "property": "n_clicks", + "value": 8 + }, + { + "id": { + "_id": "inp-2", + "_type": "btn3" + }, + "property": "n_clicks", + "value": 9 + } + ] + ] + }, + "args": [ + [ + 1611736147644, + 1611733454654, + 1611733454754 + ], + [ + 8, + 8, + 9 + ] + ], + "kwargs": {}, + "result": "pattern ALL - ([1611736147644, 1611733454654, 1611733454754], [8, 8, 9])" + }, + { + "body": { + "output": "out3.children", + "outputs": { + "id": "out3", + "property": "children" + }, + "inputs": [ + [ + { + "id": { + "_id": "inp-0", + "_type": "btn3" + }, + "property": "n_clicks_timestamp", + "value": 1611736147644 + }, + { + "id": { + "_id": "inp-1", + "_type": "btn3" + }, + "property": "n_clicks_timestamp", + "value": 1611736148172 + }, + { + "id": { + "_id": "inp-2", + "_type": "btn3" + }, + "property": "n_clicks_timestamp", + "value": 1611733454754 + } + ] + ], + "changedPropIds": [ + "{\"_id\":\"inp-1\",\"_type\":\"btn3\"}.n_clicks_timestamp" + ], + "state": [ + [ + { + "id": { + "_id": "inp-0", + "_type": "btn3" + }, + "property": "n_clicks", + "value": 8 + }, + { + "id": { + "_id": "inp-1", + "_type": "btn3" + }, + "property": "n_clicks", + "value": 9 + }, + { + "id": { + "_id": "inp-2", + "_type": "btn3" + }, + "property": "n_clicks", + "value": 9 + } + ] + ] + }, + "args": [ + [ + 1611736147644, + 1611736148172, + 1611733454754 + ], + [ + 8, + 9, + 9 + ] + ], + "kwargs": {}, + "result": "pattern ALL - ([1611736147644, 1611736148172, 1611733454754], [8, 9, 9])" + }, + { + "body": { + "output": "out3.children", + "outputs": { + "id": "out3", + "property": "children" + }, + "inputs": [ + [ + { + "id": { + "_id": "inp-0", + "_type": "btn3" + }, + "property": "n_clicks_timestamp", + "value": 1611736147644 + }, + { + "id": { + "_id": "inp-1", + "_type": "btn3" + }, + "property": "n_clicks_timestamp", + "value": 1611736148172 + }, + { + "id": { + "_id": "inp-2", + "_type": "btn3" + }, + "property": "n_clicks_timestamp", + "value": 1611736149140 + } + ] + ], + "changedPropIds": [ + "{\"_id\":\"inp-2\",\"_type\":\"btn3\"}.n_clicks_timestamp" + ], + "state": [ + [ + { + "id": { + "_id": "inp-0", + "_type": "btn3" + }, + "property": "n_clicks", + "value": 8 + }, + { + "id": { + "_id": "inp-1", + "_type": "btn3" + }, + "property": "n_clicks", + "value": 9 + }, + { + "id": { + "_id": "inp-2", + "_type": "btn3" + }, + "property": "n_clicks", + "value": 10 + } + ] + ] + }, + "args": [ + [ + 1611736147644, + 1611736148172, + 1611736149140 + ], + [ + 8, + 9, + 10 + ] + ], + "kwargs": {}, + "result": "pattern ALL - ([1611736147644, 1611736148172, 1611736149140], [8, 9, 10])" + }, + { + "body": { + "output": "{\"_id\":[\"MATCH\"],\"_type\":\"btn4\"}.children", + "outputs": { + "id": { + "_id": "inp-1", + "_type": "btn4" + }, + "property": "children" + }, + "inputs": [ + [ + { + "id": { + "_id": "inp-0", + "_type": "btn4" + }, + "property": "n_clicks", + "value": 12 + } + ] + ], + "changedPropIds": [ + "{\"_id\":\"inp-0\",\"_type\":\"btn4\"}.n_clicks" + ], + "state": [ + [ + { + "id": { + "_id": "inp-0", + "_type": "btn4" + }, + "property": "id", + "value": { + "_id": "inp-0", + "_type": "btn4" + } + } + ] + ] + }, + "args": [ + [ + 12 + ], + [ + { + "_id": "inp-0", + "_type": "btn4" + } + ] + ], + "kwargs": {}, + "result": "pattern ALLSMALLER - ([12], [{'_id': 'inp-0', '_type': 'btn4'}])" + }, + { + "body": { + "output": "{\"_id\":[\"MATCH\"],\"_type\":\"btn4\"}.children", + "outputs": { + "id": { + "_id": "inp-2", + "_type": "btn4" + }, + "property": "children" + }, + "inputs": [ + [ + { + "id": { + "_id": "inp-0", + "_type": "btn4" + }, + "property": "n_clicks", + "value": 12 + }, + { + "id": { + "_id": "inp-1", + "_type": "btn4" + }, + "property": "n_clicks", + "value": 12 + } + ] + ], + "changedPropIds": [ + "{\"_id\":\"inp-0\",\"_type\":\"btn4\"}.n_clicks" + ], + "state": [ + [ + { + "id": { + "_id": "inp-0", + "_type": "btn4" + }, + "property": "id", + "value": { + "_id": "inp-0", + "_type": "btn4" + } + }, + { + "id": { + "_id": "inp-1", + "_type": "btn4" + }, + "property": "id", + "value": { + "_id": "inp-1", + "_type": "btn4" + } + } + ] + ] + }, + "args": [ + [ + 12, + 12 + ], + [ + { + "_id": "inp-0", + "_type": "btn4" + }, + { + "_id": "inp-1", + "_type": "btn4" + } + ] + ], + "kwargs": {}, + "result": "pattern ALLSMALLER - ([12, 12], [{'_id': 'inp-0', '_type': 'btn4'}, {'_id': 'inp-1', '_type': 'btn4'}])" + }, + { + "body": { + "output": "{\"_id\":[\"MATCH\"],\"_type\":\"btn4\"}.children", + "outputs": { + "id": { + "_id": "inp-2", + "_type": "btn4" + }, + "property": "children" + }, + "inputs": [ + [ + { + "id": { + "_id": "inp-0", + "_type": "btn4" + }, + "property": "n_clicks", + "value": 12 + }, + { + "id": { + "_id": "inp-1", + "_type": "btn4" + }, + "property": "n_clicks", + "value": 13 + } + ] + ], + "changedPropIds": [ + "{\"_id\":\"inp-1\",\"_type\":\"btn4\"}.n_clicks" + ], + "state": [ + [ + { + "id": { + "_id": "inp-0", + "_type": "btn4" + }, + "property": "id", + "value": { + "_id": "inp-0", + "_type": "btn4" + } + }, + { + "id": { + "_id": "inp-1", + "_type": "btn4" + }, + "property": "id", + "value": { + "_id": "inp-1", + "_type": "btn4" + } + } + ] + ] + }, + "args": [ + [ + 12, + 13 + ], + [ + { + "_id": "inp-0", + "_type": "btn4" + }, + { + "_id": "inp-1", + "_type": "btn4" + } + ] + ], + "kwargs": {}, + "result": "pattern ALLSMALLER - ([12, 13], [{'_id': 'inp-0', '_type': 'btn4'}, {'_id': 'inp-1', '_type': 'btn4'}])" + }, + { + "body": { + "output": "{\"_id\":[\"MATCH\"],\"_type\":\"btn5\"}.children", + "outputs": { + "id": { + "_id": "inp-0", + "_type": "btn5" + }, + "property": "children" + }, + "inputs": [ + { + "id": { + "_id": "inp-0", + "_type": "btn5" + }, + "property": "n_clicks", + "value": 16 + } + ], + "changedPropIds": [ + "{\"_id\":\"inp-0\",\"_type\":\"btn5\"}.n_clicks" + ], + "state": [ + { + "id": { + "_id": "inp-0", + "_type": "btn5" + }, + "property": "id", + "value": { + "_id": "inp-0", + "_type": "btn5" + } + } + ] + }, + "args": [ + 16, + { + "_id": "inp-0", + "_type": "btn5" + } + ], + "kwargs": {}, + "result": "pattern MATCH - (16, {'_id': 'inp-0', '_type': 'btn5'})" + }, + { + "body": { + "output": "{\"_id\":[\"MATCH\"],\"_type\":\"btn5\"}.children", + "outputs": { + "id": { + "_id": "inp-1", + "_type": "btn5" + }, + "property": "children" + }, + "inputs": [ + { + "id": { + "_id": "inp-1", + "_type": "btn5" + }, + "property": "n_clicks", + "value": 17 + } + ], + "changedPropIds": [ + "{\"_id\":\"inp-1\",\"_type\":\"btn5\"}.n_clicks" + ], + "state": [ + { + "id": { + "_id": "inp-1", + "_type": "btn5" + }, + "property": "id", + "value": { + "_id": "inp-1", + "_type": "btn5" + } + } + ] + }, + "args": [ + 17, + { + "_id": "inp-1", + "_type": "btn5" + } + ], + "kwargs": {}, + "result": "pattern MATCH - (17, {'_id': 'inp-1', '_type': 'btn5'})" + }, + { + "body": { + "output": "{\"_id\":[\"MATCH\"],\"_type\":\"btn5\"}.children", + "outputs": { + "id": { + "_id": "inp-1", + "_type": "btn5" + }, + "property": "children" + }, + "inputs": [ + { + "id": { + "_id": "inp-1", + "_type": "btn5" + }, + "property": "n_clicks", + "value": 18 + } + ], + "changedPropIds": [ + "{\"_id\":\"inp-1\",\"_type\":\"btn5\"}.n_clicks" + ], + "state": [ + { + "id": { + "_id": "inp-1", + "_type": "btn5" + }, + "property": "id", + "value": { + "_id": "inp-1", + "_type": "btn5" + } + } + ] + }, + "args": [ + 18, + { + "_id": "inp-1", + "_type": "btn5" + } + ], + "kwargs": {}, + "result": "pattern MATCH - (18, {'_id': 'inp-1', '_type': 'btn5'})" + }, + { + "body": { + "output": "{\"_id\":[\"MATCH\"],\"_type\":\"btn5\"}.children", + "outputs": { + "id": { + "_id": "inp-2", + "_type": "btn5" + }, + "property": "children" + }, + "inputs": [ + { + "id": { + "_id": "inp-2", + "_type": "btn5" + }, + "property": "n_clicks", + "value": 18 + } + ], + "changedPropIds": [ + "{\"_id\":\"inp-2\",\"_type\":\"btn5\"}.n_clicks" + ], + "state": [ + { + "id": { + "_id": "inp-2", + "_type": "btn5" + }, + "property": "id", + "value": { + "_id": "inp-2", + "_type": "btn5" + } + } + ] + }, + "args": [ + 18, + { + "_id": "inp-2", + "_type": "btn5" + } + ], + "kwargs": {}, + "result": "pattern MATCH - (18, {'_id': 'inp-2', '_type': 'btn5'})" + }, + { + "body": { + "output": "{\"_id\":[\"MATCH\"],\"_type\":\"btn5\"}.children", + "outputs": { + "id": { + "_id": "inp-2", + "_type": "btn5" + }, + "property": "children" + }, + "inputs": [ + { + "id": { + "_id": "inp-2", + "_type": "btn5" + }, + "property": "n_clicks", + "value": 19 + } + ], + "changedPropIds": [ + "{\"_id\":\"inp-2\",\"_type\":\"btn5\"}.n_clicks" + ], + "state": [ + { + "id": { + "_id": "inp-2", + "_type": "btn5" + }, + "property": "id", + "value": { + "_id": "inp-2", + "_type": "btn5" + } + } + ] + }, + "args": [ + 19, + { + "_id": "inp-2", + "_type": "btn5" + } + ], + "kwargs": {}, + "result": "pattern MATCH - (19, {'_id': 'inp-2', '_type': 'btn5'})" + } +] \ No newline at end of file diff --git a/django_plotly_dash/tests_dash_contract.py b/django_plotly_dash/tests_dash_contract.py new file mode 100644 index 00000000..94cd3239 --- /dev/null +++ b/django_plotly_dash/tests_dash_contract.py @@ -0,0 +1,207 @@ +"""Test helpers to configure an app that tests many cases (single/multiple outputs, patterns, multi triggers, ...). + + +""" + +import json +from functools import wraps +from pathlib import Path +from typing import Union + +import dash +import dash_html_components as html +import flask +from dash import Dash, no_update +from dash.dependencies import MATCH, ALL, ALLSMALLER, Input, Output, State +from dash.development.base_component import Component +from flask import request + +from django_plotly_dash import DjangoDash + +dash_contract_data = Path(__file__).with_suffix(".json") + + +def fill_in_test_app(app: Union[DjangoDash, Dash], write=False): + """Takes an unitialized Dash (or DjangoDash) app and add dash components and callbacks to test + - single/multiple outputs + - patterns (ALL,ALLSMALLER,MATCH) + - multi triggers + + :param app: the Dash/DjangoDash app to initialize with components and callbacks + :param write: if True, it will record/log in the tests_dash_contract.json all client callbacks to the app + :return: None + """ + + # define decorator that logs the body/response if write==True + if not write: + log_body_response = lambda f: f + else: + record_session = dash_contract_data.open("w") + record_session.write("[") + + def log_body_response(f): + @wraps(f) + def wrapper(*args, **kwargs): + result = f(*args, **kwargs) + record_session.write( + json.dumps( + { + "body": flask.request.get_json(), + "args": args, + "kwargs": kwargs, + "result": result, + } + ) + ) + record_session.write(",") + return result + + return wrapper + + def add_outputs_multi(): + """Test output format (in list or not)""" + inp1 = html.Button("Multi-single-output", id="inp1") + outs = [html.Div(id=f"out1-{i}") for i in range(4)] + + app.layout.children.append(html.Div([inp1] + outs)) + + @app.callback( + Output(outs[0].id, "children"), + [Input(inp1.id, "n_clicks_timestamp")], + [State(inp1.id, "n_clicks")], + ) + @log_body_response + def test_single_output(*args): + return f"single - {args}" + + @app.callback( + [Output(outs[1].id, "children")], + [Input(inp1.id, "n_clicks_timestamp")], + [State(inp1.id, "n_clicks")], + ) + @log_body_response + def test_single_output_list(*args): + return [f"single in list - {args}"] + + @app.callback( + [Output(outs[2].id, "children"), Output(outs[3].id, "children")], + [Input(inp1.id, "n_clicks_timestamp")], + [State(inp1.id, "n_clicks")], + ) + @log_body_response + def test_multi_output(*args): + return [f"multi in list - {args}"] * 2 + + def add_multi_triggered(): + """Test a callback getting more than one element in the triggered context""" + inp1 = html.Button("Multiple triggered", id="inp2") + outs = [html.Div(id=f"out2-{i}") for i in range(1)] + + app.layout.children.append(html.Div([inp1] + outs)) + + @app.callback( + Output(outs[0].id, "children"), + [Input(inp1.id, "n_clicks_timestamp"), Input(inp1.id, "n_clicks")], + ) + @log_body_response + def test_single_output(*args, **kwargs): + print(kwargs) + return f"multi triggered - {args} - {dash.callback_context.triggered or []}" + + def add_pattern_all(): + inps = [ + html.Button(f"Pattern ALL {i}", id={"_id": f"inp-{i}", "_type": "btn3"}) + for i in range(3) + ] + out = html.Div(id=f"out3") + + app.layout.children.append(html.Div(inps + [out])) + + @app.callback( + Output(out.id, "children"), + [Input({"_id": ALL, "_type": "btn3"}, "n_clicks_timestamp")], + [State({"_id": ALL, "_type": "btn3"}, "n_clicks")], + ) + @log_body_response + def test_single_output(*args): + return f"pattern ALL - {args}" + + def add_pattern_allsmaller(): + inps = [ + html.Button(f"Pattern ALLSMALLER {i}", id={"_id": f"inp-{i}", "_type": "btn4"}) + for i in range(3) + ] + out = html.Div(id=f"out4") + + app.layout.children.append(html.Div(inps + [out])) + + @app.callback( + Output({"_id": MATCH, "_type": "btn4"}, "children"), + [Input({"_id": ALLSMALLER, "_type": "btn4"}, "n_clicks")], + [State({"_id": ALLSMALLER, "_type": "btn4"}, "id")], + ) + @log_body_response + def test_single_output(*args): + return f"pattern ALLSMALLER - {args}" + + def add_pattern_match(): + inps = [ + html.Button(f"Pattern MATCH {i}", id={"_id": f"inp-{i}", "_type": "btn5"}) + for i in range(3) + ] + out = html.Div(id=f"out5") + + app.layout.children.append(html.Div(inps + [out])) + + @app.callback( + Output({"_id": MATCH, "_type": "btn5"}, "children"), + [Input({"_id": MATCH, "_type": "btn5"}, "n_clicks")], + [State({"_id": MATCH, "_type": "btn5"}, "id")], + ) + @log_body_response + def test_single_output(*args): + return f"pattern MATCH - {args}" + + def add_stop(): + app.layout.children.append(html.Button("stop", id="stop")) + + @app.callback(Output("stop", "children"), [Input("stop", "n_clicks")]) + def stop_server(nclicks): + if nclicks: + if write: + # close the file recording the session + record_session.write("]") + record_session.close() + func = request.environ.get("werkzeug.server.shutdown") + func() + return no_update + + app.layout = html.Div([]) + add_outputs_multi() + add_multi_triggered() + add_pattern_all() + add_pattern_allsmaller() + add_pattern_match() + add_stop() + + # add to all elements a n_clicks/n_clicks_timestamp values to have populate_values detect properties + i = 0 + ts = 1611733453854 + for comp in app.layout.children: + for elem in comp.children: + if isinstance(elem, Component): + elem.n_clicks = i + elem.n_clicks_timestamp = ts + i += 1 + ts += 100 + + +if __name__ == "__main__": + # to generate the test_dash_contract.json, run the pure Dash app and click on each button once successively + # if you regenerate the test_dash_contract.json, the final assert of the test test_dash_stateful_app will fail + # as the timestamp will be different => the test will have to be updated accordingly + test_app = Dash("DashContractApp") + + fill_in_test_app(test_app, write=True) + + test_app.run_server(debug=True)