Files

277 lines
7.8 KiB
Python
Raw Permalink Normal View History

2025-09-07 22:09:54 +02:00
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()