374 lines
13 KiB
Python
374 lines
13 KiB
Python
import json
|
|
from dash.development.base_component import Component
|
|
|
|
from ._validate import validate_callback
|
|
from ._grouping import flatten_grouping, make_grouping_by_index
|
|
|
|
|
|
class _Wildcard: # pylint: disable=too-few-public-methods
|
|
def __init__(self, name):
|
|
self._name = name
|
|
|
|
def __str__(self):
|
|
return self._name
|
|
|
|
def __repr__(self):
|
|
return f"<{self}>"
|
|
|
|
def to_json(self):
|
|
# used in serializing wildcards - arrays are not allowed as
|
|
# id values, so make the wildcards look like length-1 arrays.
|
|
return f'["{self._name}"]'
|
|
|
|
|
|
MATCH = _Wildcard("MATCH")
|
|
ALL = _Wildcard("ALL")
|
|
ALLSMALLER = _Wildcard("ALLSMALLER")
|
|
|
|
|
|
class DashDependency: # pylint: disable=too-few-public-methods
|
|
def __init__(self, component_id, component_property):
|
|
|
|
if isinstance(component_id, Component):
|
|
self.component_id = component_id._set_random_id()
|
|
else:
|
|
self.component_id = component_id
|
|
|
|
self.component_property = component_property
|
|
self.allow_duplicate = False
|
|
|
|
def __str__(self):
|
|
return f"{self.component_id_str()}.{self.component_property}"
|
|
|
|
def __repr__(self):
|
|
return f"<{self.__class__.__name__} `{self}`>"
|
|
|
|
def component_id_str(self):
|
|
i = self.component_id
|
|
|
|
def _dump(v):
|
|
return json.dumps(v, sort_keys=True, separators=(",", ":"))
|
|
|
|
def _json(k, v):
|
|
vstr = v.to_json() if hasattr(v, "to_json") else json.dumps(v)
|
|
return f"{json.dumps(k)}:{vstr}"
|
|
|
|
if isinstance(i, dict):
|
|
return "{" + ",".join(_json(k, i[k]) for k in sorted(i)) + "}"
|
|
|
|
return i
|
|
|
|
def to_dict(self):
|
|
return {"id": self.component_id_str(), "property": self.component_property}
|
|
|
|
def __eq__(self, other):
|
|
"""
|
|
We use "==" to denote two deps that refer to the same prop on
|
|
the same component. In the case of wildcard deps, this means
|
|
the same prop on *at least one* of the same components.
|
|
"""
|
|
return (
|
|
isinstance(other, DashDependency)
|
|
and self.component_property == other.component_property
|
|
and self._id_matches(other)
|
|
)
|
|
|
|
def _id_matches(self, other):
|
|
my_id = self.component_id
|
|
other_id = other.component_id
|
|
self_dict = isinstance(my_id, dict)
|
|
other_dict = isinstance(other_id, dict)
|
|
|
|
if self_dict != other_dict:
|
|
return False
|
|
if self_dict:
|
|
if set(my_id.keys()) != set(other_id.keys()):
|
|
return False
|
|
|
|
for k, v in my_id.items():
|
|
other_v = other_id[k]
|
|
if v == other_v:
|
|
continue
|
|
v_wild = isinstance(v, _Wildcard)
|
|
other_wild = isinstance(other_v, _Wildcard)
|
|
if v_wild or other_wild:
|
|
if not (v_wild and other_wild):
|
|
continue # one wild, one not
|
|
if v is ALL or other_v is ALL:
|
|
continue # either ALL
|
|
if v is MATCH or other_v is MATCH:
|
|
return False # one MATCH, one ALLSMALLER
|
|
else:
|
|
return False
|
|
return True
|
|
|
|
# both strings
|
|
return my_id == other_id
|
|
|
|
def __hash__(self):
|
|
return hash(str(self))
|
|
|
|
def has_wildcard(self):
|
|
"""
|
|
Return true if id contains a wildcard (MATCH, ALL, or ALLSMALLER)
|
|
"""
|
|
if isinstance(self.component_id, dict):
|
|
for v in self.component_id.values():
|
|
if isinstance(v, _Wildcard):
|
|
return True
|
|
return False
|
|
|
|
|
|
class Output(DashDependency): # pylint: disable=too-few-public-methods
|
|
"""Output of a callback."""
|
|
|
|
allowed_wildcards = (MATCH, ALL)
|
|
|
|
def __init__(self, component_id, component_property, allow_duplicate=False):
|
|
super().__init__(component_id, component_property)
|
|
self.allow_duplicate = allow_duplicate
|
|
|
|
|
|
class Input(DashDependency): # pylint: disable=too-few-public-methods
|
|
"""Input of callback: trigger an update when it is updated."""
|
|
|
|
allowed_wildcards = (MATCH, ALL, ALLSMALLER)
|
|
|
|
|
|
class State(DashDependency): # pylint: disable=too-few-public-methods
|
|
"""Use the value of a State in a callback but don't trigger updates."""
|
|
|
|
allowed_wildcards = (MATCH, ALL, ALLSMALLER)
|
|
|
|
|
|
class ClientsideFunction: # pylint: disable=too-few-public-methods
|
|
def __init__(self, namespace=None, function_name=None):
|
|
|
|
if namespace.startswith("_dashprivate_"):
|
|
raise ValueError("Namespaces cannot start with '_dashprivate_'.")
|
|
|
|
if namespace in ["PreventUpdate", "no_update"]:
|
|
raise ValueError(
|
|
f'"{namespace}" is a forbidden namespace in dash_clientside.'
|
|
)
|
|
|
|
self.namespace = namespace
|
|
self.function_name = function_name
|
|
|
|
def __repr__(self):
|
|
return f"ClientsideFunction({self.namespace}, {self.function_name})"
|
|
|
|
|
|
def extract_grouped_output_callback_args(args, kwargs):
|
|
if "output" in kwargs:
|
|
parameters = kwargs["output"]
|
|
# Normalize list/tuple of multiple positional outputs to a tuple
|
|
if isinstance(parameters, (list, tuple)):
|
|
parameters = list(parameters)
|
|
|
|
# Make sure dependency grouping contains only Output objects
|
|
for dep in flatten_grouping(parameters):
|
|
if not isinstance(dep, Output):
|
|
raise ValueError(
|
|
f"Invalid value provided where an Output dependency "
|
|
f"object was expected: {dep}"
|
|
)
|
|
|
|
return parameters
|
|
|
|
parameters = []
|
|
while args:
|
|
next_deps = flatten_grouping(args[0])
|
|
if all(isinstance(d, Output) for d in next_deps):
|
|
parameters.append(args.pop(0))
|
|
else:
|
|
break
|
|
return parameters
|
|
|
|
|
|
def extract_grouped_input_state_callback_args_from_kwargs(kwargs):
|
|
input_parameters = kwargs["inputs"]
|
|
if isinstance(input_parameters, DashDependency):
|
|
input_parameters = [input_parameters]
|
|
|
|
state_parameters = kwargs.get("state", None)
|
|
if isinstance(state_parameters, DashDependency):
|
|
state_parameters = [state_parameters]
|
|
|
|
if isinstance(input_parameters, dict):
|
|
# Wrapped function will be called with named keyword arguments
|
|
if state_parameters:
|
|
if not isinstance(state_parameters, dict):
|
|
raise ValueError(
|
|
"The input argument to app.callback was a dict, "
|
|
"but the state argument was not.\n"
|
|
"input and state arguments must have the same type"
|
|
)
|
|
|
|
# Merge into state dependencies
|
|
parameters = state_parameters
|
|
parameters.update(input_parameters)
|
|
else:
|
|
parameters = input_parameters
|
|
|
|
return parameters
|
|
|
|
if isinstance(input_parameters, (list, tuple)):
|
|
# Wrapped function will be called with positional arguments
|
|
parameters = list(input_parameters)
|
|
if state_parameters:
|
|
if not isinstance(state_parameters, (list, tuple)):
|
|
raise ValueError(
|
|
"The input argument to app.callback was a list, "
|
|
"but the state argument was not.\n"
|
|
"input and state arguments must have the same type"
|
|
)
|
|
|
|
parameters += list(state_parameters)
|
|
|
|
return parameters
|
|
|
|
raise ValueError(
|
|
"The input argument to app.callback may be a dict, list, or tuple,\n"
|
|
f"but received value of type {type(input_parameters)}"
|
|
)
|
|
|
|
|
|
def extract_grouped_input_state_callback_args_from_args(args):
|
|
# Collect input and state from args
|
|
parameters = []
|
|
while args:
|
|
next_deps = flatten_grouping(args[0])
|
|
if all(isinstance(d, (Input, State)) for d in next_deps):
|
|
parameters.append(args.pop(0))
|
|
else:
|
|
break
|
|
|
|
if len(parameters) == 1:
|
|
# Only one output grouping, return as-is
|
|
return parameters[0]
|
|
|
|
# Multiple output groupings, return wrap in tuple
|
|
return parameters
|
|
|
|
|
|
def extract_grouped_input_state_callback_args(args, kwargs):
|
|
if "inputs" in kwargs:
|
|
return extract_grouped_input_state_callback_args_from_kwargs(kwargs)
|
|
|
|
if "state" in kwargs:
|
|
# Not valid to provide state as kwarg without input as kwarg
|
|
raise ValueError(
|
|
"The state keyword argument may not be provided without "
|
|
"the input keyword argument"
|
|
)
|
|
|
|
return extract_grouped_input_state_callback_args_from_args(args)
|
|
|
|
|
|
def compute_input_state_grouping_indices(input_state_grouping):
|
|
# Flatten grouping of Input and State dependencies into a flat list
|
|
flat_deps = flatten_grouping(input_state_grouping)
|
|
|
|
# Split into separate flat lists of Input and State dependencies
|
|
flat_inputs = [dep for dep in flat_deps if isinstance(dep, Input)]
|
|
flat_state = [dep for dep in flat_deps if isinstance(dep, State)]
|
|
|
|
# For each entry in the grouping, compute the index into the
|
|
# concatenation of flat_inputs and flat_state
|
|
total_inputs = len(flat_inputs)
|
|
input_count = 0
|
|
state_count = 0
|
|
flat_inds = []
|
|
for dep in flat_deps:
|
|
if isinstance(dep, Input):
|
|
flat_inds.append(input_count)
|
|
input_count += 1
|
|
else:
|
|
flat_inds.append(total_inputs + state_count)
|
|
state_count += 1
|
|
|
|
# Reshape this flat list of indices to match the input grouping
|
|
grouping_inds = make_grouping_by_index(input_state_grouping, flat_inds)
|
|
return flat_inputs, flat_state, grouping_inds
|
|
|
|
|
|
def handle_grouped_callback_args(args, kwargs):
|
|
"""Split args into outputs, inputs and states"""
|
|
prevent_initial_call = kwargs.get("prevent_initial_call", None)
|
|
if prevent_initial_call is None and args and isinstance(args[-1], bool):
|
|
args, prevent_initial_call = args[:-1], args[-1]
|
|
|
|
# flatten args, to support the older syntax where outputs, inputs, and states
|
|
# each needed to be in their own list
|
|
flat_args = []
|
|
for arg in args:
|
|
flat_args += arg if isinstance(arg, (list, tuple)) else [arg]
|
|
|
|
outputs = extract_grouped_output_callback_args(flat_args, kwargs)
|
|
flat_outputs = flatten_grouping(outputs)
|
|
|
|
if isinstance(outputs, (list, tuple)) and len(outputs) == 1:
|
|
out0 = kwargs.get("output", args[0] if args else None)
|
|
if not isinstance(out0, (list, tuple)):
|
|
# unless it was explicitly provided as a list, a single output
|
|
# should be unwrapped. That ensures the return value of the
|
|
# callback is also not expected to be wrapped in a list.
|
|
outputs = outputs[0]
|
|
|
|
inputs_state = extract_grouped_input_state_callback_args(flat_args, kwargs)
|
|
flat_inputs, flat_state, input_state_indices = compute_input_state_grouping_indices(
|
|
inputs_state
|
|
)
|
|
|
|
types = Input, Output, State
|
|
validate_callback(flat_outputs, flat_inputs, flat_state, flat_args, types)
|
|
|
|
return outputs, flat_inputs, flat_state, input_state_indices, prevent_initial_call
|
|
|
|
|
|
def extract_callback_args(args, kwargs, name, type_):
|
|
"""Extract arguments for callback from a name and type"""
|
|
parameters = kwargs.get(name, [])
|
|
if parameters:
|
|
if not isinstance(parameters, (list, tuple)):
|
|
# accept a single item, not wrapped in a list, for any of the
|
|
# categories as a named arg (even though previously only output
|
|
# could be given unwrapped)
|
|
return [parameters]
|
|
else:
|
|
while args and isinstance(args[0], type_):
|
|
parameters.append(args.pop(0))
|
|
return parameters
|
|
|
|
|
|
def handle_callback_args(args, kwargs):
|
|
"""Split args into outputs, inputs and states"""
|
|
prevent_initial_call = kwargs.get("prevent_initial_call", None)
|
|
if prevent_initial_call is None and args and isinstance(args[-1], bool):
|
|
args, prevent_initial_call = args[:-1], args[-1]
|
|
|
|
# flatten args, to support the older syntax where outputs, inputs, and states
|
|
# each needed to be in their own list
|
|
flat_args = []
|
|
for arg in args:
|
|
flat_args += arg if isinstance(arg, (list, tuple)) else [arg]
|
|
|
|
outputs = extract_callback_args(flat_args, kwargs, "output", Output)
|
|
validate_outputs = outputs
|
|
if len(outputs) == 1:
|
|
out0 = kwargs.get("output", args[0] if args else None)
|
|
if not isinstance(out0, (list, tuple)):
|
|
# unless it was explicitly provided as a list, a single output
|
|
# should be unwrapped. That ensures the return value of the
|
|
# callback is also not expected to be wrapped in a list.
|
|
outputs = outputs[0]
|
|
|
|
inputs = extract_callback_args(flat_args, kwargs, "inputs", Input)
|
|
states = extract_callback_args(flat_args, kwargs, "state", State)
|
|
|
|
types = Input, Output, State
|
|
validate_callback(validate_outputs, inputs, states, flat_args, types)
|
|
|
|
return outputs, inputs, states, prevent_initial_call
|