From 49da8491b2da3571b0dad0d45791ba27f34eddf1 Mon Sep 17 00:00:00 2001 From: philippe Date: Fri, 15 Aug 2025 15:59:18 -0400 Subject: [PATCH 1/2] Add app_context to get_app, allowing to get the current app during callback/index/layout/hook-routes --- dash/_get_app.py | 46 +++++++++++++- dash/dash.py | 18 +++++- tests/integration/test_hooks.py | 102 +++++++++++++++++++++++++++++++- 3 files changed, 161 insertions(+), 5 deletions(-) diff --git a/dash/_get_app.py b/dash/_get_app.py index 339ca522b7..a088e593ff 100644 --- a/dash/_get_app.py +++ b/dash/_get_app.py @@ -1,16 +1,58 @@ +import functools + +from contextvars import ContextVar, copy_context from textwrap import dedent APP = None +app_context = ContextVar("dash_app_context") + + +def with_app_context(func): + @functools.wraps(func) + def wrap(self, *args, **kwargs): + app_context.set(self) + ctx = copy_context() + return ctx.run(func, self, *args, **kwargs) + + return wrap + + +def with_app_context_async(func): + @functools.wraps(func) + async def wrap(self, *args, **kwargs): + app_context.set(self) + ctx = copy_context() + print("copied and set") + return await ctx.run(func, self, *args, **kwargs) + + return wrap + + +def with_app_context_factory(func, app): + @functools.wraps(func) + def wrap(*args, **kwargs): + app_context.set(app) + ctx = copy_context() + return ctx.run(func, *args, **kwargs) + + return wrap + def get_app(): + try: + ctx_app = app_context.get() + if ctx_app is not None: + return ctx_app + except LookupError: + pass + if APP is None: raise Exception( dedent( """ App object is not yet defined. `app = dash.Dash()` needs to be run - before `dash.get_app()` is called and can only be used within apps that use - the `pages` multi-page app feature: `dash.Dash(use_pages=True)`. + before `dash.get_app()`. `dash.get_app()` is used to get around circular import issues when Python files within the pages/` folder need to reference the `app` object. diff --git a/dash/dash.py b/dash/dash.py index eab3e3358e..c4181ef80e 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -68,6 +68,7 @@ from . import _watch from . import _get_app +from ._get_app import with_app_context, with_app_context_async, with_app_context_factory from ._grouping import map_grouping, grouping_len, update_args_group from ._obsolete import ObsoleteChecker @@ -773,7 +774,11 @@ def _setup_routes(self): ) for hook in self._hooks.get_hooks("routes"): - self._add_url(hook.data["name"], hook.func, hook.data["methods"]) + self._add_url( + hook.data["name"], + with_app_context_factory(hook.func, self), + hook.data["methods"], + ) # catch-all for front-end routes, used by dcc.Location self._add_url("", self.index) @@ -840,6 +845,7 @@ def index_string(self, value: str) -> None: _validate.validate_index("index string", checks, value) self._index_string = value + @with_app_context def serve_layout(self): layout = self._layout_value() @@ -1143,6 +1149,7 @@ def serve_component_suites(self, package_name, fingerprinted_path): return response + @with_app_context def index(self, *args, **kwargs): # pylint: disable=unused-argument scripts = self._generate_scripts_html() css = self._generate_css_dist_html() @@ -1256,6 +1263,7 @@ def interpolate_index(self, **kwargs): app_entry=app_entry, ) + @with_app_context def dependencies(self): return flask.Response( to_json(self._callback_list), @@ -1464,6 +1472,7 @@ def _execute_callback(self, func, args, outputs_list, g): ) return partial_func + @with_app_context_async async def async_dispatch(self): body = flask.request.get_json() g = self._initialize_context(body) @@ -1483,6 +1492,7 @@ async def async_dispatch(self): g.dash_response.set_data(response_data) return g.dash_response + @with_app_context def dispatch(self): body = flask.request.get_json() g = self._initialize_context(body) @@ -1833,7 +1843,11 @@ def setup_startup_routes(self) -> None: Initialize the startup routes stored in STARTUP_ROUTES. """ for _name, _view_func, _methods in self.STARTUP_ROUTES: - self._add_url(f"_dash_startup_route/{_name}", _view_func, _methods) + self._add_url( + f"_dash_startup_route/{_name}", + with_app_context_factory(_view_func, self), + _methods, + ) self.STARTUP_ROUTES = [] def _setup_dev_tools(self, **kwargs): diff --git a/tests/integration/test_hooks.py b/tests/integration/test_hooks.py index ef6c0cc4a4..518439d819 100644 --- a/tests/integration/test_hooks.py +++ b/tests/integration/test_hooks.py @@ -2,7 +2,7 @@ import requests import pytest -from dash import Dash, Input, Output, html, hooks, set_props, ctx +from dash import Dash, Input, Output, html, hooks, set_props, ctx, get_app @pytest.fixture @@ -240,3 +240,103 @@ def cb(_): ) dash_duo.wait_for_element("#devtool").click() dash_duo.wait_for_text_to_equal("#output", "hooked from devtools") + + +def test_hook012_get_app_available_in_hooks_on_routes(hook_cleanup, dash_duo): + """Test that get_app() is available during hooks when @with_app_context decorated routes are called.""" + + # Track which hooks were able to access get_app() + hook_access_results = { + "layout_hook": False, + "error_hook": False, + "callback_hook": False, + } + + @hooks.layout() + def layout_hook(layout): + try: + retrieved_app = get_app() + hook_access_results["layout_hook"] = retrieved_app is not None + except Exception: + hook_access_results["layout_hook"] = False + return layout + + @hooks.error() + def error_hook(error): + try: + retrieved_app = get_app() + hook_access_results["error_hook"] = retrieved_app is not None + except Exception: + hook_access_results["error_hook"] = False + + @hooks.callback( + Output("hook-output", "children"), + Input("hook-button", "n_clicks"), + prevent_initial_call=True, + ) + def callback_hook(n_clicks): + try: + retrieved_app = get_app() + hook_access_results["callback_hook"] = retrieved_app is not None + return f"Hook callback: {n_clicks}" + except Exception as err: + hook_access_results["callback_hook"] = False + return f"Error in hook callback: {err}" + + app = Dash(__name__) + app.layout = [ + html.Div("Test get_app in hooks", id="main"), + html.Button("Click for callback", id="button"), + html.Div(id="output"), + html.Button("Hook callback", id="hook-button"), + html.Div(id="hook-output"), + html.Button("Error", id="error-btn"), + html.Div(id="error-output"), + ] + + @app.callback( + Output("output", "children"), + Input("button", "n_clicks"), + prevent_initial_call=True, + ) + def test_callback(n_clicks): + return f"Clicked {n_clicks} times" + + @app.callback( + Output("error-output", "children"), + Input("error-btn", "n_clicks"), + prevent_initial_call=True, + ) + def error_callback(n_clicks): + if n_clicks: + raise ValueError("Test error for hook") + return "" + + dash_duo.start_server(app) + + # Test the @with_app_context decorated routes + + # 2. Test layout hook via index route (GET /) + dash_duo.wait_for_text_to_equal("#main", "Test get_app in hooks") + + # 3. Test callback hook via dispatch route (POST /_dash-update-component) + dash_duo.wait_for_element("#hook-button").click() + dash_duo.wait_for_text_to_equal("#hook-output", "Hook callback: 1") + + # 4. Test error hook via dispatch route when error occurs + dash_duo.wait_for_element("#error-btn").click() + # Give error hook time to execute + import time + + time.sleep(0.5) + + # Verify that get_app() worked in hooks during route calls with @with_app_context + assert hook_access_results[ + "layout_hook" + ], "get_app() should be accessible in layout hook when serve_layout/index routes have @with_app_context" + assert hook_access_results[ + "callback_hook" + ], "get_app() should be accessible in callback hook when dispatch route has @with_app_context" + assert hook_access_results[ + "error_hook" + ], "get_app() should be accessible in error hook when dispatch route has @with_app_context" From c050d7a3781dc2aa5a9662ac7f9947c2db8bc88e Mon Sep 17 00:00:00 2001 From: philippe Date: Mon, 18 Aug 2025 09:11:51 -0400 Subject: [PATCH 2/2] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2205020c41..4f468ece0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Added - [#3395](https://github.com/plotly/dash/pull/3396) Add position argument to hooks.devtool +- [#3403](https://github.com/plotly/dash/pull/3403) Add app_context to get_app, allowing to get the current app in routes. ## Fixed - [#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)