2780 lines
85 KiB
Python
2780 lines
85 KiB
Python
import base64
|
|
import numbers
|
|
import textwrap
|
|
import uuid
|
|
from importlib import import_module
|
|
import copy
|
|
import io
|
|
import re
|
|
import sys
|
|
import warnings
|
|
|
|
from _plotly_utils.optional_imports import get_module
|
|
|
|
|
|
# back-port of fullmatch from Py3.4+
|
|
def fullmatch(regex, string, flags=0):
|
|
"""Emulate python-3.4 re.fullmatch()."""
|
|
if "pattern" in dir(regex):
|
|
regex_string = regex.pattern
|
|
else:
|
|
regex_string = regex
|
|
return re.match("(?:" + regex_string + r")\Z", string, flags=flags)
|
|
|
|
|
|
# Utility functions
|
|
# -----------------
|
|
def to_scalar_or_list(v):
|
|
# Handle the case where 'v' is a non-native scalar-like type,
|
|
# such as numpy.float32. Without this case, the object might be
|
|
# considered numpy-convertable and therefore promoted to a
|
|
# 0-dimensional array, but we instead want it converted to a
|
|
# Python native scalar type ('float' in the example above).
|
|
# We explicitly check if is has the 'item' method, which conventionally
|
|
# converts these types to native scalars.
|
|
np = get_module("numpy", should_load=False)
|
|
pd = get_module("pandas", should_load=False)
|
|
if np and np.isscalar(v) and hasattr(v, "item"):
|
|
return v.item()
|
|
if isinstance(v, (list, tuple)):
|
|
return [to_scalar_or_list(e) for e in v]
|
|
elif np and isinstance(v, np.ndarray):
|
|
if v.ndim == 0:
|
|
return v.item()
|
|
return [to_scalar_or_list(e) for e in v]
|
|
elif pd and isinstance(v, (pd.Series, pd.Index)):
|
|
return [to_scalar_or_list(e) for e in v]
|
|
elif is_numpy_convertable(v):
|
|
return to_scalar_or_list(np.array(v))
|
|
else:
|
|
return v
|
|
|
|
|
|
def copy_to_readonly_numpy_array(v, kind=None, force_numeric=False):
|
|
"""
|
|
Convert an array-like value into a read-only numpy array
|
|
|
|
Parameters
|
|
----------
|
|
v : array like
|
|
Array like value (list, tuple, numpy array, pandas series, etc.)
|
|
kind : str or tuple of str
|
|
If specified, the numpy dtype kind (or kinds) that the array should
|
|
have, or be converted to if possible.
|
|
If not specified then let numpy infer the datatype
|
|
force_numeric : bool
|
|
If true, raise an exception if the resulting numpy array does not
|
|
have a numeric dtype (i.e. dtype.kind not in ['u', 'i', 'f'])
|
|
Returns
|
|
-------
|
|
np.ndarray
|
|
Numpy array with the 'WRITEABLE' flag set to False
|
|
"""
|
|
np = get_module("numpy")
|
|
|
|
# Don't force pandas to be loaded, we only want to know if it's already loaded
|
|
pd = get_module("pandas", should_load=False)
|
|
assert np is not None
|
|
|
|
# ### Process kind ###
|
|
if not kind:
|
|
kind = ()
|
|
elif isinstance(kind, str):
|
|
kind = (kind,)
|
|
|
|
first_kind = kind[0] if kind else None
|
|
|
|
# u: unsigned int, i: signed int, f: float
|
|
numeric_kinds = {"u", "i", "f"}
|
|
kind_default_dtypes = {
|
|
"u": "uint32",
|
|
"i": "int32",
|
|
"f": "float64",
|
|
"O": "object",
|
|
}
|
|
|
|
# Handle pandas Series and Index objects
|
|
if pd and isinstance(v, (pd.Series, pd.Index)):
|
|
if v.dtype.kind in numeric_kinds:
|
|
# Get the numeric numpy array so we use fast path below
|
|
v = v.values
|
|
elif v.dtype.kind == "M":
|
|
# Convert datetime Series/Index to numpy array of datetimes
|
|
if isinstance(v, pd.Series):
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore", FutureWarning)
|
|
# Series.dt.to_pydatetime will return Index[object]
|
|
# https://github.com/pandas-dev/pandas/pull/52459
|
|
v = np.array(v.dt.to_pydatetime())
|
|
else:
|
|
# DatetimeIndex
|
|
v = v.to_pydatetime()
|
|
elif pd and isinstance(v, pd.DataFrame) and len(set(v.dtypes)) == 1:
|
|
dtype = v.dtypes.tolist()[0]
|
|
if dtype.kind in numeric_kinds:
|
|
v = v.values
|
|
elif dtype.kind == "M":
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore", FutureWarning)
|
|
# Series.dt.to_pydatetime will return Index[object]
|
|
# https://github.com/pandas-dev/pandas/pull/52459
|
|
v = [
|
|
np.array(row.dt.to_pydatetime()).tolist() for i, row in v.iterrows()
|
|
]
|
|
|
|
if not isinstance(v, np.ndarray):
|
|
# v has its own logic on how to convert itself into a numpy array
|
|
if is_numpy_convertable(v):
|
|
return copy_to_readonly_numpy_array(
|
|
np.array(v), kind=kind, force_numeric=force_numeric
|
|
)
|
|
else:
|
|
# v is not homogenous array
|
|
v_list = [to_scalar_or_list(e) for e in v]
|
|
|
|
# Lookup dtype for requested kind, if any
|
|
dtype = kind_default_dtypes.get(first_kind, None)
|
|
|
|
# construct new array from list
|
|
new_v = np.array(v_list, order="C", dtype=dtype)
|
|
elif v.dtype.kind in numeric_kinds:
|
|
# v is a homogenous numeric array
|
|
if kind and v.dtype.kind not in kind:
|
|
# Kind(s) were specified and this array doesn't match
|
|
# Convert to the default dtype for the first kind
|
|
dtype = kind_default_dtypes.get(first_kind, None)
|
|
new_v = np.ascontiguousarray(v.astype(dtype))
|
|
else:
|
|
# Either no kind was requested or requested kind is satisfied
|
|
new_v = np.ascontiguousarray(v.copy())
|
|
else:
|
|
# v is a non-numeric homogenous array
|
|
new_v = v.copy()
|
|
|
|
# Handle force numeric param
|
|
# --------------------------
|
|
if force_numeric and new_v.dtype.kind not in numeric_kinds:
|
|
raise ValueError(
|
|
"Input value is not numeric and force_numeric parameter set to True"
|
|
)
|
|
|
|
if "U" not in kind:
|
|
# Force non-numeric arrays to have object type
|
|
# --------------------------------------------
|
|
# Here we make sure that non-numeric arrays have the object
|
|
# datatype. This works around cases like np.array([1, 2, '3']) where
|
|
# numpy converts the integers to strings and returns array of dtype
|
|
# '<U21'
|
|
if new_v.dtype.kind not in ["u", "i", "f", "O", "M"]:
|
|
new_v = np.array(v, dtype="object")
|
|
|
|
# Set new array to be read-only
|
|
# -----------------------------
|
|
new_v.flags["WRITEABLE"] = False
|
|
|
|
return new_v
|
|
|
|
|
|
def is_numpy_convertable(v):
|
|
"""
|
|
Return whether a value is meaningfully convertable to a numpy array
|
|
via 'numpy.array'
|
|
"""
|
|
return hasattr(v, "__array__") or hasattr(v, "__array_interface__")
|
|
|
|
|
|
def is_homogeneous_array(v):
|
|
"""
|
|
Return whether a value is considered to be a homogeneous array
|
|
"""
|
|
np = get_module("numpy", should_load=False)
|
|
pd = get_module("pandas", should_load=False)
|
|
if (
|
|
np
|
|
and isinstance(v, np.ndarray)
|
|
or (pd and isinstance(v, (pd.Series, pd.Index)))
|
|
):
|
|
return True
|
|
if is_numpy_convertable(v):
|
|
np = get_module("numpy", should_load=True)
|
|
if np:
|
|
v_numpy = np.array(v)
|
|
# v is essentially a scalar and so shouldn't count as an array
|
|
if v_numpy.shape == ():
|
|
return False
|
|
else:
|
|
return True # v_numpy.dtype.kind in ["u", "i", "f", "M", "U"]
|
|
return False
|
|
|
|
|
|
def is_simple_array(v):
|
|
"""
|
|
Return whether a value is considered to be an simple array
|
|
"""
|
|
return isinstance(v, (list, tuple))
|
|
|
|
|
|
def is_array(v):
|
|
"""
|
|
Return whether a value is considered to be an array
|
|
"""
|
|
return is_simple_array(v) or is_homogeneous_array(v)
|
|
|
|
|
|
def type_str(v):
|
|
"""
|
|
Return a type string of the form module.name for the input value v
|
|
"""
|
|
if not isinstance(v, type):
|
|
v = type(v)
|
|
|
|
return "'{module}.{name}'".format(module=v.__module__, name=v.__name__)
|
|
|
|
|
|
# Validators
|
|
# ----------
|
|
class BaseValidator(object):
|
|
"""
|
|
Base class for all validator classes
|
|
"""
|
|
|
|
def __init__(self, plotly_name, parent_name, role=None, **_):
|
|
"""
|
|
Construct a validator instance
|
|
|
|
Parameters
|
|
----------
|
|
plotly_name : str
|
|
Name of the property being validated
|
|
parent_name : str
|
|
Names of all of the ancestors of this property joined on '.'
|
|
characters. e.g.
|
|
plotly_name == 'range' and parent_name == 'layout.xaxis'
|
|
role : str
|
|
The role string for the property as specified in
|
|
plot-schema.json
|
|
"""
|
|
self.parent_name = parent_name
|
|
self.plotly_name = plotly_name
|
|
self.role = role
|
|
self.array_ok = False
|
|
|
|
def description(self):
|
|
"""
|
|
Returns a string that describes the values that are acceptable
|
|
to the validator
|
|
|
|
Should start with:
|
|
The '{plotly_name}' property is a...
|
|
|
|
For consistancy, string should have leading 4-space indent
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
def raise_invalid_val(self, v, inds=None):
|
|
"""
|
|
Helper method to raise an informative exception when an invalid
|
|
value is passed to the validate_coerce method.
|
|
|
|
Parameters
|
|
----------
|
|
v :
|
|
Value that was input to validate_coerce and could not be coerced
|
|
inds: list of int or None (default)
|
|
Indexes to display after property name. e.g. if self.plotly_name
|
|
is 'prop' and inds=[2, 1] then the name in the validation error
|
|
message will be 'prop[2][1]`
|
|
Raises
|
|
-------
|
|
ValueError
|
|
"""
|
|
name = self.plotly_name
|
|
if inds:
|
|
for i in inds:
|
|
name += "[" + str(i) + "]"
|
|
|
|
raise ValueError(
|
|
"""
|
|
Invalid value of type {typ} received for the '{name}' property of {pname}
|
|
Received value: {v}
|
|
|
|
{valid_clr_desc}""".format(
|
|
name=name,
|
|
pname=self.parent_name,
|
|
typ=type_str(v),
|
|
v=repr(v),
|
|
valid_clr_desc=self.description(),
|
|
)
|
|
)
|
|
|
|
def raise_invalid_elements(self, invalid_els):
|
|
if invalid_els:
|
|
raise ValueError(
|
|
"""
|
|
Invalid element(s) received for the '{name}' property of {pname}
|
|
Invalid elements include: {invalid}
|
|
|
|
{valid_clr_desc}""".format(
|
|
name=self.plotly_name,
|
|
pname=self.parent_name,
|
|
invalid=invalid_els[:10],
|
|
valid_clr_desc=self.description(),
|
|
)
|
|
)
|
|
|
|
def validate_coerce(self, v):
|
|
"""
|
|
Validate whether an input value is compatible with this property,
|
|
and coerce the value to be compatible of possible.
|
|
|
|
Parameters
|
|
----------
|
|
v
|
|
The input value to be validated
|
|
|
|
Raises
|
|
------
|
|
ValueError
|
|
if `v` cannot be coerced into a compatible form
|
|
|
|
Returns
|
|
-------
|
|
The input `v` in a form that's compatible with this property
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
def present(self, v):
|
|
"""
|
|
Convert output value of a previous call to `validate_coerce` into a
|
|
form suitable to be returned to the user on upon property
|
|
access.
|
|
|
|
Note: The value returned by present must be either immutable or an
|
|
instance of BasePlotlyType, otherwise the value could be mutated by
|
|
the user and we wouldn't get notified about the change.
|
|
|
|
Parameters
|
|
----------
|
|
v
|
|
A value that was the ouput of a previous call the
|
|
`validate_coerce` method on the same object
|
|
|
|
Returns
|
|
-------
|
|
|
|
"""
|
|
if is_homogeneous_array(v):
|
|
# Note: numpy array was already coerced into read-only form so
|
|
# we don't need to copy it here.
|
|
return v
|
|
elif is_simple_array(v):
|
|
return tuple(v)
|
|
else:
|
|
return v
|
|
|
|
|
|
class DataArrayValidator(BaseValidator):
|
|
"""
|
|
"data_array": {
|
|
"description": "An {array} of data. The value MUST be an
|
|
{array}, or we ignore it.",
|
|
"requiredOpts": [],
|
|
"otherOpts": [
|
|
"dflt"
|
|
]
|
|
},
|
|
"""
|
|
|
|
def __init__(self, plotly_name, parent_name, **kwargs):
|
|
super(DataArrayValidator, self).__init__(
|
|
plotly_name=plotly_name, parent_name=parent_name, **kwargs
|
|
)
|
|
|
|
self.array_ok = True
|
|
|
|
def description(self):
|
|
return """\
|
|
The '{plotly_name}' property is an array that may be specified as a tuple,
|
|
list, numpy array, or pandas Series""".format(
|
|
plotly_name=self.plotly_name
|
|
)
|
|
|
|
def validate_coerce(self, v):
|
|
|
|
if v is None:
|
|
# Pass None through
|
|
pass
|
|
elif is_homogeneous_array(v):
|
|
v = copy_to_readonly_numpy_array(v)
|
|
elif is_simple_array(v):
|
|
v = to_scalar_or_list(v)
|
|
else:
|
|
self.raise_invalid_val(v)
|
|
return v
|
|
|
|
|
|
class EnumeratedValidator(BaseValidator):
|
|
"""
|
|
"enumerated": {
|
|
"description": "Enumerated value type. The available values are
|
|
listed in `values`.",
|
|
"requiredOpts": [
|
|
"values"
|
|
],
|
|
"otherOpts": [
|
|
"dflt",
|
|
"coerceNumber",
|
|
"arrayOk"
|
|
]
|
|
},
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
plotly_name,
|
|
parent_name,
|
|
values,
|
|
array_ok=False,
|
|
coerce_number=False,
|
|
**kwargs,
|
|
):
|
|
super(EnumeratedValidator, self).__init__(
|
|
plotly_name=plotly_name, parent_name=parent_name, **kwargs
|
|
)
|
|
|
|
# Save params
|
|
# -----------
|
|
self.values = values
|
|
self.array_ok = array_ok
|
|
# coerce_number is rarely used and not implemented
|
|
self.coerce_number = coerce_number
|
|
self.kwargs = kwargs
|
|
|
|
# Handle regular expressions
|
|
# --------------------------
|
|
# Compiled regexs
|
|
self.val_regexs = []
|
|
|
|
# regex replacements that run before the matching regex
|
|
# So far, this is only used to cast 'x1' -> 'x' for anchor-style
|
|
# enumeration properties
|
|
self.regex_replacements = []
|
|
|
|
# Loop over enumeration values
|
|
# ----------------------------
|
|
# Look for regular expressions
|
|
for v in self.values:
|
|
if v and isinstance(v, str) and v[0] == "/" and v[-1] == "/" and len(v) > 1:
|
|
# String is a regex with leading and trailing '/' character
|
|
regex_str = v[1:-1]
|
|
self.val_regexs.append(re.compile(regex_str))
|
|
self.regex_replacements.append(
|
|
EnumeratedValidator.build_regex_replacement(regex_str)
|
|
)
|
|
else:
|
|
self.val_regexs.append(None)
|
|
self.regex_replacements.append(None)
|
|
|
|
def __deepcopy__(self, memodict={}):
|
|
"""
|
|
A custom deepcopy method is needed here because compiled regex
|
|
objects don't support deepcopy
|
|
"""
|
|
cls = self.__class__
|
|
return cls(self.plotly_name, self.parent_name, values=self.values)
|
|
|
|
@staticmethod
|
|
def build_regex_replacement(regex_str):
|
|
# Example: regex_str == r"^y([2-9]|[1-9][0-9]+)?$"
|
|
#
|
|
# When we see a regular expression like the one above, we want to
|
|
# build regular expression replacement params that will remove a
|
|
# suffix of 1 from the input string ('y1' -> 'y' in this example)
|
|
#
|
|
# Why?: Regular expressions like this one are used in enumeration
|
|
# properties that refer to subplotids (e.g. layout.annotation.xref)
|
|
# The regular expressions forbid suffixes of 1, like 'x1'. But we
|
|
# want to accept 'x1' and coerce it into 'x'
|
|
#
|
|
# To be cautious, we only perform this conversion for enumerated
|
|
# values that match the anchor-style regex
|
|
match = re.match(
|
|
r"\^(\w)\(\[2\-9\]\|\[1\-9\]\[0\-9\]\+\)\?\( domain\)\?\$", regex_str
|
|
)
|
|
|
|
if match:
|
|
anchor_char = match.group(1)
|
|
return "^" + anchor_char + "1$", anchor_char
|
|
else:
|
|
return None
|
|
|
|
def perform_replacemenet(self, v):
|
|
"""
|
|
Return v with any applicable regex replacements applied
|
|
"""
|
|
if isinstance(v, str):
|
|
for repl_args in self.regex_replacements:
|
|
if repl_args:
|
|
v = re.sub(repl_args[0], repl_args[1], v)
|
|
|
|
return v
|
|
|
|
def description(self):
|
|
|
|
# Separate regular values from regular expressions
|
|
enum_vals = []
|
|
enum_regexs = []
|
|
for v, regex in zip(self.values, self.val_regexs):
|
|
if regex is not None:
|
|
enum_regexs.append(regex.pattern)
|
|
else:
|
|
enum_vals.append(v)
|
|
desc = """\
|
|
The '{name}' property is an enumeration that may be specified as:""".format(
|
|
name=self.plotly_name
|
|
)
|
|
|
|
if enum_vals:
|
|
enum_vals_str = "\n".join(
|
|
textwrap.wrap(
|
|
repr(enum_vals),
|
|
initial_indent=" " * 12,
|
|
subsequent_indent=" " * 12,
|
|
break_on_hyphens=False,
|
|
)
|
|
)
|
|
|
|
desc = (
|
|
desc
|
|
+ """
|
|
- One of the following enumeration values:
|
|
{enum_vals_str}""".format(
|
|
enum_vals_str=enum_vals_str
|
|
)
|
|
)
|
|
|
|
if enum_regexs:
|
|
enum_regexs_str = "\n".join(
|
|
textwrap.wrap(
|
|
repr(enum_regexs),
|
|
initial_indent=" " * 12,
|
|
subsequent_indent=" " * 12,
|
|
break_on_hyphens=False,
|
|
)
|
|
)
|
|
|
|
desc = (
|
|
desc
|
|
+ """
|
|
- A string that matches one of the following regular expressions:
|
|
{enum_regexs_str}""".format(
|
|
enum_regexs_str=enum_regexs_str
|
|
)
|
|
)
|
|
|
|
if self.array_ok:
|
|
desc = (
|
|
desc
|
|
+ """
|
|
- A tuple, list, or one-dimensional numpy array of the above"""
|
|
)
|
|
|
|
return desc
|
|
|
|
def in_values(self, e):
|
|
"""
|
|
Return whether a value matches one of the enumeration options
|
|
"""
|
|
is_str = isinstance(e, str)
|
|
for v, regex in zip(self.values, self.val_regexs):
|
|
if is_str and regex:
|
|
in_values = fullmatch(regex, e) is not None
|
|
# in_values = regex.fullmatch(e) is not None
|
|
else:
|
|
in_values = e == v
|
|
|
|
if in_values:
|
|
return True
|
|
|
|
return False
|
|
|
|
def validate_coerce(self, v):
|
|
if v is None:
|
|
# Pass None through
|
|
pass
|
|
elif self.array_ok and is_array(v):
|
|
v_replaced = [self.perform_replacemenet(v_el) for v_el in v]
|
|
|
|
invalid_els = [e for e in v_replaced if (not self.in_values(e))]
|
|
if invalid_els:
|
|
self.raise_invalid_elements(invalid_els[:10])
|
|
|
|
if is_homogeneous_array(v):
|
|
v = copy_to_readonly_numpy_array(v)
|
|
else:
|
|
v = to_scalar_or_list(v)
|
|
else:
|
|
v = self.perform_replacemenet(v)
|
|
if not self.in_values(v):
|
|
self.raise_invalid_val(v)
|
|
return v
|
|
|
|
|
|
class BooleanValidator(BaseValidator):
|
|
"""
|
|
"boolean": {
|
|
"description": "A boolean (true/false) value.",
|
|
"requiredOpts": [],
|
|
"otherOpts": [
|
|
"dflt"
|
|
]
|
|
},
|
|
"""
|
|
|
|
def __init__(self, plotly_name, parent_name, **kwargs):
|
|
super(BooleanValidator, self).__init__(
|
|
plotly_name=plotly_name, parent_name=parent_name, **kwargs
|
|
)
|
|
|
|
def description(self):
|
|
return """\
|
|
The '{plotly_name}' property must be specified as a bool
|
|
(either True, or False)""".format(
|
|
plotly_name=self.plotly_name
|
|
)
|
|
|
|
def validate_coerce(self, v):
|
|
if v is None:
|
|
# Pass None through
|
|
pass
|
|
elif not isinstance(v, bool):
|
|
self.raise_invalid_val(v)
|
|
|
|
return v
|
|
|
|
|
|
class SrcValidator(BaseValidator):
|
|
def __init__(self, plotly_name, parent_name, **kwargs):
|
|
super(SrcValidator, self).__init__(
|
|
plotly_name=plotly_name, parent_name=parent_name, **kwargs
|
|
)
|
|
|
|
self.chart_studio = get_module("chart_studio")
|
|
|
|
def description(self):
|
|
return """\
|
|
The '{plotly_name}' property must be specified as a string or
|
|
as a plotly.grid_objs.Column object""".format(
|
|
plotly_name=self.plotly_name
|
|
)
|
|
|
|
def validate_coerce(self, v):
|
|
if v is None:
|
|
# Pass None through
|
|
pass
|
|
elif isinstance(v, str):
|
|
pass
|
|
elif self.chart_studio and isinstance(v, self.chart_studio.grid_objs.Column):
|
|
# Convert to id string
|
|
v = v.id
|
|
else:
|
|
self.raise_invalid_val(v)
|
|
|
|
return v
|
|
|
|
|
|
class NumberValidator(BaseValidator):
|
|
"""
|
|
"number": {
|
|
"description": "A number or a numeric value (e.g. a number
|
|
inside a string). When applicable, values
|
|
greater (less) than `max` (`min`) are coerced to
|
|
the `dflt`.",
|
|
"requiredOpts": [],
|
|
"otherOpts": [
|
|
"dflt",
|
|
"min",
|
|
"max",
|
|
"arrayOk"
|
|
]
|
|
},
|
|
"""
|
|
|
|
def __init__(
|
|
self, plotly_name, parent_name, min=None, max=None, array_ok=False, **kwargs
|
|
):
|
|
super(NumberValidator, self).__init__(
|
|
plotly_name=plotly_name, parent_name=parent_name, **kwargs
|
|
)
|
|
|
|
# Handle min
|
|
if min is None and max is not None:
|
|
# Max was specified, so make min -inf
|
|
self.min_val = float("-inf")
|
|
else:
|
|
self.min_val = min
|
|
|
|
# Handle max
|
|
if max is None and min is not None:
|
|
# Min was specified, so make min inf
|
|
self.max_val = float("inf")
|
|
else:
|
|
self.max_val = max
|
|
|
|
if min is not None or max is not None:
|
|
self.has_min_max = True
|
|
else:
|
|
self.has_min_max = False
|
|
|
|
self.array_ok = array_ok
|
|
|
|
def description(self):
|
|
desc = """\
|
|
The '{plotly_name}' property is a number and may be specified as:""".format(
|
|
plotly_name=self.plotly_name
|
|
)
|
|
|
|
if not self.has_min_max:
|
|
desc = (
|
|
desc
|
|
+ """
|
|
- An int or float"""
|
|
)
|
|
|
|
else:
|
|
desc = (
|
|
desc
|
|
+ """
|
|
- An int or float in the interval [{min_val}, {max_val}]""".format(
|
|
min_val=self.min_val, max_val=self.max_val
|
|
)
|
|
)
|
|
|
|
if self.array_ok:
|
|
desc = (
|
|
desc
|
|
+ """
|
|
- A tuple, list, or one-dimensional numpy array of the above"""
|
|
)
|
|
|
|
return desc
|
|
|
|
def validate_coerce(self, v):
|
|
if v is None:
|
|
# Pass None through
|
|
pass
|
|
elif self.array_ok and is_homogeneous_array(v):
|
|
np = get_module("numpy")
|
|
try:
|
|
v_array = copy_to_readonly_numpy_array(v, force_numeric=True)
|
|
except (ValueError, TypeError, OverflowError):
|
|
self.raise_invalid_val(v)
|
|
|
|
# Check min/max
|
|
if self.has_min_max:
|
|
v_valid = np.logical_and(
|
|
self.min_val <= v_array, v_array <= self.max_val
|
|
)
|
|
|
|
if not np.all(v_valid):
|
|
# Grab up to the first 10 invalid values
|
|
v_invalid = np.logical_not(v_valid)
|
|
some_invalid_els = np.array(v, dtype="object")[v_invalid][
|
|
:10
|
|
].tolist()
|
|
|
|
self.raise_invalid_elements(some_invalid_els)
|
|
|
|
v = v_array # Always numeric numpy array
|
|
elif self.array_ok and is_simple_array(v):
|
|
# Check numeric
|
|
invalid_els = [e for e in v if not isinstance(e, numbers.Number)]
|
|
|
|
if invalid_els:
|
|
self.raise_invalid_elements(invalid_els[:10])
|
|
|
|
# Check min/max
|
|
if self.has_min_max:
|
|
invalid_els = [e for e in v if not (self.min_val <= e <= self.max_val)]
|
|
|
|
if invalid_els:
|
|
self.raise_invalid_elements(invalid_els[:10])
|
|
|
|
v = to_scalar_or_list(v)
|
|
else:
|
|
# Check numeric
|
|
if not isinstance(v, numbers.Number):
|
|
self.raise_invalid_val(v)
|
|
|
|
# Check min/max
|
|
if self.has_min_max:
|
|
if not (self.min_val <= v <= self.max_val):
|
|
self.raise_invalid_val(v)
|
|
return v
|
|
|
|
|
|
class IntegerValidator(BaseValidator):
|
|
"""
|
|
"integer": {
|
|
"description": "An integer or an integer inside a string. When
|
|
applicable, values greater (less) than `max`
|
|
(`min`) are coerced to the `dflt`.",
|
|
"requiredOpts": [],
|
|
"otherOpts": [
|
|
"dflt",
|
|
"min",
|
|
"max",
|
|
"arrayOk"
|
|
]
|
|
},
|
|
"""
|
|
|
|
def __init__(
|
|
self, plotly_name, parent_name, min=None, max=None, array_ok=False, **kwargs
|
|
):
|
|
super(IntegerValidator, self).__init__(
|
|
plotly_name=plotly_name, parent_name=parent_name, **kwargs
|
|
)
|
|
|
|
# Handle min
|
|
if min is None and max is not None:
|
|
# Max was specified, so make min -inf
|
|
self.min_val = -sys.maxsize - 1
|
|
else:
|
|
self.min_val = min
|
|
|
|
# Handle max
|
|
if max is None and min is not None:
|
|
# Min was specified, so make min inf
|
|
self.max_val = sys.maxsize
|
|
else:
|
|
self.max_val = max
|
|
|
|
if min is not None or max is not None:
|
|
self.has_min_max = True
|
|
else:
|
|
self.has_min_max = False
|
|
|
|
self.array_ok = array_ok
|
|
|
|
def description(self):
|
|
desc = """\
|
|
The '{plotly_name}' property is a integer and may be specified as:""".format(
|
|
plotly_name=self.plotly_name
|
|
)
|
|
|
|
if not self.has_min_max:
|
|
desc = (
|
|
desc
|
|
+ """
|
|
- An int (or float that will be cast to an int)"""
|
|
)
|
|
else:
|
|
desc = desc + (
|
|
"""
|
|
- An int (or float that will be cast to an int)
|
|
in the interval [{min_val}, {max_val}]""".format(
|
|
min_val=self.min_val, max_val=self.max_val
|
|
)
|
|
)
|
|
|
|
if self.array_ok:
|
|
desc = (
|
|
desc
|
|
+ """
|
|
- A tuple, list, or one-dimensional numpy array of the above"""
|
|
)
|
|
|
|
return desc
|
|
|
|
def validate_coerce(self, v):
|
|
if v is None:
|
|
# Pass None through
|
|
pass
|
|
elif self.array_ok and is_homogeneous_array(v):
|
|
np = get_module("numpy")
|
|
v_array = copy_to_readonly_numpy_array(
|
|
v, kind=("i", "u"), force_numeric=True
|
|
)
|
|
|
|
if v_array.dtype.kind not in ["i", "u"]:
|
|
self.raise_invalid_val(v)
|
|
|
|
# Check min/max
|
|
if self.has_min_max:
|
|
v_valid = np.logical_and(
|
|
self.min_val <= v_array, v_array <= self.max_val
|
|
)
|
|
|
|
if not np.all(v_valid):
|
|
# Grab up to the first 10 invalid values
|
|
v_invalid = np.logical_not(v_valid)
|
|
some_invalid_els = np.array(v, dtype="object")[v_invalid][
|
|
:10
|
|
].tolist()
|
|
self.raise_invalid_elements(some_invalid_els)
|
|
|
|
v = v_array
|
|
elif self.array_ok and is_simple_array(v):
|
|
# Check integer type
|
|
invalid_els = [e for e in v if not isinstance(e, int)]
|
|
|
|
if invalid_els:
|
|
self.raise_invalid_elements(invalid_els[:10])
|
|
|
|
# Check min/max
|
|
if self.has_min_max:
|
|
invalid_els = [e for e in v if not (self.min_val <= e <= self.max_val)]
|
|
|
|
if invalid_els:
|
|
self.raise_invalid_elements(invalid_els[:10])
|
|
|
|
v = to_scalar_or_list(v)
|
|
else:
|
|
# Check int
|
|
if not isinstance(v, int):
|
|
# don't let int() cast strings to ints
|
|
self.raise_invalid_val(v)
|
|
|
|
# Check min/max
|
|
if self.has_min_max:
|
|
if not (self.min_val <= v <= self.max_val):
|
|
self.raise_invalid_val(v)
|
|
|
|
return v
|
|
|
|
|
|
class StringValidator(BaseValidator):
|
|
"""
|
|
"string": {
|
|
"description": "A string value. Numbers are converted to strings
|
|
except for attributes with `strict` set to true.",
|
|
"requiredOpts": [],
|
|
"otherOpts": [
|
|
"dflt",
|
|
"noBlank",
|
|
"strict",
|
|
"arrayOk",
|
|
"values"
|
|
]
|
|
},
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
plotly_name,
|
|
parent_name,
|
|
no_blank=False,
|
|
strict=False,
|
|
array_ok=False,
|
|
values=None,
|
|
**kwargs,
|
|
):
|
|
super(StringValidator, self).__init__(
|
|
plotly_name=plotly_name, parent_name=parent_name, **kwargs
|
|
)
|
|
self.no_blank = no_blank
|
|
self.strict = strict
|
|
self.array_ok = array_ok
|
|
self.values = values
|
|
|
|
@staticmethod
|
|
def to_str_or_unicode_or_none(v):
|
|
"""
|
|
Convert a value to a string if it's not None, a string,
|
|
or a unicode (on Python 2).
|
|
"""
|
|
if v is None or isinstance(v, str):
|
|
return v
|
|
else:
|
|
return str(v)
|
|
|
|
def description(self):
|
|
desc = """\
|
|
The '{plotly_name}' property is a string and must be specified as:""".format(
|
|
plotly_name=self.plotly_name
|
|
)
|
|
|
|
if self.no_blank:
|
|
desc = (
|
|
desc
|
|
+ """
|
|
- A non-empty string"""
|
|
)
|
|
elif self.values:
|
|
valid_str = "\n".join(
|
|
textwrap.wrap(
|
|
repr(self.values),
|
|
initial_indent=" " * 12,
|
|
subsequent_indent=" " * 12,
|
|
break_on_hyphens=False,
|
|
)
|
|
)
|
|
|
|
desc = (
|
|
desc
|
|
+ """
|
|
- One of the following strings:
|
|
{valid_str}""".format(
|
|
valid_str=valid_str
|
|
)
|
|
)
|
|
else:
|
|
desc = (
|
|
desc
|
|
+ """
|
|
- A string"""
|
|
)
|
|
|
|
if not self.strict:
|
|
desc = (
|
|
desc
|
|
+ """
|
|
- A number that will be converted to a string"""
|
|
)
|
|
|
|
if self.array_ok:
|
|
desc = (
|
|
desc
|
|
+ """
|
|
- A tuple, list, or one-dimensional numpy array of the above"""
|
|
)
|
|
|
|
return desc
|
|
|
|
def validate_coerce(self, v):
|
|
if v is None:
|
|
# Pass None through
|
|
pass
|
|
elif self.array_ok and is_array(v):
|
|
|
|
# If strict, make sure all elements are strings.
|
|
if self.strict:
|
|
invalid_els = [e for e in v if not isinstance(e, str)]
|
|
if invalid_els:
|
|
self.raise_invalid_elements(invalid_els)
|
|
|
|
if is_homogeneous_array(v):
|
|
np = get_module("numpy")
|
|
|
|
# If not strict, let numpy cast elements to strings
|
|
v = copy_to_readonly_numpy_array(v, kind="U")
|
|
|
|
# Check no_blank
|
|
if self.no_blank:
|
|
invalid_els = v[v == ""][:10].tolist()
|
|
if invalid_els:
|
|
self.raise_invalid_elements(invalid_els)
|
|
|
|
# Check values
|
|
if self.values:
|
|
invalid_inds = np.logical_not(np.isin(v, self.values))
|
|
invalid_els = v[invalid_inds][:10].tolist()
|
|
if invalid_els:
|
|
self.raise_invalid_elements(invalid_els)
|
|
elif is_simple_array(v):
|
|
if not self.strict:
|
|
v = [StringValidator.to_str_or_unicode_or_none(e) for e in v]
|
|
|
|
# Check no_blank
|
|
if self.no_blank:
|
|
invalid_els = [e for e in v if e == ""]
|
|
if invalid_els:
|
|
self.raise_invalid_elements(invalid_els)
|
|
|
|
# Check values
|
|
if self.values:
|
|
invalid_els = [e for e in v if v not in self.values]
|
|
if invalid_els:
|
|
self.raise_invalid_elements(invalid_els)
|
|
|
|
v = to_scalar_or_list(v)
|
|
|
|
else:
|
|
if self.strict:
|
|
if not isinstance(v, str):
|
|
self.raise_invalid_val(v)
|
|
else:
|
|
if isinstance(v, str):
|
|
pass
|
|
elif isinstance(v, (int, float)):
|
|
# Convert value to a string
|
|
v = str(v)
|
|
else:
|
|
self.raise_invalid_val(v)
|
|
|
|
if self.no_blank and len(v) == 0:
|
|
self.raise_invalid_val(v)
|
|
|
|
if self.values and v not in self.values:
|
|
self.raise_invalid_val(v)
|
|
|
|
return v
|
|
|
|
|
|
class ColorValidator(BaseValidator):
|
|
"""
|
|
"color": {
|
|
"description": "A string describing color. Supported formats:
|
|
- hex (e.g. '#d3d3d3')
|
|
- rgb (e.g. 'rgb(255, 0, 0)')
|
|
- rgba (e.g. 'rgb(255, 0, 0, 0.5)')
|
|
- hsl (e.g. 'hsl(0, 100%, 50%)')
|
|
- hsv (e.g. 'hsv(0, 100%, 100%)')
|
|
- named colors(full list:
|
|
http://www.w3.org/TR/css3-color/#svg-color)",
|
|
"requiredOpts": [],
|
|
"otherOpts": [
|
|
"dflt",
|
|
"arrayOk"
|
|
]
|
|
},
|
|
"""
|
|
|
|
re_hex = re.compile(r"#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})")
|
|
re_rgb_etc = re.compile(r"(rgb|hsl|hsv)a?\([\d.]+%?(,[\d.]+%?){2,3}\)")
|
|
re_ddk = re.compile(r"var\(\-\-.*\)")
|
|
|
|
named_colors = [
|
|
"aliceblue",
|
|
"antiquewhite",
|
|
"aqua",
|
|
"aquamarine",
|
|
"azure",
|
|
"beige",
|
|
"bisque",
|
|
"black",
|
|
"blanchedalmond",
|
|
"blue",
|
|
"blueviolet",
|
|
"brown",
|
|
"burlywood",
|
|
"cadetblue",
|
|
"chartreuse",
|
|
"chocolate",
|
|
"coral",
|
|
"cornflowerblue",
|
|
"cornsilk",
|
|
"crimson",
|
|
"cyan",
|
|
"darkblue",
|
|
"darkcyan",
|
|
"darkgoldenrod",
|
|
"darkgray",
|
|
"darkgrey",
|
|
"darkgreen",
|
|
"darkkhaki",
|
|
"darkmagenta",
|
|
"darkolivegreen",
|
|
"darkorange",
|
|
"darkorchid",
|
|
"darkred",
|
|
"darksalmon",
|
|
"darkseagreen",
|
|
"darkslateblue",
|
|
"darkslategray",
|
|
"darkslategrey",
|
|
"darkturquoise",
|
|
"darkviolet",
|
|
"deeppink",
|
|
"deepskyblue",
|
|
"dimgray",
|
|
"dimgrey",
|
|
"dodgerblue",
|
|
"firebrick",
|
|
"floralwhite",
|
|
"forestgreen",
|
|
"fuchsia",
|
|
"gainsboro",
|
|
"ghostwhite",
|
|
"gold",
|
|
"goldenrod",
|
|
"gray",
|
|
"grey",
|
|
"green",
|
|
"greenyellow",
|
|
"honeydew",
|
|
"hotpink",
|
|
"indianred",
|
|
"indigo",
|
|
"ivory",
|
|
"khaki",
|
|
"lavender",
|
|
"lavenderblush",
|
|
"lawngreen",
|
|
"lemonchiffon",
|
|
"lightblue",
|
|
"lightcoral",
|
|
"lightcyan",
|
|
"lightgoldenrodyellow",
|
|
"lightgray",
|
|
"lightgrey",
|
|
"lightgreen",
|
|
"lightpink",
|
|
"lightsalmon",
|
|
"lightseagreen",
|
|
"lightskyblue",
|
|
"lightslategray",
|
|
"lightslategrey",
|
|
"lightsteelblue",
|
|
"lightyellow",
|
|
"lime",
|
|
"limegreen",
|
|
"linen",
|
|
"magenta",
|
|
"maroon",
|
|
"mediumaquamarine",
|
|
"mediumblue",
|
|
"mediumorchid",
|
|
"mediumpurple",
|
|
"mediumseagreen",
|
|
"mediumslateblue",
|
|
"mediumspringgreen",
|
|
"mediumturquoise",
|
|
"mediumvioletred",
|
|
"midnightblue",
|
|
"mintcream",
|
|
"mistyrose",
|
|
"moccasin",
|
|
"navajowhite",
|
|
"navy",
|
|
"oldlace",
|
|
"olive",
|
|
"olivedrab",
|
|
"orange",
|
|
"orangered",
|
|
"orchid",
|
|
"palegoldenrod",
|
|
"palegreen",
|
|
"paleturquoise",
|
|
"palevioletred",
|
|
"papayawhip",
|
|
"peachpuff",
|
|
"peru",
|
|
"pink",
|
|
"plum",
|
|
"powderblue",
|
|
"purple",
|
|
"red",
|
|
"rosybrown",
|
|
"royalblue",
|
|
"rebeccapurple",
|
|
"saddlebrown",
|
|
"salmon",
|
|
"sandybrown",
|
|
"seagreen",
|
|
"seashell",
|
|
"sienna",
|
|
"silver",
|
|
"skyblue",
|
|
"slateblue",
|
|
"slategray",
|
|
"slategrey",
|
|
"snow",
|
|
"springgreen",
|
|
"steelblue",
|
|
"tan",
|
|
"teal",
|
|
"thistle",
|
|
"tomato",
|
|
"turquoise",
|
|
"violet",
|
|
"wheat",
|
|
"white",
|
|
"whitesmoke",
|
|
"yellow",
|
|
"yellowgreen",
|
|
]
|
|
|
|
def __init__(
|
|
self, plotly_name, parent_name, array_ok=False, colorscale_path=None, **kwargs
|
|
):
|
|
super(ColorValidator, self).__init__(
|
|
plotly_name=plotly_name, parent_name=parent_name, **kwargs
|
|
)
|
|
|
|
self.array_ok = array_ok
|
|
|
|
# colorscale_path is the path to the colorscale associated with this
|
|
# color property, or None if no such colorscale exists. Only colors
|
|
# with an associated colorscale may take on numeric values
|
|
self.colorscale_path = colorscale_path
|
|
|
|
def numbers_allowed(self):
|
|
return self.colorscale_path is not None
|
|
|
|
def description(self):
|
|
|
|
named_clrs_str = "\n".join(
|
|
textwrap.wrap(
|
|
", ".join(self.named_colors),
|
|
width=79 - 16,
|
|
initial_indent=" " * 12,
|
|
subsequent_indent=" " * 12,
|
|
)
|
|
)
|
|
|
|
valid_color_description = """\
|
|
The '{plotly_name}' property is a color and may be specified as:
|
|
- A hex string (e.g. '#ff0000')
|
|
- An rgb/rgba string (e.g. 'rgb(255,0,0)')
|
|
- An hsl/hsla string (e.g. 'hsl(0,100%,50%)')
|
|
- An hsv/hsva string (e.g. 'hsv(0,100%,100%)')
|
|
- A named CSS color:
|
|
{clrs}""".format(
|
|
plotly_name=self.plotly_name, clrs=named_clrs_str
|
|
)
|
|
|
|
if self.colorscale_path:
|
|
valid_color_description = (
|
|
valid_color_description
|
|
+ """
|
|
- A number that will be interpreted as a color
|
|
according to {colorscale_path}""".format(
|
|
colorscale_path=self.colorscale_path
|
|
)
|
|
)
|
|
|
|
if self.array_ok:
|
|
valid_color_description = (
|
|
valid_color_description
|
|
+ """
|
|
- A list or array of any of the above"""
|
|
)
|
|
|
|
return valid_color_description
|
|
|
|
def validate_coerce(self, v, should_raise=True):
|
|
if v is None:
|
|
# Pass None through
|
|
pass
|
|
elif self.array_ok and is_homogeneous_array(v):
|
|
v = copy_to_readonly_numpy_array(v)
|
|
if self.numbers_allowed() and v.dtype.kind in ["u", "i", "f"]:
|
|
# Numbers are allowed and we have an array of numbers.
|
|
# All good
|
|
pass
|
|
else:
|
|
validated_v = [self.validate_coerce(e, should_raise=False) for e in v]
|
|
|
|
invalid_els = self.find_invalid_els(v, validated_v)
|
|
|
|
if invalid_els and should_raise:
|
|
self.raise_invalid_elements(invalid_els)
|
|
|
|
# ### Check that elements have valid colors types ###
|
|
elif self.numbers_allowed() or invalid_els:
|
|
v = copy_to_readonly_numpy_array(validated_v, kind="O")
|
|
else:
|
|
v = copy_to_readonly_numpy_array(validated_v, kind="U")
|
|
elif self.array_ok and is_simple_array(v):
|
|
validated_v = [self.validate_coerce(e, should_raise=False) for e in v]
|
|
|
|
invalid_els = self.find_invalid_els(v, validated_v)
|
|
|
|
if invalid_els and should_raise:
|
|
self.raise_invalid_elements(invalid_els)
|
|
else:
|
|
v = validated_v
|
|
else:
|
|
# Validate scalar color
|
|
validated_v = self.vc_scalar(v)
|
|
if validated_v is None and should_raise:
|
|
self.raise_invalid_val(v)
|
|
|
|
v = validated_v
|
|
|
|
return v
|
|
|
|
def find_invalid_els(self, orig, validated, invalid_els=None):
|
|
"""
|
|
Helper method to find invalid elements in orig array.
|
|
Elements are invalid if their corresponding element in
|
|
the validated array is None.
|
|
|
|
This method handles deeply nested list structures
|
|
"""
|
|
if invalid_els is None:
|
|
invalid_els = []
|
|
|
|
for orig_el, validated_el in zip(orig, validated):
|
|
if is_array(orig_el):
|
|
self.find_invalid_els(orig_el, validated_el, invalid_els)
|
|
else:
|
|
if validated_el is None:
|
|
invalid_els.append(orig_el)
|
|
|
|
return invalid_els
|
|
|
|
def vc_scalar(self, v):
|
|
"""Helper to validate/coerce a scalar color"""
|
|
return ColorValidator.perform_validate_coerce(
|
|
v, allow_number=self.numbers_allowed()
|
|
)
|
|
|
|
@staticmethod
|
|
def perform_validate_coerce(v, allow_number=None):
|
|
"""
|
|
Validate, coerce, and return a single color value. If input cannot be
|
|
coerced to a valid color then return None.
|
|
|
|
Parameters
|
|
----------
|
|
v : number or str
|
|
Candidate color value
|
|
|
|
allow_number : bool
|
|
True if numbers are allowed as colors
|
|
|
|
Returns
|
|
-------
|
|
number or str or None
|
|
"""
|
|
|
|
if isinstance(v, numbers.Number) and allow_number:
|
|
# If allow_numbers then any number is ok
|
|
return v
|
|
elif not isinstance(v, str):
|
|
# If not allow_numbers then value must be a string
|
|
return None
|
|
else:
|
|
# Remove spaces so regexes don't need to bother with them.
|
|
v_normalized = v.replace(" ", "").lower()
|
|
|
|
# if ColorValidator.re_hex.fullmatch(v_normalized):
|
|
if fullmatch(ColorValidator.re_hex, v_normalized):
|
|
# valid hex color (e.g. #f34ab3)
|
|
return v
|
|
elif fullmatch(ColorValidator.re_rgb_etc, v_normalized):
|
|
# elif ColorValidator.re_rgb_etc.fullmatch(v_normalized):
|
|
# Valid rgb(a), hsl(a), hsv(a) color
|
|
# (e.g. rgba(10, 234, 200, 50%)
|
|
return v
|
|
elif fullmatch(ColorValidator.re_ddk, v_normalized):
|
|
# Valid var(--*) DDK theme variable, inspired by CSS syntax
|
|
# (e.g. var(--accent) )
|
|
# DDK will crawl & eval var(-- colors for Graph theming
|
|
return v
|
|
elif v_normalized in ColorValidator.named_colors:
|
|
# Valid named color (e.g. 'coral')
|
|
return v
|
|
else:
|
|
# Not a valid color
|
|
return None
|
|
|
|
|
|
class ColorlistValidator(BaseValidator):
|
|
"""
|
|
"colorlist": {
|
|
"description": "A list of colors. Must be an {array} containing
|
|
valid colors.",
|
|
"requiredOpts": [],
|
|
"otherOpts": [
|
|
"dflt"
|
|
]
|
|
}
|
|
"""
|
|
|
|
def __init__(self, plotly_name, parent_name, **kwargs):
|
|
super(ColorlistValidator, self).__init__(
|
|
plotly_name=plotly_name, parent_name=parent_name, **kwargs
|
|
)
|
|
|
|
def description(self):
|
|
return """\
|
|
The '{plotly_name}' property is a colorlist that may be specified
|
|
as a tuple, list, one-dimensional numpy array, or pandas Series of valid
|
|
color strings""".format(
|
|
plotly_name=self.plotly_name
|
|
)
|
|
|
|
def validate_coerce(self, v):
|
|
|
|
if v is None:
|
|
# Pass None through
|
|
pass
|
|
elif is_array(v):
|
|
validated_v = [
|
|
ColorValidator.perform_validate_coerce(e, allow_number=False) for e in v
|
|
]
|
|
|
|
invalid_els = [
|
|
el for el, validated_el in zip(v, validated_v) if validated_el is None
|
|
]
|
|
if invalid_els:
|
|
self.raise_invalid_elements(invalid_els)
|
|
|
|
v = to_scalar_or_list(v)
|
|
else:
|
|
self.raise_invalid_val(v)
|
|
return v
|
|
|
|
|
|
class ColorscaleValidator(BaseValidator):
|
|
"""
|
|
"colorscale": {
|
|
"description": "A Plotly colorscale either picked by a name:
|
|
(any of Greys, YlGnBu, Greens, YlOrRd, Bluered,
|
|
RdBu, Reds, Blues, Picnic, Rainbow, Portland,
|
|
Jet, Hot, Blackbody, Earth, Electric, Viridis)
|
|
customized as an {array} of 2-element {arrays}
|
|
where the first element is the normalized color
|
|
level value (starting at *0* and ending at *1*),
|
|
and the second item is a valid color string.",
|
|
"requiredOpts": [],
|
|
"otherOpts": [
|
|
"dflt"
|
|
]
|
|
},
|
|
"""
|
|
|
|
def __init__(self, plotly_name, parent_name, **kwargs):
|
|
super(ColorscaleValidator, self).__init__(
|
|
plotly_name=plotly_name, parent_name=parent_name, **kwargs
|
|
)
|
|
|
|
# named colorscales initialized on first use
|
|
self._named_colorscales = None
|
|
|
|
@property
|
|
def named_colorscales(self):
|
|
if self._named_colorscales is None:
|
|
import inspect
|
|
import itertools
|
|
from plotly import colors
|
|
|
|
colorscale_members = itertools.chain(
|
|
inspect.getmembers(colors.sequential),
|
|
inspect.getmembers(colors.diverging),
|
|
inspect.getmembers(colors.cyclical),
|
|
)
|
|
|
|
self._named_colorscales = {
|
|
c[0].lower(): c[1]
|
|
for c in colorscale_members
|
|
if isinstance(c, tuple)
|
|
and len(c) == 2
|
|
and isinstance(c[0], str)
|
|
and isinstance(c[1], list)
|
|
and not c[0].endswith("_r")
|
|
and not c[0].startswith("_")
|
|
}
|
|
|
|
return self._named_colorscales
|
|
|
|
def description(self):
|
|
colorscales_str = "\n".join(
|
|
textwrap.wrap(
|
|
repr(sorted(list(self.named_colorscales))),
|
|
initial_indent=" " * 12,
|
|
subsequent_indent=" " * 13,
|
|
break_on_hyphens=False,
|
|
width=80,
|
|
)
|
|
)
|
|
|
|
desc = """\
|
|
The '{plotly_name}' property is a colorscale and may be
|
|
specified as:
|
|
- A list of colors that will be spaced evenly to create the colorscale.
|
|
Many predefined colorscale lists are included in the sequential, diverging,
|
|
and cyclical modules in the plotly.colors package.
|
|
- A list of 2-element lists where the first element is the
|
|
normalized color level value (starting at 0 and ending at 1),
|
|
and the second item is a valid color string.
|
|
(e.g. [[0, 'green'], [0.5, 'red'], [1.0, 'rgb(0, 0, 255)']])
|
|
- One of the following named colorscales:
|
|
{colorscales_str}.
|
|
Appending '_r' to a named colorscale reverses it.
|
|
""".format(
|
|
plotly_name=self.plotly_name, colorscales_str=colorscales_str
|
|
)
|
|
|
|
return desc
|
|
|
|
def validate_coerce(self, v):
|
|
v_valid = False
|
|
|
|
if v is None:
|
|
v_valid = True
|
|
elif isinstance(v, str):
|
|
v_lower = v.lower()
|
|
if v_lower in self.named_colorscales:
|
|
# Convert to color list
|
|
v = self.named_colorscales[v_lower]
|
|
v_valid = True
|
|
elif v_lower.endswith("_r") and v_lower[:-2] in self.named_colorscales:
|
|
v = self.named_colorscales[v_lower[:-2]][::-1]
|
|
v_valid = True
|
|
#
|
|
if v_valid:
|
|
# Convert to list of lists colorscale
|
|
d = len(v) - 1
|
|
v = [[(1.0 * i) / (1.0 * d), x] for i, x in enumerate(v)]
|
|
|
|
elif is_array(v) and len(v) > 0:
|
|
# If firset element is a string, treat as colorsequence
|
|
if isinstance(v[0], str):
|
|
invalid_els = [
|
|
e for e in v if ColorValidator.perform_validate_coerce(e) is None
|
|
]
|
|
|
|
if len(invalid_els) == 0:
|
|
v_valid = True
|
|
|
|
# Convert to list of lists colorscale
|
|
d = len(v) - 1
|
|
v = [[(1.0 * i) / (1.0 * d), x] for i, x in enumerate(v)]
|
|
else:
|
|
invalid_els = [
|
|
e
|
|
for e in v
|
|
if (
|
|
not is_array(e)
|
|
or len(e) != 2
|
|
or not isinstance(e[0], numbers.Number)
|
|
or not (0 <= e[0] <= 1)
|
|
or not isinstance(e[1], str)
|
|
or ColorValidator.perform_validate_coerce(e[1]) is None
|
|
)
|
|
]
|
|
|
|
if len(invalid_els) == 0:
|
|
v_valid = True
|
|
|
|
# Convert to list of lists
|
|
v = [
|
|
[e[0], ColorValidator.perform_validate_coerce(e[1])] for e in v
|
|
]
|
|
|
|
if not v_valid:
|
|
self.raise_invalid_val(v)
|
|
|
|
return v
|
|
|
|
def present(self, v):
|
|
# Return-type must be immutable
|
|
if v is None:
|
|
return None
|
|
elif isinstance(v, str):
|
|
return v
|
|
else:
|
|
return tuple([tuple(e) for e in v])
|
|
|
|
|
|
class AngleValidator(BaseValidator):
|
|
"""
|
|
"angle": {
|
|
"description": "A number (in degree) between -180 and 180.",
|
|
"requiredOpts": [],
|
|
"otherOpts": [
|
|
"dflt",
|
|
"arrayOk"
|
|
]
|
|
},
|
|
"""
|
|
|
|
def __init__(self, plotly_name, parent_name, array_ok=False, **kwargs):
|
|
super(AngleValidator, self).__init__(
|
|
plotly_name=plotly_name, parent_name=parent_name, **kwargs
|
|
)
|
|
self.array_ok = array_ok
|
|
|
|
def description(self):
|
|
desc = """\
|
|
The '{plotly_name}' property is a angle (in degrees) that may be
|
|
specified as a number between -180 and 180{array_ok}.
|
|
Numeric values outside this range are converted to the equivalent value
|
|
(e.g. 270 is converted to -90).
|
|
""".format(
|
|
plotly_name=self.plotly_name,
|
|
array_ok=", or a list, numpy array or other iterable thereof"
|
|
if self.array_ok
|
|
else "",
|
|
)
|
|
|
|
return desc
|
|
|
|
def validate_coerce(self, v):
|
|
if v is None:
|
|
# Pass None through
|
|
pass
|
|
elif self.array_ok and is_homogeneous_array(v):
|
|
try:
|
|
v_array = copy_to_readonly_numpy_array(v, force_numeric=True)
|
|
except (ValueError, TypeError, OverflowError):
|
|
self.raise_invalid_val(v)
|
|
v = v_array # Always numeric numpy array
|
|
# Normalize v onto the interval [-180, 180)
|
|
v = (v + 180) % 360 - 180
|
|
elif self.array_ok and is_simple_array(v):
|
|
# Check numeric
|
|
invalid_els = [e for e in v if not isinstance(e, numbers.Number)]
|
|
|
|
if invalid_els:
|
|
self.raise_invalid_elements(invalid_els[:10])
|
|
|
|
v = [(x + 180) % 360 - 180 for x in to_scalar_or_list(v)]
|
|
elif not isinstance(v, numbers.Number):
|
|
self.raise_invalid_val(v)
|
|
else:
|
|
# Normalize v onto the interval [-180, 180)
|
|
v = (v + 180) % 360 - 180
|
|
|
|
return v
|
|
|
|
|
|
class SubplotidValidator(BaseValidator):
|
|
"""
|
|
"subplotid": {
|
|
"description": "An id string of a subplot type (given by dflt),
|
|
optionally followed by an integer >1. e.g. if
|
|
dflt='geo', we can have 'geo', 'geo2', 'geo3',
|
|
...",
|
|
"requiredOpts": [
|
|
"dflt"
|
|
],
|
|
"otherOpts": [
|
|
"regex"
|
|
]
|
|
}
|
|
"""
|
|
|
|
def __init__(self, plotly_name, parent_name, dflt=None, regex=None, **kwargs):
|
|
|
|
if dflt is None and regex is None:
|
|
raise ValueError("One or both of regex and deflt must be specified")
|
|
|
|
super(SubplotidValidator, self).__init__(
|
|
plotly_name=plotly_name, parent_name=parent_name, **kwargs
|
|
)
|
|
|
|
if dflt is not None:
|
|
self.base = dflt
|
|
else:
|
|
# e.g. regex == '/^y([2-9]|[1-9][0-9]+)?$/'
|
|
self.base = re.match(r"/\^(\w+)", regex).group(1)
|
|
|
|
self.regex = self.base + r"(\d*)"
|
|
|
|
def description(self):
|
|
|
|
desc = """\
|
|
The '{plotly_name}' property is an identifier of a particular
|
|
subplot, of type '{base}', that may be specified as the string '{base}'
|
|
optionally followed by an integer >= 1
|
|
(e.g. '{base}', '{base}1', '{base}2', '{base}3', etc.)
|
|
""".format(
|
|
plotly_name=self.plotly_name, base=self.base
|
|
)
|
|
return desc
|
|
|
|
def validate_coerce(self, v):
|
|
if v is None:
|
|
pass
|
|
elif not isinstance(v, str):
|
|
self.raise_invalid_val(v)
|
|
else:
|
|
# match = re.fullmatch(self.regex, v)
|
|
match = fullmatch(self.regex, v)
|
|
if not match:
|
|
is_valid = False
|
|
else:
|
|
digit_str = match.group(1)
|
|
if len(digit_str) > 0 and int(digit_str) == 0:
|
|
is_valid = False
|
|
elif len(digit_str) > 0 and int(digit_str) == 1:
|
|
# Remove 1 suffix (e.g. x1 -> x)
|
|
v = self.base
|
|
is_valid = True
|
|
else:
|
|
is_valid = True
|
|
|
|
if not is_valid:
|
|
self.raise_invalid_val(v)
|
|
return v
|
|
|
|
|
|
class FlaglistValidator(BaseValidator):
|
|
"""
|
|
"flaglist": {
|
|
"description": "A string representing a combination of flags
|
|
(order does not matter here). Combine any of the
|
|
available `flags` with *+*.
|
|
(e.g. ('lines+markers')). Values in `extras`
|
|
cannot be combined.",
|
|
"requiredOpts": [
|
|
"flags"
|
|
],
|
|
"otherOpts": [
|
|
"dflt",
|
|
"extras",
|
|
"arrayOk"
|
|
]
|
|
},
|
|
"""
|
|
|
|
def __init__(
|
|
self, plotly_name, parent_name, flags, extras=None, array_ok=False, **kwargs
|
|
):
|
|
super(FlaglistValidator, self).__init__(
|
|
plotly_name=plotly_name, parent_name=parent_name, **kwargs
|
|
)
|
|
self.flags = flags
|
|
self.extras = extras if extras is not None else []
|
|
self.array_ok = array_ok
|
|
|
|
def description(self):
|
|
|
|
desc = (
|
|
"""\
|
|
The '{plotly_name}' property is a flaglist and may be specified
|
|
as a string containing:"""
|
|
).format(plotly_name=self.plotly_name)
|
|
|
|
# Flags
|
|
desc = (
|
|
desc
|
|
+ (
|
|
"""
|
|
- Any combination of {flags} joined with '+' characters
|
|
(e.g. '{eg_flag}')"""
|
|
).format(flags=self.flags, eg_flag="+".join(self.flags[:2]))
|
|
)
|
|
|
|
# Extras
|
|
if self.extras:
|
|
desc = (
|
|
desc
|
|
+ (
|
|
"""
|
|
OR exactly one of {extras} (e.g. '{eg_extra}')"""
|
|
).format(extras=self.extras, eg_extra=self.extras[-1])
|
|
)
|
|
|
|
if self.array_ok:
|
|
desc = (
|
|
desc
|
|
+ """
|
|
- A list or array of the above"""
|
|
)
|
|
|
|
return desc
|
|
|
|
def vc_scalar(self, v):
|
|
if isinstance(v, str):
|
|
v = v.strip()
|
|
|
|
if v in self.extras:
|
|
return v
|
|
|
|
if not isinstance(v, str):
|
|
return None
|
|
|
|
# To be generous we accept flags separated on plus ('+'),
|
|
# or comma (',') and we accept whitespace around the flags
|
|
split_vals = [e.strip() for e in re.split("[,+]", v)]
|
|
|
|
# Are all flags valid names?
|
|
if all(f in self.flags for f in split_vals):
|
|
return "+".join(split_vals)
|
|
else:
|
|
return None
|
|
|
|
def validate_coerce(self, v):
|
|
if v is None:
|
|
# Pass None through
|
|
pass
|
|
elif self.array_ok and is_array(v):
|
|
|
|
# Coerce individual strings
|
|
validated_v = [self.vc_scalar(e) for e in v]
|
|
|
|
invalid_els = [
|
|
el for el, validated_el in zip(v, validated_v) if validated_el is None
|
|
]
|
|
if invalid_els:
|
|
self.raise_invalid_elements(invalid_els)
|
|
|
|
if is_homogeneous_array(v):
|
|
v = copy_to_readonly_numpy_array(validated_v, kind="U")
|
|
else:
|
|
v = to_scalar_or_list(v)
|
|
else:
|
|
|
|
validated_v = self.vc_scalar(v)
|
|
if validated_v is None:
|
|
self.raise_invalid_val(v)
|
|
|
|
v = validated_v
|
|
|
|
return v
|
|
|
|
|
|
class AnyValidator(BaseValidator):
|
|
"""
|
|
"any": {
|
|
"description": "Any type.",
|
|
"requiredOpts": [],
|
|
"otherOpts": [
|
|
"dflt",
|
|
"values",
|
|
"arrayOk"
|
|
]
|
|
},
|
|
"""
|
|
|
|
def __init__(self, plotly_name, parent_name, values=None, array_ok=False, **kwargs):
|
|
super(AnyValidator, self).__init__(
|
|
plotly_name=plotly_name, parent_name=parent_name, **kwargs
|
|
)
|
|
self.values = values
|
|
self.array_ok = array_ok
|
|
|
|
def description(self):
|
|
|
|
desc = """\
|
|
The '{plotly_name}' property accepts values of any type
|
|
""".format(
|
|
plotly_name=self.plotly_name
|
|
)
|
|
return desc
|
|
|
|
def validate_coerce(self, v):
|
|
if v is None:
|
|
# Pass None through
|
|
pass
|
|
elif self.array_ok and is_homogeneous_array(v):
|
|
v = copy_to_readonly_numpy_array(v, kind="O")
|
|
elif self.array_ok and is_simple_array(v):
|
|
v = to_scalar_or_list(v)
|
|
return v
|
|
|
|
|
|
class InfoArrayValidator(BaseValidator):
|
|
"""
|
|
"info_array": {
|
|
"description": "An {array} of plot information.",
|
|
"requiredOpts": [
|
|
"items"
|
|
],
|
|
"otherOpts": [
|
|
"dflt",
|
|
"freeLength",
|
|
"dimensions"
|
|
]
|
|
}
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
plotly_name,
|
|
parent_name,
|
|
items,
|
|
free_length=None,
|
|
dimensions=None,
|
|
**kwargs,
|
|
):
|
|
super(InfoArrayValidator, self).__init__(
|
|
plotly_name=plotly_name, parent_name=parent_name, **kwargs
|
|
)
|
|
|
|
self.items = items
|
|
self.dimensions = dimensions if dimensions else 1
|
|
self.free_length = free_length
|
|
|
|
# Instantiate validators for each info array element
|
|
self.item_validators = []
|
|
info_array_items = self.items if isinstance(self.items, list) else [self.items]
|
|
|
|
for i, item in enumerate(info_array_items):
|
|
element_name = "{name}[{i}]".format(name=plotly_name, i=i)
|
|
item_validator = InfoArrayValidator.build_validator(
|
|
item, element_name, parent_name
|
|
)
|
|
self.item_validators.append(item_validator)
|
|
|
|
def description(self):
|
|
|
|
# Cases
|
|
# 1) self.items is array, self.dimensions is 1
|
|
# a) free_length=True
|
|
# b) free_length=False
|
|
# 2) self.items is array, self.dimensions is 2
|
|
# (requires free_length=True)
|
|
# 3) self.items is scalar (requires free_length=True)
|
|
# a) dimensions=1
|
|
# b) dimensions=2
|
|
#
|
|
# dimensions can be set to '1-2' to indicate the both are accepted
|
|
#
|
|
desc = """\
|
|
The '{plotly_name}' property is an info array that may be specified as:\
|
|
""".format(
|
|
plotly_name=self.plotly_name
|
|
)
|
|
|
|
if isinstance(self.items, list):
|
|
# ### Case 1 ###
|
|
if self.dimensions in (1, "1-2"):
|
|
upto = " up to" if self.free_length and self.dimensions == 1 else ""
|
|
desc += """
|
|
|
|
* a list or tuple of{upto} {N} elements where:\
|
|
""".format(
|
|
upto=upto, N=len(self.item_validators)
|
|
)
|
|
|
|
for i, item_validator in enumerate(self.item_validators):
|
|
el_desc = item_validator.description().strip()
|
|
desc = (
|
|
desc
|
|
+ """
|
|
({i}) {el_desc}""".format(
|
|
i=i, el_desc=el_desc
|
|
)
|
|
)
|
|
|
|
# ### Case 2 ###
|
|
if self.dimensions in ("1-2", 2):
|
|
assert self.free_length
|
|
|
|
desc += """
|
|
|
|
* a 2D list where:"""
|
|
for i, item_validator in enumerate(self.item_validators):
|
|
# Update name for 2d
|
|
orig_name = item_validator.plotly_name
|
|
item_validator.plotly_name = "{name}[i][{i}]".format(
|
|
name=self.plotly_name, i=i
|
|
)
|
|
|
|
el_desc = item_validator.description().strip()
|
|
desc = (
|
|
desc
|
|
+ """
|
|
({i}) {el_desc}""".format(
|
|
i=i, el_desc=el_desc
|
|
)
|
|
)
|
|
item_validator.plotly_name = orig_name
|
|
else:
|
|
# ### Case 3 ###
|
|
assert self.free_length
|
|
item_validator = self.item_validators[0]
|
|
orig_name = item_validator.plotly_name
|
|
|
|
if self.dimensions in (1, "1-2"):
|
|
item_validator.plotly_name = "{name}[i]".format(name=self.plotly_name)
|
|
|
|
el_desc = item_validator.description().strip()
|
|
|
|
desc += """
|
|
* a list of elements where:
|
|
{el_desc}
|
|
""".format(
|
|
el_desc=el_desc
|
|
)
|
|
|
|
if self.dimensions in ("1-2", 2):
|
|
item_validator.plotly_name = "{name}[i][j]".format(
|
|
name=self.plotly_name
|
|
)
|
|
|
|
el_desc = item_validator.description().strip()
|
|
desc += """
|
|
* a 2D list where:
|
|
{el_desc}
|
|
""".format(
|
|
el_desc=el_desc
|
|
)
|
|
|
|
item_validator.plotly_name = orig_name
|
|
|
|
return desc
|
|
|
|
@staticmethod
|
|
def build_validator(validator_info, plotly_name, parent_name):
|
|
datatype = validator_info["valType"] # type: str
|
|
validator_classname = datatype.title().replace("_", "") + "Validator"
|
|
validator_class = eval(validator_classname)
|
|
|
|
kwargs = {
|
|
k: validator_info[k]
|
|
for k in validator_info
|
|
if k not in ["valType", "description", "role"]
|
|
}
|
|
|
|
return validator_class(
|
|
plotly_name=plotly_name, parent_name=parent_name, **kwargs
|
|
)
|
|
|
|
def validate_element_with_indexed_name(self, val, validator, inds):
|
|
"""
|
|
Helper to add indexes to a validator's name, call validate_coerce on
|
|
a value, then restore the original validator name.
|
|
|
|
This makes sure that if a validation error message is raised, the
|
|
property name the user sees includes the index(es) of the offending
|
|
element.
|
|
|
|
Parameters
|
|
----------
|
|
val:
|
|
A value to be validated
|
|
validator
|
|
A validator
|
|
inds
|
|
List of one or more non-negative integers that represent the
|
|
nested index of the value being validated
|
|
Returns
|
|
-------
|
|
val
|
|
validated value
|
|
|
|
Raises
|
|
------
|
|
ValueError
|
|
if val fails validation
|
|
"""
|
|
orig_name = validator.plotly_name
|
|
new_name = self.plotly_name
|
|
for i in inds:
|
|
new_name += "[" + str(i) + "]"
|
|
validator.plotly_name = new_name
|
|
try:
|
|
val = validator.validate_coerce(val)
|
|
finally:
|
|
validator.plotly_name = orig_name
|
|
|
|
return val
|
|
|
|
def validate_coerce(self, v):
|
|
if v is None:
|
|
# Pass None through
|
|
return None
|
|
elif not is_array(v):
|
|
self.raise_invalid_val(v)
|
|
|
|
# Save off original v value to use in error reporting
|
|
orig_v = v
|
|
|
|
# Convert everything into nested lists
|
|
# This way we don't need to worry about nested numpy arrays
|
|
v = to_scalar_or_list(v)
|
|
|
|
is_v_2d = v and is_array(v[0])
|
|
|
|
if is_v_2d and self.dimensions in ("1-2", 2):
|
|
if is_array(self.items):
|
|
# e.g. 2D list as parcoords.dimensions.constraintrange
|
|
# check that all items are there for each nested element
|
|
for i, row in enumerate(v):
|
|
# Check row length
|
|
if not is_array(row) or len(row) != len(self.items):
|
|
self.raise_invalid_val(orig_v[i], [i])
|
|
|
|
for j, validator in enumerate(self.item_validators):
|
|
row[j] = self.validate_element_with_indexed_name(
|
|
v[i][j], validator, [i, j]
|
|
)
|
|
else:
|
|
# e.g. 2D list as layout.grid.subplots
|
|
# check that all elements match individual validator
|
|
validator = self.item_validators[0]
|
|
for i, row in enumerate(v):
|
|
if not is_array(row):
|
|
self.raise_invalid_val(orig_v[i], [i])
|
|
|
|
for j, el in enumerate(row):
|
|
row[j] = self.validate_element_with_indexed_name(
|
|
el, validator, [i, j]
|
|
)
|
|
elif v and self.dimensions == 2:
|
|
# e.g. 1D list passed as layout.grid.subplots
|
|
self.raise_invalid_val(orig_v[0], [0])
|
|
elif not is_array(self.items):
|
|
# e.g. 1D list passed as layout.grid.xaxes
|
|
validator = self.item_validators[0]
|
|
for i, el in enumerate(v):
|
|
v[i] = self.validate_element_with_indexed_name(el, validator, [i])
|
|
|
|
elif not self.free_length and len(v) != len(self.item_validators):
|
|
# e.g. 3 element list as layout.xaxis.range
|
|
self.raise_invalid_val(orig_v)
|
|
elif self.free_length and len(v) > len(self.item_validators):
|
|
# e.g. 4 element list as layout.updatemenu.button.args
|
|
self.raise_invalid_val(orig_v)
|
|
else:
|
|
# We have a 1D array of the correct length
|
|
for i, (el, validator) in enumerate(zip(v, self.item_validators)):
|
|
# Validate coerce elements
|
|
v[i] = validator.validate_coerce(el)
|
|
|
|
return v
|
|
|
|
def present(self, v):
|
|
if v is None:
|
|
return None
|
|
else:
|
|
if (
|
|
self.dimensions == 2
|
|
or self.dimensions == "1-2"
|
|
and v
|
|
and is_array(v[0])
|
|
):
|
|
|
|
# 2D case
|
|
v = copy.deepcopy(v)
|
|
for row in v:
|
|
for i, (el, validator) in enumerate(zip(row, self.item_validators)):
|
|
row[i] = validator.present(el)
|
|
|
|
return tuple(tuple(row) for row in v)
|
|
else:
|
|
# 1D case
|
|
v = copy.copy(v)
|
|
# Call present on each of the item validators
|
|
for i, (el, validator) in enumerate(zip(v, self.item_validators)):
|
|
# Validate coerce elements
|
|
v[i] = validator.present(el)
|
|
|
|
# Return tuple form of
|
|
return tuple(v)
|
|
|
|
|
|
class LiteralValidator(BaseValidator):
|
|
"""
|
|
Validator for readonly literal values
|
|
"""
|
|
|
|
def __init__(self, plotly_name, parent_name, val, **kwargs):
|
|
super(LiteralValidator, self).__init__(
|
|
plotly_name=plotly_name, parent_name=parent_name, **kwargs
|
|
)
|
|
self.val = val
|
|
|
|
def validate_coerce(self, v):
|
|
if v != self.val:
|
|
raise ValueError(
|
|
"""\
|
|
The '{plotly_name}' property of {parent_name} is read-only""".format(
|
|
plotly_name=self.plotly_name, parent_name=self.parent_name
|
|
)
|
|
)
|
|
else:
|
|
return v
|
|
|
|
|
|
class DashValidator(EnumeratedValidator):
|
|
"""
|
|
Special case validator for handling dash properties that may be specified
|
|
as lists of dash lengths. These are not currently specified in the
|
|
schema.
|
|
|
|
"dash": {
|
|
"valType": "string",
|
|
"values": [
|
|
"solid",
|
|
"dot",
|
|
"dash",
|
|
"longdash",
|
|
"dashdot",
|
|
"longdashdot"
|
|
],
|
|
"dflt": "solid",
|
|
"role": "style",
|
|
"editType": "style",
|
|
"description": "Sets the dash style of lines. Set to a dash type
|
|
string (*solid*, *dot*, *dash*, *longdash*, *dashdot*, or
|
|
*longdashdot*) or a dash length list in px (eg *5px,10px,2px,2px*)."
|
|
},
|
|
"""
|
|
|
|
def __init__(self, plotly_name, parent_name, values, **kwargs):
|
|
|
|
# Add regex to handle dash length lists
|
|
dash_list_regex = r"/^\d+(\.\d+)?(px|%)?((,|\s)\s*\d+(\.\d+)?(px|%)?)*$/"
|
|
|
|
values = values + [dash_list_regex]
|
|
|
|
# Call EnumeratedValidator superclass
|
|
super(DashValidator, self).__init__(
|
|
plotly_name=plotly_name, parent_name=parent_name, values=values, **kwargs
|
|
)
|
|
|
|
def description(self):
|
|
|
|
# Separate regular values from regular expressions
|
|
enum_vals = []
|
|
enum_regexs = []
|
|
for v, regex in zip(self.values, self.val_regexs):
|
|
if regex is not None:
|
|
enum_regexs.append(regex.pattern)
|
|
else:
|
|
enum_vals.append(v)
|
|
desc = """\
|
|
The '{name}' property is an enumeration that may be specified as:""".format(
|
|
name=self.plotly_name
|
|
)
|
|
|
|
if enum_vals:
|
|
enum_vals_str = "\n".join(
|
|
textwrap.wrap(
|
|
repr(enum_vals),
|
|
initial_indent=" " * 12,
|
|
subsequent_indent=" " * 12,
|
|
break_on_hyphens=False,
|
|
width=80,
|
|
)
|
|
)
|
|
|
|
desc = (
|
|
desc
|
|
+ """
|
|
- One of the following dash styles:
|
|
{enum_vals_str}""".format(
|
|
enum_vals_str=enum_vals_str
|
|
)
|
|
)
|
|
|
|
desc = (
|
|
desc
|
|
+ """
|
|
- A string containing a dash length list in pixels or percentages
|
|
(e.g. '5px 10px 2px 2px', '5, 10, 2, 2', '10% 20% 40%', etc.)
|
|
"""
|
|
)
|
|
return desc
|
|
|
|
|
|
class ImageUriValidator(BaseValidator):
|
|
_PIL = None
|
|
|
|
try:
|
|
_PIL = import_module("PIL")
|
|
except ImportError:
|
|
pass
|
|
|
|
def __init__(self, plotly_name, parent_name, **kwargs):
|
|
super(ImageUriValidator, self).__init__(
|
|
plotly_name=plotly_name, parent_name=parent_name, **kwargs
|
|
)
|
|
|
|
def description(self):
|
|
|
|
desc = """\
|
|
The '{plotly_name}' property is an image URI that may be specified as:
|
|
- A remote image URI string
|
|
(e.g. 'http://www.somewhere.com/image.png')
|
|
- A data URI image string
|
|
(e.g. 'data:image/png;base64,iVBORw0KGgoAAAANSU')
|
|
- A PIL.Image.Image object which will be immediately converted
|
|
to a data URI image string
|
|
See http://pillow.readthedocs.io/en/latest/reference/Image.html
|
|
""".format(
|
|
plotly_name=self.plotly_name
|
|
)
|
|
return desc
|
|
|
|
def validate_coerce(self, v):
|
|
if v is None:
|
|
pass
|
|
elif isinstance(v, str):
|
|
# Future possibilities:
|
|
# - Detect filesystem system paths and convert to URI
|
|
# - Validate either url or data uri
|
|
pass
|
|
elif self._PIL and isinstance(v, self._PIL.Image.Image):
|
|
# Convert PIL image to png data uri string
|
|
v = self.pil_image_to_uri(v)
|
|
else:
|
|
self.raise_invalid_val(v)
|
|
|
|
return v
|
|
|
|
@staticmethod
|
|
def pil_image_to_uri(v):
|
|
in_mem_file = io.BytesIO()
|
|
v.save(in_mem_file, format="PNG")
|
|
in_mem_file.seek(0)
|
|
img_bytes = in_mem_file.read()
|
|
base64_encoded_result_bytes = base64.b64encode(img_bytes)
|
|
base64_encoded_result_str = base64_encoded_result_bytes.decode("ascii")
|
|
v = "data:image/png;base64,{base64_encoded_result_str}".format(
|
|
base64_encoded_result_str=base64_encoded_result_str
|
|
)
|
|
return v
|
|
|
|
|
|
class CompoundValidator(BaseValidator):
|
|
def __init__(self, plotly_name, parent_name, data_class_str, data_docs, **kwargs):
|
|
super(CompoundValidator, self).__init__(
|
|
plotly_name=plotly_name, parent_name=parent_name, **kwargs
|
|
)
|
|
|
|
# Save element class string
|
|
self.data_class_str = data_class_str
|
|
self._data_class = None
|
|
self.data_docs = data_docs
|
|
self.module_str = CompoundValidator.compute_graph_obj_module_str(
|
|
self.data_class_str, parent_name
|
|
)
|
|
|
|
@staticmethod
|
|
def compute_graph_obj_module_str(data_class_str, parent_name):
|
|
if parent_name == "frame" and data_class_str in ["Data", "Layout"]:
|
|
# Special case. There are no graph_objs.frame.Data or
|
|
# graph_objs.frame.Layout classes. These are remapped to
|
|
# graph_objs.Data and graph_objs.Layout
|
|
|
|
parent_parts = parent_name.split(".")
|
|
module_str = ".".join(["plotly.graph_objs"] + parent_parts[1:])
|
|
elif parent_name == "layout.template" and data_class_str == "Layout":
|
|
# Remap template's layout to regular layout
|
|
module_str = "plotly.graph_objs"
|
|
elif "layout.template.data" in parent_name:
|
|
# Remap template's traces to regular traces
|
|
parent_name = parent_name.replace("layout.template.data.", "")
|
|
if parent_name:
|
|
module_str = "plotly.graph_objs." + parent_name
|
|
else:
|
|
module_str = "plotly.graph_objs"
|
|
elif parent_name:
|
|
module_str = "plotly.graph_objs." + parent_name
|
|
else:
|
|
module_str = "plotly.graph_objs"
|
|
|
|
return module_str
|
|
|
|
@property
|
|
def data_class(self):
|
|
if self._data_class is None:
|
|
module = import_module(self.module_str)
|
|
self._data_class = getattr(module, self.data_class_str)
|
|
|
|
return self._data_class
|
|
|
|
def description(self):
|
|
|
|
desc = (
|
|
"""\
|
|
The '{plotly_name}' property is an instance of {class_str}
|
|
that may be specified as:
|
|
- An instance of :class:`{module_str}.{class_str}`
|
|
- A dict of string/value properties that will be passed
|
|
to the {class_str} constructor
|
|
|
|
Supported dict properties:
|
|
{constructor_params_str}"""
|
|
).format(
|
|
plotly_name=self.plotly_name,
|
|
class_str=self.data_class_str,
|
|
module_str=self.module_str,
|
|
constructor_params_str=self.data_docs,
|
|
)
|
|
|
|
return desc
|
|
|
|
def validate_coerce(self, v, skip_invalid=False, _validate=True):
|
|
if v is None:
|
|
v = self.data_class()
|
|
|
|
elif isinstance(v, dict):
|
|
v = self.data_class(v, skip_invalid=skip_invalid, _validate=_validate)
|
|
|
|
elif isinstance(v, self.data_class):
|
|
# Copy object
|
|
v = self.data_class(v)
|
|
else:
|
|
if skip_invalid:
|
|
v = self.data_class()
|
|
else:
|
|
self.raise_invalid_val(v)
|
|
|
|
v._plotly_name = self.plotly_name
|
|
return v
|
|
|
|
def present(self, v):
|
|
# Return compound object as-is
|
|
return v
|
|
|
|
|
|
class TitleValidator(CompoundValidator):
|
|
"""
|
|
This is a special validator to allow compound title properties
|
|
(e.g. layout.title, layout.xaxis.title, etc.) to be set as strings
|
|
or numbers. These strings are mapped to the 'text' property of the
|
|
compound validator.
|
|
"""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super(TitleValidator, self).__init__(*args, **kwargs)
|
|
|
|
def validate_coerce(self, v, skip_invalid=False):
|
|
if isinstance(v, (str, int, float)):
|
|
v = {"text": v}
|
|
return super(TitleValidator, self).validate_coerce(v, skip_invalid=skip_invalid)
|
|
|
|
|
|
class CompoundArrayValidator(BaseValidator):
|
|
def __init__(self, plotly_name, parent_name, data_class_str, data_docs, **kwargs):
|
|
super(CompoundArrayValidator, self).__init__(
|
|
plotly_name=plotly_name, parent_name=parent_name, **kwargs
|
|
)
|
|
|
|
# Save element class string
|
|
self.data_class_str = data_class_str
|
|
self._data_class = None
|
|
|
|
self.data_docs = data_docs
|
|
self.module_str = CompoundValidator.compute_graph_obj_module_str(
|
|
self.data_class_str, parent_name
|
|
)
|
|
|
|
def description(self):
|
|
|
|
desc = (
|
|
"""\
|
|
The '{plotly_name}' property is a tuple of instances of
|
|
{class_str} that may be specified as:
|
|
- A list or tuple of instances of {module_str}.{class_str}
|
|
- A list or tuple of dicts of string/value properties that
|
|
will be passed to the {class_str} constructor
|
|
|
|
Supported dict properties:
|
|
{constructor_params_str}"""
|
|
).format(
|
|
plotly_name=self.plotly_name,
|
|
class_str=self.data_class_str,
|
|
module_str=self.module_str,
|
|
constructor_params_str=self.data_docs,
|
|
)
|
|
|
|
return desc
|
|
|
|
@property
|
|
def data_class(self):
|
|
if self._data_class is None:
|
|
module = import_module(self.module_str)
|
|
self._data_class = getattr(module, self.data_class_str)
|
|
|
|
return self._data_class
|
|
|
|
def validate_coerce(self, v, skip_invalid=False):
|
|
|
|
if v is None:
|
|
v = []
|
|
|
|
elif isinstance(v, (list, tuple)):
|
|
res = []
|
|
invalid_els = []
|
|
for v_el in v:
|
|
if isinstance(v_el, self.data_class):
|
|
res.append(self.data_class(v_el))
|
|
elif isinstance(v_el, dict):
|
|
res.append(self.data_class(v_el, skip_invalid=skip_invalid))
|
|
else:
|
|
if skip_invalid:
|
|
res.append(self.data_class())
|
|
else:
|
|
res.append(None)
|
|
invalid_els.append(v_el)
|
|
|
|
if invalid_els:
|
|
self.raise_invalid_elements(invalid_els)
|
|
|
|
v = to_scalar_or_list(res)
|
|
else:
|
|
if skip_invalid:
|
|
v = []
|
|
else:
|
|
self.raise_invalid_val(v)
|
|
|
|
return v
|
|
|
|
def present(self, v):
|
|
# Return compound object as tuple
|
|
return tuple(v)
|
|
|
|
|
|
class BaseDataValidator(BaseValidator):
|
|
def __init__(
|
|
self, class_strs_map, plotly_name, parent_name, set_uid=False, **kwargs
|
|
):
|
|
super(BaseDataValidator, self).__init__(
|
|
plotly_name=plotly_name, parent_name=parent_name, **kwargs
|
|
)
|
|
|
|
self.class_strs_map = class_strs_map
|
|
self._class_map = {}
|
|
self.set_uid = set_uid
|
|
|
|
def description(self):
|
|
|
|
trace_types = str(list(self.class_strs_map.keys()))
|
|
|
|
trace_types_wrapped = "\n".join(
|
|
textwrap.wrap(
|
|
trace_types,
|
|
initial_indent=" One of: ",
|
|
subsequent_indent=" " * 21,
|
|
width=79 - 12,
|
|
)
|
|
)
|
|
|
|
desc = (
|
|
"""\
|
|
The '{plotly_name}' property is a tuple of trace instances
|
|
that may be specified as:
|
|
- A list or tuple of trace instances
|
|
(e.g. [Scatter(...), Bar(...)])
|
|
- A single trace instance
|
|
(e.g. Scatter(...), Bar(...), etc.)
|
|
- A list or tuple of dicts of string/value properties where:
|
|
- The 'type' property specifies the trace type
|
|
{trace_types}
|
|
|
|
- All remaining properties are passed to the constructor of
|
|
the specified trace type
|
|
|
|
(e.g. [{{'type': 'scatter', ...}}, {{'type': 'bar, ...}}])"""
|
|
).format(plotly_name=self.plotly_name, trace_types=trace_types_wrapped)
|
|
|
|
return desc
|
|
|
|
def get_trace_class(self, trace_name):
|
|
# Import trace classes
|
|
if trace_name not in self._class_map:
|
|
trace_module = import_module("plotly.graph_objs")
|
|
trace_class_name = self.class_strs_map[trace_name]
|
|
self._class_map[trace_name] = getattr(trace_module, trace_class_name)
|
|
|
|
return self._class_map[trace_name]
|
|
|
|
def validate_coerce(self, v, skip_invalid=False, _validate=True):
|
|
from plotly.basedatatypes import BaseTraceType
|
|
|
|
# Import Histogram2dcontour, this is the deprecated name of the
|
|
# Histogram2dContour trace.
|
|
from plotly.graph_objs import Histogram2dcontour
|
|
|
|
if v is None:
|
|
v = []
|
|
else:
|
|
if not isinstance(v, (list, tuple)):
|
|
v = [v]
|
|
|
|
res = []
|
|
invalid_els = []
|
|
for v_el in v:
|
|
|
|
if isinstance(v_el, BaseTraceType):
|
|
if isinstance(v_el, Histogram2dcontour):
|
|
v_el = dict(type="histogram2dcontour", **v_el._props)
|
|
else:
|
|
v_el = v_el._props
|
|
|
|
if isinstance(v_el, dict):
|
|
type_in_v_el = "type" in v_el
|
|
trace_type = v_el.pop("type", "scatter")
|
|
|
|
if trace_type not in self.class_strs_map:
|
|
if skip_invalid:
|
|
# Treat as scatter trace
|
|
trace = self.get_trace_class("scatter")(
|
|
skip_invalid=skip_invalid, _validate=_validate, **v_el
|
|
)
|
|
res.append(trace)
|
|
else:
|
|
res.append(None)
|
|
invalid_els.append(v_el)
|
|
else:
|
|
trace = self.get_trace_class(trace_type)(
|
|
skip_invalid=skip_invalid, _validate=_validate, **v_el
|
|
)
|
|
res.append(trace)
|
|
|
|
if type_in_v_el:
|
|
# Restore type in v_el
|
|
v_el["type"] = trace_type
|
|
else:
|
|
if skip_invalid:
|
|
# Add empty scatter trace
|
|
trace = self.get_trace_class("scatter")()
|
|
res.append(trace)
|
|
else:
|
|
res.append(None)
|
|
invalid_els.append(v_el)
|
|
|
|
if invalid_els:
|
|
self.raise_invalid_elements(invalid_els)
|
|
|
|
v = to_scalar_or_list(res)
|
|
|
|
# Set new UIDs
|
|
if self.set_uid:
|
|
for trace in v:
|
|
trace.uid = str(uuid.uuid4())
|
|
|
|
return v
|
|
|
|
|
|
class BaseTemplateValidator(CompoundValidator):
|
|
def __init__(self, plotly_name, parent_name, data_class_str, data_docs, **kwargs):
|
|
|
|
super(BaseTemplateValidator, self).__init__(
|
|
plotly_name=plotly_name,
|
|
parent_name=parent_name,
|
|
data_class_str=data_class_str,
|
|
data_docs=data_docs,
|
|
**kwargs,
|
|
)
|
|
|
|
def description(self):
|
|
compound_description = super(BaseTemplateValidator, self).description()
|
|
compound_description += """
|
|
- The name of a registered template where current registered templates
|
|
are stored in the plotly.io.templates configuration object. The names
|
|
of all registered templates can be retrieved with:
|
|
>>> import plotly.io as pio
|
|
>>> list(pio.templates) # doctest: +ELLIPSIS
|
|
['ggplot2', 'seaborn', 'simple_white', 'plotly', 'plotly_white', ...]
|
|
|
|
- A string containing multiple registered template names, joined on '+'
|
|
characters (e.g. 'template1+template2'). In this case the resulting
|
|
template is computed by merging together the collection of registered
|
|
templates"""
|
|
|
|
return compound_description
|
|
|
|
def validate_coerce(self, v, skip_invalid=False):
|
|
import plotly.io as pio
|
|
|
|
try:
|
|
# Check if v is a template identifier
|
|
# (could be any hashable object)
|
|
if v in pio.templates:
|
|
return copy.deepcopy(pio.templates[v])
|
|
# Otherwise, if v is a string, check to see if it consists of
|
|
# multiple template names joined on '+' characters
|
|
elif isinstance(v, str):
|
|
template_names = v.split("+")
|
|
if all([name in pio.templates for name in template_names]):
|
|
return pio.templates.merge_templates(*template_names)
|
|
|
|
except TypeError:
|
|
# v is un-hashable
|
|
pass
|
|
|
|
# Check for empty template
|
|
if v == {} or isinstance(v, self.data_class) and v.to_plotly_json() == {}:
|
|
# Replace empty template with {'data': {'scatter': [{}]}} so that we can
|
|
# tell the difference between an un-initialized template and a template
|
|
# explicitly set to empty.
|
|
return self.data_class(data_scatter=[{}])
|
|
|
|
return super(BaseTemplateValidator, self).validate_coerce(
|
|
v, skip_invalid=skip_invalid
|
|
)
|