553 lines
19 KiB
Python
553 lines
19 KiB
Python
from collections.abc import MutableSequence
|
||
import re
|
||
from textwrap import dedent
|
||
from keyword import iskeyword
|
||
import flask
|
||
|
||
from ._grouping import grouping_len, map_grouping
|
||
from .development.base_component import Component
|
||
from . import exceptions
|
||
from ._utils import (
|
||
patch_collections_abc,
|
||
stringify_id,
|
||
to_json,
|
||
coerce_to_list,
|
||
clean_property_name,
|
||
)
|
||
|
||
|
||
def validate_callback(outputs, inputs, state, extra_args, types):
|
||
Input, Output, State = types
|
||
if extra_args:
|
||
if not isinstance(extra_args[0], (Output, Input, State)):
|
||
raise exceptions.IncorrectTypeException(
|
||
dedent(
|
||
f"""
|
||
Callback arguments must be `Output`, `Input`, or `State` objects,
|
||
optionally wrapped in a list or tuple. We found (possibly after
|
||
unwrapping a list or tuple):
|
||
{repr(extra_args[0])}
|
||
"""
|
||
)
|
||
)
|
||
|
||
raise exceptions.IncorrectTypeException(
|
||
dedent(
|
||
f"""
|
||
In a callback definition, you must provide all Outputs first,
|
||
then all Inputs, then all States. After this item:
|
||
{(outputs + inputs + state)[-1]!r}
|
||
we found this item next:
|
||
{extra_args[0]!r}
|
||
"""
|
||
)
|
||
)
|
||
|
||
for args in [outputs, inputs, state]:
|
||
for arg in args:
|
||
validate_callback_arg(arg)
|
||
|
||
|
||
def validate_callback_arg(arg):
|
||
if not isinstance(getattr(arg, "component_property", None), str):
|
||
raise exceptions.IncorrectTypeException(
|
||
dedent(
|
||
f"""
|
||
component_property must be a string, found {arg.component_property!r}
|
||
"""
|
||
)
|
||
)
|
||
|
||
if hasattr(arg, "component_event"):
|
||
raise exceptions.NonExistentEventException(
|
||
"""
|
||
Events have been removed.
|
||
Use the associated property instead.
|
||
"""
|
||
)
|
||
|
||
if isinstance(arg.component_id, dict):
|
||
validate_id_dict(arg)
|
||
|
||
elif isinstance(arg.component_id, str):
|
||
validate_id_string(arg)
|
||
|
||
else:
|
||
raise exceptions.IncorrectTypeException(
|
||
dedent(
|
||
f"""
|
||
component_id must be a string or dict, found {arg.component_id!r}
|
||
"""
|
||
)
|
||
)
|
||
|
||
|
||
def validate_id_dict(arg):
|
||
arg_id = arg.component_id
|
||
|
||
for k in arg_id:
|
||
# Need to keep key type validation on the Python side, since
|
||
# non-string keys will be converted to strings in json.dumps and may
|
||
# cause unwanted collisions
|
||
if not isinstance(k, str):
|
||
raise exceptions.IncorrectTypeException(
|
||
dedent(
|
||
f"""
|
||
Wildcard ID keys must be non-empty strings,
|
||
found {k!r} in id {arg_id!r}
|
||
"""
|
||
)
|
||
)
|
||
|
||
|
||
def validate_id_string(arg):
|
||
arg_id = arg.component_id
|
||
|
||
invalid_chars = ".{"
|
||
invalid_found = [x for x in invalid_chars if x in arg_id]
|
||
if invalid_found:
|
||
raise exceptions.InvalidComponentIdError(
|
||
f"""
|
||
The element `{arg_id}` contains `{"`, `".join(invalid_found)}` in its ID.
|
||
Characters `{"`, `".join(invalid_chars)}` are not allowed in IDs.
|
||
"""
|
||
)
|
||
|
||
|
||
def validate_output_spec(output, output_spec, Output):
|
||
"""
|
||
This validation is for security and internal debugging, not for users,
|
||
so the messages are not intended to be clear.
|
||
`output` comes from the callback definition, `output_spec` from the request.
|
||
"""
|
||
if not isinstance(output, (list, tuple)):
|
||
output, output_spec = [output], [output_spec]
|
||
elif len(output) != len(output_spec):
|
||
raise exceptions.CallbackException("Wrong length output_spec")
|
||
|
||
for outi, speci in zip(output, output_spec):
|
||
speci_list = speci if isinstance(speci, (list, tuple)) else [speci]
|
||
for specij in speci_list:
|
||
if (
|
||
not Output(specij["id"], clean_property_name(specij["property"]))
|
||
== outi
|
||
):
|
||
raise exceptions.CallbackException(
|
||
"Output does not match callback definition"
|
||
)
|
||
|
||
|
||
def validate_and_group_input_args(flat_args, arg_index_grouping):
|
||
if grouping_len(arg_index_grouping) != len(flat_args):
|
||
raise exceptions.CallbackException("Inputs do not match callback definition")
|
||
|
||
args_grouping = map_grouping(lambda ind: flat_args[ind], arg_index_grouping)
|
||
if isinstance(arg_index_grouping, dict):
|
||
func_args = []
|
||
func_kwargs = args_grouping
|
||
for key in func_kwargs:
|
||
if not key.isidentifier():
|
||
raise exceptions.CallbackException(
|
||
f"{key} is not a valid Python variable name"
|
||
)
|
||
elif isinstance(arg_index_grouping, (tuple, list)):
|
||
func_args = list(args_grouping)
|
||
func_kwargs = {}
|
||
else:
|
||
# Scalar input
|
||
func_args = [args_grouping]
|
||
func_kwargs = {}
|
||
|
||
return func_args, func_kwargs
|
||
|
||
|
||
def validate_multi_return(output_lists, output_values, callback_id):
|
||
if not isinstance(output_values, (list, tuple)):
|
||
raise exceptions.InvalidCallbackReturnValue(
|
||
dedent(
|
||
f"""
|
||
The callback {callback_id} is a multi-output.
|
||
Expected the output type to be a list or tuple but got:
|
||
{output_values!r}.
|
||
"""
|
||
)
|
||
)
|
||
|
||
if len(output_values) != len(output_lists):
|
||
raise exceptions.InvalidCallbackReturnValue(
|
||
f"""
|
||
Invalid number of output values for {callback_id}.
|
||
Expected {len(output_lists)}, got {len(output_values)}
|
||
"""
|
||
)
|
||
|
||
for i, output_spec in enumerate(output_lists):
|
||
if isinstance(output_spec, list):
|
||
output_value = output_values[i]
|
||
if not isinstance(output_value, (list, tuple)):
|
||
raise exceptions.InvalidCallbackReturnValue(
|
||
dedent(
|
||
f"""
|
||
The callback {callback_id} output {i} is a wildcard multi-output.
|
||
Expected the output type to be a list or tuple but got:
|
||
{output_value!r}.
|
||
output spec: {output_spec!r}
|
||
"""
|
||
)
|
||
)
|
||
|
||
if len(output_value) != len(output_spec):
|
||
raise exceptions.InvalidCallbackReturnValue(
|
||
dedent(
|
||
f"""
|
||
Invalid number of output values for {callback_id} item {i}.
|
||
Expected {len(output_spec)}, got {len(output_value)}
|
||
output spec: {output_spec!r}
|
||
output value: {output_value!r}
|
||
"""
|
||
)
|
||
)
|
||
|
||
|
||
def fail_callback_output(output_value, output):
|
||
valid_children = (str, int, float, type(None), Component)
|
||
valid_props = (str, int, float, type(None), tuple, MutableSequence)
|
||
|
||
def _raise_invalid(bad_val, outer_val, path, index=None, toplevel=False):
|
||
bad_type = type(bad_val).__name__
|
||
outer_id = f"(id={outer_val.id:s})" if getattr(outer_val, "id", False) else ""
|
||
outer_type = type(outer_val).__name__
|
||
if toplevel:
|
||
location = dedent(
|
||
"""
|
||
The value in question is either the only value returned,
|
||
or is in the top level of the returned list,
|
||
"""
|
||
)
|
||
else:
|
||
index_string = "[*]" if index is None else f"[{index:d}]"
|
||
location = dedent(
|
||
f"""
|
||
The value in question is located at
|
||
{index_string} {outer_type} {outer_id}
|
||
{path},
|
||
"""
|
||
)
|
||
|
||
obj = "tree with one value" if not toplevel else "value"
|
||
raise exceptions.InvalidCallbackReturnValue(
|
||
dedent(
|
||
f"""
|
||
The callback for `{output!r}`
|
||
returned a {obj:s} having type `{bad_type}`
|
||
which is not JSON serializable.
|
||
|
||
{location}
|
||
and has string representation
|
||
`{bad_val}`
|
||
|
||
In general, Dash properties can only be
|
||
dash components, strings, dictionaries, numbers, None,
|
||
or lists of those.
|
||
"""
|
||
)
|
||
)
|
||
|
||
def _valid_child(val):
|
||
return isinstance(val, valid_children)
|
||
|
||
def _valid_prop(val):
|
||
return isinstance(val, valid_props)
|
||
|
||
def _can_serialize(val):
|
||
if not (_valid_child(val) or _valid_prop(val)):
|
||
return False
|
||
try:
|
||
to_json(val)
|
||
except TypeError:
|
||
return False
|
||
return True
|
||
|
||
def _validate_value(val, index=None):
|
||
# val is a Component
|
||
if isinstance(val, Component):
|
||
unserializable_items = []
|
||
# pylint: disable=protected-access
|
||
for p, j in val._traverse_with_paths():
|
||
# check each component value in the tree
|
||
if not _valid_child(j):
|
||
_raise_invalid(bad_val=j, outer_val=val, path=p, index=index)
|
||
|
||
if not _can_serialize(j):
|
||
# collect unserializable items separately, so we can report
|
||
# only the deepest level, not all the parent components that
|
||
# are just unserializable because of their children.
|
||
unserializable_items = [
|
||
i for i in unserializable_items if not p.startswith(i[0])
|
||
]
|
||
if unserializable_items:
|
||
# we already have something unserializable in a different
|
||
# branch - time to stop and fail
|
||
break
|
||
if all(not i[0].startswith(p) for i in unserializable_items):
|
||
unserializable_items.append((p, j))
|
||
|
||
# Children that are not of type Component or
|
||
# list/tuple not returned by traverse
|
||
child = getattr(j, "children", None)
|
||
if not isinstance(child, (tuple, MutableSequence)):
|
||
if child and not _can_serialize(child):
|
||
_raise_invalid(
|
||
bad_val=child,
|
||
outer_val=val,
|
||
path=p + "\n" + "[*] " + type(child).__name__,
|
||
index=index,
|
||
)
|
||
if unserializable_items:
|
||
p, j = unserializable_items[0]
|
||
# just report the first one, even if there are multiple,
|
||
# as that's how all the other errors work
|
||
_raise_invalid(bad_val=j, outer_val=val, path=p, index=index)
|
||
|
||
# Also check the child of val, as it will not be returned
|
||
child = getattr(val, "children", None)
|
||
if not isinstance(child, (tuple, MutableSequence)):
|
||
if child and not _can_serialize(val):
|
||
_raise_invalid(
|
||
bad_val=child,
|
||
outer_val=val,
|
||
path=type(child).__name__,
|
||
index=index,
|
||
)
|
||
|
||
if not _can_serialize(val):
|
||
_raise_invalid(
|
||
bad_val=val,
|
||
outer_val=type(val).__name__,
|
||
path="",
|
||
index=index,
|
||
toplevel=True,
|
||
)
|
||
|
||
if isinstance(output_value, list):
|
||
for i, val in enumerate(output_value):
|
||
_validate_value(val, index=i)
|
||
else:
|
||
_validate_value(output_value)
|
||
|
||
# if we got this far, raise a generic JSON error
|
||
raise exceptions.InvalidCallbackReturnValue(
|
||
f"""
|
||
The callback for output `{output!r}`
|
||
returned a value which is not JSON serializable.
|
||
|
||
In general, Dash properties can only be dash components, strings,
|
||
dictionaries, numbers, None, or lists of those.
|
||
"""
|
||
)
|
||
|
||
|
||
def check_obsolete(kwargs):
|
||
for key in kwargs:
|
||
if key in ["components_cache_max_age", "static_folder"]:
|
||
raise exceptions.ObsoleteKwargException(
|
||
f"""
|
||
{key} is no longer a valid keyword argument in Dash since v1.0.
|
||
See https://dash.plotly.com for details.
|
||
"""
|
||
)
|
||
# any other kwarg mimic the built-in exception
|
||
raise TypeError(f"Dash() got an unexpected keyword argument '{key}'")
|
||
|
||
|
||
def validate_js_path(registered_paths, package_name, path_in_package_dist):
|
||
if package_name not in registered_paths:
|
||
raise exceptions.DependencyException(
|
||
f"""
|
||
Error loading dependency. "{package_name}" is not a registered library.
|
||
Registered libraries are:
|
||
{list(registered_paths.keys())}
|
||
"""
|
||
)
|
||
|
||
if path_in_package_dist not in registered_paths[package_name]:
|
||
raise exceptions.DependencyException(
|
||
f"""
|
||
"{package_name}" is registered but the path requested is not valid.
|
||
The path requested: "{path_in_package_dist}"
|
||
List of registered paths: {registered_paths}
|
||
"""
|
||
)
|
||
|
||
|
||
def validate_index(name, checks, index):
|
||
missing = [i for check, i in checks if not re.compile(check).search(index)]
|
||
if missing:
|
||
plural = "s" if len(missing) > 1 else ""
|
||
raise exceptions.InvalidIndexException(
|
||
f"Missing item{plural} {', '.join(missing)} in {name}."
|
||
)
|
||
|
||
|
||
def validate_layout_type(value):
|
||
if not isinstance(value, (Component, patch_collections_abc("Callable"))):
|
||
raise exceptions.NoLayoutException(
|
||
"Layout must be a dash component "
|
||
"or a function that returns a dash component."
|
||
)
|
||
|
||
|
||
def validate_layout(layout, layout_value):
|
||
if layout is None:
|
||
raise exceptions.NoLayoutException(
|
||
"""
|
||
The layout was `None` at the time that `run_server` was called.
|
||
Make sure to set the `layout` attribute of your application
|
||
before running the server.
|
||
"""
|
||
)
|
||
|
||
layout_id = stringify_id(getattr(layout_value, "id", None))
|
||
|
||
component_ids = {layout_id} if layout_id else set()
|
||
for component in layout_value._traverse(): # pylint: disable=protected-access
|
||
component_id = stringify_id(getattr(component, "id", None))
|
||
if component_id and component_id in component_ids:
|
||
raise exceptions.DuplicateIdError(
|
||
f"""
|
||
Duplicate component id found in the initial layout: `{component_id}`
|
||
"""
|
||
)
|
||
component_ids.add(component_id)
|
||
|
||
|
||
def validate_template(template):
|
||
variable_names = re.findall("<(.*?)>", template)
|
||
|
||
for name in variable_names:
|
||
if not name.isidentifier() or iskeyword(name):
|
||
raise Exception(
|
||
f'`{name}` is not a valid Python variable name in `path_template`: "{template}".'
|
||
)
|
||
|
||
|
||
def check_for_duplicate_pathnames(registry):
|
||
path_to_module = {}
|
||
for page in registry.values():
|
||
if page["path"] not in path_to_module:
|
||
path_to_module[page["path"]] = [page["module"]]
|
||
else:
|
||
path_to_module[page["path"]].append(page["module"])
|
||
|
||
for modules in path_to_module.values():
|
||
if len(modules) > 1:
|
||
raise Exception(f"modules {modules} have duplicate paths")
|
||
|
||
|
||
def validate_registry(registry):
|
||
for page in registry.values():
|
||
if "layout" not in page:
|
||
raise exceptions.NoLayoutException(
|
||
f"No layout in module `{page['module']}` in dash.page_registry"
|
||
)
|
||
if page["module"] == "__main__":
|
||
raise Exception(
|
||
"""
|
||
When registering pages from app.py, `__name__` is not a valid module name. Use a string instead.
|
||
For example, `dash.register_page("my_module_name")`, rather than `dash.register_page(__name__)`
|
||
"""
|
||
)
|
||
|
||
|
||
def validate_pages_layout(module, page):
|
||
if not hasattr(page, "layout"):
|
||
raise exceptions.NoLayoutException(
|
||
f"""
|
||
No layout found in module {module}
|
||
A variable or a function named "layout" is required.
|
||
"""
|
||
)
|
||
|
||
|
||
def validate_use_pages(config):
|
||
if not config.get("assets_folder", None):
|
||
raise exceptions.PageError(
|
||
"`dash.register_page()` must be called after app instantiation"
|
||
)
|
||
|
||
if flask.has_request_context():
|
||
raise exceptions.PageError(
|
||
"""
|
||
dash.register_page() can’t be called within a callback as it updates dash.page_registry, which is a global variable.
|
||
For more details, see https://dash.plotly.com/sharing-data-between-callbacks#why-global-variables-will-break-your-app
|
||
"""
|
||
)
|
||
|
||
|
||
def validate_module_name(module):
|
||
if not isinstance(module, str):
|
||
raise exceptions.PageError(
|
||
"The first attribute of dash.register_page() must be a string or '__name__'"
|
||
)
|
||
return module
|
||
|
||
|
||
def validate_long_callbacks(callback_map):
|
||
# Validate that long callback side output & inputs are not circular
|
||
# If circular, triggering a long callback would result in a fatal server/computer crash.
|
||
all_outputs = set()
|
||
input_indexed = {}
|
||
for callback in callback_map.values():
|
||
out = coerce_to_list(callback["output"])
|
||
all_outputs.update(out)
|
||
for o in out:
|
||
input_indexed.setdefault(o, set())
|
||
input_indexed[o].update(coerce_to_list(callback["raw_inputs"]))
|
||
|
||
for callback in (x for x in callback_map.values() if x.get("long")):
|
||
long_info = callback["long"]
|
||
progress = long_info.get("progress", [])
|
||
running = long_info.get("running", [])
|
||
|
||
long_inputs = coerce_to_list(callback["raw_inputs"])
|
||
outputs = set([x[0] for x in running] + progress)
|
||
circular = [
|
||
x
|
||
for x in set(k for k, v in input_indexed.items() if v.intersection(outputs))
|
||
if x in long_inputs
|
||
]
|
||
|
||
if circular:
|
||
raise exceptions.LongCallbackError(
|
||
f"Long callback circular error!\n{circular} is used as input for a long callback"
|
||
f" but also used as output from an input that is updated with progress or running argument."
|
||
)
|
||
|
||
|
||
def validate_duplicate_output(
|
||
output, prevent_initial_call, config_prevent_initial_call
|
||
):
|
||
if "initial_duplicate" in (prevent_initial_call, config_prevent_initial_call):
|
||
return
|
||
|
||
def _valid(out):
|
||
if (
|
||
out.allow_duplicate
|
||
and not prevent_initial_call
|
||
and not config_prevent_initial_call
|
||
):
|
||
raise exceptions.DuplicateCallback(
|
||
"allow_duplicate requires prevent_initial_call to be True. The order of the call is not"
|
||
" guaranteed to be the same on every page load. "
|
||
"To enable duplicate callback with initial call, set prevent_initial_call='initial_duplicate' "
|
||
" or globally in the config prevent_initial_callbacks='initial_duplicate'"
|
||
)
|
||
|
||
if isinstance(output, (list, tuple)):
|
||
for o in output:
|
||
_valid(o)
|
||
|
||
return
|
||
|
||
_valid(output)
|