Skip to content

Commit 90c9c5b

Browse files
authored
Merge branch 'dev' into layout-function-patch
2 parents f26d8bf + 129c942 commit 90c9c5b

File tree

13 files changed

+204
-26
lines changed

13 files changed

+204
-26
lines changed

.pylintrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,7 @@ max-bool-expr=5
414414
max-branches=15
415415

416416
# Maximum number of locals for function / method body
417-
max-locals=20
417+
max-locals=25
418418

419419
# Maximum number of parents for a class (see R0901).
420420
max-parents=7

MAKE_A_NEW_BACK_END.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Each back end must have a way to generate its own wrappers for these React compo
3232
There is a relatively small set of routes (urls/url patterns) that a Dash server must be prepared to serve. A good way to get a feel for them is to load https://dash.plotly.com/ and look at the page structure (“Elements” tab in Chrome devtools) and the network requests that are made on page load and their responses (“Network” tab). You can see all of the route patterns if you look at the main [`dash.dash` file](https://github.com/plotly/dash/blob/dev/dash/dash.py) and search for `self._add_url` - plus the one `self.server.register_blueprint` call above it. These routes are:
3333
- `""` and anything not caught by other routes listed below (see https://dash.plotly.com/urls): the “index” route, serving the HTML page skeleton. The Python back end implements a bunch of customization options for this, but for a first version these are unnecessary. See the template given in [`_default_index`](https://github.com/plotly/dash/blob/357f22167d40ef00c92ff165aa6df23c622799f6/dash/dash.py#L58-L74) for the basic structure and pieces that need to be included, most importantly `{%config}` that includes info like whether to display in-browser devtools, and `{%scripts}` and `{%renderer}` that load the necessary `<script>` tags and initialize the dash renderer.
3434
- `_dash-component-suites/<package_name>/<path>`: serve the JavaScript and related files given by each component package, as described above. Note that we include an extra cache-busting portion in each filename. In the Python version this is the version number and unix timestamp of the file as reported by the filesystem
35-
- `_dash-layout"`: Gives the JSON structure of the initial Dash components to render on the page (`app.layout` in Python)
35+
- `_dash-layout`: Gives the JSON structure of the initial Dash components to render on the page (`app.layout` in Python)
3636
- `_dash-dependencies`: A JSON array of all callback functions defined in the app.
3737
- `_dash-update-component`: Handles all server-side callbacks. Responds in JSON. Note that `children` outputs can contain Dash components, which must be JSON encoded the same way as in `_dash-layout`.
3838
- `_reload-hash`: once hot reloading is implemented and enabled (normally only during development), this route tells the page when something has changed on the server and the page should refresh.

dash/_callback.py

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import collections
22
import hashlib
33
from functools import wraps
4+
from typing import Callable, Optional, Any
45

56
import flask
67

@@ -67,6 +68,7 @@ def callback(
6768
cancel=None,
6869
manager=None,
6970
cache_args_to_ignore=None,
71+
on_error: Optional[Callable[[Exception], Any]] = None,
7072
**_kwargs,
7173
):
7274
"""
@@ -137,6 +139,10 @@ def callback(
137139
this should be a list of argument indices as integers.
138140
:param interval:
139141
Time to wait between the long callback update requests.
142+
:param on_error:
143+
Function to call when the callback raises an exception. Receives the
144+
exception object as first argument. The callback_context can be used
145+
to access the original callback inputs, states and output.
140146
"""
141147

142148
long_spec = None
@@ -186,6 +192,7 @@ def callback(
186192
long=long_spec,
187193
manager=manager,
188194
running=running,
195+
on_error=on_error,
189196
)
190197

191198

@@ -226,7 +233,7 @@ def insert_callback(
226233
long=None,
227234
manager=None,
228235
running=None,
229-
dynamic_creator=False,
236+
dynamic_creator: Optional[bool] = False,
230237
no_output=False,
231238
):
232239
if prevent_initial_call is None:
@@ -272,8 +279,16 @@ def insert_callback(
272279
return callback_id
273280

274281

275-
# pylint: disable=R0912, R0915
276-
def register_callback( # pylint: disable=R0914
282+
def _set_side_update(ctx, response) -> bool:
283+
side_update = dict(ctx.updated_props)
284+
if len(side_update) > 0:
285+
response["sideUpdate"] = side_update
286+
return True
287+
return False
288+
289+
290+
# pylint: disable=too-many-branches,too-many-statements
291+
def register_callback(
277292
callback_list, callback_map, config_prevent_initial_callbacks, *_args, **_kwargs
278293
):
279294
(
@@ -297,6 +312,7 @@ def register_callback( # pylint: disable=R0914
297312
long = _kwargs.get("long")
298313
manager = _kwargs.get("manager")
299314
running = _kwargs.get("running")
315+
on_error = _kwargs.get("on_error")
300316
if running is not None:
301317
if not isinstance(running[0], (list, tuple)):
302318
running = [running]
@@ -342,6 +358,8 @@ def add_context(*args, **kwargs):
342358
"callback_context", AttributeDict({"updated_props": {}})
343359
)
344360
callback_manager = long and long.get("manager", app_callback_manager)
361+
error_handler = on_error or kwargs.pop("app_on_error", None)
362+
345363
if has_output:
346364
_validate.validate_output_spec(insert_output, output_spec, Output)
347365

@@ -351,7 +369,7 @@ def add_context(*args, **kwargs):
351369
args, inputs_state_indices
352370
)
353371

354-
response = {"multi": True}
372+
response: dict = {"multi": True}
355373
has_update = False
356374

357375
if long is not None:
@@ -440,10 +458,24 @@ def add_context(*args, **kwargs):
440458
isinstance(output_value, dict)
441459
and "long_callback_error" in output_value
442460
):
443-
error = output_value.get("long_callback_error")
444-
raise LongCallbackError(
461+
error = output_value.get("long_callback_error", {})
462+
exc = LongCallbackError(
445463
f"An error occurred inside a long callback: {error['msg']}\n{error['tb']}"
446464
)
465+
if error_handler:
466+
output_value = error_handler(exc)
467+
468+
if output_value is None:
469+
output_value = NoUpdate()
470+
# set_props from the error handler uses the original ctx
471+
# instead of manager.get_updated_props since it runs in the
472+
# request process.
473+
has_update = (
474+
_set_side_update(callback_ctx, response)
475+
or output_value is not None
476+
)
477+
else:
478+
raise exc
447479

448480
if job_running and output_value is not callback_manager.UNDEFINED:
449481
# cached results.
@@ -462,10 +494,22 @@ def add_context(*args, **kwargs):
462494
if output_value is callback_manager.UNDEFINED:
463495
return to_json(response)
464496
else:
465-
output_value = _invoke_callback(func, *func_args, **func_kwargs)
466-
467-
if NoUpdate.is_no_update(output_value):
468-
raise PreventUpdate
497+
try:
498+
output_value = _invoke_callback(func, *func_args, **func_kwargs)
499+
except PreventUpdate as err:
500+
raise err
501+
except Exception as err: # pylint: disable=broad-exception-caught
502+
if error_handler:
503+
output_value = error_handler(err)
504+
505+
# If the error returns nothing, automatically puts NoUpdate for response.
506+
if output_value is None:
507+
if not multi:
508+
output_value = NoUpdate()
509+
else:
510+
output_value = [NoUpdate for _ in output_spec]
511+
else:
512+
raise err
469513

470514
component_ids = collections.defaultdict(dict)
471515

@@ -487,12 +531,12 @@ def add_context(*args, **kwargs):
487531
)
488532

489533
for val, spec in zip(flat_output_values, output_spec):
490-
if isinstance(val, NoUpdate):
534+
if NoUpdate.is_no_update(val):
491535
continue
492536
for vali, speci in (
493537
zip(val, spec) if isinstance(spec, list) else [[val, spec]]
494538
):
495-
if not isinstance(vali, NoUpdate):
539+
if not NoUpdate.is_no_update(vali):
496540
has_update = True
497541
id_str = stringify_id(speci["id"])
498542
prop = clean_property_name(speci["property"])
@@ -506,10 +550,7 @@ def add_context(*args, **kwargs):
506550
flat_output_values = []
507551

508552
if not long:
509-
side_update = dict(callback_ctx.updated_props)
510-
if len(side_update) > 0:
511-
has_update = True
512-
response["sideUpdate"] = side_update
553+
has_update = _set_side_update(callback_ctx, response) or has_update
513554

514555
if not has_update:
515556
raise PreventUpdate

dash/dash.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import base64
1717
import traceback
1818
from urllib.parse import urlparse
19-
from typing import Dict, Optional, Union
19+
from typing import Any, Callable, Dict, Optional, Union
2020

2121
import flask
2222

@@ -369,6 +369,10 @@ class Dash:
369369
370370
:param description: Sets a default description for meta tags on Dash pages (use_pages=True).
371371
372+
:param on_error: Global callback error handler to call when
373+
an exception is raised. Receives the exception object as first argument.
374+
The callback_context can be used to access the original callback inputs,
375+
states and output.
372376
"""
373377

374378
_plotlyjs_url: str
@@ -409,6 +413,7 @@ def __init__( # pylint: disable=too-many-statements
409413
hooks: Union[RendererHooks, None] = None,
410414
routing_callback_inputs: Optional[Dict[str, Union[Input, State]]] = None,
411415
description=None,
416+
on_error: Optional[Callable[[Exception], Any]] = None,
412417
**obsolete,
413418
):
414419
_validate.check_obsolete(obsolete)
@@ -520,6 +525,7 @@ def __init__( # pylint: disable=too-many-statements
520525
self._layout = None
521526
self._layout_is_function = False
522527
self.validation_layout = None
528+
self._on_error = on_error
523529
self._extra_components = []
524530

525531
self._setup_dev_tools()
@@ -1355,6 +1361,7 @@ def dispatch(self):
13551361
outputs_list=outputs_list,
13561362
long_callback_manager=self._background_manager,
13571363
callback_context=g,
1364+
app_on_error=self._on_error,
13581365
)
13591366
)
13601367
)

dash/long_callback/managers/celery_manager.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ def run():
161161
c.ignore_register_page = False
162162
c.updated_props = ProxySetProps(_set_props)
163163
context_value.set(c)
164+
errored = False
164165
try:
165166
if isinstance(user_callback_args, dict):
166167
user_callback_output = fn(*maybe_progress, **user_callback_args)
@@ -170,13 +171,15 @@ def run():
170171
user_callback_output = fn(*maybe_progress, user_callback_args)
171172
except PreventUpdate:
172173
# Put NoUpdate dict directly to avoid circular imports.
174+
errored = True
173175
cache.set(
174176
result_key,
175177
json.dumps(
176178
{"_dash_no_update": "_dash_no_update"}, cls=PlotlyJSONEncoder
177179
),
178180
)
179181
except Exception as err: # pylint: disable=broad-except
182+
errored = True
180183
cache.set(
181184
result_key,
182185
json.dumps(
@@ -188,7 +191,8 @@ def run():
188191
},
189192
),
190193
)
191-
else:
194+
195+
if not errored:
192196
cache.set(
193197
result_key, json.dumps(user_callback_output, cls=PlotlyJSONEncoder)
194198
)

dash/long_callback/managers/diskcache_manager.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,8 @@ def call_job_fn(self, key, job_fn, args, context):
121121

122122
# pylint: disable-next=not-callable
123123
proc = Process(
124-
target=job_fn, args=(key, self._make_progress_key(key), args, context)
124+
target=job_fn,
125+
args=(key, self._make_progress_key(key), args, context),
125126
)
126127
proc.start()
127128
return proc.pid
@@ -187,6 +188,7 @@ def run():
187188
c.ignore_register_page = False
188189
c.updated_props = ProxySetProps(_set_props)
189190
context_value.set(c)
191+
errored = False
190192
try:
191193
if isinstance(user_callback_args, dict):
192194
user_callback_output = fn(*maybe_progress, **user_callback_args)
@@ -195,8 +197,10 @@ def run():
195197
else:
196198
user_callback_output = fn(*maybe_progress, user_callback_args)
197199
except PreventUpdate:
200+
errored = True
198201
cache.set(result_key, {"_dash_no_update": "_dash_no_update"})
199202
except Exception as err: # pylint: disable=broad-except
203+
errored = True
200204
cache.set(
201205
result_key,
202206
{
@@ -206,7 +210,8 @@ def run():
206210
}
207211
},
208212
)
209-
else:
213+
214+
if not errored:
210215
cache.set(result_key, user_callback_output)
211216

212217
ctx.run(run)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from dash import Dash, html, Input, Output, set_props
2+
3+
4+
def test_cber001_error_handler(dash_duo):
5+
def global_callback_error_handler(err):
6+
set_props("output-global", {"children": f"global: {err}"})
7+
8+
app = Dash(on_error=global_callback_error_handler)
9+
10+
app.layout = [
11+
html.Button("start", id="start-local"),
12+
html.Button("start-global", id="start-global"),
13+
html.Div(id="output"),
14+
html.Div(id="output-global"),
15+
html.Div(id="error-message"),
16+
]
17+
18+
def on_callback_error(err):
19+
set_props("error-message", {"children": f"message: {err}"})
20+
return f"callback: {err}"
21+
22+
@app.callback(
23+
Output("output", "children"),
24+
Input("start-local", "n_clicks"),
25+
on_error=on_callback_error,
26+
prevent_initial_call=True,
27+
)
28+
def on_start(_):
29+
raise Exception("local error")
30+
31+
@app.callback(
32+
Output("output-global", "children"),
33+
Input("start-global", "n_clicks"),
34+
prevent_initial_call=True,
35+
)
36+
def on_start_global(_):
37+
raise Exception("global error")
38+
39+
dash_duo.start_server(app)
40+
dash_duo.find_element("#start-local").click()
41+
42+
dash_duo.wait_for_text_to_equal("#output", "callback: local error")
43+
dash_duo.wait_for_text_to_equal("#error-message", "message: local error")
44+
45+
dash_duo.find_element("#start-global").click()
46+
dash_duo.wait_for_text_to_equal("#output-global", "global: global error")

tests/integration/dash_assets/test_dash_assets.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ def test_dada001_assets(dash_duo):
5757
def test_dada002_external_files_init(dash_duo):
5858
js_files = [
5959
"https://www.google-analytics.com/analytics.js",
60-
{"src": "https://cdn.polyfill.io/v2/polyfill.min.js"},
6160
{
6261
"src": "https://cdnjs.cloudflare.com/ajax/libs/ramda/0.26.1/ramda.min.js",
6362
"integrity": "sha256-43x9r7YRdZpZqTjDT5E0Vfrxn1ajIZLyYWtfAXsargA=",

0 commit comments

Comments
 (0)