Repository: plotly/dash

Issue:
[Feature Request] Simplified tracebacks in devtools
Currently devtools provides extremely long tracebacks when there's an error in a callback. They look something like this:
```
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/flask/app.py", line 2463, in __call__
    return self.wsgi_app(environ, start_response)
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/flask/app.py", line 2449, in wsgi_app
    response = self.handle_exception(e)
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/flask/app.py", line 1866, in handle_exception
    reraise(exc_type, exc_value, tb)
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/flask/_compat.py", line 39, in reraise
    raise value
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/flask/app.py", line 2446, in wsgi_app
    response = self.full_dispatch_request()
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/flask/app.py", line 1951, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/flask/app.py", line 1820, in handle_user_exception
    reraise(exc_type, exc_value, tb)
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/flask/_compat.py", line 39, in reraise
    raise value
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/flask/app.py", line 1949, in full_dispatch_request
    rv = self.dispatch_request()
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/flask/app.py", line 1935, in dispatch_request
    return self.view_functions[rule.endpoint](**req.view_args)
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/dash/dash.py", line 1287, in dispatch
    response.set_data(self.callback_map[output]['callback'](*args))
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/dash/dash.py", line 1171, in add_context
    output_value = func(*args, **kwargs)
  File "/Users/alex/plotly/dash-dev/agricultural_exports/agricultural_exports.py", line 58, in update_graph
    raise ValueError('boo')
ValueError: boo
```

Normally an app developer is only interested in the part in their own code (the last entry above, but could be more if their code was more involved). Even dash core devs wouldn't normally care about anything above the two extra dash entries, though @bpostlethwaite claims to have had to chase something through the flask stack in the past.

I'd propose an option like `dev_tools_short_tracebacks` with a default `True`, so normally all that shows up is the app code, but if necessary you can set it `False` and see it in all the current gory detail.


Discussion:

Resolving patch:
diff --git a/dash/dash.py b/dash/dash.py
--- a/dash/dash.py
+++ b/dash/dash.py
@@ -19,6 +19,7 @@
 import flask
 from flask import Flask, Response
 from flask_compress import Compress
+from werkzeug.debug.tbtools import get_current_traceback
 
 import plotly
 import dash_renderer
@@ -1168,7 +1169,8 @@ def callback(self, output, inputs=[], state=[]):
         def wrap_func(func):
             @wraps(func)
             def add_context(*args, **kwargs):
-                output_value = func(*args, **kwargs)
+                # don't touch the comment on the next line - used by debugger
+                output_value = func(*args, **kwargs)  # %% callback invoked %%
                 if multi:
                     if not isinstance(output_value, (list, tuple)):
                         raise exceptions.InvalidCallbackReturnValue(
@@ -1390,7 +1392,8 @@ def _setup_dev_tools(self, **kwargs):
                 'props_check',
                 'serve_dev_bundles',
                 'hot_reload',
-                'silence_routes_logging'
+                'silence_routes_logging',
+                'prune_errors'
         ):
             dev_tools[attr] = get_combined_config(
                 attr, kwargs.get(attr, None), default=debug
@@ -1419,7 +1422,8 @@ def enable_dev_tools(
             dev_tools_hot_reload_interval=None,
             dev_tools_hot_reload_watch_interval=None,
             dev_tools_hot_reload_max_retry=None,
-            dev_tools_silence_routes_logging=None):
+            dev_tools_silence_routes_logging=None,
+            dev_tools_prune_errors=None):
         """
         Activate the dev tools, called by `run_server`. If your application is
         served by wsgi and you want to activate the dev tools, you can call
@@ -1483,6 +1487,11 @@ def enable_dev_tools(
             env: ``DASH_SILENCE_ROUTES_LOGGING``
         :type dev_tools_silence_routes_logging: bool
 
+        :param dev_tools_prune_errors: Reduce tracebacks to just user code,
+            stripping out Flask and Dash pieces. `True` by default, set to
+            `False` to see the complete traceback.
+        :type dev_tools_prune_errors: bool
+
         :return: debug
         """
         if debug is None:
@@ -1497,7 +1506,8 @@ def enable_dev_tools(
             hot_reload_interval=dev_tools_hot_reload_interval,
             hot_reload_watch_interval=dev_tools_hot_reload_watch_interval,
             hot_reload_max_retry=dev_tools_hot_reload_max_retry,
-            silence_routes_logging=dev_tools_silence_routes_logging
+            silence_routes_logging=dev_tools_silence_routes_logging,
+            prune_errors=dev_tools_prune_errors
         )
 
         if dev_tools.silence_routes_logging:
@@ -1527,6 +1537,21 @@ def enable_dev_tools(
             _reload.watch_thread.daemon = True
             _reload.watch_thread.start()
 
+        if debug and dev_tools.prune_errors:
+            @self.server.errorhandler(Exception)
+            def _wrap_errors(_):
+                # find the callback invocation, if the error is from a callback
+                # and skip the traceback up to that point
+                # if the error didn't come from inside a callback, we won't
+                # skip anything.
+                tb = get_current_traceback()
+                skip = 0
+                for i, line in enumerate(tb.plaintext.splitlines()):
+                    if "%% callback invoked %%" in line:
+                        skip = int((i + 1) / 2)
+                        break
+                return get_current_traceback(skip=skip).render_full(), 500
+
         if (debug and dev_tools.serve_dev_bundles and
                 not self.scripts.config.serve_locally):
             # Dev bundles only works locally.
@@ -1594,6 +1619,7 @@ def run_server(
             dev_tools_hot_reload_watch_interval=None,
             dev_tools_hot_reload_max_retry=None,
             dev_tools_silence_routes_logging=None,
+            dev_tools_prune_errors=None,
             **flask_run_options):
         """
         Start the flask server in local mode, you should not run this on a
@@ -1652,6 +1678,11 @@ def run_server(
             env: ``DASH_SILENCE_ROUTES_LOGGING``
         :type dev_tools_silence_routes_logging: bool
 
+        :param dev_tools_prune_errors: Reduce tracebacks to just user code,
+            stripping out Flask and Dash pieces. Only available with debugging.
+            `True` by default, set to `False` to see the complete traceback.
+        :type dev_tools_prune_errors: bool
+
         :param flask_run_options: Given to `Flask.run`
 
         :return:
@@ -1666,6 +1697,7 @@ def run_server(
             dev_tools_hot_reload_watch_interval,
             dev_tools_hot_reload_max_retry,
             dev_tools_silence_routes_logging,
+            dev_tools_prune_errors
         )
 
         if self._dev_tools.silence_routes_logging:


Test patch:
diff --git a/tests/integration/devtools/test_devtools_error_handling.py b/tests/integration/devtools/test_devtools_error_handling.py
--- a/tests/integration/devtools/test_devtools_error_handling.py
+++ b/tests/integration/devtools/test_devtools_error_handling.py
@@ -6,7 +6,7 @@
 from dash.exceptions import PreventUpdate
 
 
-def test_dveh001_python_errors(dash_duo):
+def app_with_errors():
     app = dash.Dash(__name__)
 
     app.layout = html.Div(
@@ -19,10 +19,26 @@ def test_dveh001_python_errors(dash_duo):
     @app.callback(Output("output", "children"), [Input("python", "n_clicks")])
     def update_output(n_clicks):
         if n_clicks == 1:
-            1 / 0
+            return bad_sub()
         elif n_clicks == 2:
             raise Exception("Special 2 clicks exception")
 
+    def bad_sub():
+        return 1 / 0
+
+    return app
+
+
+def get_error_html(dash_duo, index):
+    # error is in an iframe so is annoying to read out - get it from the store
+    return dash_duo.driver.execute_script(
+        "return store.getState().error.backEnd[{}].error.html;".format(index)
+    )
+
+
+def test_dveh001_python_errors(dash_duo):
+    app = app_with_errors()
+
     dash_duo.start_server(
         app,
         debug=True,
@@ -49,6 +65,58 @@ def update_output(n_clicks):
     dash_duo.find_element(".test-devtools-error-toggle").click()
     dash_duo.percy_snapshot("devtools - python exception - 2 errors open")
 
+    # the top (first) error is the most recent one - ie from the second click
+    error0 = get_error_html(dash_duo, 0)
+    # user part of the traceback shown by default
+    assert 'in update_output' in error0
+    assert 'Special 2 clicks exception' in error0
+    assert 'in bad_sub' not in error0
+    # dash and flask part of the traceback not included
+    assert '%% callback invoked %%' not in error0
+    assert 'self.wsgi_app' not in error0
+
+    error1 = get_error_html(dash_duo, 1)
+    assert 'in update_output' in error1
+    assert 'in bad_sub' in error1
+    assert 'ZeroDivisionError' in error1
+    assert '%% callback invoked %%' not in error1
+    assert 'self.wsgi_app' not in error1
+
+
+def test_dveh006_long_python_errors(dash_duo):
+    app = app_with_errors()
+
+    dash_duo.start_server(
+        app,
+        debug=True,
+        use_reloader=False,
+        use_debugger=True,
+        dev_tools_hot_reload=False,
+        dev_tools_prune_errors=False,
+    )
+
+    dash_duo.find_element("#python").click()
+    dash_duo.find_element("#python").click()
+    dash_duo.wait_for_text_to_equal(dash_duo.devtools_error_count_locator, "2")
+
+    dash_duo.find_element(".test-devtools-error-toggle").click()
+
+    error0 = get_error_html(dash_duo, 0)
+    assert 'in update_output' in error0
+    assert 'Special 2 clicks exception' in error0
+    assert 'in bad_sub' not in error0
+    # dash and flask part of the traceback ARE included
+    # since we set dev_tools_prune_errors=False
+    assert '%% callback invoked %%' in error0
+    assert 'self.wsgi_app' in error0
+
+    error1 = get_error_html(dash_duo, 1)
+    assert 'in update_output' in error1
+    assert 'in bad_sub' in error1
+    assert 'ZeroDivisionError' in error1
+    assert '%% callback invoked %%' in error1
+    assert 'self.wsgi_app' in error1
+
 
 def test_dveh002_prevent_update_not_in_error_msg(dash_duo):
     # raising PreventUpdate shouldn't display the error message
diff --git a/tests/integration/devtools/test_props_check.py b/tests/integration/devtools/test_props_check.py
--- a/tests/integration/devtools/test_props_check.py
+++ b/tests/integration/devtools/test_props_check.py
@@ -15,7 +15,7 @@
         "fail": True,
         "name": 'missing required "value" inside options',
         "component": dcc.Checklist,
-        "props": {"options": [{"label": "hello"}], "values": ["test"]},
+        "props": {"options": [{"label": "hello"}], "value": ["test"]},
     },
     "invalid-nested-prop": {
         "fail": True,
@@ -23,14 +23,14 @@
         "component": dcc.Checklist,
         "props": {
             "options": [{"label": "hello", "value": True}],
-            "values": ["test"],
+            "value": ["test"],
         },
     },
     "invalid-arrayOf": {
         "fail": True,
         "name": "invalid arrayOf",
         "component": dcc.Checklist,
-        "props": {"options": "test", "values": []},
+        "props": {"options": "test", "value": []},
     },
     "invalid-oneOf": {
         "fail": True,
@@ -82,7 +82,7 @@
         "component": dcc.Checklist,
         "props": {
             "options": [{"label": "hello", "value": "test"}],
-            "values": "test",
+            "value": "test",
         },
     },
     "no-properties": {