Skip to content

Commit 7191e83

Browse files
committed
Merge branch 'dev2' into api-exposure-callbacks
2 parents c2dd48b + 89a08e7 commit 7191e83

File tree

10 files changed

+260
-26
lines changed

10 files changed

+260
-26
lines changed

CHANGELOG.md

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

77
## Added
88
- [#3395](https://github.com/plotly/dash/pull/3396) Add position argument to hooks.devtool
9+
- [#3403](https://github.com/plotly/dash/pull/3403) Add app_context to get_app, allowing to get the current app in routes.
10+
- [#3407](https://github.com/plotly/dash/pull/3407) Add `hidden` to callback arguments, hiding the callback from appearing in the devtool callback graph.
911

1012
## Fixed
1113
- [#3395](https://github.com/plotly/dash/pull/3395) Fix Components added through set_props() cannot trigger related callback functions. Fix [#3316](https://github.com/plotly/dash/issues/3316)
1214
- [#3397](https://github.com/plotly/dash/pull/3397) Add optional callbacks, suppressing callback warning for missing component ids for a single callback.
15+
- [#3415](https://github.com/plotly/dash/pull/3415) Fix the error triggered when only a single no_update is returned for client-side callback functions with multiple Outputs. Fix [#3366](https://github.com/plotly/dash/issues/3366)
1316

1417
## [3.2.0] - 2025-07-31
1518

CITATION.cff

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ authors:
99
given-names: "Alex"
1010
orcid: https://orcid.org/0000-0003-4623-4147
1111
title: "A data and analytics web app framework for Python, no JavaScript required."
12-
version: 2.18.2
12+
version: 3.2.0
1313
doi: 10.5281/zenodo.14182630
14-
date-released: 2024-11-04
14+
date-released: 2025-07-31
1515
url: https://github.com/plotly/dash

dash/_callback.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def _invoke_callback(func, *args, **kwargs): # used to mark the frame for the d
6565
GLOBAL_API_PATHS = {}
6666

6767

68-
# pylint: disable=too-many-locals
68+
# pylint: disable=too-many-locals,too-many-arguments
6969
def callback(
7070
*_args,
7171
background: bool = False,
@@ -80,6 +80,7 @@ def callback(
8080
on_error: Optional[Callable[[Exception], Any]] = None,
8181
api_endpoint: Optional[str] = None,
8282
optional: Optional[bool] = False,
83+
hidden: Optional[bool] = False,
8384
**_kwargs,
8485
) -> Callable[..., Any]:
8586
"""
@@ -164,6 +165,8 @@ def callback(
164165
to access the original callback inputs, states and output.
165166
:param optional:
166167
Mark all dependencies as not required on the initial layout checks.
168+
:param hidden:
169+
Hide the callback from the devtools callbacks tab.
167170
"""
168171

169172
background_spec = None
@@ -222,6 +225,7 @@ def callback(
222225
on_error=on_error,
223226
api_endpoint=api_endpoint,
224227
optional=optional,
228+
hidden=hidden,
225229
)
226230

227231

@@ -268,6 +272,7 @@ def insert_callback(
268272
dynamic_creator: Optional[bool] = False,
269273
no_output=False,
270274
optional=False,
275+
hidden=False,
271276
):
272277
if prevent_initial_call is None:
273278
prevent_initial_call = config_prevent_initial_callbacks
@@ -292,6 +297,7 @@ def insert_callback(
292297
"dynamic_creator": dynamic_creator,
293298
"no_output": no_output,
294299
"optional": optional,
300+
"hidden": hidden,
295301
}
296302
if running:
297303
callback_spec["running"] = running
@@ -641,6 +647,7 @@ def register_callback(
641647
running=running,
642648
no_output=not has_output,
643649
optional=_kwargs.get("optional", False),
650+
hidden=_kwargs.get("hidden", False),
644651
)
645652

646653
# pylint: disable=too-many-locals

dash/_get_app.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,58 @@
1+
import functools
2+
3+
from contextvars import ContextVar, copy_context
14
from textwrap import dedent
25

36
APP = None
47

8+
app_context = ContextVar("dash_app_context")
9+
10+
11+
def with_app_context(func):
12+
@functools.wraps(func)
13+
def wrap(self, *args, **kwargs):
14+
app_context.set(self)
15+
ctx = copy_context()
16+
return ctx.run(func, self, *args, **kwargs)
17+
18+
return wrap
19+
20+
21+
def with_app_context_async(func):
22+
@functools.wraps(func)
23+
async def wrap(self, *args, **kwargs):
24+
app_context.set(self)
25+
ctx = copy_context()
26+
print("copied and set")
27+
return await ctx.run(func, self, *args, **kwargs)
28+
29+
return wrap
30+
31+
32+
def with_app_context_factory(func, app):
33+
@functools.wraps(func)
34+
def wrap(*args, **kwargs):
35+
app_context.set(app)
36+
ctx = copy_context()
37+
return ctx.run(func, *args, **kwargs)
38+
39+
return wrap
40+
541

642
def get_app():
43+
try:
44+
ctx_app = app_context.get()
45+
if ctx_app is not None:
46+
return ctx_app
47+
except LookupError:
48+
pass
49+
750
if APP is None:
851
raise Exception(
952
dedent(
1053
"""
1154
App object is not yet defined. `app = dash.Dash()` needs to be run
12-
before `dash.get_app()` is called and can only be used within apps that use
13-
the `pages` multi-page app feature: `dash.Dash(use_pages=True)`.
55+
before `dash.get_app()`.
1456
1557
`dash.get_app()` is used to get around circular import issues when Python files
1658
within the pages/` folder need to reference the `app` object.

dash/dash-renderer/package-lock.json

Lines changed: 18 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,8 +226,16 @@ function refErr(errors: any, paths: any) {
226226
const getVals = (input: any) =>
227227
Array.isArray(input) ? pluck('value', input) : input.value;
228228

229-
const zipIfArray = (a: any, b: any) =>
230-
Array.isArray(a) ? zip(a, b) : [[a, b]];
229+
const zipIfArray = (a: any, b: any) => {
230+
if (Array.isArray(a)) {
231+
// For client-side callbacks with multiple Outputs, only return a single dash_clientside.no_update
232+
if (b === (window as any).dash_clientside.no_update) {
233+
return zip(a, [b]);
234+
}
235+
return zip(a, b);
236+
}
237+
return [[a, b]];
238+
};
231239

232240
function cleanOutputProp(property: string) {
233241
return property.split('@')[0];

dash/dash-renderer/src/components/error/CallbackGraph/CallbackGraphContainer.react.js

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,11 @@ function generateElements(graphs, profile, extraLinks) {
7878
});
7979
}
8080

81-
(graphs.callbacks || []).forEach((callback, i) => {
81+
(graphs.callbacks || []).reduce((visibleIndex, callback) => {
82+
if (callback.hidden) {
83+
return visibleIndex;
84+
}
85+
8286
const cb = `__dash_callback__.${callback.output}`;
8387
const cbProfile = profile.callbacks[callback.output] || {};
8488
const count = cbProfile.count || 0;
@@ -87,7 +91,7 @@ function generateElements(graphs, profile, extraLinks) {
8791
elements.push({
8892
data: {
8993
id: cb,
90-
label: `callback.${i}`,
94+
label: `callback.${visibleIndex}`,
9195
type: 'callback',
9296
mode: callback.clientside_function ? 'client' : 'server',
9397
count: count,
@@ -97,21 +101,23 @@ function generateElements(graphs, profile, extraLinks) {
97101
}
98102
});
99103

100-
callback.outputs.map(({id, property}) => {
104+
callback.outputs.forEach(({id, property}) => {
101105
const nodeId = recordNode(id, property);
102106
recordEdge(cb, nodeId, 'output');
103107
});
104108

105-
callback.inputs.map(({id, property}) => {
109+
callback.inputs.forEach(({id, property}) => {
106110
const nodeId = recordNode(id, property);
107111
recordEdge(nodeId, cb, 'input');
108112
});
109113

110-
callback.state.map(({id, property}) => {
114+
callback.state.forEach(({id, property}) => {
111115
const nodeId = recordNode(id, property);
112116
recordEdge(nodeId, cb, 'state');
113117
});
114-
});
118+
119+
return visibleIndex + 1;
120+
}, 0);
115121

116122
// pull together props in the same component
117123
if (extraLinks) {

dash/dash.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
from . import _watch
6969
from . import _get_app
7070

71+
from ._get_app import with_app_context, with_app_context_async, with_app_context_factory
7172
from ._grouping import map_grouping, grouping_len, update_args_group
7273
from ._obsolete import ObsoleteChecker
7374

@@ -774,7 +775,11 @@ def _setup_routes(self):
774775
)
775776

776777
for hook in self._hooks.get_hooks("routes"):
777-
self._add_url(hook.data["name"], hook.func, hook.data["methods"])
778+
self._add_url(
779+
hook.data["name"],
780+
with_app_context_factory(hook.func, self),
781+
hook.data["methods"],
782+
)
778783

779784
# catch-all for front-end routes, used by dcc.Location
780785
self._add_url("<path:path>", self.index)
@@ -876,6 +881,7 @@ def index_string(self, value: str) -> None:
876881
_validate.validate_index("index string", checks, value)
877882
self._index_string = value
878883

884+
@with_app_context
879885
def serve_layout(self):
880886
layout = self._layout_value()
881887

@@ -1179,6 +1185,7 @@ def serve_component_suites(self, package_name, fingerprinted_path):
11791185

11801186
return response
11811187

1188+
@with_app_context
11821189
def index(self, *args, **kwargs): # pylint: disable=unused-argument
11831190
scripts = self._generate_scripts_html()
11841191
css = self._generate_css_dist_html()
@@ -1292,6 +1299,7 @@ def interpolate_index(self, **kwargs):
12921299
app_entry=app_entry,
12931300
)
12941301

1302+
@with_app_context
12951303
def dependencies(self):
12961304
return flask.Response(
12971305
to_json(self._callback_list),
@@ -1501,6 +1509,7 @@ def _execute_callback(self, func, args, outputs_list, g):
15011509
)
15021510
return partial_func
15031511

1512+
@with_app_context_async
15041513
async def async_dispatch(self):
15051514
body = flask.request.get_json()
15061515
g = self._initialize_context(body)
@@ -1520,6 +1529,7 @@ async def async_dispatch(self):
15201529
g.dash_response.set_data(response_data)
15211530
return g.dash_response
15221531

1532+
@with_app_context
15231533
def dispatch(self):
15241534
body = flask.request.get_json()
15251535
g = self._initialize_context(body)
@@ -1871,7 +1881,11 @@ def setup_startup_routes(self) -> None:
18711881
Initialize the startup routes stored in STARTUP_ROUTES.
18721882
"""
18731883
for _name, _view_func, _methods in self.STARTUP_ROUTES:
1874-
self._add_url(f"_dash_startup_route/{_name}", _view_func, _methods)
1884+
self._add_url(
1885+
f"_dash_startup_route/{_name}",
1886+
with_app_context_factory(_view_func, self),
1887+
_methods,
1888+
)
18751889
self.STARTUP_ROUTES = []
18761890

18771891
def _setup_dev_tools(self, **kwargs):
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from dash import (
2+
Dash,
3+
Input,
4+
Output,
5+
html,
6+
clientside_callback,
7+
)
8+
9+
10+
def test_cmorsnu001_clientside_multiple_output_return_single_no_update(dash_duo):
11+
app = Dash(__name__)
12+
app.layout = html.Div(
13+
[
14+
html.Button("trigger", id="trigger-demo"),
15+
html.Div("demo1", id="output-demo1"),
16+
html.Div("demo2", id="output-demo2"),
17+
],
18+
style={"padding": 50},
19+
)
20+
21+
clientside_callback(
22+
"""(n_clicks) => {
23+
try {
24+
return window.dash_clientside.no_update;
25+
} catch (e) {
26+
return [null, null];
27+
}
28+
}""",
29+
Output("output-demo1", "children"),
30+
Output("output-demo2", "children"),
31+
Input("trigger-demo", "n_clicks"),
32+
prevent_initial_call=True,
33+
)
34+
35+
dash_duo.start_server(app)
36+
37+
trigger_clicker = dash_duo.wait_for_element("#trigger-demo")
38+
trigger_clicker.click()
39+
dash_duo.wait_for_text_to_equal(
40+
"#output-demo1",
41+
"demo1",
42+
)
43+
dash_duo.wait_for_text_to_equal(
44+
"#output-demo2",
45+
"demo2",
46+
)

0 commit comments

Comments
 (0)