Skip to content

Commit 54d338d

Browse files
committed
Add hooks
1 parent 2b435fe commit 54d338d

File tree

4 files changed

+205
-0
lines changed

4 files changed

+205
-0
lines changed

dash/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
from ._patch import Patch # noqa: F401,E402
4242
from ._jupyter import jupyter_dash # noqa: F401,E402
4343

44+
from . import _hooks as hooks # noqa: F401,E402
45+
4446
ctx = callback_context
4547

4648

dash/_hooks.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import typing as _t
2+
3+
_ns = {
4+
"setup": [],
5+
"layout": [],
6+
"routes": [],
7+
"error": [],
8+
"callback": [],
9+
}
10+
11+
12+
def layout(func):
13+
"""
14+
Run a function when serving the layout, the return value
15+
will be used as the layout.
16+
"""
17+
_ns["layout"].append(func)
18+
return func
19+
20+
21+
def setup(func):
22+
"""
23+
Can be used to get a reference to the app after it is instantiated.
24+
"""
25+
_ns["setup"].append(func)
26+
return func
27+
28+
29+
def route(name=None, methods=("GET",)):
30+
"""
31+
Add a route to the Dash server.
32+
"""
33+
34+
def wrap(func):
35+
_name = name or func.__name__
36+
_ns["routes"].append((_name, func, methods))
37+
return func
38+
39+
return wrap
40+
41+
42+
def error(func: _t.Callable[[Exception], _t.Any]):
43+
"""Automatically add an error handler to the dash app."""
44+
_ns["error"].append(func)
45+
return func
46+
47+
48+
def callback(*args, **kwargs):
49+
"""
50+
Add a callback to all the apps with the hook installed.
51+
"""
52+
53+
def wrap(func):
54+
_ns["callback"].append((list(args), dict(kwargs), func))
55+
return func
56+
57+
return wrap
58+
59+
60+
class HooksManager:
61+
_registered = False
62+
63+
# pylint: disable=too-few-public-methods
64+
class HookErrorHandler:
65+
def __init__(self, original, hooks):
66+
self.original = original
67+
self.hooks = hooks
68+
69+
def __call__(self, err: Exception):
70+
result = None
71+
if self.original:
72+
result = self.original(err)
73+
hook_result = None
74+
for hook in HooksManager.get_hooks("error"):
75+
hook_result = hook(err)
76+
return result or hook_result
77+
78+
@staticmethod
79+
def get_hooks(hook: str):
80+
return _ns.get(hook, []).copy()
81+
82+
@classmethod
83+
def register_setuptools(cls):
84+
if cls._registered:
85+
return
86+
87+
import importlib.metadata # pylint: disable=import-outside-toplevel
88+
89+
for dist in importlib.metadata.distributions():
90+
for entry in dist.entry_points:
91+
if entry.group != "dash":
92+
continue
93+
entry.load()

dash/dash.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,8 @@ def __init__( # pylint: disable=too-many-statements
573573
for plugin in plugins:
574574
plugin.plug(self)
575575

576+
self._setup_hooks()
577+
576578
# tracks internally if a function already handled at least one request.
577579
self._got_first_request = {"pages": False, "setup_server": False}
578580

@@ -588,6 +590,25 @@ def __init__( # pylint: disable=too-many-statements
588590
)
589591
self.setup_startup_routes()
590592

593+
def _setup_hooks(self):
594+
# pylint: disable=import-outside-toplevel
595+
from ._hooks import HooksManager
596+
597+
self._hooks = HooksManager
598+
self._hooks.register_setuptools()
599+
600+
for setup in self._hooks.get_hooks("setup"):
601+
setup(self)
602+
603+
for callback_args, callback_kwargs, callback in self._hooks.get_hooks(
604+
"callback"
605+
):
606+
self.callback(*callback_args, **callback_kwargs)(callback)
607+
608+
error_handler = self._hooks.get_hooks("error")
609+
if error_handler:
610+
self._on_error = self._hooks.HookErrorHandler(self._on_error, error_handler)
611+
591612
def init_app(self, app=None, **kwargs):
592613
"""Initialize the parts of Dash that require a flask app."""
593614

@@ -688,6 +709,9 @@ def _setup_routes(self):
688709
"_alive_" + jupyter_dash.alive_token, jupyter_dash.serve_alive
689710
)
690711

712+
for name, func, methods in self._hooks.get_hooks("routes"):
713+
self._add_url(name, func, methods)
714+
691715
# catch-all for front-end routes, used by dcc.Location
692716
self._add_url("<path:path>", self.index)
693717

@@ -754,6 +778,9 @@ def index_string(self, value):
754778
def serve_layout(self):
755779
layout = self._layout_value()
756780

781+
for hook in self._hooks.get_hooks("layout"):
782+
layout = hook(layout)
783+
757784
# TODO - Set browser cache limit - pass hash into frontend
758785
return flask.Response(
759786
to_json(layout),

tests/integration/test_hooks.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from flask import jsonify
2+
import requests
3+
4+
from dash import Dash, Input, Output, html, hooks, set_props
5+
6+
7+
def test_hook001_layout(dash_duo):
8+
@hooks.layout
9+
def on_layout(layout):
10+
return [html.Div("Header", id="header")] + layout
11+
12+
app = Dash()
13+
app.layout = [html.Div("Body", id="body")]
14+
15+
dash_duo.start_server(app)
16+
17+
dash_duo.wait_for_text_to_equal("#header", "Header")
18+
dash_duo.wait_for_text_to_equal("#body", "Body")
19+
20+
21+
def test_hook002_setup():
22+
setup_title = None
23+
24+
@hooks.setup
25+
def on_setup(app: Dash):
26+
nonlocal setup_title
27+
setup_title = app.title
28+
29+
app = Dash(title="setup-test")
30+
app.layout = html.Div("setup")
31+
32+
assert setup_title == "setup-test"
33+
34+
35+
def test_hook003_route(dash_duo):
36+
@hooks.route(methods=("POST",))
37+
def hook_route():
38+
return jsonify({"success": True})
39+
40+
app = Dash()
41+
app.layout = html.Div("hook route")
42+
43+
dash_duo.start_server(app)
44+
response = requests.post(f"{dash_duo.server_url}/hook_route")
45+
data = response.json()
46+
assert data["success"]
47+
48+
49+
def test_hook004_error(dash_duo):
50+
@hooks.error
51+
def on_error(error):
52+
set_props("error", {"children": str(error)})
53+
54+
app = Dash()
55+
app.layout = [html.Button("start", id="start"), html.Div(id="error")]
56+
57+
@app.callback(Input("start", "n_clicks"), prevent_initial_call=True)
58+
def on_click(_):
59+
raise Exception("hook error")
60+
61+
dash_duo.start_server(app)
62+
dash_duo.wait_for_element("#start").click()
63+
dash_duo.wait_for_text_to_equal("#error", "hook error")
64+
65+
66+
def test_hook005_callback(dash_duo):
67+
@hooks.callback(
68+
Output("output", "children"),
69+
Input("start", "n_clicks"),
70+
prevent_initial_call=True,
71+
)
72+
def on_hook_cb(n_clicks):
73+
return f"clicked {n_clicks}"
74+
75+
app = Dash()
76+
app.layout = [
77+
html.Button("start", id="start"),
78+
html.Div(id="output"),
79+
]
80+
81+
dash_duo.start_server(app)
82+
dash_duo.wait_for_element("#start").click()
83+
dash_duo.wait_for_text_to_equal("#output", "clicked 1")

0 commit comments

Comments
 (0)