Skip to content

Commit 59234db

Browse files
committed
Add priority and final keyword to hooks.
1 parent fde59e8 commit 59234db

File tree

5 files changed

+200
-74
lines changed

5 files changed

+200
-74
lines changed

dash/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
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
44+
from ._hooks import hooks # noqa: F401,E402
4545

4646
ctx = callback_context
4747

dash/_hooks.py

Lines changed: 143 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -3,65 +3,150 @@
33

44
import flask as _f
55

6-
_ns = {
7-
"setup": [],
8-
"layout": [],
9-
"routes": [],
10-
"error": [],
11-
"callback": [],
12-
}
13-
14-
15-
def layout(func):
16-
"""
17-
Run a function when serving the layout, the return value
18-
will be used as the layout.
19-
"""
20-
_ns["layout"].append(func)
21-
return func
22-
23-
24-
def setup(func):
25-
"""
26-
Can be used to get a reference to the app after it is instantiated.
27-
"""
28-
_ns["setup"].append(func)
29-
return func
30-
31-
32-
def route(name: _t.Optional[str] = None, methods: _t.Sequence[str] = ("GET",)):
33-
"""
34-
Add a route to the Dash server.
35-
"""
36-
37-
def wrap(func: _t.Callable[[], _f.Response]):
38-
_name = name or func.__name__
39-
_ns["routes"].append((_name, func, methods))
40-
return func
41-
42-
return wrap
43-
44-
45-
def error(func: _t.Callable[[Exception], _t.Any]):
46-
"""Automatically add an error handler to the dash app."""
47-
_ns["error"].append(func)
48-
return func
49-
50-
51-
def callback(*args, **kwargs):
52-
"""
53-
Add a callback to all the apps with the hook installed.
54-
"""
55-
56-
def wrap(func):
57-
_ns["callback"].append((list(args), dict(kwargs), func))
58-
return func
59-
60-
return wrap
6+
from .exceptions import HookError
7+
8+
9+
if _t.TYPE_CHECKING:
10+
from .dash import Dash
11+
from .development.base_component import Component
12+
13+
ComponentType = _t.TypeVar("ComponentType", bound=Component)
14+
LayoutType = _t.Union[ComponentType, _t.List[ComponentType]]
15+
else:
16+
LayoutType = None
17+
ComponentType = None
18+
Dash = None
19+
20+
21+
# pylint: disable=too-few-public-methods
22+
class _Hook:
23+
def __init__(self, func, priority, final=False, data=None):
24+
self.func = func
25+
self.final = final
26+
self.data = data
27+
self.priority = priority
28+
29+
def __call__(self, *args, **kwargs):
30+
return self.func(*args, **kwargs)
31+
32+
33+
class _Hooks:
34+
def __init__(self) -> None:
35+
self._ns = {
36+
"setup": [],
37+
"layout": [],
38+
"routes": [],
39+
"error": [],
40+
"callback": [],
41+
}
42+
self._finals = {}
43+
44+
def add_hook(
45+
self, hook: str, func: _t.Callable, priority=None, final=False, data=None
46+
):
47+
if final:
48+
existing = self._finals.get(hook)
49+
if existing:
50+
raise HookError("Final hook already present")
51+
self._finals[hook] = _Hook(func, priority, final, data=data)
52+
return
53+
hks = self._ns.get(hook, [])
54+
if not priority and len(hks):
55+
priority_max = max(h.priority for h in hks)
56+
priority = priority_max - 1
57+
elif not priority:
58+
priority = 0
59+
hks.append(_Hook(func, priority=priority, data=data))
60+
self._ns[hook] = sorted(hks, reverse=True, key=lambda h: h.priority)
61+
62+
def get_hooks(self, hook: str):
63+
final = self._finals.get(hook, None)
64+
if final:
65+
final = [final]
66+
else:
67+
final = []
68+
return self._ns.get(hook, []) + final
69+
70+
def layout(self, priority: _t.Optional[int] = None, final: bool = False):
71+
"""
72+
Run a function when serving the layout, the return value
73+
will be used as the layout.
74+
"""
75+
76+
def _wrap(func: _t.Callable[[LayoutType], LayoutType]):
77+
self.add_hook("layout", func, priority=priority, final=final)
78+
return func
79+
80+
return _wrap
81+
82+
def setup(self, priority: _t.Optional[int] = None, final: bool = False):
83+
"""
84+
Can be used to get a reference to the app after it is instantiated.
85+
"""
86+
87+
def _setup(func: _t.Callable[[Dash], None]):
88+
self.add_hook("setup", func, priority=priority, final=final)
89+
return func
90+
91+
return _setup
92+
93+
def route(
94+
self,
95+
name: _t.Optional[str] = None,
96+
methods: _t.Sequence[str] = ("GET",),
97+
priority=None,
98+
final=False,
99+
):
100+
"""
101+
Add a route to the Dash server.
102+
"""
103+
104+
def wrap(func: _t.Callable[[], _f.Response]):
105+
_name = name or func.__name__
106+
self.add_hook(
107+
"routes",
108+
func,
109+
priority=priority,
110+
final=final,
111+
data=dict(name=_name, methods=methods),
112+
)
113+
return func
114+
115+
return wrap
116+
117+
def error(self, priority=None, final=False):
118+
"""Automatically add an error handler to the dash app."""
119+
120+
def _error(func: _t.Callable[[Exception], _t.Any]):
121+
self.add_hook("error", func, priority=priority, final=final)
122+
return func
123+
124+
return _error
125+
126+
def callback(self, *args, priority=None, final=False, **kwargs):
127+
"""
128+
Add a callback to all the apps with the hook installed.
129+
"""
130+
131+
def wrap(func):
132+
self.add_hook(
133+
"callback",
134+
func,
135+
priority=priority,
136+
final=final,
137+
data=(list(args), dict(kwargs)),
138+
)
139+
return func
140+
141+
return wrap
142+
143+
144+
hooks = _Hooks()
61145

62146

63147
class HooksManager:
64148
_registered = False
149+
hooks = hooks
65150

66151
# pylint: disable=too-few-public-methods
67152
class HookErrorHandler:
@@ -77,9 +162,9 @@ def __call__(self, err: Exception):
77162
hook_result = hook(err)
78163
return result or hook_result
79164

80-
@staticmethod
81-
def get_hooks(hook: str):
82-
return _ns.get(hook, []).copy()
165+
@classmethod
166+
def get_hooks(cls, hook: str):
167+
return cls.hooks.get_hooks(hook)
83168

84169
@classmethod
85170
def register_setuptools(cls):

dash/dash.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -600,10 +600,9 @@ def _setup_hooks(self):
600600
for setup in self._hooks.get_hooks("setup"):
601601
setup(self)
602602

603-
for callback_args, callback_kwargs, callback in self._hooks.get_hooks(
604-
"callback"
605-
):
606-
self.callback(*callback_args, **callback_kwargs)(callback)
603+
for hook in self._hooks.get_hooks("callback"):
604+
callback_args, callback_kwargs = hook.data
605+
self.callback(*callback_args, **callback_kwargs)(hook.func)
607606

608607
if self._hooks.get_hooks("error"):
609608
self._on_error = self._hooks.HookErrorHandler(self._on_error)
@@ -708,8 +707,8 @@ def _setup_routes(self):
708707
"_alive_" + jupyter_dash.alive_token, jupyter_dash.serve_alive
709708
)
710709

711-
for name, func, methods in self._hooks.get_hooks("routes"):
712-
self._add_url(name, func, methods)
710+
for hook in self._hooks.get_hooks("routes"):
711+
self._add_url(hook.data["name"], hook.func, hook.data["methods"])
713712

714713
# catch-all for front-end routes, used by dcc.Location
715714
self._add_url("<path:path>", self.index)

dash/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,7 @@ class PageError(DashException):
101101

102102
class ImportedInsideCallbackError(DashException):
103103
pass
104+
105+
106+
class HookError(DashException):
107+
pass

tests/integration/test_hooks.py

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from dash import Dash, Input, Output, html, hooks, set_props
66

77

8-
@pytest.fixture(scope="module", autouse=True)
8+
@pytest.fixture
99
def hook_cleanup():
1010
yield
1111
hooks._ns["layout"] = []
@@ -15,8 +15,8 @@ def hook_cleanup():
1515
hooks._ns["callback"] = []
1616

1717

18-
def test_hook001_layout(dash_duo):
19-
@hooks.layout
18+
def test_hook001_layout(hook_cleanup, dash_duo):
19+
@hooks.layout()
2020
def on_layout(layout):
2121
return [html.Div("Header", id="header")] + layout
2222

@@ -29,10 +29,10 @@ def on_layout(layout):
2929
dash_duo.wait_for_text_to_equal("#body", "Body")
3030

3131

32-
def test_hook002_setup():
32+
def test_hook002_setup(hook_cleanup):
3333
setup_title = None
3434

35-
@hooks.setup
35+
@hooks.setup()
3636
def on_setup(app: Dash):
3737
nonlocal setup_title
3838
setup_title = app.title
@@ -43,7 +43,7 @@ def on_setup(app: Dash):
4343
assert setup_title == "setup-test"
4444

4545

46-
def test_hook003_route(dash_duo):
46+
def test_hook003_route(hook_cleanup, dash_duo):
4747
@hooks.route(methods=("POST",))
4848
def hook_route():
4949
return jsonify({"success": True})
@@ -57,8 +57,8 @@ def hook_route():
5757
assert data["success"]
5858

5959

60-
def test_hook004_error(dash_duo):
61-
@hooks.error
60+
def test_hook004_error(hook_cleanup, dash_duo):
61+
@hooks.error()
6262
def on_error(error):
6363
set_props("error", {"children": str(error)})
6464

@@ -74,7 +74,7 @@ def on_click(_):
7474
dash_duo.wait_for_text_to_equal("#error", "hook error")
7575

7676

77-
def test_hook005_callback(dash_duo):
77+
def test_hook005_callback(hook_cleanup, dash_duo):
7878
@hooks.callback(
7979
Output("output", "children"),
8080
Input("start", "n_clicks"),
@@ -92,3 +92,41 @@ def on_hook_cb(n_clicks):
9292
dash_duo.start_server(app)
9393
dash_duo.wait_for_element("#start").click()
9494
dash_duo.wait_for_text_to_equal("#output", "clicked 1")
95+
96+
97+
def test_hook006_priority_final(hook_cleanup, dash_duo):
98+
@hooks.layout(final=True)
99+
def hook_final(layout):
100+
return html.Div([html.Div("final")] + [layout], id="final-wrapper")
101+
102+
@hooks.layout()
103+
def hook1(layout):
104+
layout.children.append(html.Div("first"))
105+
return layout
106+
107+
@hooks.layout()
108+
def hook2(layout):
109+
layout.children.append(html.Div("second"))
110+
return layout
111+
112+
@hooks.layout()
113+
def hook3(layout):
114+
layout.children.append(html.Div("third"))
115+
return layout
116+
117+
@hooks.layout(priority=6)
118+
def hook4(layout):
119+
layout.children.insert(0, html.Div("Prime"))
120+
return layout
121+
122+
app = Dash()
123+
124+
app.layout = html.Div([html.Div("layout")], id="body")
125+
126+
dash_duo.start_server(app)
127+
dash_duo.wait_for_text_to_equal("#final-wrapper > div:first-child", "final")
128+
dash_duo.wait_for_text_to_equal("#body > div:first-child", "Prime")
129+
dash_duo.wait_for_text_to_equal("#body > div:nth-child(2)", "layout")
130+
dash_duo.wait_for_text_to_equal("#body > div:nth-child(3)", "first")
131+
dash_duo.wait_for_text_to_equal("#body > div:nth-child(4)", "second")
132+
dash_duo.wait_for_text_to_equal("#body > div:nth-child(5)", "third")

0 commit comments

Comments
 (0)