Skip to content

Commit

Permalink
handling State for pattern + fix #292 (#316)
Browse files Browse the repository at this point in the history
* fix #292

* add support for handling state of pattern ids

* add test on augmented layout with pattern ids

* finalize pattern handling:
- state mgt for patterns
- dispatch for wildcards (ALL,MATCH,ALLSMALLER)

* finalize pattern handling:
- state mgt for patterns
- dispatch for wildcards (ALL,MATCH,ALLSMALLER)

* finalize pattern handling:
- state mgt for patterns
- dispatch for wildcards (ALL,MATCH,ALLSMALLER)

* remove unused dispatch() method

* fix bug when udpating state for ALL/ALLSMALLER

* improve testing exhaustivity by replaying a real dash app interaction
- add an app with complex dash features
- add a JSON record of real interactions between the client and the dash app
- add a test exploiting the JSON record to test dpd client contract and state management

Co-authored-by: GFJ138 <[email protected]>
  • Loading branch information
sdementen and sebastiendementen authored Feb 3, 2021
1 parent 8464398 commit cddf575
Show file tree
Hide file tree
Showing 7 changed files with 1,767 additions and 43 deletions.
24 changes: 24 additions & 0 deletions demo/demo/plotly_apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
65 changes: 28 additions & 37 deletions django_plotly_dash/dash_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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']:
Expand All @@ -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:
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions django_plotly_dash/migrations/0002_add_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down
7 changes: 3 additions & 4 deletions django_plotly_dash/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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):
Expand All @@ -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:
Expand Down
159 changes: 157 additions & 2 deletions django_plotly_dash/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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'

Expand Down Expand Up @@ -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'
Expand Down
Loading

0 comments on commit cddf575

Please sign in to comment.