Skip to content

Commit 0b5d3fb

Browse files
Merge branch 'dev' into Coding-with-Adam-patch-1
2 parents 5dde871 + 36c7171 commit 0b5d3fb

File tree

6 files changed

+183
-12
lines changed

6 files changed

+183
-12
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ 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)

dash/_callback.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ def _invoke_callback(func, *args, **kwargs): # used to mark the frame for the d
6464
GLOBAL_INLINE_SCRIPTS = []
6565

6666

67-
# pylint: disable=too-many-locals
67+
# pylint: disable=too-many-locals,too-many-arguments
6868
def callback(
6969
*_args,
7070
background: bool = False,
@@ -78,6 +78,7 @@ def callback(
7878
cache_ignore_triggered=True,
7979
on_error: Optional[Callable[[Exception], Any]] = None,
8080
optional: Optional[bool] = False,
81+
hidden: Optional[bool] = False,
8182
**_kwargs,
8283
) -> Callable[..., Any]:
8384
"""
@@ -162,6 +163,8 @@ def callback(
162163
to access the original callback inputs, states and output.
163164
:param optional:
164165
Mark all dependencies as not required on the initial layout checks.
166+
:param hidden:
167+
Hide the callback from the devtools callbacks tab.
165168
"""
166169

167170
background_spec = None
@@ -217,6 +220,7 @@ def callback(
217220
running=running,
218221
on_error=on_error,
219222
optional=optional,
223+
hidden=hidden,
220224
)
221225

222226

@@ -263,6 +267,7 @@ def insert_callback(
263267
dynamic_creator: Optional[bool] = False,
264268
no_output=False,
265269
optional=False,
270+
hidden=False,
266271
):
267272
if prevent_initial_call is None:
268273
prevent_initial_call = config_prevent_initial_callbacks
@@ -287,6 +292,7 @@ def insert_callback(
287292
"dynamic_creator": dynamic_creator,
288293
"no_output": no_output,
289294
"optional": optional,
295+
"hidden": hidden,
290296
}
291297
if running:
292298
callback_spec["running"] = running
@@ -631,6 +637,7 @@ def register_callback(
631637
running=running,
632638
no_output=not has_output,
633639
optional=_kwargs.get("optional", False),
640+
hidden=_kwargs.get("hidden", False),
634641
)
635642

636643
# 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/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

@@ -773,7 +774,11 @@ def _setup_routes(self):
773774
)
774775

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

778783
# catch-all for front-end routes, used by dcc.Location
779784
self._add_url("<path:path>", self.index)
@@ -840,6 +845,7 @@ def index_string(self, value: str) -> None:
840845
_validate.validate_index("index string", checks, value)
841846
self._index_string = value
842847

848+
@with_app_context
843849
def serve_layout(self):
844850
layout = self._layout_value()
845851

@@ -1143,6 +1149,7 @@ def serve_component_suites(self, package_name, fingerprinted_path):
11431149

11441150
return response
11451151

1152+
@with_app_context
11461153
def index(self, *args, **kwargs): # pylint: disable=unused-argument
11471154
scripts = self._generate_scripts_html()
11481155
css = self._generate_css_dist_html()
@@ -1256,6 +1263,7 @@ def interpolate_index(self, **kwargs):
12561263
app_entry=app_entry,
12571264
)
12581265

1266+
@with_app_context
12591267
def dependencies(self):
12601268
return flask.Response(
12611269
to_json(self._callback_list),
@@ -1464,6 +1472,7 @@ def _execute_callback(self, func, args, outputs_list, g):
14641472
)
14651473
return partial_func
14661474

1475+
@with_app_context_async
14671476
async def async_dispatch(self):
14681477
body = flask.request.get_json()
14691478
g = self._initialize_context(body)
@@ -1483,6 +1492,7 @@ async def async_dispatch(self):
14831492
g.dash_response.set_data(response_data)
14841493
return g.dash_response
14851494

1495+
@with_app_context
14861496
def dispatch(self):
14871497
body = flask.request.get_json()
14881498
g = self._initialize_context(body)
@@ -1833,7 +1843,11 @@ def setup_startup_routes(self) -> None:
18331843
Initialize the startup routes stored in STARTUP_ROUTES.
18341844
"""
18351845
for _name, _view_func, _methods in self.STARTUP_ROUTES:
1836-
self._add_url(f"_dash_startup_route/{_name}", _view_func, _methods)
1846+
self._add_url(
1847+
f"_dash_startup_route/{_name}",
1848+
with_app_context_factory(_view_func, self),
1849+
_methods,
1850+
)
18371851
self.STARTUP_ROUTES = []
18381852

18391853
def _setup_dev_tools(self, **kwargs):

tests/integration/test_hooks.py

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import requests
33
import pytest
44

5-
from dash import Dash, Input, Output, html, hooks, set_props, ctx
5+
from dash import Dash, Input, Output, html, hooks, set_props, ctx, get_app
66

77

88
@pytest.fixture
@@ -240,3 +240,103 @@ def cb(_):
240240
)
241241
dash_duo.wait_for_element("#devtool").click()
242242
dash_duo.wait_for_text_to_equal("#output", "hooked from devtools")
243+
244+
245+
def test_hook012_get_app_available_in_hooks_on_routes(hook_cleanup, dash_duo):
246+
"""Test that get_app() is available during hooks when @with_app_context decorated routes are called."""
247+
248+
# Track which hooks were able to access get_app()
249+
hook_access_results = {
250+
"layout_hook": False,
251+
"error_hook": False,
252+
"callback_hook": False,
253+
}
254+
255+
@hooks.layout()
256+
def layout_hook(layout):
257+
try:
258+
retrieved_app = get_app()
259+
hook_access_results["layout_hook"] = retrieved_app is not None
260+
except Exception:
261+
hook_access_results["layout_hook"] = False
262+
return layout
263+
264+
@hooks.error()
265+
def error_hook(error):
266+
try:
267+
retrieved_app = get_app()
268+
hook_access_results["error_hook"] = retrieved_app is not None
269+
except Exception:
270+
hook_access_results["error_hook"] = False
271+
272+
@hooks.callback(
273+
Output("hook-output", "children"),
274+
Input("hook-button", "n_clicks"),
275+
prevent_initial_call=True,
276+
)
277+
def callback_hook(n_clicks):
278+
try:
279+
retrieved_app = get_app()
280+
hook_access_results["callback_hook"] = retrieved_app is not None
281+
return f"Hook callback: {n_clicks}"
282+
except Exception as err:
283+
hook_access_results["callback_hook"] = False
284+
return f"Error in hook callback: {err}"
285+
286+
app = Dash(__name__)
287+
app.layout = [
288+
html.Div("Test get_app in hooks", id="main"),
289+
html.Button("Click for callback", id="button"),
290+
html.Div(id="output"),
291+
html.Button("Hook callback", id="hook-button"),
292+
html.Div(id="hook-output"),
293+
html.Button("Error", id="error-btn"),
294+
html.Div(id="error-output"),
295+
]
296+
297+
@app.callback(
298+
Output("output", "children"),
299+
Input("button", "n_clicks"),
300+
prevent_initial_call=True,
301+
)
302+
def test_callback(n_clicks):
303+
return f"Clicked {n_clicks} times"
304+
305+
@app.callback(
306+
Output("error-output", "children"),
307+
Input("error-btn", "n_clicks"),
308+
prevent_initial_call=True,
309+
)
310+
def error_callback(n_clicks):
311+
if n_clicks:
312+
raise ValueError("Test error for hook")
313+
return ""
314+
315+
dash_duo.start_server(app)
316+
317+
# Test the @with_app_context decorated routes
318+
319+
# 2. Test layout hook via index route (GET /)
320+
dash_duo.wait_for_text_to_equal("#main", "Test get_app in hooks")
321+
322+
# 3. Test callback hook via dispatch route (POST /_dash-update-component)
323+
dash_duo.wait_for_element("#hook-button").click()
324+
dash_duo.wait_for_text_to_equal("#hook-output", "Hook callback: 1")
325+
326+
# 4. Test error hook via dispatch route when error occurs
327+
dash_duo.wait_for_element("#error-btn").click()
328+
# Give error hook time to execute
329+
import time
330+
331+
time.sleep(0.5)
332+
333+
# Verify that get_app() worked in hooks during route calls with @with_app_context
334+
assert hook_access_results[
335+
"layout_hook"
336+
], "get_app() should be accessible in layout hook when serve_layout/index routes have @with_app_context"
337+
assert hook_access_results[
338+
"callback_hook"
339+
], "get_app() should be accessible in callback hook when dispatch route has @with_app_context"
340+
assert hook_access_results[
341+
"error_hook"
342+
], "get_app() should be accessible in error hook when dispatch route has @with_app_context"

0 commit comments

Comments
 (0)