Skip to content

Add app_context to get_app, allowing to get the current app in routes #3403

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
46 changes: 44 additions & 2 deletions dash/_get_app.py
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
18 changes: 16 additions & 2 deletions dash/dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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("<path:path>", self.index)
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
102 changes: 101 additions & 1 deletion tests/integration/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"