Repository: plotly/dash

Issue:
[BUG] Regression on callbacks using name argument `output=Output(...)`
Caused by https://github.com/plotly/dash/pull/1180

Example DCC failing test: https://github.com/plotly/dash-core-components/blob/dev/tests/test_integration_1.py#L391

Removing (output=) makes it work:
```
Output(component_id='test-pathname', component_property='children'),
```

The problematic line seems to be https://github.com/plotly/dash/blob/dev/dash/dependencies.py#L163

Updating to 👇 makes the test pass:
```
if isinstance(outputs, (list, tuple)) and len(outputs) == 1:
```

Expected result:
- DCC tests pass
- Added test in Dash for named arguments

@alexcjohnson 


Discussion:

Resolving patch:
diff --git a/dash/dependencies.py b/dash/dependencies.py
--- a/dash/dependencies.py
+++ b/dash/dependencies.py
@@ -140,7 +140,13 @@ def __repr__(self):
 def extract_callback_args(args, kwargs, name, type_):
     """Extract arguments for callback from a name and type"""
     parameters = kwargs.get(name, [])
-    if not parameters:
+    if parameters:
+        if not isinstance(parameters, (list, tuple)):
+            # accept a single item, not wrapped in a list, for any of the
+            # categories as a named arg (even though previously only output
+            # could be given unwrapped)
+            return [parameters]
+    else:
         while args and isinstance(args[0], type_):
             parameters.append(args.pop(0))
     return parameters
@@ -163,6 +169,9 @@ def handle_callback_args(args, kwargs):
     if len(outputs) == 1:
         out0 = kwargs.get("output", args[0] if args else None)
         if not isinstance(out0, (list, tuple)):
+            # unless it was explicitly provided as a list, a single output
+            # sholuld be unwrapped. That ensures the return value of the
+            # callback is also not expected to be wrapped in a list.
             outputs = outputs[0]
 
     inputs = extract_callback_args(flat_args, kwargs, "inputs", Input)


Test patch:
diff --git a/tests/integration/callbacks/test_validation.py b/tests/integration/callbacks/test_validation.py
--- a/tests/integration/callbacks/test_validation.py
+++ b/tests/integration/callbacks/test_validation.py
@@ -144,3 +144,52 @@ def o2(i):
     dash_duo.start_server(app)
     dash_duo.wait_for_text_to_equal("#out1", "1: Hi")
     dash_duo.wait_for_text_to_equal("#out2", "2: Hi")
+
+
+@pytest.mark.parametrize("named_out", [True, False])
+@pytest.mark.parametrize("named_in", [True, False])
+@pytest.mark.parametrize("named_state", [True, False])
+def test_cbva004_named_args(named_out, named_in, named_state, dash_duo):
+    app = Dash(__name__)
+    app.layout = html.Div(
+        [
+            html.Div("Hi", id="in"),
+            html.Div("gh", id="state"),
+            html.Div(id="out1"),
+            html.Div(id="out2"),
+        ]
+    )
+
+    def make_args(*a):
+        args = []
+        kwargs = {}
+        names = ["output", "inputs", "state"]
+        flags = [named_out, named_in, named_state]
+        for ai, name, flag in zip(a, names, flags):
+            if flag:
+                kwargs[name] = ai
+            else:
+                args.append(ai)
+        return args, kwargs
+
+    args, kwargs = make_args(
+        Output("out1", "children"), Input("in", "children"), State("state", "children")
+    )
+
+    @app.callback(*args, **kwargs)
+    def o1(i, s):
+        return "1: " + i + s
+
+    args, kwargs = make_args(
+        [Output("out2", "children")],
+        [Input("in", "children")],
+        [State("state", "children")],
+    )
+
+    @app.callback(*args, **kwargs)
+    def o2(i, s):
+        return ("2: " + i + s,)
+
+    dash_duo.start_server(app)
+    dash_duo.wait_for_text_to_equal("#out1", "1: High")
+    dash_duo.wait_for_text_to_equal("#out2", "2: High")