Skip to content

Commit 5fe20c7

Browse files
authored
Merge pull request #3029 from plotly/feat/hooks
Add hooks
2 parents 9705ae5 + 7e2f37e commit 5fe20c7

File tree

10 files changed

+541
-37
lines changed

10 files changed

+541
-37
lines changed

.pylintrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ disable=fixme,
7575
unnecessary-lambda-assignment,
7676
broad-exception-raised,
7777
consider-using-generator,
78+
too-many-ancestors
7879

7980

8081
# Enable the message, report, category or checker with the given id(s). You can

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 ._hooks import hooks # noqa: F401,E402
45+
4446
ctx = callback_context
4547

4648

dash/_callback.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import collections
22
import hashlib
33
from functools import wraps
4-
from typing import Callable, Optional, Any, List, Tuple
4+
5+
from typing import Callable, Optional, Any, List, Tuple, Union
6+
57

68
import flask
79

810
from .dependencies import (
911
handle_callback_args,
1012
handle_grouped_callback_args,
1113
Output,
14+
ClientsideFunction,
1215
Input,
1316
)
1417
from .development.base_component import ComponentRegistry
@@ -210,7 +213,10 @@ def validate_long_inputs(deps):
210213
)
211214

212215

213-
def clientside_callback(clientside_function, *args, **kwargs):
216+
ClientsideFuncType = Union[str, ClientsideFunction]
217+
218+
219+
def clientside_callback(clientside_function: ClientsideFuncType, *args, **kwargs):
214220
return register_clientside_callback(
215221
GLOBAL_CALLBACK_LIST,
216222
GLOBAL_CALLBACK_MAP,
@@ -597,7 +603,7 @@ def register_clientside_callback(
597603
callback_map,
598604
config_prevent_initial_callbacks,
599605
inline_scripts,
600-
clientside_function,
606+
clientside_function: ClientsideFuncType,
601607
*args,
602608
**kwargs,
603609
):

dash/_hooks.py

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import typing as _t
2+
3+
from importlib import metadata as _importlib_metadata
4+
5+
import typing_extensions as _tx
6+
import flask as _f
7+
8+
from .exceptions import HookError
9+
from .resources import ResourceType
10+
from ._callback import ClientsideFuncType
11+
12+
if _t.TYPE_CHECKING:
13+
from .dash import Dash
14+
from .development.base_component import Component
15+
16+
ComponentType = _t.TypeVar("ComponentType", bound=Component)
17+
LayoutType = _t.Union[ComponentType, _t.List[ComponentType]]
18+
else:
19+
LayoutType = None
20+
ComponentType = None
21+
Dash = None
22+
23+
24+
HookDataType = _tx.TypeVar("HookDataType")
25+
26+
27+
# pylint: disable=too-few-public-methods
28+
class _Hook(_tx.Generic[HookDataType]):
29+
def __init__(self, func, priority=0, final=False, data: HookDataType = None):
30+
self.func = func
31+
self.final = final
32+
self.data = data
33+
self.priority = priority
34+
35+
def __call__(self, *args, **kwargs):
36+
return self.func(*args, **kwargs)
37+
38+
39+
class _Hooks:
40+
def __init__(self) -> None:
41+
self._ns = {
42+
"setup": [],
43+
"layout": [],
44+
"routes": [],
45+
"error": [],
46+
"callback": [],
47+
"index": [],
48+
}
49+
self._js_dist = []
50+
self._css_dist = []
51+
self._clientside_callbacks: _t.List[
52+
_t.Tuple[ClientsideFuncType, _t.Any, _t.Any]
53+
] = []
54+
55+
# final hooks are a single hook added to the end of regular hooks.
56+
self._finals = {}
57+
58+
def add_hook(
59+
self,
60+
hook: str,
61+
func: _t.Callable,
62+
priority: _t.Optional[int] = None,
63+
final=False,
64+
data=None,
65+
):
66+
if final:
67+
existing = self._finals.get(hook)
68+
if existing:
69+
raise HookError("Final hook already present")
70+
self._finals[hook] = _Hook(func, final, data=data)
71+
return
72+
hks = self._ns.get(hook, [])
73+
74+
p = 0
75+
if not priority and len(hks):
76+
priority_max = max(h.priority for h in hks)
77+
p = priority_max - 1
78+
79+
hks.append(_Hook(func, priority=p, data=data))
80+
self._ns[hook] = sorted(hks, reverse=True, key=lambda h: h.priority)
81+
82+
def get_hooks(self, hook: str) -> _t.List[_Hook]:
83+
final = self._finals.get(hook, None)
84+
if final:
85+
final = [final]
86+
else:
87+
final = []
88+
return self._ns.get(hook, []) + final
89+
90+
def layout(self, priority: _t.Optional[int] = None, final: bool = False):
91+
"""
92+
Run a function when serving the layout, the return value
93+
will be used as the layout.
94+
"""
95+
96+
def _wrap(func: _t.Callable[[LayoutType], LayoutType]):
97+
self.add_hook("layout", func, priority=priority, final=final)
98+
return func
99+
100+
return _wrap
101+
102+
def setup(self, priority: _t.Optional[int] = None, final: bool = False):
103+
"""
104+
Can be used to get a reference to the app after it is instantiated.
105+
"""
106+
107+
def _setup(func: _t.Callable[[Dash], None]):
108+
self.add_hook("setup", func, priority=priority, final=final)
109+
return func
110+
111+
return _setup
112+
113+
def route(
114+
self,
115+
name: _t.Optional[str] = None,
116+
methods: _t.Sequence[str] = ("GET",),
117+
priority: _t.Optional[int] = None,
118+
final=False,
119+
):
120+
"""
121+
Add a route to the Dash server.
122+
"""
123+
124+
def wrap(func: _t.Callable[[], _f.Response]):
125+
_name = name or func.__name__
126+
self.add_hook(
127+
"routes",
128+
func,
129+
priority=priority,
130+
final=final,
131+
data=dict(name=_name, methods=methods),
132+
)
133+
return func
134+
135+
return wrap
136+
137+
def error(self, priority: _t.Optional[int] = None, final=False):
138+
"""Automatically add an error handler to the dash app."""
139+
140+
def _error(func: _t.Callable[[Exception], _t.Any]):
141+
self.add_hook("error", func, priority=priority, final=final)
142+
return func
143+
144+
return _error
145+
146+
def callback(self, *args, priority: _t.Optional[int] = None, final=False, **kwargs):
147+
"""
148+
Add a callback to all the apps with the hook installed.
149+
"""
150+
151+
def wrap(func):
152+
self.add_hook(
153+
"callback",
154+
func,
155+
priority=priority,
156+
final=final,
157+
data=(list(args), dict(kwargs)),
158+
)
159+
return func
160+
161+
return wrap
162+
163+
def clientside_callback(
164+
self, clientside_function: ClientsideFuncType, *args, **kwargs
165+
):
166+
"""
167+
Add a callback to all the apps with the hook installed.
168+
"""
169+
self._clientside_callbacks.append((clientside_function, args, kwargs))
170+
171+
def script(self, distribution: _t.List[ResourceType]):
172+
"""Add js scripts to the page."""
173+
self._js_dist.extend(distribution)
174+
175+
def stylesheet(self, distribution: _t.List[ResourceType]):
176+
"""Add stylesheets to the page."""
177+
self._css_dist.extend(distribution)
178+
179+
def index(self, priority: _t.Optional[int] = None, final=False):
180+
"""Modify the index of the apps."""
181+
182+
def wrap(func):
183+
self.add_hook(
184+
"index",
185+
func,
186+
priority=priority,
187+
final=final,
188+
)
189+
return func
190+
191+
return wrap
192+
193+
194+
hooks = _Hooks()
195+
196+
197+
class HooksManager:
198+
# Flag to only run `register_setuptools` once
199+
_registered = False
200+
hooks = hooks
201+
202+
# pylint: disable=too-few-public-methods
203+
class HookErrorHandler:
204+
def __init__(self, original):
205+
self.original = original
206+
207+
def __call__(self, err: Exception):
208+
result = None
209+
if self.original:
210+
result = self.original(err)
211+
hook_result = None
212+
for hook in HooksManager.get_hooks("error"):
213+
hook_result = hook(err)
214+
return result or hook_result
215+
216+
@classmethod
217+
def get_hooks(cls, hook: str):
218+
return cls.hooks.get_hooks(hook)
219+
220+
@classmethod
221+
def register_setuptools(cls):
222+
if cls._registered:
223+
# Only have to register once.
224+
return
225+
226+
for dist in _importlib_metadata.distributions():
227+
for entry in dist.entry_points:
228+
# Look for setup.py entry points named `dash-hooks`
229+
if entry.group != "dash-hooks":
230+
continue
231+
entry.load()

dash/dash.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,8 @@ def __init__( # pylint: disable=too-many-statements
567567
for plugin in plugins:
568568
plugin.plug(self)
569569

570+
self._setup_hooks()
571+
570572
# tracks internally if a function already handled at least one request.
571573
self._got_first_request = {"pages": False, "setup_server": False}
572574

@@ -582,6 +584,38 @@ def __init__( # pylint: disable=too-many-statements
582584
)
583585
self.setup_startup_routes()
584586

587+
def _setup_hooks(self):
588+
# pylint: disable=import-outside-toplevel,protected-access
589+
from ._hooks import HooksManager
590+
591+
self._hooks = HooksManager
592+
self._hooks.register_setuptools()
593+
594+
for setup in self._hooks.get_hooks("setup"):
595+
setup(self)
596+
597+
for hook in self._hooks.get_hooks("callback"):
598+
callback_args, callback_kwargs = hook.data
599+
self.callback(*callback_args, **callback_kwargs)(hook.func)
600+
601+
for (
602+
clientside_function,
603+
args,
604+
kwargs,
605+
) in self._hooks.hooks._clientside_callbacks:
606+
_callback.register_clientside_callback(
607+
self._callback_list,
608+
self.callback_map,
609+
self.config.prevent_initial_callbacks,
610+
self._inline_scripts,
611+
clientside_function,
612+
*args,
613+
**kwargs,
614+
)
615+
616+
if self._hooks.get_hooks("error"):
617+
self._on_error = self._hooks.HookErrorHandler(self._on_error)
618+
585619
def init_app(self, app=None, **kwargs):
586620
"""Initialize the parts of Dash that require a flask app."""
587621

@@ -682,6 +716,9 @@ def _setup_routes(self):
682716
"_alive_" + jupyter_dash.alive_token, jupyter_dash.serve_alive
683717
)
684718

719+
for hook in self._hooks.get_hooks("routes"):
720+
self._add_url(hook.data["name"], hook.func, hook.data["methods"])
721+
685722
# catch-all for front-end routes, used by dcc.Location
686723
self._add_url("<path:path>", self.index)
687724

@@ -748,6 +785,9 @@ def index_string(self, value):
748785
def serve_layout(self):
749786
layout = self._layout_value()
750787

788+
for hook in self._hooks.get_hooks("layout"):
789+
layout = hook(layout)
790+
751791
# TODO - Set browser cache limit - pass hash into frontend
752792
return flask.Response(
753793
to_json(layout),
@@ -890,9 +930,13 @@ def _relative_url_path(relative_package_path="", namespace=""):
890930

891931
return srcs
892932

933+
# pylint: disable=protected-access
893934
def _generate_css_dist_html(self):
894935
external_links = self.config.external_stylesheets
895-
links = self._collect_and_register_resources(self.css.get_all_css())
936+
links = self._collect_and_register_resources(
937+
self.css.get_all_css()
938+
+ self.css._resources._filter_resources(self._hooks.hooks._css_dist)
939+
)
896940

897941
return "\n".join(
898942
[
@@ -941,6 +985,9 @@ def _generate_scripts_html(self):
941985
+ self.scripts._resources._filter_resources(
942986
dash_table._js_dist, dev_bundles=dev
943987
)
988+
+ self.scripts._resources._filter_resources(
989+
self._hooks.hooks._js_dist, dev_bundles=dev
990+
)
944991
)
945992
)
946993

@@ -1064,6 +1111,9 @@ def index(self, *args, **kwargs): # pylint: disable=unused-argument
10641111
renderer=renderer,
10651112
)
10661113

1114+
for hook in self._hooks.get_hooks("index"):
1115+
index = hook(index)
1116+
10671117
checks = (
10681118
_re_index_entry_id,
10691119
_re_index_config_id,

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

0 commit comments

Comments
 (0)