549 lines
20 KiB
Python
549 lines
20 KiB
Python
|
import collections
|
||
|
import hashlib
|
||
|
from functools import wraps
|
||
|
|
||
|
import flask
|
||
|
|
||
|
from .dependencies import (
|
||
|
handle_callback_args,
|
||
|
handle_grouped_callback_args,
|
||
|
Output,
|
||
|
)
|
||
|
from .exceptions import (
|
||
|
PreventUpdate,
|
||
|
WildcardInLongCallback,
|
||
|
MissingLongCallbackManagerError,
|
||
|
LongCallbackError,
|
||
|
)
|
||
|
|
||
|
from ._grouping import (
|
||
|
flatten_grouping,
|
||
|
make_grouping_by_index,
|
||
|
grouping_len,
|
||
|
)
|
||
|
from ._utils import (
|
||
|
create_callback_id,
|
||
|
stringify_id,
|
||
|
to_json,
|
||
|
coerce_to_list,
|
||
|
AttributeDict,
|
||
|
clean_property_name,
|
||
|
)
|
||
|
|
||
|
from . import _validate
|
||
|
from .long_callback.managers import BaseLongCallbackManager
|
||
|
from ._callback_context import context_value
|
||
|
|
||
|
|
||
|
class NoUpdate:
|
||
|
def to_plotly_json(self): # pylint: disable=no-self-use
|
||
|
return {"_dash_no_update": "_dash_no_update"}
|
||
|
|
||
|
@staticmethod
|
||
|
def is_no_update(obj):
|
||
|
return isinstance(obj, NoUpdate) or (
|
||
|
isinstance(obj, dict) and obj == {"_dash_no_update": "_dash_no_update"}
|
||
|
)
|
||
|
|
||
|
|
||
|
GLOBAL_CALLBACK_LIST = []
|
||
|
GLOBAL_CALLBACK_MAP = {}
|
||
|
GLOBAL_INLINE_SCRIPTS = []
|
||
|
|
||
|
|
||
|
# pylint: disable=too-many-locals
|
||
|
def callback(
|
||
|
*_args,
|
||
|
background=False,
|
||
|
interval=1000,
|
||
|
progress=None,
|
||
|
progress_default=None,
|
||
|
running=None,
|
||
|
cancel=None,
|
||
|
manager=None,
|
||
|
cache_args_to_ignore=None,
|
||
|
**_kwargs,
|
||
|
):
|
||
|
"""
|
||
|
Normally used as a decorator, `@dash.callback` provides a server-side
|
||
|
callback relating the values of one or more `Output` items to one or
|
||
|
more `Input` items which will trigger the callback when they change,
|
||
|
and optionally `State` items which provide additional information but
|
||
|
do not trigger the callback directly.
|
||
|
|
||
|
`@dash.callback` is an alternative to `@app.callback` (where `app = dash.Dash()`)
|
||
|
introduced in Dash 2.0.
|
||
|
It allows you to register callbacks without defining or importing the `app`
|
||
|
object. The call signature is identical and it can be used instead of `app.callback`
|
||
|
in all cases.
|
||
|
|
||
|
The last, optional argument `prevent_initial_call` causes the callback
|
||
|
not to fire when its outputs are first added to the page. Defaults to
|
||
|
`False` and unlike `app.callback` is not configurable at the app level.
|
||
|
|
||
|
:Keyword Arguments:
|
||
|
:param background:
|
||
|
Mark the callback as a long callback to execute in a manager for
|
||
|
callbacks that take a long time without locking up the Dash app
|
||
|
or timing out.
|
||
|
:param manager:
|
||
|
A long callback manager instance. Currently, an instance of one of
|
||
|
`DiskcacheManager` or `CeleryManager`.
|
||
|
Defaults to the `background_callback_manager` instance provided to the
|
||
|
`dash.Dash constructor`.
|
||
|
- A diskcache manager (`DiskcacheManager`) that runs callback
|
||
|
logic in a separate process and stores the results to disk using the
|
||
|
diskcache library. This is the easiest backend to use for local
|
||
|
development.
|
||
|
- A Celery manager (`CeleryManager`) that runs callback logic
|
||
|
in a celery worker and returns results to the Dash app through a Celery
|
||
|
broker like RabbitMQ or Redis.
|
||
|
:param running:
|
||
|
A list of 3-element tuples. The first element of each tuple should be
|
||
|
an `Output` dependency object referencing a property of a component in
|
||
|
the app layout. The second element is the value that the property
|
||
|
should be set to while the callback is running, and the third element
|
||
|
is the value the property should be set to when the callback completes.
|
||
|
:param cancel:
|
||
|
A list of `Input` dependency objects that reference a property of a
|
||
|
component in the app's layout. When the value of this property changes
|
||
|
while a callback is running, the callback is canceled.
|
||
|
Note that the value of the property is not significant, any change in
|
||
|
value will result in the cancellation of the running job (if any).
|
||
|
:param progress:
|
||
|
An `Output` dependency grouping that references properties of
|
||
|
components in the app's layout. When provided, the decorated function
|
||
|
will be called with an extra argument as the first argument to the
|
||
|
function. This argument, is a function handle that the decorated
|
||
|
function should call in order to provide updates to the app on its
|
||
|
current progress. This function accepts a single argument, which
|
||
|
correspond to the grouping of properties specified in the provided
|
||
|
`Output` dependency grouping
|
||
|
:param progress_default:
|
||
|
A grouping of values that should be assigned to the components
|
||
|
specified by the `progress` argument when the callback is not in
|
||
|
progress. If `progress_default` is not provided, all the dependency
|
||
|
properties specified in `progress` will be set to `None` when the
|
||
|
callback is not running.
|
||
|
:param cache_args_to_ignore:
|
||
|
Arguments to ignore when caching is enabled. If callback is configured
|
||
|
with keyword arguments (Input/State provided in a dict),
|
||
|
this should be a list of argument names as strings. Otherwise,
|
||
|
this should be a list of argument indices as integers.
|
||
|
:param interval:
|
||
|
Time to wait between the long callback update requests.
|
||
|
"""
|
||
|
|
||
|
long_spec = None
|
||
|
|
||
|
config_prevent_initial_callbacks = _kwargs.pop(
|
||
|
"config_prevent_initial_callbacks", False
|
||
|
)
|
||
|
callback_map = _kwargs.pop("callback_map", GLOBAL_CALLBACK_MAP)
|
||
|
callback_list = _kwargs.pop("callback_list", GLOBAL_CALLBACK_LIST)
|
||
|
|
||
|
if background:
|
||
|
long_spec = {
|
||
|
"interval": interval,
|
||
|
}
|
||
|
|
||
|
if manager:
|
||
|
long_spec["manager"] = manager
|
||
|
|
||
|
if progress:
|
||
|
long_spec["progress"] = coerce_to_list(progress)
|
||
|
validate_long_inputs(long_spec["progress"])
|
||
|
|
||
|
if progress_default:
|
||
|
long_spec["progressDefault"] = coerce_to_list(progress_default)
|
||
|
|
||
|
if not len(long_spec["progress"]) == len(long_spec["progressDefault"]):
|
||
|
raise Exception(
|
||
|
"Progress and progress default needs to be of same length"
|
||
|
)
|
||
|
|
||
|
if running:
|
||
|
long_spec["running"] = coerce_to_list(running)
|
||
|
validate_long_inputs(x[0] for x in long_spec["running"])
|
||
|
|
||
|
if cancel:
|
||
|
cancel_inputs = coerce_to_list(cancel)
|
||
|
validate_long_inputs(cancel_inputs)
|
||
|
|
||
|
long_spec["cancel"] = [c.to_dict() for c in cancel_inputs]
|
||
|
long_spec["cancel_inputs"] = cancel_inputs
|
||
|
|
||
|
if cache_args_to_ignore:
|
||
|
long_spec["cache_args_to_ignore"] = cache_args_to_ignore
|
||
|
|
||
|
return register_callback(
|
||
|
callback_list,
|
||
|
callback_map,
|
||
|
config_prevent_initial_callbacks,
|
||
|
*_args,
|
||
|
**_kwargs,
|
||
|
long=long_spec,
|
||
|
manager=manager,
|
||
|
)
|
||
|
|
||
|
|
||
|
def validate_long_inputs(deps):
|
||
|
for dep in deps:
|
||
|
if dep.has_wildcard():
|
||
|
raise WildcardInLongCallback(
|
||
|
f"""
|
||
|
long callbacks does not support dependencies with
|
||
|
pattern-matching ids
|
||
|
Received: {repr(dep)}\n"""
|
||
|
)
|
||
|
|
||
|
|
||
|
def clientside_callback(clientside_function, *args, **kwargs):
|
||
|
return register_clientside_callback(
|
||
|
GLOBAL_CALLBACK_LIST,
|
||
|
GLOBAL_CALLBACK_MAP,
|
||
|
False,
|
||
|
GLOBAL_INLINE_SCRIPTS,
|
||
|
clientside_function,
|
||
|
*args,
|
||
|
**kwargs,
|
||
|
)
|
||
|
|
||
|
|
||
|
# pylint: disable=too-many-arguments
|
||
|
def insert_callback(
|
||
|
callback_list,
|
||
|
callback_map,
|
||
|
config_prevent_initial_callbacks,
|
||
|
output,
|
||
|
outputs_indices,
|
||
|
inputs,
|
||
|
state,
|
||
|
inputs_state_indices,
|
||
|
prevent_initial_call,
|
||
|
long=None,
|
||
|
manager=None,
|
||
|
dynamic_creator=False,
|
||
|
):
|
||
|
if prevent_initial_call is None:
|
||
|
prevent_initial_call = config_prevent_initial_callbacks
|
||
|
|
||
|
_validate.validate_duplicate_output(
|
||
|
output, prevent_initial_call, config_prevent_initial_callbacks
|
||
|
)
|
||
|
|
||
|
callback_id = create_callback_id(output, inputs)
|
||
|
callback_spec = {
|
||
|
"output": callback_id,
|
||
|
"inputs": [c.to_dict() for c in inputs],
|
||
|
"state": [c.to_dict() for c in state],
|
||
|
"clientside_function": None,
|
||
|
# prevent_initial_call can be a string "initial_duplicates"
|
||
|
# which should not prevent the initial call.
|
||
|
"prevent_initial_call": prevent_initial_call is True,
|
||
|
"long": long
|
||
|
and {
|
||
|
"interval": long["interval"],
|
||
|
},
|
||
|
"dynamic_creator": dynamic_creator,
|
||
|
}
|
||
|
|
||
|
callback_map[callback_id] = {
|
||
|
"inputs": callback_spec["inputs"],
|
||
|
"state": callback_spec["state"],
|
||
|
"outputs_indices": outputs_indices,
|
||
|
"inputs_state_indices": inputs_state_indices,
|
||
|
"long": long,
|
||
|
"output": output,
|
||
|
"raw_inputs": inputs,
|
||
|
"manager": manager,
|
||
|
}
|
||
|
callback_list.append(callback_spec)
|
||
|
|
||
|
return callback_id
|
||
|
|
||
|
|
||
|
# pylint: disable=R0912, R0915
|
||
|
def register_callback( # pylint: disable=R0914
|
||
|
callback_list, callback_map, config_prevent_initial_callbacks, *_args, **_kwargs
|
||
|
):
|
||
|
(
|
||
|
output,
|
||
|
flat_inputs,
|
||
|
flat_state,
|
||
|
inputs_state_indices,
|
||
|
prevent_initial_call,
|
||
|
) = handle_grouped_callback_args(_args, _kwargs)
|
||
|
if isinstance(output, Output):
|
||
|
# Insert callback with scalar (non-multi) Output
|
||
|
insert_output = output
|
||
|
multi = False
|
||
|
else:
|
||
|
# Insert callback as multi Output
|
||
|
insert_output = flatten_grouping(output)
|
||
|
multi = True
|
||
|
|
||
|
long = _kwargs.get("long")
|
||
|
manager = _kwargs.get("manager")
|
||
|
allow_dynamic_callbacks = _kwargs.get("_allow_dynamic_callbacks")
|
||
|
|
||
|
output_indices = make_grouping_by_index(output, list(range(grouping_len(output))))
|
||
|
callback_id = insert_callback(
|
||
|
callback_list,
|
||
|
callback_map,
|
||
|
config_prevent_initial_callbacks,
|
||
|
insert_output,
|
||
|
output_indices,
|
||
|
flat_inputs,
|
||
|
flat_state,
|
||
|
inputs_state_indices,
|
||
|
prevent_initial_call,
|
||
|
long=long,
|
||
|
manager=manager,
|
||
|
dynamic_creator=allow_dynamic_callbacks,
|
||
|
)
|
||
|
|
||
|
# pylint: disable=too-many-locals
|
||
|
def wrap_func(func):
|
||
|
|
||
|
if long is not None:
|
||
|
long_key = BaseLongCallbackManager.register_func(
|
||
|
func,
|
||
|
long.get("progress") is not None,
|
||
|
callback_id,
|
||
|
)
|
||
|
|
||
|
@wraps(func)
|
||
|
def add_context(*args, **kwargs):
|
||
|
output_spec = kwargs.pop("outputs_list")
|
||
|
app_callback_manager = kwargs.pop("long_callback_manager", None)
|
||
|
callback_ctx = kwargs.pop("callback_context", {})
|
||
|
callback_manager = long and long.get("manager", app_callback_manager)
|
||
|
_validate.validate_output_spec(insert_output, output_spec, Output)
|
||
|
|
||
|
context_value.set(callback_ctx)
|
||
|
|
||
|
func_args, func_kwargs = _validate.validate_and_group_input_args(
|
||
|
args, inputs_state_indices
|
||
|
)
|
||
|
|
||
|
response = {"multi": True}
|
||
|
|
||
|
if long is not None:
|
||
|
if not callback_manager:
|
||
|
raise MissingLongCallbackManagerError(
|
||
|
"Running `long` callbacks requires a manager to be installed.\n"
|
||
|
"Available managers:\n"
|
||
|
"- Diskcache (`pip install dash[diskcache]`) to run callbacks in a separate Process"
|
||
|
" and store results on the local filesystem.\n"
|
||
|
"- Celery (`pip install dash[celery]`) to run callbacks in a celery worker"
|
||
|
" and store results on redis.\n"
|
||
|
)
|
||
|
|
||
|
progress_outputs = long.get("progress")
|
||
|
cache_key = flask.request.args.get("cacheKey")
|
||
|
job_id = flask.request.args.get("job")
|
||
|
old_job = flask.request.args.getlist("oldJob")
|
||
|
|
||
|
current_key = callback_manager.build_cache_key(
|
||
|
func,
|
||
|
# Inputs provided as dict is kwargs.
|
||
|
func_args if func_args else func_kwargs,
|
||
|
long.get("cache_args_to_ignore", []),
|
||
|
)
|
||
|
|
||
|
if old_job:
|
||
|
for job in old_job:
|
||
|
callback_manager.terminate_job(job)
|
||
|
|
||
|
if not cache_key:
|
||
|
cache_key = current_key
|
||
|
|
||
|
job_fn = callback_manager.func_registry.get(long_key)
|
||
|
|
||
|
job = callback_manager.call_job_fn(
|
||
|
cache_key,
|
||
|
job_fn,
|
||
|
func_args if func_args else func_kwargs,
|
||
|
AttributeDict(
|
||
|
args_grouping=callback_ctx.args_grouping,
|
||
|
using_args_grouping=callback_ctx.using_args_grouping,
|
||
|
outputs_grouping=callback_ctx.outputs_grouping,
|
||
|
using_outputs_grouping=callback_ctx.using_outputs_grouping,
|
||
|
inputs_list=callback_ctx.inputs_list,
|
||
|
states_list=callback_ctx.states_list,
|
||
|
outputs_list=callback_ctx.outputs_list,
|
||
|
input_values=callback_ctx.input_values,
|
||
|
state_values=callback_ctx.state_values,
|
||
|
triggered_inputs=callback_ctx.triggered_inputs,
|
||
|
ignore_register_page=True,
|
||
|
),
|
||
|
)
|
||
|
|
||
|
data = {
|
||
|
"cacheKey": cache_key,
|
||
|
"job": job,
|
||
|
}
|
||
|
|
||
|
running = long.get("running")
|
||
|
|
||
|
if running:
|
||
|
data["running"] = {str(r[0]): r[1] for r in running}
|
||
|
data["runningOff"] = {str(r[0]): r[2] for r in running}
|
||
|
cancel = long.get("cancel")
|
||
|
if cancel:
|
||
|
data["cancel"] = cancel
|
||
|
|
||
|
progress_default = long.get("progressDefault")
|
||
|
if progress_default:
|
||
|
data["progressDefault"] = {
|
||
|
str(o): x
|
||
|
for o, x in zip(progress_outputs, progress_default)
|
||
|
}
|
||
|
return to_json(data)
|
||
|
if progress_outputs:
|
||
|
# Get the progress before the result as it would be erased after the results.
|
||
|
progress = callback_manager.get_progress(cache_key)
|
||
|
if progress:
|
||
|
response["progress"] = {
|
||
|
str(x): progress[i] for i, x in enumerate(progress_outputs)
|
||
|
}
|
||
|
|
||
|
output_value = callback_manager.get_result(cache_key, job_id)
|
||
|
# Must get job_running after get_result since get_results terminates it.
|
||
|
job_running = callback_manager.job_running(job_id)
|
||
|
if not job_running and output_value is callback_manager.UNDEFINED:
|
||
|
# Job canceled -> no output to close the loop.
|
||
|
output_value = NoUpdate()
|
||
|
|
||
|
elif (
|
||
|
isinstance(output_value, dict)
|
||
|
and "long_callback_error" in output_value
|
||
|
):
|
||
|
error = output_value.get("long_callback_error")
|
||
|
raise LongCallbackError(
|
||
|
f"An error occurred inside a long callback: {error['msg']}\n{error['tb']}"
|
||
|
)
|
||
|
|
||
|
if job_running and output_value is not callback_manager.UNDEFINED:
|
||
|
# cached results.
|
||
|
callback_manager.terminate_job(job_id)
|
||
|
|
||
|
if multi and isinstance(output_value, (list, tuple)):
|
||
|
output_value = [
|
||
|
NoUpdate() if NoUpdate.is_no_update(r) else r
|
||
|
for r in output_value
|
||
|
]
|
||
|
|
||
|
if output_value is callback_manager.UNDEFINED:
|
||
|
return to_json(response)
|
||
|
else:
|
||
|
# don't touch the comment on the next line - used by debugger
|
||
|
output_value = func(*func_args, **func_kwargs) # %% callback invoked %%
|
||
|
|
||
|
if NoUpdate.is_no_update(output_value):
|
||
|
raise PreventUpdate
|
||
|
|
||
|
if not multi:
|
||
|
output_value, output_spec = [output_value], [output_spec]
|
||
|
flat_output_values = output_value
|
||
|
else:
|
||
|
if isinstance(output_value, (list, tuple)):
|
||
|
# For multi-output, allow top-level collection to be
|
||
|
# list or tuple
|
||
|
output_value = list(output_value)
|
||
|
|
||
|
# Flatten grouping and validate grouping structure
|
||
|
flat_output_values = flatten_grouping(output_value, output)
|
||
|
|
||
|
_validate.validate_multi_return(
|
||
|
output_spec, flat_output_values, callback_id
|
||
|
)
|
||
|
|
||
|
component_ids = collections.defaultdict(dict)
|
||
|
has_update = False
|
||
|
for val, spec in zip(flat_output_values, output_spec):
|
||
|
if isinstance(val, NoUpdate):
|
||
|
continue
|
||
|
for vali, speci in (
|
||
|
zip(val, spec) if isinstance(spec, list) else [[val, spec]]
|
||
|
):
|
||
|
if not isinstance(vali, NoUpdate):
|
||
|
has_update = True
|
||
|
id_str = stringify_id(speci["id"])
|
||
|
prop = clean_property_name(speci["property"])
|
||
|
component_ids[id_str][prop] = vali
|
||
|
|
||
|
if not has_update:
|
||
|
raise PreventUpdate
|
||
|
|
||
|
response["response"] = component_ids
|
||
|
|
||
|
try:
|
||
|
jsonResponse = to_json(response)
|
||
|
except TypeError:
|
||
|
_validate.fail_callback_output(output_value, output)
|
||
|
|
||
|
return jsonResponse
|
||
|
|
||
|
callback_map[callback_id]["callback"] = add_context
|
||
|
|
||
|
return func
|
||
|
|
||
|
return wrap_func
|
||
|
|
||
|
|
||
|
_inline_clientside_template = """
|
||
|
var clientside = window.dash_clientside = window.dash_clientside || {{}};
|
||
|
var ns = clientside["{namespace}"] = clientside["{namespace}"] || {{}};
|
||
|
ns["{function_name}"] = {clientside_function};
|
||
|
"""
|
||
|
|
||
|
|
||
|
def register_clientside_callback(
|
||
|
callback_list,
|
||
|
callback_map,
|
||
|
config_prevent_initial_callbacks,
|
||
|
inline_scripts,
|
||
|
clientside_function,
|
||
|
*args,
|
||
|
**kwargs,
|
||
|
):
|
||
|
output, inputs, state, prevent_initial_call = handle_callback_args(args, kwargs)
|
||
|
insert_callback(
|
||
|
callback_list,
|
||
|
callback_map,
|
||
|
config_prevent_initial_callbacks,
|
||
|
output,
|
||
|
None,
|
||
|
inputs,
|
||
|
state,
|
||
|
None,
|
||
|
prevent_initial_call,
|
||
|
)
|
||
|
|
||
|
# If JS source is explicitly given, create a namespace and function
|
||
|
# name, then inject the code.
|
||
|
if isinstance(clientside_function, str):
|
||
|
namespace = "_dashprivate_clientside_funcs"
|
||
|
# Create a hash from the function, it will be the same always
|
||
|
function_name = hashlib.md5(clientside_function.encode("utf-8")).hexdigest()
|
||
|
|
||
|
inline_scripts.append(
|
||
|
_inline_clientside_template.format(
|
||
|
namespace=namespace,
|
||
|
function_name=function_name,
|
||
|
clientside_function=clientside_function,
|
||
|
)
|
||
|
)
|
||
|
|
||
|
# Callback is stored in an external asset.
|
||
|
else:
|
||
|
namespace = clientside_function.namespace
|
||
|
function_name = clientside_function.function_name
|
||
|
|
||
|
callback_list[-1]["clientside_function"] = {
|
||
|
"namespace": namespace,
|
||
|
"function_name": function_name,
|
||
|
}
|