Skip to content

Commit 8d6468f

Browse files
Merge branch 'dev' into windows-user-friendly-contribute
2 parents e72429e + 64ed7fc commit 8d6468f

File tree

20 files changed

+344
-59
lines changed

20 files changed

+344
-59
lines changed

.circleci/config.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ version: 2.1
33
orbs:
44
win: circleci/[email protected]
55
percy: percy/[email protected]
6-
browser-tools: circleci/[email protected].6
6+
browser-tools: circleci/[email protected].8
77

88

99
jobs:

.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

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,13 @@ This project adheres to [Semantic Versioning](https://semver.org/).
1010

1111
## Fixed
1212

13+
- [#2898](https://github.com/plotly/dash/pull/2898) Fix error thrown when using non-existent components in callback running keyword. Fixes [#2897](https://github.com/plotly/dash/issues/2897).
1314
- [#2892](https://github.com/plotly/dash/pull/2860) Fix ensures dcc.Dropdown menu maxHeight option works with Datatable. Fixes [#2529](https://github.com/plotly/dash/issues/2529) [#2225](https://github.com/plotly/dash/issues/2225)
1415
- [#2896](https://github.com/plotly/dash/pull/2896) The tabIndex parameter of Div can accept number or string type. Fixes [#2891](https://github.com/plotly/dash/issues/2891)
16+
- [#2900](https://github.com/plotly/dash/pull/2900) Allow strings in layout list. Fixes [#2890](https://github.com/plotly/dash/issues/2890)
17+
- [#2908](https://github.com/plotly/dash/pull/2908) Fix when environment variables are ignored by Dash.run() at runtime. Fixes [#2902](https://github.com/plotly/dash/issues/2902)
18+
- [#2915](https://github.com/plotly/dash/pull/2915) Fix 'AttributeError' when layout is a function that returns a list of components. Fixes [#2905](https://github.com/plotly/dash/issues/2905)
19+
- [#2956](https://github.com/plotly/dash/pull/2956) Add missing useEffect dependency to dcc.Loading component.
1520

1621
## [2.17.1] - 2024-06-12
1722

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.

components/dash-core-components/src/components/Loading.react.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ const Loading = ({
122122
}
123123
}
124124
}
125-
}, [delay_hide, delay_show, loading_state, display]);
125+
}, [delay_hide, delay_show, loading_state, display, showSpinner]);
126126

127127
const Spinner = showSpinner && getSpinner(spinnerType);
128128

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/_validate.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,11 +439,13 @@ def _validate_id(comp):
439439

440440
if isinstance(layout_value, (list, tuple)):
441441
for component in layout_value:
442+
if isinstance(component, (str,)):
443+
continue
442444
if isinstance(component, (Component,)):
443445
_validate(component)
444446
else:
445447
raise exceptions.NoLayoutException(
446-
"List of components as layout must be a list of components only."
448+
"Only strings and components are allowed in a list layout."
447449
)
448450
else:
449451
_validate(layout_value)

dash/dash-renderer/src/actions/callbacks.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import {
3636
} from '../types/callbacks';
3737
import {isMultiValued, stringifyId, isMultiOutputProp} from './dependencies';
3838
import {urlBase} from './utils';
39-
import {getCSRFHeader} from '.';
39+
import {getCSRFHeader, dispatchError} from '.';
4040
import {createAction, Action} from 'redux-actions';
4141
import {addHttpHeaders} from '../actions';
4242
import {notifyObservers, updateProps} from './index';
@@ -330,10 +330,29 @@ async function handleClientside(
330330
return result;
331331
}
332332

333-
function updateComponent(component_id: any, props: any) {
333+
function updateComponent(component_id: any, props: any, cb: ICallbackPayload) {
334334
return function (dispatch: any, getState: any) {
335-
const paths = getState().paths;
335+
const {paths, config} = getState();
336336
const componentPath = getPath(paths, component_id);
337+
if (!componentPath) {
338+
if (!config.suppress_callback_exceptions) {
339+
dispatchError(dispatch)(
340+
'ID running component not found in layout',
341+
[
342+
'Component defined in running keyword not found in layout.',
343+
`Component id: "${stringifyId(component_id)}"`,
344+
'This ID was used in the callback(s) for Output(s):',
345+
`${cb.output}`,
346+
'You can suppress this exception by setting',
347+
'`suppress_callback_exceptions=True`.'
348+
]
349+
);
350+
}
351+
// We need to stop further processing because functions further on
352+
// can't operate on an 'undefined' object, and they will throw an
353+
// error.
354+
return;
355+
}
337356
dispatch(
338357
updateProps({
339358
props,
@@ -381,7 +400,7 @@ function sideUpdate(outputs: SideUpdateOutput, cb: ICallbackPayload) {
381400
return acc;
382401
}, [] as any[])
383402
.forEach(([id, idProps]) => {
384-
dispatch(updateComponent(id, idProps));
403+
dispatch(updateComponent(id, idProps, cb));
385404
});
386405
};
387406
}

dash/dash.py

Lines changed: 18 additions & 27 deletions
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()
@@ -708,31 +714,9 @@ def layout(self, value):
708714
and not self.config.suppress_callback_exceptions
709715
):
710716

711-
def simple_clone(c, children=None):
712-
cls = type(c)
713-
# in Py3 we can use the __init__ signature to reduce to just
714-
# required args and id; in Py2 this doesn't work so we just
715-
# empty out children.
716-
sig = getattr(cls.__init__, "__signature__", None)
717-
props = {
718-
p: getattr(c, p)
719-
for p in c._prop_names # pylint: disable=protected-access
720-
if hasattr(c, p)
721-
and (
722-
p == "id" or not sig or sig.parameters[p].default == c.REQUIRED
723-
)
724-
}
725-
if props.get("children", children):
726-
props["children"] = children or []
727-
return cls(**props)
728-
729717
layout_value = self._layout_value()
730718
_validate.validate_layout(value, layout_value)
731-
self.validation_layout = simple_clone(
732-
# pylint: disable=protected-access
733-
layout_value,
734-
[simple_clone(c) for c in layout_value._traverse_ids()],
735-
)
719+
self.validation_layout = layout_value
736720

737721
@property
738722
def index_string(self):
@@ -1377,6 +1361,7 @@ def dispatch(self):
13771361
outputs_list=outputs_list,
13781362
long_callback_manager=self._background_manager,
13791363
callback_context=g,
1364+
app_on_error=self._on_error,
13801365
)
13811366
)
13821367
)
@@ -1981,9 +1966,9 @@ def delete_resource(resources):
19811966

19821967
def run(
19831968
self,
1984-
host=os.getenv("HOST", "127.0.0.1"),
1985-
port=os.getenv("PORT", "8050"),
1986-
proxy=os.getenv("DASH_PROXY", None),
1969+
host="127.0.0.1",
1970+
port="8050",
1971+
proxy=None,
19871972
debug=None,
19881973
jupyter_mode: JupyterDisplayMode = None,
19891974
jupyter_width="100%",
@@ -2109,6 +2094,12 @@ def run(
21092094
dev_tools_prune_errors,
21102095
)
21112096

2097+
# Evaluate the env variables at runtime
2098+
2099+
host = os.getenv("HOST", host)
2100+
port = os.getenv("PORT", port)
2101+
proxy = os.getenv("DASH_PROXY", proxy)
2102+
21122103
# Verify port value
21132104
try:
21142105
port = int(port)

0 commit comments

Comments
 (0)