Skip to content

Commit a2ed613

Browse files
authored
Merge pull request #3403 from plotly/app-context
Add app_context to get_app, allowing to get the current app in routes
2 parents fca6388 + c050d7a commit a2ed613

File tree

4 files changed

+162
-5
lines changed

4 files changed

+162
-5
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ 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.
910

1011
## Fixed
1112
- [#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/_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.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)