Skip to content

Commit cddf575

Browse files
handling State for pattern + fix #292 (#316)
* 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 <sebastien.dementen@engie.com>
1 parent 8464398 commit cddf575

File tree

7 files changed

+1767
-43
lines changed

7 files changed

+1767
-43
lines changed

demo/demo/plotly_apps.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from django.core.cache import cache
3535

3636
import dash
37+
from dash.dependencies import MATCH, ALL
3738
import dash_core_components as dcc
3839
import dash_html_components as html
3940

@@ -436,3 +437,26 @@ def exp_callback_standard(button_clicks):
436437
[dash.dependencies.Input('button', 'n_clicks')])
437438
def exp_callback_dash_app_id(button_clicks, dash_app_id):
438439
return dash_app_id
440+
441+
442+
pattern_state_callbacks = DjangoDash("PatternStateCallbacks")
443+
444+
pattern_state_callbacks.layout = html.Div([
445+
html.Div(id={"_id": "output-one", "_type": "divo"}),
446+
html.Div(id={"_id": "output-two", "_type": "div"}),
447+
html.Div(id={"_id": "output-three", "_type": "div"})
448+
])
449+
450+
451+
@pattern_state_callbacks.callback(
452+
dash.dependencies.Output({"_type": "div", "_id": MATCH}, 'children'),
453+
[dash.dependencies.Input({"_type": "div", "_id": MATCH}, 'n_clicks')])
454+
def pattern_match(values):
455+
return str(values)
456+
457+
458+
@pattern_state_callbacks.callback(
459+
dash.dependencies.Output({"_type": "divo", "_id": "output-one"}, 'children'),
460+
[dash.dependencies.Input({"_type": "div", "_id": ALL}, 'children')])
461+
def pattern_all(values):
462+
return str(values)

django_plotly_dash/dash_wrapper.py

Lines changed: 28 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,6 @@ def triggered(self):
103103

104104
def add_usable_app(name, app):
105105
'Add app to local registry by name'
106-
name = slugify(name)
107106
global usable_apps # pylint: disable=global-statement
108107
usable_apps[name] = app
109108
return name
@@ -121,8 +120,6 @@ def get_local_stateless_by_name(name):
121120
'''
122121
Locate a registered dash app by name, and return a DjangoDash instance encapsulating the app.
123122
'''
124-
name = slugify(name)
125-
126123
sa = usable_apps.get(name, None)
127124

128125
if not sa:
@@ -398,12 +395,14 @@ def register_blueprint(self, *args, **kwargs):
398395
pass
399396

400397

401-
def compare(id_python, id_dash):
402-
"""Compare an id of a dash component as a python object with an id of a component
403-
in dash syntax. It handles both id as str or as dict (pattern-matching)"""
404-
if isinstance(id_python, dict):
405-
return "{" in id_dash and id_python == json.loads(id_dash)
406-
return id_python == id_dash
398+
def wid2str(wid):
399+
"""Convert an python id (str or dict) into its Dash representation.
400+
401+
see https://github.com/plotly/dash/blob/c5ba38f0ae7b7f8c173bda10b4a8ddd035f1d867/dash-renderer/src/actions/dependencies.js#L114"""
402+
if isinstance(wid, str):
403+
return wid
404+
data = ",".join(f"{json.dumps(k)}:{json.dumps(v)}" for k, v in sorted(wid.items()))
405+
return f"{{{data}}}"
407406

408407

409408
class WrappedDash(Dash):
@@ -455,10 +454,7 @@ def augment_initial_layout(self, base_response, initial_arguments=None):
455454
baseData = json.loads(baseDataInBytes.decode('utf-8'))
456455

457456
# Also add in any initial arguments
458-
if initial_arguments:
459-
if isinstance(initial_arguments, str):
460-
initial_arguments = json.loads(initial_arguments)
461-
else:
457+
if not initial_arguments:
462458
initial_arguments = {}
463459

464460
# Define overrides as self._replacements updated with initial_arguments
@@ -477,10 +473,11 @@ def augment_initial_layout(self, base_response, initial_arguments=None):
477473
def walk_tree_and_extract(self, data, target):
478474
'Walk tree of properties and extract identifiers and associated values'
479475
if isinstance(data, dict):
480-
for key in ['children', 'props',]:
476+
for key in ['children', 'props']:
481477
self.walk_tree_and_extract(data.get(key, None), target)
482478
ident = data.get('id', None)
483479
if ident is not None:
480+
ident = wid2str(ident)
484481
idVals = target.get(ident, {})
485482
for key, value in data.items():
486483
if key not in ['props', 'options', 'children', 'id']:
@@ -503,8 +500,9 @@ def walk_tree_and_replace(self, data, overrides):
503500
thisID = data.get('id', None)
504501
if isinstance(thisID, dict):
505502
# handle case of thisID being a dict (pattern) => linear search in overrides dict
503+
thisID = wid2str(thisID)
506504
for k, v in overrides.items():
507-
if compare(id_python=thisID, id_dash=k):
505+
if thisID == k:
508506
replacements = v
509507
break
510508
elif thisID is not None:
@@ -607,12 +605,6 @@ def clientside_callback(self, clientside_function, output, inputs=[], state=[]):
607605
[self._fix_callback_item(x) for x in inputs],
608606
[self._fix_callback_item(x) for x in state])
609607

610-
def dispatch(self):
611-
'Perform dispatch, using request embedded within flask global state'
612-
import flask
613-
body = flask.request.get_json()
614-
return self.dispatch_with_args(body, argMap=dict())
615-
616608
#pylint: disable=too-many-locals
617609
def dispatch_with_args(self, body, argMap):
618610
'Perform callback dispatching, with enhanced arguments and recording of response'
@@ -651,27 +643,26 @@ def dispatch_with_args(self, body, argMap):
651643
# multiple outputs in a list (the list could contain a single item)
652644
outputs = output[2:-2].split('...')
653645

654-
args = []
655646

656647
da = argMap.get('dash_app', None)
657648

658649
callback_info = self.callback_map[output]
659650

660-
for component_registration in callback_info['inputs']:
661-
for c in inputs:
662-
if c['property'] == component_registration['property'] and compare(id_python=c['id'],id_dash=component_registration['id']):
663-
v = c.get('value', None)
664-
args.append(v)
665-
if da:
666-
da.update_current_state(c['id'], c['property'], v)
667-
668-
for component_registration in callback_info['state']:
669-
for c in states:
670-
if c['property'] == component_registration['property'] and compare(id_python=c['id'],id_dash=component_registration['id']):
671-
v = c.get('value', None)
672-
args.append(v)
673-
if da:
674-
da.update_current_state(c['id'], c['property'], v)
651+
args = []
652+
653+
for c in inputs + states:
654+
if isinstance(c, list): # ALL, ALLSMALLER
655+
v = [ci.get("value") for ci in c]
656+
if da:
657+
for ci, vi in zip(c, v):
658+
da.update_current_state(ci['id'], ci['property'], vi)
659+
else:
660+
v = c.get("value")
661+
if da:
662+
da.update_current_state(c['id'], c['property'], v)
663+
664+
args.append(v)
665+
675666

676667
# Dash 1.11 introduces a set of outputs
677668
outputs_list = body.get('outputs') or split_callback_id(output)

django_plotly_dash/migrations/0002_add_examples.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,17 @@ def addExamples(apps, schema_editor):
8383

8484
da5.save()
8585

86+
sa6 = StatelessApp(app_name="PatternStateCallbacks",
87+
slug="pattern-state-callback")
88+
89+
sa6.save()
90+
91+
da6 = DashApp(stateless_app=sa6,
92+
instance_name="Pattern and State saving Example",
93+
slug="pattern-state-callback")
94+
95+
da6.save()
96+
8697

8798
def remExamples(apps, schema_editor):
8899

django_plotly_dash/models.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,7 @@
3030
from django.utils.text import slugify
3131
from django.shortcuts import get_object_or_404
3232

33-
from .dash_wrapper import get_local_stateless_by_name, get_local_stateless_list
34-
33+
from .dash_wrapper import get_local_stateless_by_name, get_local_stateless_list, wid2str
3534

3635
logger = logging.getLogger(__name__)
3736

@@ -158,7 +157,7 @@ def handle_current_state(self):
158157
def have_current_state_entry(self, wid, key):
159158
'Return True if there is a cached current state for this app'
160159
cscoll = self.current_state()
161-
c_state = cscoll.get(wid, {})
160+
c_state = cscoll.get(wid2str(wid), {})
162161
return key in c_state
163162

164163
def update_current_state(self, wid, key, value):
@@ -168,7 +167,7 @@ def update_current_state(self, wid, key, value):
168167
If the key does not represent an existing entry, then ignore it
169168
'''
170169
cscoll = self.current_state()
171-
c_state = cscoll.get(wid, {})
170+
c_state = cscoll.get(wid2str(wid), {})
172171
if key in c_state:
173172
current_value = c_state.get(key, None)
174173
if current_value != value:

django_plotly_dash/tests.py

Lines changed: 157 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,18 @@
2727
2828
'''
2929

30-
import pytest
3130
import json
3231
from unittest.mock import patch
3332

34-
#pylint: disable=bare-except
33+
import pytest
34+
# pylint: disable=bare-except
3535
from dash.dependencies import Input
36+
from django.urls import reverse
3637

3738
from django_plotly_dash import DjangoDash
39+
from django_plotly_dash.dash_wrapper import get_local_stateless_list, get_local_stateless_by_name
40+
from django_plotly_dash.models import DashApp, find_stateless_by_name
41+
from django_plotly_dash.tests_dash_contract import fill_in_test_app, dash_contract_data
3842

3943

4044
def test_dash_app():
@@ -48,6 +52,156 @@ def test_dash_app():
4852
assert str(stateless_a) == stateless_a.app_name
4953

5054

55+
@pytest.mark.django_db
56+
def test_dash_stateful_app_client_contract(client):
57+
'Test the state management of a DashApp as well as the contract between the client and the Dash app'
58+
59+
from django_plotly_dash.models import StatelessApp
60+
61+
# create a DjangoDash, StatelessApp and DashApp
62+
ddash = DjangoDash(name="DDash")
63+
fill_in_test_app(ddash, write=False)
64+
65+
stateless_a = StatelessApp(app_name="DDash")
66+
stateless_a.save()
67+
stateful_a = DashApp(stateless_app=stateless_a,
68+
instance_name="Some name",
69+
slug="my-app", save_on_change=True)
70+
stateful_a.save()
71+
72+
# check app can be found back
73+
assert "DDash" in get_local_stateless_list()
74+
assert get_local_stateless_by_name("DDash") == ddash
75+
assert find_stateless_by_name("DDash") == ddash
76+
77+
# check the current_state is empty
78+
assert stateful_a.current_state() == {}
79+
80+
# set the initial expected state
81+
expected_state = {'inp1': {'n_clicks': 0, 'n_clicks_timestamp': 1611733453854},
82+
'inp2': {'n_clicks': 5, 'n_clicks_timestamp': 1611733454354},
83+
'out1-0': {'n_clicks': 1, 'n_clicks_timestamp': 1611733453954},
84+
'out1-1': {'n_clicks': 2, 'n_clicks_timestamp': 1611733454054},
85+
'out1-2': {'n_clicks': 3, 'n_clicks_timestamp': 1611733454154},
86+
'out1-3': {'n_clicks': 4, 'n_clicks_timestamp': 1611733454254},
87+
'out2-0': {'n_clicks': 6, 'n_clicks_timestamp': 1611733454454},
88+
'out3': {'n_clicks': 10, 'n_clicks_timestamp': 1611733454854},
89+
'out4': {'n_clicks': 14, 'n_clicks_timestamp': 1611733455254},
90+
'out5': {'n_clicks': 18, 'n_clicks_timestamp': 1611733455654},
91+
'{"_id":"inp-0","_type":"btn3"}': {'n_clicks': 7,
92+
'n_clicks_timestamp': 1611733454554},
93+
'{"_id":"inp-0","_type":"btn4"}': {'n_clicks': 11,
94+
'n_clicks_timestamp': 1611733454954},
95+
'{"_id":"inp-0","_type":"btn5"}': {'n_clicks': 15,
96+
'n_clicks_timestamp': 1611733455354},
97+
'{"_id":"inp-1","_type":"btn3"}': {'n_clicks': 8,
98+
'n_clicks_timestamp': 1611733454654},
99+
'{"_id":"inp-1","_type":"btn4"}': {'n_clicks': 12,
100+
'n_clicks_timestamp': 1611733455054},
101+
'{"_id":"inp-1","_type":"btn5"}': {'n_clicks': 16,
102+
'n_clicks_timestamp': 1611733455454},
103+
'{"_id":"inp-2","_type":"btn3"}': {'n_clicks': 9,
104+
'n_clicks_timestamp': 1611733454754},
105+
'{"_id":"inp-2","_type":"btn4"}': {'n_clicks': 13,
106+
'n_clicks_timestamp': 1611733455154},
107+
'{"_id":"inp-2","_type":"btn5"}': {'n_clicks': 17,
108+
'n_clicks_timestamp': 1611733455554}}
109+
110+
########## test state management of the app and conversion of components ids
111+
# search for state values in dash layout
112+
stateful_a.populate_values()
113+
assert stateful_a.current_state() == expected_state
114+
assert stateful_a.have_current_state_entry("inp1", "n_clicks")
115+
assert stateful_a.have_current_state_entry({"_type": "btn3", "_id": "inp-0"}, "n_clicks_timestamp")
116+
assert stateful_a.have_current_state_entry('{"_id":"inp-0","_type":"btn3"}', "n_clicks_timestamp")
117+
assert not stateful_a.have_current_state_entry("checklist", "other-prop")
118+
119+
# update a non existent state => no effect on current_state
120+
stateful_a.update_current_state("foo", "value", "random")
121+
assert stateful_a.current_state() == expected_state
122+
123+
# update an existent state => update current_state
124+
stateful_a.update_current_state('{"_id":"inp-2","_type":"btn5"}', "n_clicks", 100)
125+
expected_state['{"_id":"inp-2","_type":"btn5"}'] = {'n_clicks': 100, 'n_clicks_timestamp': 1611733455554}
126+
assert stateful_a.current_state() == expected_state
127+
128+
assert DashApp.objects.get(instance_name="Some name").current_state() == {}
129+
130+
stateful_a.handle_current_state()
131+
132+
assert DashApp.objects.get(instance_name="Some name").current_state() == expected_state
133+
134+
# check initial layout serve has the correct values injected
135+
dash_instance = stateful_a.as_dash_instance()
136+
resp = dash_instance.serve_layout()
137+
138+
# initialise layout with app state
139+
layout, mimetype = dash_instance.augment_initial_layout(resp, {})
140+
assert '"n_clicks": 100' in layout
141+
142+
# initialise layout with initial arguments
143+
layout, mimetype = dash_instance.augment_initial_layout(resp, {
144+
'{"_id":"inp-2","_type":"btn5"}': {"n_clicks": 200}})
145+
assert '"n_clicks": 100' not in layout
146+
assert '"n_clicks": 200' in layout
147+
148+
########### test contract between client and app by replaying interactions recorded in tests_dash_contract.json
149+
# get update component route
150+
url = reverse('the_django_plotly_dash:update-component', kwargs={'ident': 'my-app'})
151+
152+
# for all interactions in the tests_dash_contract.json
153+
for scenario in json.load(dash_contract_data.open("r")):
154+
body = scenario["body"]
155+
156+
response = client.post(url, json.dumps(body), content_type="application/json")
157+
158+
assert response.status_code == 200
159+
160+
response = json.loads(response.content)
161+
162+
# compare first item in response with first result
163+
result = scenario["result"]
164+
if isinstance(result, list):
165+
result = result[0]
166+
content = response["response"].popitem()[1].popitem()[1]
167+
assert content == result
168+
169+
# handle state
170+
stateful_a.handle_current_state()
171+
172+
# check final state has been changed accordingly
173+
final_state = {'inp1': {'n_clicks': 1, 'n_clicks_timestamp': 1611736145932},
174+
'inp2': {'n_clicks': 6, 'n_clicks_timestamp': 1611736146875},
175+
'out1-0': {'n_clicks': 1, 'n_clicks_timestamp': 1611733453954},
176+
'out1-1': {'n_clicks': 2, 'n_clicks_timestamp': 1611733454054},
177+
'out1-2': {'n_clicks': 3, 'n_clicks_timestamp': 1611733454154},
178+
'out1-3': {'n_clicks': 4, 'n_clicks_timestamp': 1611733454254},
179+
'out2-0': {'n_clicks': 6, 'n_clicks_timestamp': 1611733454454},
180+
'out3': {'n_clicks': 10, 'n_clicks_timestamp': 1611733454854},
181+
'out4': {'n_clicks': 14, 'n_clicks_timestamp': 1611733455254},
182+
'out5': {'n_clicks': 18, 'n_clicks_timestamp': 1611733455654},
183+
'{"_id":"inp-0","_type":"btn3"}': {'n_clicks': 8,
184+
'n_clicks_timestamp': 1611736147644},
185+
'{"_id":"inp-0","_type":"btn4"}': {'n_clicks': 12,
186+
'n_clicks_timestamp': 1611733454954},
187+
'{"_id":"inp-0","_type":"btn5"}': {'n_clicks': 16,
188+
'n_clicks_timestamp': 1611733455354},
189+
'{"_id":"inp-1","_type":"btn3"}': {'n_clicks': 9,
190+
'n_clicks_timestamp': 1611736148172},
191+
'{"_id":"inp-1","_type":"btn4"}': {'n_clicks': 13,
192+
'n_clicks_timestamp': 1611733455054},
193+
'{"_id":"inp-1","_type":"btn5"}': {'n_clicks': 18,
194+
'n_clicks_timestamp': 1611733455454},
195+
'{"_id":"inp-2","_type":"btn3"}': {'n_clicks': 10,
196+
'n_clicks_timestamp': 1611736149140},
197+
'{"_id":"inp-2","_type":"btn4"}': {'n_clicks': 13,
198+
'n_clicks_timestamp': 1611733455154},
199+
'{"_id":"inp-2","_type":"btn5"}': {'n_clicks': 19,
200+
'n_clicks_timestamp': 1611733455554}}
201+
202+
assert DashApp.objects.get(instance_name="Some name").current_state() == final_state
203+
204+
51205
def test_util_error_cases(settings):
52206
'Test handling of missing settings'
53207

@@ -258,6 +412,7 @@ def test_flexible_expanded_callbacks(client):
258412
resp = json.loads(response.content.decode('utf-8'))
259413
assert resp["response"]=={"output-three": {"children": "flexible_expanded_callbacks"}}
260414

415+
261416
@pytest.mark.django_db
262417
def test_injection_updating(client):
263418
'Check updating of an app using demo test data'

0 commit comments

Comments
 (0)