Repository: GibbsConsulting/django-plotly-dash

Issue:
Add support for wildcard callbacks
Dash now permits the use of wildcards in callbacks. This issue is a placeholder for investigating the work needed to extend support to cover this feature and for any resultant implementation.

If you are interested in sponsoring this work please reach out [here](https://gibbsconsulting.ca/).


Discussion:

Patch:
diff --git a/check_code_dpd b/check_code_dpd
index a6483f9..09d12c8 100755
--- a/check_code_dpd
+++ b/check_code_dpd
@@ -2,4 +2,4 @@
 #
 source env/bin/activate
 #
-pylint django_plotly_dash
+pylint django_plotly_dash --rcfile=pylintrc
diff --git a/demo/demo/dash_apps.py b/demo/demo/dash_apps.py
index e8a5a55..f85c763 100644
--- a/demo/demo/dash_apps.py
+++ b/demo/demo/dash_apps.py
@@ -50,9 +50,10 @@ dash_example1.layout = html.Div(id='main',
                                         className='col-md-12',
                                     ),
 
-                                    html.Div(id='test-output-div2')
+                                    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'),
@@ -93,10 +94,39 @@ 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'])])]
 
     return children
+
+@dash_example1.expanded_callback(
+    [dash.dependencies.Output('test-output-div3', 'children')],
+    [dash.dependencies.Input('my-dropdown1', 'value')])
+def callback_test(*args, **kwargs): #pylint: disable=unused-argument
+    'Callback to generate test data on each change of the dropdown'
+
+    # Creating a random Graph from a Plotly example:
+    N = 500
+    random_x = np.linspace(0, 1, N)
+    random_y = np.random.randn(N)
+
+    # Create a trace
+    trace = go.Scatter(x=random_x,
+                       y=random_y)
+
+    data = [trace]
+
+    layout = dict(title='',
+                  yaxis=dict(zeroline=False, title='Total Expense (£)',),
+                  xaxis=dict(zeroline=False, title='Date', tickangle=0),
+                  margin=dict(t=20, b=50, l=50, r=40),
+                  height=350,
+                 )
+
+
+    fig = dict(data=data, layout=layout)
+    line_graph = dcc.Graph(id='line-area-graph2', figure=fig, style={'display':'inline-block', 'width':'100%',
+                                                                     'height':'100%;'})
+    children = [line_graph]
+
+    return [children]
diff --git a/demo/demo/plotly_apps.py b/demo/demo/plotly_apps.py
index bf429ce..b1ea680 100644
--- a/demo/demo/plotly_apps.py
+++ b/demo/demo/plotly_apps.py
@@ -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
diff --git a/demo/demo/scaffold.py b/demo/demo/scaffold.py
index 4327f7d..02cbccb 100644
--- a/demo/demo/scaffold.py
+++ b/demo/demo/scaffold.py
@@ -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):
 
diff --git a/dev_requirements.txt b/dev_requirements.txt
index 98901fd..45e2fcf 100644
--- a/dev_requirements.txt
+++ b/dev_requirements.txt
@@ -4,6 +4,7 @@ coveralls>=1.6.0
 channels>=2.0
 channels-redis
 daphne
+dash-bootstrap-components
 Django>=2.0
 django-bootstrap4
 django-redis
diff --git a/django_plotly_dash/dash_wrapper.py b/django_plotly_dash/dash_wrapper.py
index db4202b..d7f8159 100644
--- a/django_plotly_dash/dash_wrapper.py
+++ b/django_plotly_dash/dash_wrapper.py
@@ -24,7 +24,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 '''
-
+import itertools
 import json
 import inspect
 
@@ -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
@@ -186,7 +185,6 @@ class DjangoDash:
         else:
             self._serve_locally = serve_locally
 
-        self._expanded_callbacks = expanded_callbacks
         self._suppress_callback_exceptions = suppress_callback_exceptions
 
         if add_bootstrap_links:
@@ -268,7 +266,6 @@ class DjangoDash:
             ndid = self._uid
 
         rd = WrappedDash(base_pathname=base_pathname,
-                         expanded_callbacks=self._expanded_callbacks,
                          replacements=replacements,
                          ndid=ndid,
                          serve_locally=self._serve_locally)
@@ -287,26 +284,58 @@ class DjangoDash:
 
         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'
@@ -354,12 +383,20 @@ class PseudoFlask(Flask):
     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
+
+
 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
@@ -378,7 +415,6 @@ class WrappedDash(Dash):
         self.scripts.config.serve_locally = serve_locally
 
         self._adjust_id = False
-        self._dash_dispatch = not expanded_callbacks
         if replacements:
             self._replacements = replacements
         else:
@@ -387,10 +423,6 @@ class WrappedDash(Dash):
 
         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.
@@ -413,10 +445,16 @@ class WrappedDash(Dash):
         if initial_arguments:
             if isinstance(initial_arguments, str):
                 initial_arguments = json.loads(initial_arguments)
+        else:
+            initial_arguments = {}
+
+        # Define overrides as self._replacements updated with initial_arguments
+        overrides = dict(self._replacements)
+        overrides.update(initial_arguments)
 
         # Walk tree. If at any point we have an element whose id
         # matches, then replace any named values at this level
-        reworked_data = self.walk_tree_and_replace(baseData, initial_arguments)
+        reworked_data = self.walk_tree_and_replace(baseData, overrides)
 
         response_data = json.dumps(reworked_data,
                                    cls=PlotlyJSONEncoder)
@@ -450,10 +488,15 @@ class WrappedDash(Dash):
             replacements = {}
             # look for id entry
             thisID = data.get('id', None)
-            if thisID is not None:
-                replacements = overrides.get(thisID, None) if overrides else None
-                if not replacements:
-                    replacements = self._replacements.get(thisID, {})
+            if isinstance(thisID, dict):
+                # handle case of thisID being a dict (pattern) => linear search in overrides dict
+                for k, v in overrides.items():
+                    if compare(id_python=thisID, id_dash=k):
+                        replacements = v
+                        break
+            elif thisID is not None:
+                # handle standard case of string thisID => key lookup
+                replacements = overrides.get(thisID, {})
             # walk all keys and replace if needed
             for k, v in data.items():
                 r = replacements.get(k, None)
@@ -589,45 +632,31 @@ class WrappedDash(Dash):
         if len(argMap) > 0:
             argMap['callback_context'] = callback_context
 
-        outputs = []
-        try:
-            if output[:2] == '..' and output[-2:] == '..':
-                # Multiple outputs
-                outputs = output[2:-2].split('...')
-                target_id = output
-                # Special case of a single output
-                if len(outputs) == 1:
-                    target_id = output[2:-2]
-        except:
-            pass
-
-        single_case = False
-        if len(outputs) < 1:
-            try:
-                output_id = output['id']
-                output_property = output['property']
-                target_id = "%s.%s" %(output_id, output_property)
-            except:
-                target_id = output
-                output_id, output_property = output.split(".")
-            single_case = True
-            outputs = [output,]
+        single_case = not(output.startswith('..') and output.endswith('..'))
+        if single_case:
+            # single Output (not in a list)
+            outputs = [output]
+        else:
+            # multiple outputs in a list (the list could contain a single item)
+            outputs = output[2:-2].split('...')
 
         args = []
 
         da = argMap.get('dash_app', None)
 
-        for component_registration in self.callback_map[target_id]['inputs']:
+        callback_info = self.callback_map[output]
+
+        for component_registration in callback_info['inputs']:
             for c in inputs:
-                if c['property'] == component_registration['property'] and c['id'] == component_registration['id']:
+                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 self.callback_map[target_id]['state']:
+        for component_registration in callback_info['state']:
             for c in states:
-                if c['property'] == component_registration['property'] and c['id'] == component_registration['id']:
+                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:
@@ -638,27 +667,32 @@ class WrappedDash(Dash):
         argMap['outputs_list'] = outputs_list
 
         # Special: intercept case of insufficient arguments
-        # This happens when a propery has been updated with a pipe component
+        # This happens when a property has been updated with a pipe component
         # TODO see if this can be attacked from the client end
 
-        if len(args) < len(self.callback_map[target_id]['inputs']):
+        if len(args) < len(callback_info['inputs']):
             return 'EDGECASEEXIT'
 
-        res = self.callback_map[target_id]['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:
-            if single_case and da.have_current_state_entry(output_id, output_property):
-                response = json.loads(res.data.decode('utf-8'))
-                value = response.get('response', {}).get('props', {}).get(output_property, None)
-                da.update_current_state(output_id, output_property, value)
+            root_value = json.loads(res).get('response', {})
 
-            response = json.loads(res)
-            root_value = response.get('response', {})
             for output_item in outputs:
                 if isinstance(output_item, str):
                     output_id, output_property = output_item.split('.')
                     if da.have_current_state_entry(output_id, output_property):
                         value = root_value.get(output_id,{}).get(output_property, None)
                         da.update_current_state(output_id, output_property, value)
+                else:
+                    # todo: implement saving of state for pattern matching ouputs
+                    raise NotImplementedError("Updating state for dict keys (pattern matching) is not yet implemented")
 
         return res
 
diff --git a/django_plotly_dash/views.py b/django_plotly_dash/views.py
index 656fd7c..bcbc2bc 100644
--- a/django_plotly_dash/views.py
+++ b/django_plotly_dash/views.py
@@ -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':
diff --git a/docs/demo_notes.rst b/docs/demo_notes.rst
index dab931e..37238a0 100644
--- a/docs/demo_notes.rst
+++ b/docs/demo_notes.rst
@@ -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'),]
         )
diff --git a/docs/extended_callbacks.rst b/docs/extended_callbacks.rst
index 459f892..02ef631 100644
--- a/docs/extended_callbacks.rst
+++ b/docs/extended_callbacks.rst
@@ -5,9 +5,13 @@ Extended callback syntax
 
 The ``DjangoDash`` class allows callbacks to request extra arguments when registered.
 
-To do this, simply replace ``callback`` with ``expanded_callback`` when registering any callback. This will cause **all** of the callbacks
-registered with this application
-to receive extra ``kwargs`` in addition to the callback parameters.
+To do this, simply add to your callback function the extra arguments you would like to receive
+after the usual parameters for your ``Input`` and ``State``.
+This will cause these callbacks registered with this application to receive extra parameters
+in addition to their usual callback parameters.
+
+If you specify a ``kwargs`` in your callback, it will receive all possible extra parameters (see below for a list).
+If you specify explicitly extra parameters from the list below, only these will be passed to your callback.
 
 For example, the ``plotly_apps.py`` example contains this dash application:
 
@@ -28,19 +32,18 @@ For example, the ``plotly_apps.py`` example contains this dash application:
       html.Div(id="output-one")
       ])
 
-  @a2.expanded_callback(
+  @a2.callback(
       dash.dependencies.Output('output-one','children'),
       [dash.dependencies.Input('dropdown-one','value')]
       )
-
   def callback_c(*args,**kwargs):
       da = kwargs['dash_app']
-      return "Args are [%s] and kwargs are %s" %(",".join(args),str(kwargs))
+      return "Args are [%s] and kwargs are %s" %(",".join(args), kwargs)
 
 The additional arguments, which are reported as the ``kwargs`` content in this example, include
 
 :callback_context: The ``Dash`` callback context. See the `documentation <https://dash.plotly.com/advanced-callbacks`_ on the content of
-                   this variable. This variable is provided as an argument to the expanded callback as well as
+                   this variable. This variable is provided as an argument to the callback as well as
                    the ``dash.callback_context`` global variable.
 :dash_app: For stateful applications, the ``DashApp`` model instance
 :dash_app_id: The application identifier. For stateless applications, this is the (slugified) name given to the ``DjangoDash`` constructor.
@@ -50,6 +53,25 @@ The additional arguments, which are reported as the ``kwargs`` content in this e
                 callback are persisted as part of the Django session framework.
 :user: The Django User instance.
 
+Possible alternatives to ``kwargs``
+
+.. code-block:: python
+
+  @a2.callback(
+      dash.dependencies.Output('output-one','children'),
+      [dash.dependencies.Input('dropdown-one','value')]
+      )
+  def callback_c(*args, dash_app):
+      return "Args are [%s] and the extra parameter dash_app is %s" %(",".join(args), dash_app)
+
+  @a2.callback(
+      dash.dependencies.Output('output-one','children'),
+      [dash.dependencies.Input('dropdown-one','value')]
+      )
+  def callback_c(*args, dash_app, **kwargs):
+      return "Args are [%s], the extra parameter dash_app is %s and kwargs are %s" %(",".join(args), dash_app, kwargs)
+
+
 The ``DashApp`` model instance can also be configured to persist itself on any change. This is discussed
 in the :ref:`models_and_state` section.
 


Test patch:
diff --git a/django_plotly_dash/tests.py b/django_plotly_dash/tests.py
index 0366082..fddd701 100644
--- a/django_plotly_dash/tests.py
+++ b/django_plotly_dash/tests.py
@@ -31,6 +31,9 @@ import pytest
 import json
 
 #pylint: disable=bare-except
+from dash.dependencies import Input
+
+from django_plotly_dash import DjangoDash
 
 
 def test_dash_app():
@@ -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'
@@ -221,10 +276,10 @@ def test_injection_updating(client):
 
         rStart = b'{"response": {"test-output-div": {"children": [{"props": {"id": "line-area-graph2"'
 
-        assert response.content[:len(rStart)] == rStart
+        assert response.content.startswith(rStart)
         assert response.status_code == 200
 
-        # New variant of output has a string used to name the properties
+        # Single output callback, output=="component_id.component_prop"
         response = client.post(url, json.dumps({'output':'test-output-div.children',
                                                 'inputs':[{'id':'my-dropdown1',
                                                            'property':'value',
@@ -233,49 +288,60 @@ def test_injection_updating(client):
 
         rStart = b'{"response": {"test-output-div": {"children": [{"props": {"id": "line-area-graph2"'
 
-        assert response.content[:len(rStart)] == rStart
+        assert response.content.startswith(rStart)
         assert response.status_code == 200
 
-        # Second variant has a single-entry mulitple property output
-        response = client.post(url, json.dumps({'output':'..test-output-div.children..',
+        # Single output callback, fails if output=="..component_id.component_prop.."
+        with pytest.raises(KeyError, match="..test-output-div.children.."):
+            client.post(url, json.dumps({'output':'..test-output-div.children..',
                                                 'inputs':[{'id':'my-dropdown1',
                                                            'property':'value',
                                                            'value':'TestIt'},
                                                          ]}), content_type="application/json")
 
-        rStart = b'{"response": {"test-output-div": {"children": {"props": {"id": "line-area-graph2"'
 
-        assert response.content[:len(rStart)] == rStart
-        assert response.status_code == 200
+        # Multiple output callback, fails if output=="component_id.component_prop"
+        with pytest.raises(KeyError, match="test-output-div3.children"):
+            client.post(url, json.dumps({'output':'test-output-div3.children',
+                                                'inputs':[{'id':'my-dropdown1',
+                                                           'property':'value',
+                                                           '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',
+                                                           'property':'value',
+                                                           'value':'TestIt'},
+                                                         ]}), content_type="application/json")
 
-        have_thrown = False
+        rStart = b'{"response": {"test-output-div3": {"children": [{"props": {"id": "line-area-graph2"'
+
+        assert response.content.startswith(rStart)
+        assert response.status_code == 200
 
-        try:
+        with pytest.raises(KeyError, match="django_to_dash_context"):
             client.post(url, json.dumps({'output': 'test-output-div2.children',
                                          'inputs':[{'id':'my-dropdown2',
                                                     'property':'value',
                                                     'value':'TestIt'},
                                                   ]}), content_type="application/json")
-        except:
-            have_thrown = True
-
-        assert have_thrown
 
         session = client.session
         session['django_plotly_dash'] = {'django_to_dash_context': 'Test 789 content'}
         session.save()
 
-        response3 = client.post(url, json.dumps({'output': 'test-output-div2.children',
+        response = client.post(url, json.dumps({'output': 'test-output-div2.children',
                                                  'inputs':[{'id':'my-dropdown2',
                                                             'property':'value',
                                                             'value':'TestIt'},
                                                           ]}), content_type="application/json")
-        rStart3 = b'{"response": {"test-output-div2": {"children": [{"props": {"children": ["You have '
+        rStart = b'{"response": {"test-output-div2": {"children": [{"props": {"children": ["You have '
 
-        assert response3.content[:len(rStart3)] == rStart3
-        assert response3.status_code == 200
+        assert response.content.startswith(rStart)
+        assert response.status_code == 200
 
-        assert response3.content.find(b'Test 789 content') > 0
+        assert response.content.find(b'Test 789 content') > 0
 
 
 @pytest.mark.django_db
@@ -380,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