Skip to content

Commit b2d160f

Browse files
Flexible expanded callback (#304)
* add missing dev req dash-bootstrap-components * add rcfile option for check_code_dpd * fix bug when callback has a single output but in a list * allow a more flexible use of expanded_callback: - no obligation to adapt the signature of a callback when going from app.callback to app.expanded_callback - allow to specify the name of the specific extra parameters needed by the function (no need for **kwargs) to improve readability * use inspect.signature instead of inspect.getfullargspec as the latter does not work with @wraps(...) * simplify initialisation of callback_set dict * simplify wrap_func * make expanded callback the normal case: - always use dpd dispatch (remove the _expanded_callbacks variable and expanded_callbacks arguments in __init__, remove use_dash_dispatch()) - refactor the callback inspection logic to get_expanded_arguments and test it explicitly - refactur callback_set to use [] instead of {} as default - expanded_callback = callback - fix bug in handling callback.expanded - update doc * remove spurious print(...) Co-authored-by: GFJ138 <sebastien.dementen@engie.com>
1 parent f410c48 commit b2d160f

File tree

9 files changed

+234
-65
lines changed

9 files changed

+234
-65
lines changed

check_code_dpd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
#
33
source env/bin/activate
44
#
5-
pylint django_plotly_dash
5+
pylint django_plotly_dash --rcfile=pylintrc

demo/demo/dash_apps.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
html.Div(id='test-output-div2'),
5454
html.Div(id='test-output-div3')
5555

56-
]) # end of 'main'
56+
]) # end of 'main'
5757

5858
@dash_example1.expanded_callback(
5959
dash.dependencies.Output('test-output-div', 'children'),
@@ -94,9 +94,6 @@ def callback_test(*args, **kwargs): #pylint: disable=unused-argument
9494
def callback_test2(*args, **kwargs):
9595
'Callback to exercise session functionality'
9696

97-
print(args)
98-
print(kwargs)
99-
10097
children = [html.Div(["You have selected %s." %(args[0])]),
10198
html.Div(["The session context message is '%s'" %(kwargs['session_state']['django_to_dash_context'])])]
10299

demo/demo/plotly_apps.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,3 +377,37 @@ def multiple_callbacks_two(button_clicks, color_choice, **kwargs):
377377
"Output 2: %s %s %s" % (button_clicks, color_choice, kwargs['callback_context'].triggered),
378378
"Output 3: %s %s [%s]" % (button_clicks, color_choice, kwargs)
379379
]
380+
381+
382+
flexible_expanded_callbacks = DjangoDash("FlexibleExpandedCallbacks")
383+
384+
flexible_expanded_callbacks.layout = html.Div([
385+
html.Button("Press Me",
386+
id="button"),
387+
dcc.RadioItems(id='dropdown-color',
388+
options=[{'label': c, 'value': c.lower()} for c in ['Red', 'Green', 'Blue']],
389+
value='red'
390+
),
391+
html.Div(id="output-one"),
392+
html.Div(id="output-two"),
393+
html.Div(id="output-three")
394+
])
395+
396+
@flexible_expanded_callbacks.expanded_callback(
397+
dash.dependencies.Output('output-one', 'children'),
398+
[dash.dependencies.Input('button', 'n_clicks')])
399+
def exp_callback_kwargs(button_clicks, **kwargs):
400+
return str(kwargs)
401+
402+
403+
@flexible_expanded_callbacks.expanded_callback(
404+
dash.dependencies.Output('output-two', 'children'),
405+
[dash.dependencies.Input('button', 'n_clicks')])
406+
def exp_callback_standard(button_clicks):
407+
return "ok"
408+
409+
@flexible_expanded_callbacks.expanded_callback(
410+
dash.dependencies.Output('output-three', 'children'),
411+
[dash.dependencies.Input('button', 'n_clicks')])
412+
def exp_callback_dash_app_id(button_clicks, dash_app_id):
413+
return dash_app_id

demo/demo/scaffold.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from django_plotly_dash import DjangoDash
44
from django.utils.module_loading import import_string
55

6-
from demo.plotly_apps import multiple_callbacks
6+
from demo.plotly_apps import multiple_callbacks, flexible_expanded_callbacks
77

88
def stateless_app_loader(app_name):
99

django_plotly_dash/dash_wrapper.py

Lines changed: 58 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2525
SOFTWARE.
2626
'''
27-
27+
import itertools
2828
import json
2929
import inspect
3030

@@ -160,7 +160,6 @@ class DjangoDash:
160160
'''
161161
#pylint: disable=too-many-instance-attributes
162162
def __init__(self, name=None, serve_locally=None,
163-
expanded_callbacks=False,
164163
add_bootstrap_links=False,
165164
suppress_callback_exceptions=False,
166165
**kwargs): # pylint: disable=unused-argument, too-many-arguments
@@ -186,7 +185,6 @@ def __init__(self, name=None, serve_locally=None,
186185
else:
187186
self._serve_locally = serve_locally
188187

189-
self._expanded_callbacks = expanded_callbacks
190188
self._suppress_callback_exceptions = suppress_callback_exceptions
191189

192190
if add_bootstrap_links:
@@ -268,7 +266,6 @@ def form_dash_instance(self, replacements=None, ndid=None, base_pathname=None):
268266
ndid = self._uid
269267

270268
rd = WrappedDash(base_pathname=base_pathname,
271-
expanded_callbacks=self._expanded_callbacks,
272269
replacements=replacements,
273270
ndid=ndid,
274271
serve_locally=self._serve_locally)
@@ -287,26 +284,58 @@ def form_dash_instance(self, replacements=None, ndid=None, base_pathname=None):
287284

288285
return rd
289286

287+
@staticmethod
288+
def get_expanded_arguments(func, inputs, state):
289+
"""Analyse a callback function signature to detect the expanded arguments to add when called.
290+
It uses the inputs and the state information to identify what arguments are already coming from Dash.
291+
292+
It returns a list of the expanded parameters to inject (can be [] if nothing should be injected)
293+
or None if all parameters should be injected."""
294+
n_dash_parameters = len(inputs or []) + len(state or [])
295+
296+
parameter_types = {kind: [p.name for p in parameters] for kind, parameters in
297+
itertools.groupby(inspect.signature(func).parameters.values(), lambda p: p.kind)}
298+
if inspect.Parameter.VAR_KEYWORD in parameter_types:
299+
# there is some **kwargs, inject all parameters
300+
expanded = None
301+
elif inspect.Parameter.VAR_POSITIONAL in parameter_types:
302+
# there is a *args, assume all parameters afterwards (KEYWORD_ONLY) are to be injected
303+
# some of these parameters may not be expanded arguments but that is ok
304+
expanded = parameter_types.get(inspect.Parameter.KEYWORD_ONLY, [])
305+
else:
306+
# there is no **kwargs, filter argMap to take only the keyword arguments
307+
expanded = parameter_types.get(inspect.Parameter.POSITIONAL_OR_KEYWORD, [])[
308+
n_dash_parameters:] + parameter_types.get(inspect.Parameter.KEYWORD_ONLY, [])
309+
310+
return expanded
311+
290312
def callback(self, output, inputs=None, state=None, events=None):
291-
'Form a callback function by wrapping, in the same way as the underlying Dash application would'
292-
callback_set = {'output':output,
293-
'inputs':inputs and inputs or dict(),
294-
'state':state and state or dict(),
295-
'events':events and events or dict()}
296-
def wrap_func(func, callback_set=callback_set, callback_sets=self._callback_sets): # pylint: disable=dangerous-default-value, missing-docstring
297-
callback_sets.append((callback_set, func))
298-
return func
299-
return wrap_func
313+
'''Form a callback function by wrapping, in the same way as the underlying Dash application would
314+
but handling extra arguments provided by dpd.
300315
301-
def expanded_callback(self, output, inputs=[], state=[], events=[]): # pylint: disable=dangerous-default-value
302-
'''
303-
Form an expanded callback.
316+
It will inspect the signature of the function to ensure only relevant expanded arguments are passed to the callback.
317+
318+
If the function accepts a **kwargs => all expanded arguments are sent to the function in the kwargs.
319+
If the function has a *args => expanded arguments matching parameters after the *args are injected.
320+
Otherwise, take all arguments beyond the one provided by Dash (based on the Inputs/States provided).
304321
305-
This function registers the callback function, and sets an internal flag that mandates that all
306-
callbacks are passed the enhanced arguments.
307322
'''
308-
self._expanded_callbacks = True
309-
return self.callback(output, inputs, state, events)
323+
callback_set = {'output': output,
324+
'inputs': inputs or [],
325+
'state': state or [],
326+
'events': events or []}
327+
328+
def wrap_func(func):
329+
self._callback_sets.append((callback_set, func))
330+
# add an expanded attribute to the function with the information to use in dispatch_with_args
331+
# to inject properly only the expanded arguments the function can accept
332+
# if .expanded is None => inject all
333+
# if .expanded is a list => inject only
334+
func.expanded = DjangoDash.get_expanded_arguments(func, inputs, state)
335+
return func
336+
return wrap_func
337+
338+
expanded_callback = callback
310339

311340
def clientside_callback(self, clientside_function, output, inputs=None, state=None):
312341
'Form a callback function by wrapping, in the same way as the underlying Dash application would'
@@ -358,8 +387,7 @@ class WrappedDash(Dash):
358387
'Wrapper around the Plotly Dash application instance'
359388
# pylint: disable=too-many-arguments, too-many-instance-attributes
360389
def __init__(self,
361-
base_pathname=None, replacements=None, ndid=None,
362-
expanded_callbacks=False, serve_locally=False,
390+
base_pathname=None, replacements=None, ndid=None, serve_locally=False,
363391
**kwargs):
364392

365393
self._uid = ndid
@@ -378,7 +406,6 @@ def __init__(self,
378406
self.scripts.config.serve_locally = serve_locally
379407

380408
self._adjust_id = False
381-
self._dash_dispatch = not expanded_callbacks
382409
if replacements:
383410
self._replacements = replacements
384411
else:
@@ -387,10 +414,6 @@ def __init__(self,
387414

388415
self._return_embedded = False
389416

390-
def use_dash_dispatch(self):
391-
'Indicate if dispatch is using underlying dash code or the wrapped code'
392-
return self._dash_dispatch
393-
394417
def use_dash_layout(self):
395418
'''
396419
Indicate if the underlying dash layout can be used.
@@ -630,7 +653,14 @@ def dispatch_with_args(self, body, argMap):
630653
if len(args) < len(callback_info['inputs']):
631654
return 'EDGECASEEXIT'
632655

633-
res = callback_info['callback'](*args, **argMap)
656+
callback = callback_info["callback"]
657+
# smart injection of parameters if .expanded is defined
658+
if callback.expanded is not None:
659+
parameters_to_inject = {*callback.expanded, 'outputs_list'}
660+
res = callback(*args, **{k: v for k, v in argMap.items() if k in parameters_to_inject})
661+
else:
662+
res = callback(*args, **argMap)
663+
634664
if da:
635665
root_value = json.loads(res).get('response', {})
636666

django_plotly_dash/tests.py

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@
3131
import json
3232

3333
#pylint: disable=bare-except
34+
from dash.dependencies import Input
35+
36+
from django_plotly_dash import DjangoDash
3437

3538

3639
def test_dash_app():
@@ -201,6 +204,58 @@ def test_injection_updating_multiple_callbacks(client):
201204
assert resp_detail['output-two']['children'] == "Output 2: 10 purple-ish yellow with a hint of greeny orange []"
202205

203206

207+
@pytest.mark.django_db
208+
def test_flexible_expanded_callbacks(client):
209+
'Check updating of an app using demo test data for flexible expanded callbacks'
210+
211+
from django.urls import reverse
212+
213+
route_name = 'update-component'
214+
215+
for prefix, arg_map in [('app-', {'ident':'flexible_expanded_callbacks'}),]:
216+
url = reverse('the_django_plotly_dash:%s%s' % (prefix, route_name), kwargs=arg_map)
217+
218+
# output contains all arguments of the expanded_callback
219+
response = client.post(url, json.dumps({'output':'output-one.children',
220+
'inputs':[
221+
{'id':'button',
222+
'property':'n_clicks',
223+
'value':'10'},
224+
]}), content_type="application/json")
225+
226+
assert response.status_code == 200
227+
228+
resp = json.loads(response.content.decode('utf-8'))
229+
for key in ["dash_app_id", "dash_app", "callback_context"]:
230+
assert key in resp["response"]['output-one']['children']
231+
232+
# output contains all arguments of the expanded_callback
233+
response = client.post(url, json.dumps({'output':'output-two.children',
234+
'inputs':[
235+
{'id':'button',
236+
'property':'n_clicks',
237+
'value':'10'},
238+
]}), content_type="application/json")
239+
240+
assert response.status_code == 200
241+
242+
resp = json.loads(response.content.decode('utf-8'))
243+
assert resp["response"]=={'output-two': {'children': 'ok'}}
244+
245+
246+
# output contains all arguments of the expanded_callback
247+
response = client.post(url, json.dumps({'output':'output-three.children',
248+
'inputs':[
249+
{'id':'button',
250+
'property':'n_clicks',
251+
'value':'10'},
252+
]}), content_type="application/json")
253+
254+
assert response.status_code == 200
255+
256+
resp = json.loads(response.content.decode('utf-8'))
257+
assert resp["response"]=={"output-three": {"children": "flexible_expanded_callbacks"}}
258+
204259
@pytest.mark.django_db
205260
def test_injection_updating(client):
206261
'Check updating of an app using demo test data'
@@ -253,7 +308,6 @@ def test_injection_updating(client):
253308
'value':'TestIt'},
254309
]}), content_type="application/json")
255310

256-
257311
# Multiple output callback, output=="..component_id.component_prop.."
258312
response = client.post(url, json.dumps({'output':'..test-output-div3.children..',
259313
'inputs':[{'id':'my-dropdown1',
@@ -392,3 +446,46 @@ def test_app_loading(client):
392446
assert response.status_code == 302
393447

394448

449+
def test_callback_decorator():
450+
inputs = [Input("one", "value"),
451+
Input("two", "value"),
452+
]
453+
states = [Input("three", "value"),
454+
Input("four", "value"),
455+
]
456+
457+
def callback_standard(one, two, three, four):
458+
return
459+
460+
assert DjangoDash.get_expanded_arguments(callback_standard, inputs, states) == []
461+
462+
def callback_standard(one, two, three, four, extra_1):
463+
return
464+
465+
assert DjangoDash.get_expanded_arguments(callback_standard, inputs, states) == ['extra_1']
466+
467+
def callback_args(one, *args):
468+
return
469+
470+
assert DjangoDash.get_expanded_arguments(callback_args, inputs, states) == []
471+
472+
def callback_args_extra(one, *args, extra_1):
473+
return
474+
475+
assert DjangoDash.get_expanded_arguments(callback_args_extra, inputs, states) == ['extra_1' ]
476+
477+
def callback_args_extra_star(one, *, extra_1):
478+
return
479+
480+
assert DjangoDash.get_expanded_arguments(callback_args_extra_star, inputs, states) == ['extra_1' ]
481+
482+
483+
def callback_kwargs(one, two, three, four, extra_1, **kwargs):
484+
return
485+
486+
assert DjangoDash.get_expanded_arguments(callback_kwargs, inputs, states) == None
487+
488+
def callback_kwargs(one, two, three, four, *, extra_1, **kwargs, ):
489+
return
490+
491+
assert DjangoDash.get_expanded_arguments(callback_kwargs, inputs, states) == None

django_plotly_dash/views.py

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -81,27 +81,16 @@ def _update(request, ident, stateless=False, **kwargs):
8181

8282
request_body = json.loads(request.body.decode('utf-8'))
8383

84-
if app.use_dash_dispatch():
85-
# Force call through dash
86-
view_func = app.locate_endpoint_function('dash-update-component')
87-
88-
import flask
89-
with app.test_request_context():
90-
# Fudge request object
91-
# pylint: disable=protected-access
92-
flask.request._cached_json = (request_body, flask.request._cached_json[True])
93-
resp = view_func()
94-
else:
95-
# Use direct dispatch with extra arguments in the argMap
96-
app_state = request.session.get("django_plotly_dash", dict())
97-
arg_map = {'dash_app_id': ident,
98-
'dash_app': dash_app,
99-
'user': request.user,
100-
'request':request,
101-
'session_state': app_state}
102-
resp = app.dispatch_with_args(request_body, arg_map)
103-
request.session['django_plotly_dash'] = app_state
104-
dash_app.handle_current_state()
84+
# Use direct dispatch with extra arguments in the argMap
85+
app_state = request.session.get("django_plotly_dash", dict())
86+
arg_map = {'dash_app_id': ident,
87+
'dash_app': dash_app,
88+
'user': request.user,
89+
'request':request,
90+
'session_state': app_state}
91+
resp = app.dispatch_with_args(request_body, arg_map)
92+
request.session['django_plotly_dash'] = app_state
93+
dash_app.handle_current_state()
10594

10695
# Special for ws-driven edge case
10796
if str(resp) == 'EDGECASEEXIT':

docs/demo_notes.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,10 @@ are used to form the layout::
8787
]
8888
)
8989

90-
Within the :ref:`expanded callback <extended_callbacks>`, the session state is passed as an extra
90+
Within the :ref:`extended callback <extended_callbacks>`, the session state is passed as an extra
9191
argument compared to the standard ``Dash`` callback::
9292

93-
@dis.expanded_callback(
93+
@dis.callback(
9494
dash.dependencies.Output("danger-alert", 'children'),
9595
[dash.dependencies.Input('update-button', 'n_clicks'),]
9696
)

0 commit comments

Comments
 (0)