Skip to content

Commit

Permalink
Flexible expanded callback (#304)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
sdementen and sebastiendementen committed Jan 24, 2021
1 parent f410c48 commit b2d160f
Show file tree
Hide file tree
Showing 9 changed files with 234 additions and 65 deletions.
2 changes: 1 addition & 1 deletion check_code_dpd
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
#
source env/bin/activate
#
pylint django_plotly_dash
pylint django_plotly_dash --rcfile=pylintrc
5 changes: 1 addition & 4 deletions demo/demo/dash_apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
html.Div(id='test-output-div2'),
html.Div(id='test-output-div3')

]) # end of 'main'
]) # end of 'main'

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

print(args)
print(kwargs)

children = [html.Div(["You have selected %s." %(args[0])]),
html.Div(["The session context message is '%s'" %(kwargs['session_state']['django_to_dash_context'])])]

Expand Down
34 changes: 34 additions & 0 deletions demo/demo/plotly_apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,3 +377,37 @@ def multiple_callbacks_two(button_clicks, color_choice, **kwargs):
"Output 2: %s %s %s" % (button_clicks, color_choice, kwargs['callback_context'].triggered),
"Output 3: %s %s [%s]" % (button_clicks, color_choice, kwargs)
]


flexible_expanded_callbacks = DjangoDash("FlexibleExpandedCallbacks")

flexible_expanded_callbacks.layout = html.Div([
html.Button("Press Me",
id="button"),
dcc.RadioItems(id='dropdown-color',
options=[{'label': c, 'value': c.lower()} for c in ['Red', 'Green', 'Blue']],
value='red'
),
html.Div(id="output-one"),
html.Div(id="output-two"),
html.Div(id="output-three")
])

@flexible_expanded_callbacks.expanded_callback(
dash.dependencies.Output('output-one', 'children'),
[dash.dependencies.Input('button', 'n_clicks')])
def exp_callback_kwargs(button_clicks, **kwargs):
return str(kwargs)


@flexible_expanded_callbacks.expanded_callback(
dash.dependencies.Output('output-two', 'children'),
[dash.dependencies.Input('button', 'n_clicks')])
def exp_callback_standard(button_clicks):
return "ok"

@flexible_expanded_callbacks.expanded_callback(
dash.dependencies.Output('output-three', 'children'),
[dash.dependencies.Input('button', 'n_clicks')])
def exp_callback_dash_app_id(button_clicks, dash_app_id):
return dash_app_id
2 changes: 1 addition & 1 deletion demo/demo/scaffold.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from django_plotly_dash import DjangoDash
from django.utils.module_loading import import_string

from demo.plotly_apps import multiple_callbacks
from demo.plotly_apps import multiple_callbacks, flexible_expanded_callbacks

def stateless_app_loader(app_name):

Expand Down
86 changes: 58 additions & 28 deletions django_plotly_dash/dash_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
'''

import itertools
import json
import inspect

Expand Down Expand Up @@ -160,7 +160,6 @@ class DjangoDash:
'''
#pylint: disable=too-many-instance-attributes
def __init__(self, name=None, serve_locally=None,
expanded_callbacks=False,
add_bootstrap_links=False,
suppress_callback_exceptions=False,
**kwargs): # pylint: disable=unused-argument, too-many-arguments
Expand All @@ -186,7 +185,6 @@ def __init__(self, name=None, serve_locally=None,
else:
self._serve_locally = serve_locally

self._expanded_callbacks = expanded_callbacks
self._suppress_callback_exceptions = suppress_callback_exceptions

if add_bootstrap_links:
Expand Down Expand Up @@ -268,7 +266,6 @@ def form_dash_instance(self, replacements=None, ndid=None, base_pathname=None):
ndid = self._uid

rd = WrappedDash(base_pathname=base_pathname,
expanded_callbacks=self._expanded_callbacks,
replacements=replacements,
ndid=ndid,
serve_locally=self._serve_locally)
Expand All @@ -287,26 +284,58 @@ def form_dash_instance(self, replacements=None, ndid=None, base_pathname=None):

return rd

@staticmethod
def get_expanded_arguments(func, inputs, state):
"""Analyse a callback function signature to detect the expanded arguments to add when called.
It uses the inputs and the state information to identify what arguments are already coming from Dash.
It returns a list of the expanded parameters to inject (can be [] if nothing should be injected)
or None if all parameters should be injected."""
n_dash_parameters = len(inputs or []) + len(state or [])

parameter_types = {kind: [p.name for p in parameters] for kind, parameters in
itertools.groupby(inspect.signature(func).parameters.values(), lambda p: p.kind)}
if inspect.Parameter.VAR_KEYWORD in parameter_types:
# there is some **kwargs, inject all parameters
expanded = None
elif inspect.Parameter.VAR_POSITIONAL in parameter_types:
# there is a *args, assume all parameters afterwards (KEYWORD_ONLY) are to be injected
# some of these parameters may not be expanded arguments but that is ok
expanded = parameter_types.get(inspect.Parameter.KEYWORD_ONLY, [])
else:
# there is no **kwargs, filter argMap to take only the keyword arguments
expanded = parameter_types.get(inspect.Parameter.POSITIONAL_OR_KEYWORD, [])[
n_dash_parameters:] + parameter_types.get(inspect.Parameter.KEYWORD_ONLY, [])

return expanded

def callback(self, output, inputs=None, state=None, events=None):
'Form a callback function by wrapping, in the same way as the underlying Dash application would'
callback_set = {'output':output,
'inputs':inputs and inputs or dict(),
'state':state and state or dict(),
'events':events and events or dict()}
def wrap_func(func, callback_set=callback_set, callback_sets=self._callback_sets): # pylint: disable=dangerous-default-value, missing-docstring
callback_sets.append((callback_set, func))
return func
return wrap_func
'''Form a callback function by wrapping, in the same way as the underlying Dash application would
but handling extra arguments provided by dpd.
def expanded_callback(self, output, inputs=[], state=[], events=[]): # pylint: disable=dangerous-default-value
'''
Form an expanded callback.
It will inspect the signature of the function to ensure only relevant expanded arguments are passed to the callback.
If the function accepts a **kwargs => all expanded arguments are sent to the function in the kwargs.
If the function has a *args => expanded arguments matching parameters after the *args are injected.
Otherwise, take all arguments beyond the one provided by Dash (based on the Inputs/States provided).
This function registers the callback function, and sets an internal flag that mandates that all
callbacks are passed the enhanced arguments.
'''
self._expanded_callbacks = True
return self.callback(output, inputs, state, events)
callback_set = {'output': output,
'inputs': inputs or [],
'state': state or [],
'events': events or []}

def wrap_func(func):
self._callback_sets.append((callback_set, func))
# add an expanded attribute to the function with the information to use in dispatch_with_args
# to inject properly only the expanded arguments the function can accept
# if .expanded is None => inject all
# if .expanded is a list => inject only
func.expanded = DjangoDash.get_expanded_arguments(func, inputs, state)
return func
return wrap_func

expanded_callback = callback

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

self._uid = ndid
Expand All @@ -378,7 +406,6 @@ def __init__(self,
self.scripts.config.serve_locally = serve_locally

self._adjust_id = False
self._dash_dispatch = not expanded_callbacks
if replacements:
self._replacements = replacements
else:
Expand All @@ -387,10 +414,6 @@ def __init__(self,

self._return_embedded = False

def use_dash_dispatch(self):
'Indicate if dispatch is using underlying dash code or the wrapped code'
return self._dash_dispatch

def use_dash_layout(self):
'''
Indicate if the underlying dash layout can be used.
Expand Down Expand Up @@ -630,7 +653,14 @@ def dispatch_with_args(self, body, argMap):
if len(args) < len(callback_info['inputs']):
return 'EDGECASEEXIT'

res = callback_info['callback'](*args, **argMap)
callback = callback_info["callback"]
# smart injection of parameters if .expanded is defined
if callback.expanded is not None:
parameters_to_inject = {*callback.expanded, 'outputs_list'}
res = callback(*args, **{k: v for k, v in argMap.items() if k in parameters_to_inject})
else:
res = callback(*args, **argMap)

if da:
root_value = json.loads(res).get('response', {})

Expand Down
99 changes: 98 additions & 1 deletion django_plotly_dash/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
import json

#pylint: disable=bare-except
from dash.dependencies import Input

from django_plotly_dash import DjangoDash


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


@pytest.mark.django_db
def test_flexible_expanded_callbacks(client):
'Check updating of an app using demo test data for flexible expanded callbacks'

from django.urls import reverse

route_name = 'update-component'

for prefix, arg_map in [('app-', {'ident':'flexible_expanded_callbacks'}),]:
url = reverse('the_django_plotly_dash:%s%s' % (prefix, route_name), kwargs=arg_map)

# output contains all arguments of the expanded_callback
response = client.post(url, json.dumps({'output':'output-one.children',
'inputs':[
{'id':'button',
'property':'n_clicks',
'value':'10'},
]}), content_type="application/json")

assert response.status_code == 200

resp = json.loads(response.content.decode('utf-8'))
for key in ["dash_app_id", "dash_app", "callback_context"]:
assert key in resp["response"]['output-one']['children']

# output contains all arguments of the expanded_callback
response = client.post(url, json.dumps({'output':'output-two.children',
'inputs':[
{'id':'button',
'property':'n_clicks',
'value':'10'},
]}), content_type="application/json")

assert response.status_code == 200

resp = json.loads(response.content.decode('utf-8'))
assert resp["response"]=={'output-two': {'children': 'ok'}}


# output contains all arguments of the expanded_callback
response = client.post(url, json.dumps({'output':'output-three.children',
'inputs':[
{'id':'button',
'property':'n_clicks',
'value':'10'},
]}), content_type="application/json")

assert response.status_code == 200

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 Expand Up @@ -253,7 +308,6 @@ def test_injection_updating(client):
'value':'TestIt'},
]}), content_type="application/json")


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


def test_callback_decorator():
inputs = [Input("one", "value"),
Input("two", "value"),
]
states = [Input("three", "value"),
Input("four", "value"),
]

def callback_standard(one, two, three, four):
return

assert DjangoDash.get_expanded_arguments(callback_standard, inputs, states) == []

def callback_standard(one, two, three, four, extra_1):
return

assert DjangoDash.get_expanded_arguments(callback_standard, inputs, states) == ['extra_1']

def callback_args(one, *args):
return

assert DjangoDash.get_expanded_arguments(callback_args, inputs, states) == []

def callback_args_extra(one, *args, extra_1):
return

assert DjangoDash.get_expanded_arguments(callback_args_extra, inputs, states) == ['extra_1' ]

def callback_args_extra_star(one, *, extra_1):
return

assert DjangoDash.get_expanded_arguments(callback_args_extra_star, inputs, states) == ['extra_1' ]


def callback_kwargs(one, two, three, four, extra_1, **kwargs):
return

assert DjangoDash.get_expanded_arguments(callback_kwargs, inputs, states) == None

def callback_kwargs(one, two, three, four, *, extra_1, **kwargs, ):
return

assert DjangoDash.get_expanded_arguments(callback_kwargs, inputs, states) == None
31 changes: 10 additions & 21 deletions django_plotly_dash/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,27 +81,16 @@ def _update(request, ident, stateless=False, **kwargs):

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

if app.use_dash_dispatch():
# Force call through dash
view_func = app.locate_endpoint_function('dash-update-component')

import flask
with app.test_request_context():
# Fudge request object
# pylint: disable=protected-access
flask.request._cached_json = (request_body, flask.request._cached_json[True])
resp = view_func()
else:
# Use direct dispatch with extra arguments in the argMap
app_state = request.session.get("django_plotly_dash", dict())
arg_map = {'dash_app_id': ident,
'dash_app': dash_app,
'user': request.user,
'request':request,
'session_state': app_state}
resp = app.dispatch_with_args(request_body, arg_map)
request.session['django_plotly_dash'] = app_state
dash_app.handle_current_state()
# Use direct dispatch with extra arguments in the argMap
app_state = request.session.get("django_plotly_dash", dict())
arg_map = {'dash_app_id': ident,
'dash_app': dash_app,
'user': request.user,
'request':request,
'session_state': app_state}
resp = app.dispatch_with_args(request_body, arg_map)
request.session['django_plotly_dash'] = app_state
dash_app.handle_current_state()

# Special for ws-driven edge case
if str(resp) == 'EDGECASEEXIT':
Expand Down
4 changes: 2 additions & 2 deletions docs/demo_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,10 @@ are used to form the layout::
]
)

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

@dis.expanded_callback(
@dis.callback(
dash.dependencies.Output("danger-alert", 'children'),
[dash.dependencies.Input('update-button', 'n_clicks'),]
)
Expand Down

0 comments on commit b2d160f

Please sign in to comment.