diff --git a/.circleci/requirements/dev-requirements-py37.txt b/.circleci/requirements/dev-requirements-py37.txt index 80bb309dc2..67d4afa559 100644 --- a/.circleci/requirements/dev-requirements-py37.txt +++ b/.circleci/requirements/dev-requirements-py37.txt @@ -14,3 +14,6 @@ requests[security] flake8 pylint==2.2.2 astroid==2.1.0 +Cerberus +numpy +pandas diff --git a/.circleci/requirements/dev-requirements.txt b/.circleci/requirements/dev-requirements.txt index 5793728632..3e6a820253 100644 --- a/.circleci/requirements/dev-requirements.txt +++ b/.circleci/requirements/dev-requirements.txt @@ -14,3 +14,6 @@ plotly==3.6.1 requests[security] flake8 pylint==1.9.4 +Cerberus +numpy +pandas diff --git a/dash/_utils.py b/dash/_utils.py index 45787ac940..0b8f7a1f83 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -84,3 +84,9 @@ def first(self, *names): value = self.get(name) if value: return value + + +def _merge(x, y): + z = x.copy() + z.update(y) + return z diff --git a/dash/dash.py b/dash/dash.py index 0f04362689..99a4bfbeb6 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -11,6 +11,7 @@ import threading import warnings import re +import pprint import logging from functools import wraps @@ -25,6 +26,8 @@ from .dependencies import Input, Output, State from .resources import Scripts, Css from .development.base_component import Component, ComponentRegistry +from .development.validator import (DashValidator, + generate_validation_error_message) from . import exceptions from ._utils import AttributeDict as _AttributeDict from ._utils import interpolate_str as _interpolate @@ -69,6 +72,27 @@ _re_index_config_id = re.compile(r'id="_dash-config"') _re_index_scripts_id = re.compile(r'src=".*dash[-_]renderer.*"') +_callback_validation_error_template = """ +A Dash Callback produced an invalid value! + +Dash tried to update the `{component_property}` prop of the +`{component_name}` with id `{component_id}` by calling the +`{callback_func_name}` function with `{args}` as arguments. + +This function call returned `{value}`, which did not pass +validation tests for the `{component_name}` component. + +The expected schema for the `{component_property}` prop of the +`{component_name}` component is: + +*************************************************************** +{component_schema} +*************************************************************** + +The errors in validation are as follows: + +""" + # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-arguments, too-many-locals @@ -92,6 +116,7 @@ def __init__( external_scripts=None, external_stylesheets=None, suppress_callback_exceptions=None, + suppress_validation_exceptions=None, components_cache_max_age=None, **kwargs): @@ -128,6 +153,10 @@ def __init__( 'suppress_callback_exceptions', suppress_callback_exceptions, env_configs, False ), + 'suppress_validation_exceptions': _configs.get_config( + 'suppress_validation_exceptions', + suppress_validation_exceptions, env_configs, False + ), 'routes_pathname_prefix': routes_pathname_prefix, 'requests_pathname_prefix': requests_pathname_prefix, 'include_assets_files': _configs.get_config( @@ -765,7 +794,7 @@ def _validate_callback(self, output, inputs, state): output.component_id, output.component_property).replace(' ', '')) - def _validate_callback_output(self, output_value, output): + def _debug_callback_serialization_error(self, output_value, output): valid = [str, dict, int, float, type(None), Component] def _raise_invalid(bad_val, outer_val, bad_type, path, index=None, @@ -901,13 +930,11 @@ def callback(self, output, inputs=[], state=[]): def wrap_func(func): @wraps(func) - def add_context(*args, **kwargs): - - output_value = func(*args, **kwargs) + def add_context(validated_output): response = { 'response': { 'props': { - output.component_property: output_value + output.component_property: validated_output } } } @@ -918,7 +945,10 @@ def add_context(*args, **kwargs): cls=plotly.utils.PlotlyJSONEncoder ) except TypeError: - self._validate_callback_output(output_value, output) + self._debug_callback_serialization_error( + validated_output, + output + ) raise exceptions.InvalidCallbackReturnValue(''' The callback for property `{property:s}` of component `{id:s}` returned a value @@ -935,6 +965,7 @@ def add_context(*args, **kwargs): mimetype='application/json' ) + self.callback_map[callback_id]['func'] = func self.callback_map[callback_id]['callback'] = add_context return add_context @@ -978,7 +1009,62 @@ def dispatch(self): c['id'] == component_registration['id'] ][0]) - return self.callback_map[target_id]['callback'](*args) + output_value = self.callback_map[target_id]['func'](*args) + + # Only validate if we get required information from renderer + # and validation is not turned off by user + if not self.config.suppress_validation_exceptions and \ + 'namespace' in output and 'type' in output: + # Python2.7 might make these keys and values unicode + namespace = str(output['namespace']) + component_type = str(output['type']) + component_id = str(output['id']) + component_property = str(output['property']) + callback_func_name = self.callback_map[target_id]['func'].__name__ + self._validate_callback_output(namespace, component_type, + component_id, component_property, + callback_func_name, + args, output_value) + + return self.callback_map[target_id]['callback'](output_value) + + def _validate_callback_output(self, namespace, component_type, + component_id, component_property, + callback_func_name, args, value): + module = sys.modules[namespace] + component = getattr(module, component_type) + # pylint: disable=protected-access + validator = DashValidator({ + component_property: component._schema.get(component_property, {}) + }) + valid = validator.validate({component_property: value}) + if not valid: + error_message = _callback_validation_error_template.format( + component_property=component_property, + component_name=component.__name__, + component_id=component_id, + callback_func_name=callback_func_name, + args='({})'.format(", ".join(map(repr, args))), + value=value, + component_schema=pprint.pformat( + component._schema[component_property] + ) + ) + + raise exceptions.CallbackOutputValidationError( + generate_validation_error_message( + validator.errors, + 0, + error_message + ) + ) + # Must also validate initialization of newly created components + if component_property == 'children': + if isinstance(value, Component): + value.validate() + for component in value.traverse(): + if isinstance(component, Component): + component.validate() def _validate_layout(self): if self.layout is None: @@ -995,6 +1081,11 @@ def _validate_layout(self): component_ids = {layout_id} if layout_id else set() for component in to_validate.traverse(): + if ( + not self.config.suppress_validation_exceptions and + isinstance(component, Component) + ): + component.validate() component_id = getattr(component, 'id', None) if component_id and component_id in component_ids: raise exceptions.DuplicateIdError( @@ -1292,6 +1383,10 @@ def run_server(self, dev_tools_silence_routes_logging, ) + if not debug: + # Do not throw validation exceptions in production. + self.config.suppress_validation_exceptions = True + if self._dev_tools.silence_routes_logging: # Since it's silenced, the address don't show anymore. host = flask_run_options.get('host', '127.0.0.1') diff --git a/dash/development/_py_components_generation.py b/dash/development/_py_components_generation.py index 74462ba96b..59ae91512d 100644 --- a/dash/development/_py_components_generation.py +++ b/dash/development/_py_components_generation.py @@ -2,13 +2,16 @@ import copy import os -from dash.development.base_component import _explicitize_args +from dash.development.base_component import ( + _explicitize_args, + generate_property_schema +) from dash.exceptions import NonExistentEventException from ._all_keywords import python_keywords from .base_component import Component -# pylint: disable=unused-argument +# pylint: disable=unused-argument,too-many-locals def generate_class_string(typename, props, description, namespace): """ Dynamically generate class strings to have nicely formatted docstrings, @@ -43,8 +46,12 @@ def generate_class_string(typename, props, description, namespace): # it to be `null` or whether that was just the default value. # The solution might be to deal with default values better although # not all component authors will supply those. - c = '''class {typename}(Component): + c = '''\ +schema = {schema} + +class {typename}(Component): """{docstring}""" + _schema = schema @_explicitize_args def __init__(self, {default_argtext}): self._prop_names = {list_of_valid_keys} @@ -55,7 +62,6 @@ def __init__(self, {default_argtext}): self.available_properties = {list_of_valid_keys} self.available_wildcard_properties =\ {list_of_valid_wildcard_attr_prefixes} - _explicit_args = kwargs.pop('_explicit_args') _locals = locals() _locals.update(kwargs) # For wildcard attrs @@ -85,7 +91,7 @@ def __init__(self, {default_argtext}): default_argtext = "children=None, " argtext = 'children=children, **args' else: - default_argtext = "" + default_argtext = '' argtext = '**args' default_argtext += ", ".join( [('{:s}=Component.REQUIRED'.format(p) @@ -97,6 +103,10 @@ def __init__(self, {default_argtext}): p != 'setProps'] + ["**kwargs"] ) required_args = required_props(props) + schema = { + k: generate_property_schema(v) + for k, v in props.items() if not k.endswith("-*") + } return c.format( typename=typename, namespace=namespace, @@ -106,7 +116,8 @@ def __init__(self, {default_argtext}): docstring=docstring, default_argtext=default_argtext, argtext=argtext, - required_props=required_args + required_props=required_args, + schema=schema ) diff --git a/dash/development/base_component.py b/dash/development/base_component.py index d4e30dea39..fedd214d68 100644 --- a/dash/development/base_component.py +++ b/dash/development/base_component.py @@ -2,10 +2,65 @@ import abc import inspect import sys +import pprint import six -from .._utils import patch_collections_abc +from ..exceptions import ComponentInitializationValidationError +from .._utils import _merge, patch_collections_abc +from .validator import DashValidator, generate_validation_error_message + +_initialization_validation_error_callback = """ +A Dash Component was initialized with invalid properties! + +Dash tried to create a `{component_name}` component with the +following arguments, which caused a validation failure: + +*************************************************************** +{component_args} +*************************************************************** + +The expected schema for the `{component_name}` component is: + +*************************************************************** +{component_schema} +*************************************************************** + +The errors in validation are as follows: + + +""" + + +def _explicitize_args(func): + # Python 2 + if hasattr(func, 'func_code'): + varnames = func.func_code.co_varnames + # Python 3 + else: + varnames = func.__code__.co_varnames + + def wrapper(*args, **kwargs): + if '_explicit_args' in kwargs.keys(): + raise Exception('Variable _explicit_args should not be set.') + kwargs['_explicit_args'] = \ + list( + set( + list(varnames[:len(args)]) + [k for k, _ in kwargs.items()] + ) + ) + if 'self' in kwargs['_explicit_args']: + kwargs['_explicit_args'].remove('self') + return func(*args, **kwargs) + + # If Python 3, we can set the function signature to be correct + if hasattr(inspect, 'signature'): + # pylint: disable=no-member + new_sig = inspect.signature(wrapper).replace( + parameters=inspect.signature(func).parameters.values() + ) + wrapper.__signature__ = new_sig + return wrapper # pylint: disable=no-init,too-few-public-methods @@ -79,6 +134,8 @@ def __str__(self): REQUIRED = _REQUIRED() + _schema = {} + def __init__(self, **kwargs): # pylint: disable=super-init-not-called for k, v in list(kwargs.items()): @@ -242,6 +299,36 @@ def traverse_with_paths(self): for p, t in i.traverse_with_paths(): yield "\n".join([list_path, p]), t + def validate(self): + # Make sure arguments have valid values + DashValidator.set_component_class(Component) + validator = DashValidator( + self._schema, + allow_unknown=True, + ) + # pylint: disable=no-member + args = { + k: v + for k, v in ((x, getattr(self, x, None)) for x in self._prop_names) + if v is not None + } + valid = validator.validate(args) + if not valid: + # pylint: disable=protected-access + error_message = _initialization_validation_error_callback.format( + component_name=self.__class__.__name__, + component_args=pprint.pformat(args), + component_schema=pprint.pformat(self.__class__._schema) + ) + + raise ComponentInitializationValidationError( + generate_validation_error_message( + validator.errors, + 0, + error_message + ) + ) + def __iter__(self): """Yield IDs in the tree of children.""" for t in self.traverse(): @@ -302,32 +389,123 @@ def __repr__(self): ) -def _explicitize_args(func): - # Python 2 - if hasattr(func, 'func_code'): - varnames = func.func_code.co_varnames - # Python 3 - else: - varnames = func.__code__.co_varnames - - def wrapper(*args, **kwargs): - if '_explicit_args' in kwargs.keys(): - raise Exception('Variable _explicit_args should not be set.') - kwargs['_explicit_args'] = \ - list( - set( - list(varnames[:len(args)]) + [k for k, _ in kwargs.items()] - ) +def schema_is_nullable(type_object): + if type_object and 'name' in type_object: + if type_object['name'] == 'enum': + values = type_object['value'] + for v in values: + value = v['value'] + if value == 'null': + return True + elif type_object['name'] == 'union': + values = type_object['value'] + if any(schema_is_nullable(v) for v in values): + return True + return False + + +def js_to_cerberus_type(type_object): + def _enum(x): + schema = {'allowed': [], + 'type': ('string', 'number')} + values = x['value'] + for v in values: + value = v['value'] + if value == 'null': + schema['nullable'] = True + schema['allowed'].append(None) + elif value == 'true': + schema['allowed'].append(True) + elif value == 'false': + schema['allowed'].append(False) + else: + string_value = v['value'].strip("'\"'") + schema['allowed'].append(string_value) + try: + int_value = int(string_value) + schema['allowed'].append(int_value) + except ValueError: + pass + try: + float_value = float(string_value) + schema['allowed'].append(float_value) + except ValueError: + pass + return schema + + converters = { + 'None': lambda x: {}, + 'func': lambda x: {}, + 'symbol': lambda x: {}, + 'custom': lambda x: {}, + 'node': lambda x: { + 'anyof': [ + {'type': 'component'}, + {'type': 'boolean'}, + {'type': 'number'}, + {'type': 'string'}, + { + 'type': 'list', + 'schema': { + 'type': ( + 'component', + 'boolean', + 'number', + 'string') + } + } + ] + }, + 'element': lambda x: {'type': 'component'}, + 'enum': _enum, + 'union': lambda x: { + 'anyof': [js_to_cerberus_type(v) for v in x['value']], + }, + 'any': lambda x: {}, # Empty means no validation is run + 'string': lambda x: {'type': 'string'}, + 'bool': lambda x: {'type': 'boolean'}, + 'number': lambda x: {'type': 'number'}, + 'integer': lambda x: {'type': 'number'}, + 'object': lambda x: {'type': 'dict'}, + 'objectOf': lambda x: { + 'type': 'dict', + 'nullable': schema_is_nullable(x), + 'valueschema': js_to_cerberus_type(x['value']) + }, + 'array': lambda x: {'type': 'list'}, + 'arrayOf': lambda x: { + 'type': 'list', + 'schema': _merge( + js_to_cerberus_type(x['value']), + {'nullable': schema_is_nullable(x['value'])} ) - if 'self' in kwargs['_explicit_args']: - kwargs['_explicit_args'].remove('self') - return func(*args, **kwargs) - - # If Python 3, we can set the function signature to be correct - if hasattr(inspect, 'signature'): - # pylint: disable=no-member - new_sig = inspect.signature(wrapper).replace( - parameters=inspect.signature(func).parameters.values() - ) - wrapper.__signature__ = new_sig - return wrapper + }, + 'shape': lambda x: { + 'type': 'dict', + 'allow_unknown': False, + 'nullable': schema_is_nullable(x), + 'schema': { + k: js_to_cerberus_type(v) for k, v in x['value'].items() + } + }, + 'instanceOf': lambda x: dict( + Date={'type': 'datetime'}, + ).get(x['value'], {}) + } + if type_object: + converter = converters[type_object.get('name', 'None')] + schema = converter(type_object) + return schema + return {} + + +def generate_property_schema(jsonSchema): + schema = {} + type_object = jsonSchema.get('type', None) + required = jsonSchema.get('required', None) + propType = js_to_cerberus_type(type_object) + if propType: + schema.update(propType) + schema['nullable'] = schema_is_nullable(type_object) + schema['required'] = required + return schema diff --git a/dash/development/component_loader.py b/dash/development/component_loader.py index 968a3fa1d1..9e460b4d71 100644 --- a/dash/development/component_loader.py +++ b/dash/development/component_loader.py @@ -11,12 +11,23 @@ from .base_component import ComponentRegistry +def _decode_hook(pairs): + new_pairs = [] + for key, value in pairs: + if type(value).__name__ == 'unicode': + value = value.encode('utf-8') + if type(key).__name__ == 'unicode': + key = key.encode('utf-8') + new_pairs.append((key, value)) + return collections.OrderedDict(new_pairs) + + def _get_metadata(metadata_path): # Start processing with open(metadata_path) as data_file: json_string = data_file.read() data = json\ - .JSONDecoder(object_pairs_hook=collections.OrderedDict)\ + .JSONDecoder(object_pairs_hook=_decode_hook)\ .decode(json_string) return data diff --git a/dash/development/validator.py b/dash/development/validator.py new file mode 100644 index 0000000000..5bd2795703 --- /dev/null +++ b/dash/development/validator.py @@ -0,0 +1,120 @@ +from textwrap import dedent +import plotly +import cerberus + + +class DashValidator(cerberus.Validator): + types_mapping = cerberus.Validator.types_mapping.copy() + types_mapping.pop('list') # To be replaced by our custom method + types_mapping.pop('number') # To be replaced by our custom method + + def _validator_plotly_figure(self, field, value): + if not isinstance(value, (dict, plotly.graph_objs.Figure)): + self._error( + field, + "Invalid Plotly Figure.") + if isinstance(value, dict): + try: + plotly.graph_objs.Figure(value) + except (ValueError, plotly.exceptions.PlotlyDictKeyError) as e: + self._error( + field, + "Invalid Plotly Figure:\n\n{}".format(e)) + + def _validator_options_with_unique_values(self, field, value): + if not isinstance(value, list): + self._error(field, "Invalid options: Not a list!") + values = set() + for i, option_dict in enumerate(value): + if not isinstance(option_dict, dict): + self._error( + field, + "The option at index {} is not a dictionary!" + .format(i) + ) + if 'value' not in option_dict: + self._error( + field, + "The option at index {} does not have a 'value' key!" + .format(i) + ) + curr = option_dict['value'] + if curr in values: + self._error( + field, + ("The options list you provided was not valid. " + "More than one of the options has the value {}." + .format(curr)) + ) + values.add(curr) + + def _validate_type_list(self, value): + if isinstance(value, (list, tuple)): + return True + # These types can be cast to list + elif isinstance(value, (self.component_class, str, set)): + return False + # Handle numpy array / pandas series + try: + value_list = list(value) + if isinstance(value_list, list): + return True + except (ValueError, TypeError): + pass + return False + + # pylint: disable=no-self-use + def _validate_type_number(self, value): + if isinstance(value, (int, float)): + return True + if isinstance(value, str): # Since int('3') works + return False + # The following handles numpy numeric types + try: + int(value) + return True + except (ValueError, TypeError, AttributeError): + pass + try: + float(value) + return True + except (ValueError, TypeError, AttributeError): + pass + return False + + @classmethod + def set_component_class(cls, component_cls): + cls.component_class = component_cls + c_type = cerberus.TypeDefinition('component', (component_cls,), ()) + cls.types_mapping['component'] = c_type + d_type = cerberus.TypeDefinition('dict', (dict,), ()) + cls.types_mapping['dict'] = d_type + + +def parse_cerberus_error_tree(errors, level=0, error_message=''): + for prop, error_tuple in errors.items(): + error_message += (' ' * level) + '* {}'.format(prop) + if len(error_tuple) == 2: + error_message += '\t<- {}\n'.format(error_tuple[0]) + error_message = parse_cerberus_error_tree( + error_tuple[1], + level + 1, + error_message) + else: + if isinstance(error_tuple[0], str): + error_message += '\t<- {}\n'.format(error_tuple[0]) + elif isinstance(error_tuple[0], dict): + error_message = parse_cerberus_error_tree( + error_tuple[0], + level + 1, + error_message + "\n") + return error_message + + +def generate_validation_error_message(errors, level=0, error_message=''): + error_message = parse_cerberus_error_tree(errors, level, error_message) + error_message += dedent(""" + You can turn off these validation exceptions by setting + `app.config.suppress_validation_exceptions=True` + """) + return error_message diff --git a/dash/exceptions.py b/dash/exceptions.py index 7ed976633d..6d30df6c52 100644 --- a/dash/exceptions.py +++ b/dash/exceptions.py @@ -62,6 +62,14 @@ class InvalidConfig(DashException): pass +class ComponentInitializationValidationError(DashException): + pass + + +class CallbackOutputValidationError(CallbackException): + pass + + class InvalidResourceError(DashException): pass diff --git a/setup.py b/setup.py index b00c1747a6..95dd55290d 100644 --- a/setup.py +++ b/setup.py @@ -20,6 +20,7 @@ 'Flask>=0.12', 'flask-compress', 'plotly', + 'Cerberus', 'dash_renderer==0.18.0', 'dash-core-components==0.43.1', 'dash-html-components==0.13.5', diff --git a/test.sh b/test.sh index 48f97a1cc5..e097278cb9 100755 --- a/test.sh +++ b/test.sh @@ -2,6 +2,7 @@ EXIT_STATE=0 python -m unittest tests.development.test_base_component || EXIT_STATE=$? python -m unittest tests.development.test_component_loader || EXIT_STATE=$? +python -m unittest tests.development.test_component_validation || EXIT_STATE=$? python -m unittest tests.test_integration || EXIT_STATE=$? python -m unittest tests.test_resources || EXIT_STATE=$? python -m unittest tests.test_configs || EXIT_STATE=$? diff --git a/tests/development/TestReactComponent.react.js b/tests/development/TestReactComponent.react.js index cc42763aa4..468b5b8c29 100644 --- a/tests/development/TestReactComponent.react.js +++ b/tests/development/TestReactComponent.react.js @@ -1,4 +1,5 @@ import React from 'react'; +import PropTypes from 'prop-types'; // A react component with all of the available proptypes to run tests over /** @@ -15,63 +16,66 @@ ReactComponent.propTypes = { /** * Description of optionalArray */ - optionalArray: React.PropTypes.array, - optionalBool: React.PropTypes.bool, - optionalFunc: React.PropTypes.func, - optionalNumber: React.PropTypes.number, - optionalObject: React.PropTypes.object, - optionalString: React.PropTypes.string, - optionalSymbol: React.PropTypes.symbol, + optionalArray: PropTypes.array, + optionalBool: PropTypes.bool, + optionalFunc: PropTypes.func, + optionalNumber: PropTypes.number, + optionalObject: PropTypes.object, + optionalString: PropTypes.string, + optionalSymbol: PropTypes.symbol, // Anything that can be rendered: numbers, strings, elements or an array // (or fragment) containing these types. - optionalNode: React.PropTypes.node, + optionalNode: PropTypes.node, // A React element. - optionalElement: React.PropTypes.element, + optionalElement: PropTypes.element, // You can also declare that a prop is an instance of a class. This uses // JS's instanceof operator. - optionalMessage: React.PropTypes.instanceOf(Message), + optionalMessage: PropTypes.instanceOf(Message), // You can ensure that your prop is limited to specific values by treating // it as an enum. - optionalEnum: React.PropTypes.oneOf(['News', 'Photos']), + optionalEnum: PropTypes.oneOf(['News', 'Photos', 1, 2, true, false]), - // An object that could be one of many types - optionalUnion: React.PropTypes.oneOfType([ - React.PropTypes.string, - React.PropTypes.number, - React.PropTypes.instanceOf(Message) + // An object that could be one of many types. + optionalUnion: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.instanceOf(Message) ]), // An array of a certain type - optionalArrayOf: React.PropTypes.arrayOf(React.PropTypes.number), + optionalArrayOf: PropTypes.arrayOf(PropTypes.number), // An object with property values of a certain type - optionalObjectOf: React.PropTypes.objectOf(React.PropTypes.number), + optionalObjectOf: PropTypes.objectOf(PropTypes.number), // An object taking on a particular shape - optionalObjectWithShapeAndNestedDescription: React.PropTypes.shape({ - color: React.PropTypes.string, - fontSize: React.PropTypes.number, + optionalObjectWithShapeAndNestedDescription: PropTypes.shape({ + color: PropTypes.string, + fontSize: PropTypes.number, /** * Figure is a plotly graph object */ - figure: React.PropTypes.shape({ + figure: PropTypes.shape({ /** * data is a collection of traces */ - data: React.PropTypes.arrayOf(React.PropTypes.object), + data: PropTypes.arrayOf(PropTypes.object), /** * layout describes the rest of the figure */ - layout: React.PropTypes.object + layout: PropTypes.object }) }), // A value of any data type - optionalAny: React.PropTypes.any, + optionalAny: PropTypes.any, + + "data-*": PropTypes.string, + "aria-*": PropTypes.string, customProp: function(props, propName, componentName) { if (!/matchme/.test(props[propName])) { @@ -82,7 +86,7 @@ ReactComponent.propTypes = { } }, - customArrayProp: React.PropTypes.arrayOf(function(propValue, key, componentName, location, propFullName) { + customArrayProp: PropTypes.arrayOf(function(propValue, key, componentName, location, propFullName) { if (!/matchme/.test(propValue[key])) { return new Error( 'Invalid prop `' + propFullName + '` supplied to' + @@ -91,9 +95,26 @@ ReactComponent.propTypes = { } }), - children: React.PropTypes.node, + children: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.bool, + PropTypes.element, + PropTypes.oneOf([null]), + PropTypes.arrayOf( + PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.bool, + PropTypes.element, + PropTypes.oneOf([null]) + ]) + ) + ]), + + in: PropTypes.string, + id: PropTypes.string, - id: React.PropTypes.string, }; ReactComponent.defaultProps = { diff --git a/tests/development/TestReactComponentRequired.react.js b/tests/development/TestReactComponentRequired.react.js index a08b0f0dda..9b52fca332 100644 --- a/tests/development/TestReactComponentRequired.react.js +++ b/tests/development/TestReactComponentRequired.react.js @@ -1,4 +1,5 @@ import React from 'react'; +import PropTypes from 'prop-types'; // A react component with all of the available proptypes to run tests over /** @@ -12,8 +13,23 @@ class ReactComponent extends Component { } ReactComponent.propTypes = { - children: React.PropTypes.node, - id: React.PropTypes.string.isRequired, + children: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.bool, + PropTypes.element, + PropTypes.oneOf([null]), + PropTypes.arrayOf( + PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.bool, + PropTypes.element, + PropTypes.oneOf([null]) + ]) + ) + ]), + id: PropTypes.string.isRequired, }; export default ReactComponent; diff --git a/tests/development/metadata_required_test.json b/tests/development/metadata_required_test.json index 9b2caa62c4..3d5abb5d19 100644 --- a/tests/development/metadata_required_test.json +++ b/tests/development/metadata_required_test.json @@ -1,10 +1,63 @@ { "description": "This is a description of the component.\nIt's multiple lines long.", + "displayName": "ReactComponent", "methods": [], "props": { "children": { "type": { - "name": "node" + "name": "union", + "value": [ + { + "name": "string" + }, + { + "name": "number" + }, + { + "name": "bool" + }, + { + "name": "element" + }, + { + "name": "enum", + "value": [ + { + "value": "null", + "computed": false + } + ] + }, + { + "name": "arrayOf", + "value": { + "name": "union", + "value": [ + { + "name": "string" + }, + { + "name": "number" + }, + { + "name": "bool" + }, + { + "name": "element" + }, + { + "name": "enum", + "value": [ + { + "value": "null", + "computed": false + } + ] + } + ] + } + } + ] }, "required": false, "description": "" diff --git a/tests/development/metadata_test.json b/tests/development/metadata_test.json index faa4bc734e..31f7bb27ba 100644 --- a/tests/development/metadata_test.json +++ b/tests/development/metadata_test.json @@ -1,5 +1,6 @@ { "description": "This is a description of the component.\nIt's multiple lines long.", + "displayName": "ReactComponent", "methods": [], "props": { "optionalArray": { @@ -92,6 +93,22 @@ { "value": "'Photos'", "computed": false + }, + { + "value": "1", + "computed": false + }, + { + "value": "2", + "computed": false + }, + { + "value": "false", + "computed": false + }, + { + "value": "true", + "computed": false } ] }, @@ -181,42 +198,94 @@ "required": false, "description": "" }, - "customProp": { + "data-*": { "type": { - "name": "custom", - "raw": "function(props, propName, componentName) {\n if (!/matchme/.test(props[propName])) {\n return new Error(\n 'Invalid prop `' + propName + '` supplied to' +\n ' `' + componentName + '`. Validation failed.'\n );\n }\n}" + "name": "string" }, "required": false, "description": "" }, - "customArrayProp": { + "aria-*": { "type": { - "name": "arrayOf", - "value": { - "name": "custom", - "raw": "function(propValue, key, componentName, location, propFullName) {\n if (!/matchme/.test(propValue[key])) {\n return new Error(\n 'Invalid prop `' + propFullName + '` supplied to' +\n ' `' + componentName + '`. Validation failed.'\n );\n }\n}" - } + "name": "string" }, "required": false, "description": "" }, - "children": { + "customProp": { "type": { - "name": "node" + "name": "custom", + "raw": "function(props, propName, componentName) {\n if (!/matchme/.test(props[propName])) {\n return new Error(\n 'Invalid prop `' + propName + '` supplied to' +\n ' `' + componentName + '`. Validation failed.'\n );\n }\n}" }, "required": false, "description": "" }, - "data-*": { + "customArrayProp": { "type": { - "name": "string" + "name": "arrayOf", + "value": { + "name": "custom", + "raw": "function(propValue, key, componentName, location, propFullName) {\n if (!/matchme/.test(propValue[key])) {\n return new Error(\n 'Invalid prop `' + propFullName + '` supplied to' +\n ' `' + componentName + '`. Validation failed.'\n );\n }\n}" + } }, "required": false, "description": "" }, - "aria-*": { + "children": { "type": { - "name": "string" + "name": "union", + "value": [ + { + "name": "string" + }, + { + "name": "number" + }, + { + "name": "bool" + }, + { + "name": "element" + }, + { + "name": "enum", + "value": [ + { + "value": "null", + "computed": false + } + ] + }, + { + "name": "arrayOf", + "value": { + "name": "union", + "value": [ + { + "name": "string" + }, + { + "name": "number" + }, + { + "name": "bool" + }, + { + "name": "element" + }, + { + "name": "enum", + "value": [ + { + "value": "null", + "computed": false + } + ] + } + ] + } + } + ] }, "required": false, "description": "" diff --git a/tests/development/metadata_test.py b/tests/development/metadata_test.py index c1a580fa6b..ee0267d620 100644 --- a/tests/development/metadata_test.py +++ b/tests/development/metadata_test.py @@ -3,13 +3,15 @@ from dash.development.base_component import Component, _explicitize_args +schema = {'in': {'required': False, 'type': 'string', 'nullable': False}, 'optionalObjectWithShapeAndNestedDescription': {'required': False, 'nullable': False, 'type': 'dict', 'allow_unknown': False, 'schema': {'color': {'type': 'string'}, 'fontSize': {'type': 'number'}, 'figure': {'schema': {'layout': {'type': 'dict'}, 'data': {'type': 'list', 'schema': {'type': 'dict', 'nullable': False}}}, 'type': 'dict', 'allow_unknown': False, 'nullable': False}}}, 'optionalNode': {'required': False, 'anyof': [{'type': 'component'}, {'type': 'boolean'}, {'type': 'number'}, {'type': 'string'}, {'type': 'list', 'schema': {'type': ('component', 'boolean', 'number', 'string')}}], 'nullable': False}, 'optionalSymbol': {'required': False, 'nullable': False}, 'optionalMessage': {'required': False, 'nullable': False}, 'optionalString': {'required': False, 'type': 'string', 'nullable': False}, 'optionalObjectOf': {'required': False, 'type': 'dict', 'valueschema': {'type': 'number'}, 'nullable': False}, 'optionalObject': {'required': False, 'type': 'dict', 'nullable': False}, 'children': {'required': False, 'anyof': [{'type': 'string'}, {'type': 'number'}, {'type': 'boolean'}, {'type': 'component'}, {'nullable': True, 'type': ('string', 'number'), 'allowed': [None]}, {'type': 'list', 'schema': {'anyof': [{'type': 'string'}, {'type': 'number'}, {'type': 'boolean'}, {'type': 'component'}, {'nullable': True, 'type': ('string', 'number'), 'allowed': [None]}], 'nullable': True}}], 'nullable': True}, 'optionalEnum': {'required': False, 'nullable': False, 'type': ('string', 'number'), 'allowed': ['News', 'Photos', '1', 1, 1.0, '2', 2, 2.0, False, True]}, 'optionalBool': {'required': False, 'type': 'boolean', 'nullable': False}, 'optionalNumber': {'required': False, 'type': 'number', 'nullable': False}, 'optionalFunc': {'required': False, 'nullable': False}, 'optionalElement': {'required': False, 'type': 'component', 'nullable': False}, 'id': {'required': False, 'type': 'string', 'nullable': False}, 'optionalArray': {'required': False, 'type': 'list', 'nullable': False}, 'optionalAny': {'required': False, 'nullable': False}, 'customProp': {'required': False, 'nullable': False}, 'optionalUnion': {'required': False, 'anyof': [{'type': 'string'}, {'type': 'number'}, {}], 'nullable': False}, 'customArrayProp': {'required': False, 'nullable': False, 'type': 'list', 'schema': {'nullable': False}}, 'optionalArrayOf': {'required': False, 'nullable': False, 'type': 'list', 'schema': {'type': 'number', 'nullable': False}}} + class Table(Component): """A Table component. This is a description of the component. It's multiple lines long. Keyword arguments: -- children (a list of or a singular dash component, string or number; optional) +- children (string | number | boolean | dash component | a value equal to: null | list; optional) - optionalArray (list; optional): Description of optionalArray - optionalBool (boolean; optional) - optionalNumber (number; optional) @@ -17,7 +19,7 @@ class Table(Component): - optionalString (string; optional) - optionalNode (a list of or a singular dash component, string or number; optional) - optionalElement (dash component; optional) -- optionalEnum (a value equal to: 'News', 'Photos'; optional) +- optionalEnum (a value equal to: 'News', 'Photos', 1, 2, false, true; optional) - optionalUnion (string | number; optional) - optionalArrayOf (list; optional) - optionalObjectOf (dict with strings as keys and values of type number; optional) @@ -30,21 +32,21 @@ class Table(Component): - data (list; optional): data is a collection of traces - layout (dict; optional): layout describes the rest of the figure - optionalAny (boolean | number | string | dict | list; optional) -- customProp (optional) -- customArrayProp (list; optional) - data-* (string; optional) - aria-* (string; optional) +- customProp (optional) +- customArrayProp (list; optional) - in (string; optional) - id (string; optional)""" + _schema = schema @_explicitize_args def __init__(self, children=None, optionalArray=Component.UNDEFINED, optionalBool=Component.UNDEFINED, optionalFunc=Component.UNDEFINED, optionalNumber=Component.UNDEFINED, optionalObject=Component.UNDEFINED, optionalString=Component.UNDEFINED, optionalSymbol=Component.UNDEFINED, optionalNode=Component.UNDEFINED, optionalElement=Component.UNDEFINED, optionalMessage=Component.UNDEFINED, optionalEnum=Component.UNDEFINED, optionalUnion=Component.UNDEFINED, optionalArrayOf=Component.UNDEFINED, optionalObjectOf=Component.UNDEFINED, optionalObjectWithShapeAndNestedDescription=Component.UNDEFINED, optionalAny=Component.UNDEFINED, customProp=Component.UNDEFINED, customArrayProp=Component.UNDEFINED, id=Component.UNDEFINED, **kwargs): - self._prop_names = ['children', 'optionalArray', 'optionalBool', 'optionalNumber', 'optionalObject', 'optionalString', 'optionalNode', 'optionalElement', 'optionalEnum', 'optionalUnion', 'optionalArrayOf', 'optionalObjectOf', 'optionalObjectWithShapeAndNestedDescription', 'optionalAny', 'customProp', 'customArrayProp', 'data-*', 'aria-*', 'in', 'id'] + self._prop_names = ['children', 'optionalArray', 'optionalBool', 'optionalNumber', 'optionalObject', 'optionalString', 'optionalNode', 'optionalElement', 'optionalEnum', 'optionalUnion', 'optionalArrayOf', 'optionalObjectOf', 'optionalObjectWithShapeAndNestedDescription', 'optionalAny', 'data-*', 'aria-*', 'customProp', 'customArrayProp', 'in', 'id'] self._type = 'Table' self._namespace = 'TableComponents' self._valid_wildcard_attributes = ['data-', 'aria-'] - self.available_properties = ['children', 'optionalArray', 'optionalBool', 'optionalNumber', 'optionalObject', 'optionalString', 'optionalNode', 'optionalElement', 'optionalEnum', 'optionalUnion', 'optionalArrayOf', 'optionalObjectOf', 'optionalObjectWithShapeAndNestedDescription', 'optionalAny', 'customProp', 'customArrayProp', 'data-*', 'aria-*', 'in', 'id'] + self.available_properties = ['children', 'optionalArray', 'optionalBool', 'optionalNumber', 'optionalObject', 'optionalString', 'optionalNode', 'optionalElement', 'optionalEnum', 'optionalUnion', 'optionalArrayOf', 'optionalObjectOf', 'optionalObjectWithShapeAndNestedDescription', 'optionalAny', 'data-*', 'aria-*', 'customProp', 'customArrayProp', 'in', 'id'] self.available_wildcard_properties = ['data-', 'aria-'] - _explicit_args = kwargs.pop('_explicit_args') _locals = locals() _locals.update(kwargs) # For wildcard attrs diff --git a/tests/development/test_base_component.py b/tests/development/test_base_component.py index a19768a3dc..4551daac1a 100644 --- a/tests/development/test_base_component.py +++ b/tests/development/test_base_component.py @@ -7,7 +7,11 @@ import unittest import plotly -from dash.development.base_component import Component +from dash.development.component_loader import _get_metadata +from dash.development.base_component import ( + Component, + _explicitize_args +) from dash.development._py_components_generation import generate_class_string, generate_class_file, generate_class, \ create_docstring, prohibit_events, js_to_py_type @@ -498,12 +502,7 @@ def test_pop(self): class TestGenerateClassFile(unittest.TestCase): def setUp(self): json_path = os.path.join('tests', 'development', 'metadata_test.json') - with open(json_path) as data_file: - json_string = data_file.read() - data = json\ - .JSONDecoder(object_pairs_hook=collections.OrderedDict)\ - .decode(json_string) - self.data = data + data = _get_metadata(json_path) # Create a folder for the new component file os.makedirs('TableComponents') @@ -542,6 +541,15 @@ def setUp(self): with open(expected_string_path, 'r') as f: self.expected_class_string = f.read() + def remove_schema(string): + tmp = string.split("\n") + return "\n".join(tmp[:5] + tmp[6:]) + + self.expected_class_string = remove_schema(self.expected_class_string) + self.component_class_string =\ + remove_schema(self.component_class_string) + self.written_class_string = remove_schema(self.written_class_string) + def tearDown(self): shutil.rmtree('TableComponents') @@ -567,12 +575,7 @@ def test_class_file(self): class TestGenerateClass(unittest.TestCase): def setUp(self): path = os.path.join('tests', 'development', 'metadata_test.json') - with open(path) as data_file: - json_string = data_file.read() - data = json\ - .JSONDecoder(object_pairs_hook=collections.OrderedDict)\ - .decode(json_string) - self.data = data + data = _get_metadata(path) self.ComponentClass = generate_class( typename='Table', @@ -618,14 +621,14 @@ def test_to_plotly_json(self): } }) - c = self.ComponentClass(id='my-id', optionalArray=None) + c = self.ComponentClass(id='my-id', optionalArray=[]) self.assertEqual(c.to_plotly_json(), { 'namespace': 'TableComponents', 'type': 'Table', 'props': { 'children': None, 'id': 'my-id', - 'optionalArray': None + 'optionalArray': [] } }) @@ -742,12 +745,12 @@ def test_call_signature(self): def test_required_props(self): with self.assertRaises(Exception): - self.ComponentClassRequired() + self.ComponentClassRequired().validate() self.ComponentClassRequired(id='test') with self.assertRaises(Exception): - self.ComponentClassRequired(id='test', lahlah='test') + self.ComponentClassRequired(id='test', lahlah='test').validate() with self.assertRaises(Exception): - self.ComponentClassRequired(children='test') + self.ComponentClassRequired(children='test').validate() class TestMetaDataConversions(unittest.TestCase): @@ -762,7 +765,7 @@ def setUp(self): self.expected_arg_strings = OrderedDict([ ['children', - 'a list of or a singular dash component, string or number'], + 'string | number | boolean | dash component | a value equal to: null | list'], ['optionalArray', 'list'], @@ -785,7 +788,7 @@ def setUp(self): ['optionalMessage', ''], - ['optionalEnum', 'a value equal to: \'News\', \'Photos\''], + ['optionalEnum', 'a value equal to: \'News\', \'Photos\', 1, 2, false, true'], ['optionalUnion', 'string | number'], @@ -850,7 +853,7 @@ def assert_docstring(assertEqual, docstring): "It's multiple lines long.", '', "Keyword arguments:", - "- children (a list of or a singular dash component, string or number; optional)", # noqa: E501 + "- children (string | number | boolean | dash component | a value equal to: null | list; optional)", # noqa: E501 "- optionalArray (list; optional): Description of optionalArray", "- optionalBool (boolean; optional)", "- optionalNumber (number; optional)", @@ -861,7 +864,7 @@ def assert_docstring(assertEqual, docstring): "string or number; optional)", "- optionalElement (dash component; optional)", - "- optionalEnum (a value equal to: 'News', 'Photos'; optional)", + "- optionalEnum (a value equal to: 'News', 'Photos', 1, 2, false, true; optional)", "- optionalUnion (string | number; optional)", "- optionalArrayOf (list; optional)", @@ -890,10 +893,10 @@ def assert_docstring(assertEqual, docstring): "- optionalAny (boolean | number | string | dict | " "list; optional)", - "- customProp (optional)", - "- customArrayProp (list; optional)", '- data-* (string; optional)', '- aria-* (string; optional)', + "- customProp (optional)", + "- customArrayProp (list; optional)", '- in (string; optional)', '- id (string; optional)', ' ' diff --git a/tests/development/test_component_loader.py b/tests/development/test_component_loader.py index 7f3ce871fb..97babc26d8 100644 --- a/tests/development/test_component_loader.py +++ b/tests/development/test_component_loader.py @@ -1,9 +1,12 @@ -import collections -import json import os import shutil import unittest -from dash.development.component_loader import load_components, generate_classes +import json +from dash.development.component_loader import ( + load_components, + generate_classes, + _decode_hook +) from dash.development.base_component import ( Component ) @@ -27,7 +30,7 @@ }, "children": { "type": { - "name": "object" + "name": "node" }, "description": "Children", "required": false @@ -89,7 +92,7 @@ }, "children": { "type": { - "name": "object" + "name": "node" }, "description": "Children", "required": false @@ -98,7 +101,7 @@ } }''' METADATA = json\ - .JSONDecoder(object_pairs_hook=collections.OrderedDict)\ + .JSONDecoder(object_pairs_hook=_decode_hook)\ .decode(METADATA_STRING) @@ -128,7 +131,7 @@ def test_loadcomponents(self): c = load_components(METADATA_PATH) MyComponentKwargs = { - 'foo': 'Hello World', + 'foo': 42, 'bar': 'Lah Lah', 'baz': 'Lemons', 'data-foo': 'Blah', diff --git a/tests/development/test_component_validation.py b/tests/development/test_component_validation.py new file mode 100644 index 0000000000..c9061abdd3 --- /dev/null +++ b/tests/development/test_component_validation.py @@ -0,0 +1,523 @@ +import os +import json +import unittest +import collections +import numpy as np +import pandas as pd +import plotly.graph_objs as go +import dash_html_components as html +import dash +from dash.development.component_loader import _get_metadata +from dash.development.base_component import Component +from dash.development.validator import DashValidator +from dash.development._py_components_generation import generate_class + +# Monkey patched html +html.Div._schema = {'children': {'anyof': [{'type': 'string'}, {'type': 'number'}, {'type': 'boolean'}, {'type': 'component'}, {'allowed': [None], 'type': ('string', 'number'), 'nullable': True}, {'type': 'list', 'schema': {'anyof': [{'type': 'string'}, {'type': 'number'}, {'type': 'boolean'}, {'type': 'component'}, {'allowed': [None], 'type': ('string', 'number'), 'nullable': True}], 'nullable': True}}], 'nullable': True}} +html.Button._schema = html.Div._schema + + +class TestComponentValidation(unittest.TestCase): + def setUp(self): + self.validator = DashValidator + path = os.path.join('tests', 'development', 'metadata_test.json') + data = _get_metadata(path) + + self.ComponentClass = generate_class( + typename='Table', + props=data['props'], + description=data['description'], + namespace='TableComponents' + ) + + path = os.path.join( + 'tests', 'development', 'metadata_required_test.json' + ) + with open(path) as data_file: + json_string = data_file.read() + required_data = json\ + .JSONDecoder(object_pairs_hook=collections.OrderedDict)\ + .decode(json_string) + self.required_data = required_data + + self.ComponentClassRequired = generate_class( + typename='TableRequired', + props=required_data['props'], + description=required_data['description'], + namespace='TableComponents' + ) + + DashValidator.set_component_class(Component) + + def make_validator(schema): + return DashValidator(schema, allow_unknown=True) + + self.component_validator = make_validator(self.ComponentClass._schema) + self.required_validator =\ + make_validator(self.ComponentClassRequired._schema) + self.figure_validator = make_validator({ + 'figure': { + 'validator': 'plotly_figure' + } + }) + self.options_validator = make_validator({ + 'options': { + 'validator': 'options_with_unique_values' + } + }) + + def test_component_in_initial_layout_is_validated(self): + app = dash.Dash(__name__) + app.config['suppress_callback_exceptions'] = True + + app.layout = html.Div(self.ComponentClass(id='hello', children=[[]])) + + with self.assertRaises( + dash.exceptions.ComponentInitializationValidationError + ) as cm: + app._validate_layout() + the_exception = cm.exception + print(the_exception) + + def test_callback_output_is_validated(self): + app = dash.Dash(__name__) + app.config['suppress_callback_exceptions'] = True + + app.layout = html.Div(children=[ + html.Button(id='put-components', children='Click me'), + html.Div(id='container'), + ]) + + @app.callback( + dash.dependencies.Output('container', 'children'), + [dash.dependencies.Input('put-components', 'n_clicks')] + ) + def put_components(n_clicks): + if n_clicks: + return [[]] + return "empty" + + with app.server.test_request_context( + "/_dash-update-component", + json={ + 'inputs': [{ + 'id': 'put-components', + 'property': 'n_clicks', + 'value': 1 + }], + 'output': { + 'namespace': 'dash_html_components', + 'type': 'Div', + 'id': 'container', + 'property': 'children' + } + } + ): + with self.assertRaises( + dash.exceptions.CallbackOutputValidationError + ) as cm: + app.dispatch() + the_exception = cm.exception + print(the_exception) + + def test_component_initialization_in_callback_is_validated(self): + app = dash.Dash(__name__) + app.config['suppress_callback_exceptions'] = True + + app.layout = html.Div(children=[ + html.Button(id='put-components', children='Click me'), + html.Div(id='container'), + ]) + + @app.callback( + dash.dependencies.Output('container', 'children'), + [dash.dependencies.Input('put-components', 'n_clicks')] + ) + def put_components(n_clicks): + if n_clicks: + return html.Button( + children=[[]], + ) + return "empty" + + with app.server.test_request_context( + "/_dash-update-component", + json={ + 'inputs': [{ + 'id': 'put-components', + 'property': 'n_clicks', + 'value': 1 + }], + 'output': { + 'namespace': 'dash_html_components', + 'type': 'Div', + 'id': 'container', + 'property': 'children' + } + } + ): + self.assertRaises( + dash.exceptions.ComponentInitializationValidationError, + app.dispatch + ) + + def test_required_validation(self): + self.assertTrue(self.required_validator.validate({ + 'id': 'required', + 'children': 'hello world' + })) + self.assertFalse(self.required_validator.validate({ + 'children': 'hello world' + })) + + def test_string_validation(self): + self.assertTrue(self.component_validator.validate({ + 'optionalString': "bananas" + })) + self.assertFalse(self.component_validator.validate({ + 'optionalString': 7 + })) + self.assertFalse(self.component_validator.validate({ + 'optionalString': None + })) + + def test_boolean_validation(self): + self.assertTrue(self.component_validator.validate({ + 'optionalBool': False + })) + self.assertFalse(self.component_validator.validate({ + 'optionalBool': "False" + })) + self.assertFalse(self.component_validator.validate({ + 'optionalBool': None + })) + + def test_number_validation(self): + numpy_types = [ + np.int_, np.intc, np.intp, np.int8, np.int16, np.int32, np.int64, + np.uint8, np.uint16, np.uint32, np.uint64, + np.float_, np.float32, np.float64 + ] + for t in numpy_types: + self.assertTrue(self.component_validator.validate({ + 'optionalNumber': t(7) + })) + self.assertTrue(self.component_validator.validate({ + 'optionalNumber': 7 + })) + self.assertFalse(self.component_validator.validate({ + 'optionalNumber': "seven" + })) + self.assertFalse(self.component_validator.validate({ + 'optionalNumber': None + })) + + def test_object_validation(self): + self.assertTrue(self.component_validator.validate({ + 'optionalObject': {'foo': 'bar'} + })) + self.assertFalse(self.component_validator.validate({ + 'optionalObject': "not a dict" + })) + self.assertFalse(self.component_validator.validate({ + 'optionalObject': self.ComponentClass() + })) + self.assertFalse(self.component_validator.validate({ + 'optionalObject': None + })) + + def test_children_validation(self): + + class MyOtherType: + def __init__(self): + pass + + self.assertFalse(self.component_validator.validate({ + 'children': MyOtherType() + })) + self.assertTrue(self.component_validator.validate({})) + self.assertTrue(self.component_validator.validate({ + 'children': None + })) + self.assertTrue(self.component_validator.validate({ + 'children': 'one' + })) + self.assertTrue(self.component_validator.validate({ + 'children': 1 + })) + self.assertTrue(self.component_validator.validate({ + 'children': False + })) + self.assertTrue(self.component_validator.validate({ + 'children': self.ComponentClass() + })) + self.assertTrue(self.component_validator.validate({ + 'children': ['one'] + })) + self.assertTrue(self.component_validator.validate({ + 'children': [1] + })) + self.assertTrue(self.component_validator.validate({ + 'children': [self.ComponentClass()] + })) + self.assertTrue(self.component_validator.validate({ + 'children': [None] + })) + self.assertTrue(self.component_validator.validate({ + 'children': () + })) + self.assertFalse(self.component_validator.validate({ + 'children': [[]] + })) + + def test_node_validation(self): + self.assertTrue(self.component_validator.validate({ + 'optionalNode': 7 + })) + self.assertTrue(self.component_validator.validate({ + 'optionalNode': "seven" + })) + self.assertFalse(self.component_validator.validate({ + 'optionalNode': None + })) + self.assertTrue(self.component_validator.validate({ + 'optionalNode': False + })) + self.assertTrue(self.component_validator.validate({ + 'optionalNode': self.ComponentClass() + })) + self.assertTrue(self.component_validator.validate({ + 'optionalNode': [ + 7, + 'seven', + False, + self.ComponentClass() + ] + })) + self.assertFalse(self.component_validator.validate({ + 'optionalNode': [["Invalid Nested Dict"]] + })) + + def test_element_validation(self): + self.assertTrue(self.component_validator.validate({ + 'optionalElement': self.ComponentClass() + })) + self.assertFalse(self.component_validator.validate({ + 'optionalElement': 7 + })) + self.assertFalse(self.component_validator.validate({ + 'optionalElement': "seven" + })) + self.assertFalse(self.component_validator.validate({ + 'optionalElement': False + })) + self.assertFalse(self.component_validator.validate({ + 'optionalElement': None + })) + + def test_enum_validation(self): + self.assertTrue(self.component_validator.validate({ + 'optionalEnum': "News" + })) + self.assertTrue(self.component_validator.validate({ + 'optionalEnum': "Photos" + })) + self.assertTrue(self.component_validator.validate({ + 'optionalEnum': 1 + })) + self.assertTrue(self.component_validator.validate({ + 'optionalEnum': 1.0 + })) + self.assertTrue(self.component_validator.validate({ + 'optionalEnum': "1" + })) + self.assertTrue(self.component_validator.validate({ + 'optionalEnum': True + })) + self.assertTrue(self.component_validator.validate({ + 'optionalEnum': False + })) + self.assertFalse(self.component_validator.validate({ + 'optionalEnum': "not_in_enum" + })) + self.assertFalse(self.component_validator.validate({ + 'optionalEnum': None + })) + + def test_union_validation(self): + self.assertTrue(self.component_validator.validate({ + 'optionalUnion': "string" + })) + self.assertTrue(self.component_validator.validate({ + 'optionalUnion': 7 + })) + # These will pass since propTypes.instanceOf(Message) + # is used in the union. We cannot validate this value, so + # we must accept everything since anything could be valid. + # TODO: Find some sort of workaround + + # self.assertFalse(self.component_validator.validate({ + # 'optionalUnion': self.ComponentClass() + # })) + # self.assertFalse(self.component_validator.validate({ + # 'optionalUnion': [1, 2, 3] + # })) + self.assertFalse(self.component_validator.validate({ + 'optionalUnion': None + })) + + def test_arrayof_validation(self): + self.assertFalse(self.component_validator.validate({ + 'optionalArrayOf': {1, 2, 3} + })) + self.assertTrue(self.component_validator.validate({ + 'optionalArrayOf': [1, 2, 3] + })) + self.assertTrue(self.component_validator.validate({ + 'optionalArrayOf': np.array([1, 2, 3]) + })) + self.assertTrue(self.component_validator.validate({ + 'optionalArrayOf': pd.Series([1, 2, 3]) + })) + self.assertFalse(self.component_validator.validate({ + 'optionalArrayOf': 7 + })) + self.assertFalse(self.component_validator.validate({ + 'optionalArrayOf': ["one", "two", "three"] + })) + self.assertFalse(self.component_validator.validate({ + 'optionalArrayOf': None + })) + + def test_objectof_validation(self): + self.assertTrue(self.component_validator.validate({ + 'optionalObjectOf': {'one': 1, 'two': 2, 'three': 3} + })) + self.assertFalse(self.component_validator.validate({ + 'optionalObjectOf': {'one': 1, 'two': '2', 'three': 3} + })) + self.assertFalse(self.component_validator.validate({ + 'optionalObjectOf': [1, 2, 3] + })) + self.assertFalse(self.component_validator.validate({ + 'optionalObjectOf': None + })) + + def test_object_with_shape_and_nested_description_validation(self): + self.assertTrue(self.component_validator.validate({ + 'optionalObjectWithShapeAndNestedDescription': { + 'color': "#431234", + 'fontSize': 2, + 'figure': { + 'data': [{'object_1': "hey"}, {'object_2': "ho"}], + 'layout': {"my": "layout"} + }, + } + })) + self.assertFalse(self.component_validator.validate({ + 'optionalObjectWithShapeAndNestedDescription': { + 'color': False, + 'fontSize': 2, + 'figure': { + 'data': [{'object_1': "hey"}, {'object_2': "ho"}], + 'layout': {"my": "layout"} + }, + } + })) + self.assertFalse(self.component_validator.validate({ + 'optionalObjectWithShapeAndNestedDescription': { + 'color': "#431234", + 'fontSize': "BAD!", + 'figure': { + 'data': [{'object_1': "hey"}, {'object_2': "ho"}], + 'layout': {"my": "layout"} + }, + } + })) + self.assertFalse(self.component_validator.validate({ + 'optionalObjectWithShapeAndNestedDescription': { + 'color': "#431234", + 'fontSize': 2, + 'figure': { + 'data': [{'object_1': "hey"}, 7], + 'layout': {"my": "layout"} + }, + } + })) + self.assertFalse(self.component_validator.validate({ + 'optionalObjectWithShapeAndNestedDescription': { + 'color': "#431234", + 'fontSize': 2, + 'figure': { + 'data': [{'object_1': "hey"}, {'object_2': "ho"}], + 'layout': ["my", "layout"] + }, + } + })) + self.assertFalse(self.component_validator.validate({ + 'optionalObjectWithShapeAndNestedDescription': None + })) + + def test_any_validation(self): + self.assertTrue(self.component_validator.validate({ + 'optionalAny': 7 + })) + self.assertTrue(self.component_validator.validate({ + 'optionalAny': "seven" + })) + self.assertTrue(self.component_validator.validate({ + 'optionalAny': False + })) + self.assertTrue(self.component_validator.validate({ + 'optionalAny': [] + })) + self.assertTrue(self.component_validator.validate({ + 'optionalAny': {} + })) + self.assertTrue(self.component_validator.validate({ + 'optionalAny': self.ComponentClass() + })) + self.assertFalse(self.component_validator.validate({ + 'optionalAny': None + })) + + def test_figure_validation(self): + self.assertFalse(self.figure_validator.validate({ + 'figure': 7 + })) + self.assertFalse(self.figure_validator.validate({ + 'figure': {} + })) + self.assertTrue(self.figure_validator.validate({ + 'figure': {'data': [{'x': [1, 2, 3], + 'y': [1, 2, 3], + 'type': 'scatter'}]} + })) + self.assertTrue(self.figure_validator.validate({ + 'figure': go.Figure( + data=[go.Scatter(x=[1, 2, 3], y=[1, 2, 3])], + layout=go.Layout() + ) + })) + self.assertFalse(self.figure_validator.validate({ + 'figure': {'doto': [{'x': [1, 2, 3], + 'y': [1, 2, 3], + 'type': 'scatter'}]} + })) + self.assertFalse(self.figure_validator.validate({ + 'figure': None + })) + + def test_options_validation(self): + self.assertFalse(self.options_validator.validate({ + 'options': [ + {'value': 'value1', 'label': 'label1'}, + {'value': 'value1', 'label': 'label1'} + ] + })) + self.assertTrue(self.options_validator.validate({ + 'options': [ + {'value': 'value1', 'label': 'label1'}, + {'value': 'value2', 'label': 'label2'} + ] + }))