277 lines
7.8 KiB
Python
277 lines
7.8 KiB
Python
|
import typing as _t
|
||
|
|
||
|
from importlib import metadata as _importlib_metadata
|
||
|
|
||
|
import typing_extensions as _tx
|
||
|
import flask as _f
|
||
|
|
||
|
from .exceptions import HookError
|
||
|
from .resources import ResourceType
|
||
|
from ._callback import ClientsideFuncType
|
||
|
|
||
|
if _t.TYPE_CHECKING:
|
||
|
from .dash import Dash
|
||
|
from .development.base_component import Component
|
||
|
|
||
|
ComponentType = _t.TypeVar("ComponentType", bound=Component)
|
||
|
LayoutType = _t.Union[ComponentType, _t.List[ComponentType]]
|
||
|
else:
|
||
|
LayoutType = None
|
||
|
ComponentType = None
|
||
|
Dash = None
|
||
|
|
||
|
|
||
|
HookDataType = _tx.TypeVar("HookDataType")
|
||
|
|
||
|
|
||
|
# pylint: disable=too-few-public-methods
|
||
|
class _Hook(_tx.Generic[HookDataType]):
|
||
|
def __init__(self, func, priority=0, final=False, data: HookDataType = None):
|
||
|
self.func = func
|
||
|
self.final = final
|
||
|
self.data = data
|
||
|
self.priority = priority
|
||
|
|
||
|
def __call__(self, *args, **kwargs):
|
||
|
return self.func(*args, **kwargs)
|
||
|
|
||
|
|
||
|
class _Hooks:
|
||
|
def __init__(self) -> None:
|
||
|
self._ns = {
|
||
|
"setup": [],
|
||
|
"layout": [],
|
||
|
"routes": [],
|
||
|
"error": [],
|
||
|
"callback": [],
|
||
|
"index": [],
|
||
|
"custom_data": [],
|
||
|
"dev_tools": [],
|
||
|
}
|
||
|
self._js_dist = []
|
||
|
self._css_dist = []
|
||
|
self._clientside_callbacks: _t.List[
|
||
|
_t.Tuple[ClientsideFuncType, _t.Any, _t.Any]
|
||
|
] = []
|
||
|
|
||
|
# final hooks are a single hook added to the end of regular hooks.
|
||
|
self._finals = {}
|
||
|
|
||
|
def add_hook(
|
||
|
self,
|
||
|
hook: str,
|
||
|
func: _t.Callable,
|
||
|
priority: _t.Optional[int] = None,
|
||
|
final: bool = False,
|
||
|
data: _t.Optional[HookDataType] = None,
|
||
|
):
|
||
|
if final:
|
||
|
existing = self._finals.get(hook)
|
||
|
if existing:
|
||
|
raise HookError("Final hook already present")
|
||
|
self._finals[hook] = _Hook(func, final, data=data)
|
||
|
return
|
||
|
hks = self._ns.get(hook, [])
|
||
|
|
||
|
p = priority or 0
|
||
|
if not priority and len(hks):
|
||
|
# Take the minimum value and remove 1 to keep the order.
|
||
|
priority_min = min(h.priority for h in hks)
|
||
|
p = priority_min - 1
|
||
|
|
||
|
hks.append(_Hook(func, priority=p, data=data))
|
||
|
self._ns[hook] = sorted(hks, reverse=True, key=lambda h: h.priority)
|
||
|
|
||
|
def get_hooks(self, hook: str) -> _t.List[_Hook]:
|
||
|
final = self._finals.get(hook, None)
|
||
|
if final:
|
||
|
final = [final]
|
||
|
else:
|
||
|
final = []
|
||
|
return self._ns.get(hook, []) + final
|
||
|
|
||
|
def layout(self, priority: _t.Optional[int] = None, final: bool = False):
|
||
|
"""
|
||
|
Run a function when serving the layout, the return value
|
||
|
will be used as the layout.
|
||
|
"""
|
||
|
|
||
|
def _wrap(func: _t.Callable[[LayoutType], LayoutType]):
|
||
|
self.add_hook("layout", func, priority=priority, final=final)
|
||
|
return func
|
||
|
|
||
|
return _wrap
|
||
|
|
||
|
def setup(self, priority: _t.Optional[int] = None, final: bool = False):
|
||
|
"""
|
||
|
Can be used to get a reference to the app after it is instantiated.
|
||
|
"""
|
||
|
|
||
|
def _setup(func: _t.Callable[[Dash], None]):
|
||
|
self.add_hook("setup", func, priority=priority, final=final)
|
||
|
return func
|
||
|
|
||
|
return _setup
|
||
|
|
||
|
def route(
|
||
|
self,
|
||
|
name: _t.Optional[str] = None,
|
||
|
methods: _t.Sequence[str] = ("GET",),
|
||
|
priority: _t.Optional[int] = None,
|
||
|
final: bool = False,
|
||
|
):
|
||
|
"""
|
||
|
Add a route to the Dash server.
|
||
|
"""
|
||
|
|
||
|
def wrap(func: _t.Callable[[], _f.Response]):
|
||
|
_name = name or func.__name__
|
||
|
self.add_hook(
|
||
|
"routes",
|
||
|
func,
|
||
|
priority=priority,
|
||
|
final=final,
|
||
|
data=dict(name=_name, methods=methods),
|
||
|
)
|
||
|
return func
|
||
|
|
||
|
return wrap
|
||
|
|
||
|
def error(self, priority: _t.Optional[int] = None, final: bool = False):
|
||
|
"""Automatically add an error handler to the dash app."""
|
||
|
|
||
|
def _error(func: _t.Callable[[Exception], _t.Any]):
|
||
|
self.add_hook("error", func, priority=priority, final=final)
|
||
|
return func
|
||
|
|
||
|
return _error
|
||
|
|
||
|
def callback(
|
||
|
self, *args, priority: _t.Optional[int] = None, final: bool = False, **kwargs
|
||
|
):
|
||
|
"""
|
||
|
Add a callback to all the apps with the hook installed.
|
||
|
"""
|
||
|
|
||
|
def wrap(func):
|
||
|
self.add_hook(
|
||
|
"callback",
|
||
|
func,
|
||
|
priority=priority,
|
||
|
final=final,
|
||
|
data=(list(args), dict(kwargs)),
|
||
|
)
|
||
|
return func
|
||
|
|
||
|
return wrap
|
||
|
|
||
|
def clientside_callback(
|
||
|
self, clientside_function: ClientsideFuncType, *args, **kwargs
|
||
|
):
|
||
|
"""
|
||
|
Add a callback to all the apps with the hook installed.
|
||
|
"""
|
||
|
self._clientside_callbacks.append((clientside_function, args, kwargs))
|
||
|
|
||
|
def script(self, distribution: _t.List[ResourceType]):
|
||
|
"""Add js scripts to the page."""
|
||
|
self._js_dist.extend(distribution)
|
||
|
|
||
|
def stylesheet(self, distribution: _t.List[ResourceType]):
|
||
|
"""Add stylesheets to the page."""
|
||
|
self._css_dist.extend(distribution)
|
||
|
|
||
|
def index(self, priority: _t.Optional[int] = None, final=False):
|
||
|
"""Modify the index of the apps."""
|
||
|
|
||
|
def wrap(func):
|
||
|
self.add_hook(
|
||
|
"index",
|
||
|
func,
|
||
|
priority=priority,
|
||
|
final=final,
|
||
|
)
|
||
|
return func
|
||
|
|
||
|
return wrap
|
||
|
|
||
|
def custom_data(
|
||
|
self, namespace: str, priority: _t.Optional[int] = None, final=False
|
||
|
):
|
||
|
"""
|
||
|
Add data to the callback_context.custom_data property under the namespace.
|
||
|
|
||
|
The hook function takes the current context_value and before the ctx is set
|
||
|
and has access to the flask request context.
|
||
|
"""
|
||
|
|
||
|
def wrap(func: _t.Callable[[_t.Dict], _t.Any]):
|
||
|
self.add_hook(
|
||
|
"custom_data",
|
||
|
func,
|
||
|
priority=priority,
|
||
|
final=final,
|
||
|
data={"namespace": namespace},
|
||
|
)
|
||
|
return func
|
||
|
|
||
|
return wrap
|
||
|
|
||
|
def devtool(self, namespace: str, component_type: str, props=None):
|
||
|
"""
|
||
|
Add a component to be rendered inside the dev tools.
|
||
|
|
||
|
If it's a dash component, it can be used in callbacks provided
|
||
|
that it has an id and the dependency is set with allow_optional=True.
|
||
|
|
||
|
`props` can be a function, in which case it will be called before
|
||
|
sending the component to the frontend.
|
||
|
"""
|
||
|
self._ns["dev_tools"].append(
|
||
|
{
|
||
|
"namespace": namespace,
|
||
|
"type": component_type,
|
||
|
"props": props or {},
|
||
|
}
|
||
|
)
|
||
|
|
||
|
|
||
|
hooks = _Hooks()
|
||
|
|
||
|
|
||
|
class HooksManager:
|
||
|
# Flag to only run `register_setuptools` once
|
||
|
_registered = False
|
||
|
hooks = hooks
|
||
|
|
||
|
# pylint: disable=too-few-public-methods
|
||
|
class HookErrorHandler:
|
||
|
def __init__(self, original):
|
||
|
self.original = original
|
||
|
|
||
|
def __call__(self, err: Exception):
|
||
|
result = None
|
||
|
if self.original:
|
||
|
result = self.original(err)
|
||
|
hook_result = None
|
||
|
for hook in HooksManager.get_hooks("error"):
|
||
|
hook_result = hook(err)
|
||
|
return result or hook_result
|
||
|
|
||
|
@classmethod
|
||
|
def get_hooks(cls, hook: str):
|
||
|
return cls.hooks.get_hooks(hook)
|
||
|
|
||
|
@classmethod
|
||
|
def register_setuptools(cls):
|
||
|
if cls._registered:
|
||
|
# Only have to register once.
|
||
|
return
|
||
|
|
||
|
for dist in _importlib_metadata.distributions():
|
||
|
for entry in dist.entry_points:
|
||
|
# Look for setup.py entry points named `dash_hooks`
|
||
|
if entry.group != "dash_hooks":
|
||
|
continue
|
||
|
entry.load()
|