diff --git a/.circleci/config.yml b/.circleci/config.yml index b189a4b6b5..a18b9361fb 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -49,6 +49,7 @@ jobs: python --version python -m unittest tests.development.test_base_component python -m unittest tests.development.test_component_loader + python -m unittest tests.development.test_component_validation python -m unittest tests.test_integration python -m unittest tests.test_resources python -m unittest tests.test_configs diff --git a/.circleci/requirements/dev-requirements-py37.txt b/.circleci/requirements/dev-requirements-py37.txt index e35c666ff1..1ff0746020 100644 --- a/.circleci/requirements/dev-requirements-py37.txt +++ b/.circleci/requirements/dev-requirements-py37.txt @@ -9,8 +9,11 @@ mock tox tox-pyenv six +numpy +pandas plotly>=2.0.8 requests[security] flake8 pylint==2.1.1 astroid==2.0.4 +Cerberus==1.2 diff --git a/.circleci/requirements/dev-requirements.txt b/.circleci/requirements/dev-requirements.txt index c80469a4f4..b97e91749e 100644 --- a/.circleci/requirements/dev-requirements.txt +++ b/.circleci/requirements/dev-requirements.txt @@ -10,7 +10,10 @@ tox tox-pyenv mock six +numpy +pandas plotly>=2.0.8 requests[security] flake8 pylint==1.9.2 +Cerberus==1.2 diff --git a/.pylintrc37 b/.pylintrc37 index 8ff5bee0db..189d5bde05 100644 --- a/.pylintrc37 +++ b/.pylintrc37 @@ -63,6 +63,7 @@ confidence= disable=invalid-name, missing-docstring, print-statement, + too-many-lines, parameter-unpacking, unpacking-in-except, old-raise-syntax, diff --git a/dash/dash.py b/dash/dash.py index 0a68d70bce..cad1174334 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -5,11 +5,13 @@ import collections import importlib import json +import pprint import pkgutil import warnings import re from functools import wraps +from textwrap import dedent import plotly import dash_renderer @@ -20,6 +22,8 @@ from .dependencies import Event, Input, Output, State from .resources import Scripts, Css from .development.base_component import Component +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 @@ -84,6 +88,7 @@ def __init__( external_scripts=None, external_stylesheets=None, suppress_callback_exceptions=None, + suppress_validation_exceptions=None, components_cache_max_age=None, **kwargs): @@ -126,6 +131,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( @@ -168,6 +177,7 @@ def _handle_error(error): self.assets_ignore = assets_ignore self.registered_paths = {} + self.namespaces = {} # urls self.routes = [] @@ -256,7 +266,6 @@ def layout(self, value): 'a dash component.') self._layout = value - layout_value = self._layout_value() # pylint: disable=protected-access self.css._update_layout(layout_value) @@ -575,7 +584,7 @@ def react(self, *args, **kwargs): 'Use `callback` instead. `callback` has a new syntax too, ' 'so make sure to call `help(app.callback)` to learn more.') - def _validate_callback(self, output, inputs, state, events): + def _validate_callback_definition(self, output, inputs, state, events): # pylint: disable=too-many-branches layout = self._cached_layout or self._layout_value() @@ -713,7 +722,7 @@ def _validate_callback(self, output, inputs, state, events): 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, @@ -831,7 +840,7 @@ def _validate_value(val, index=None): # relationships # pylint: disable=dangerous-default-value def callback(self, output, inputs=[], state=[], events=[]): - self._validate_callback(output, inputs, state, events) + self._validate_callback_definition(output, inputs, state, events) callback_id = '{}.{}'.format( output.component_id, output.component_property @@ -853,13 +862,11 @@ def callback(self, output, inputs=[], state=[], events=[]): 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 } } } @@ -870,7 +877,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 @@ -887,6 +897,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 @@ -915,7 +926,88 @@ 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 = dedent("""\ + + 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: + + """).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] + ) + ) + + error_message = generate_validation_error_message( + validator.errors, + 0, + error_message + ) + dedent(""" + You can turn off these validation exceptions by setting + `app.config.suppress_validation_exceptions=True` + """) + + raise exceptions.CallbackOutputValidationError(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: @@ -932,6 +1024,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( @@ -1057,5 +1154,9 @@ def run_server(self, :return: """ debug = self.enable_dev_tools(debug, dev_tools_serve_dev_bundles) + if not debug: + # Do not throw debugging exceptions in production. + self.config.suppress_validation_exceptions = True + self.config.suppress_callback_exceptions = True self.server.run(port=port, debug=debug, **flask_run_options) diff --git a/dash/development/base_component.py b/dash/development/base_component.py index c4998be300..661eb724d9 100644 --- a/dash/development/base_component.py +++ b/dash/development/base_component.py @@ -3,6 +3,12 @@ import os import inspect import keyword +import pprint + +from textwrap import dedent + +import dash.exceptions +from .validator import DashValidator, generate_validation_error_message def is_number(s): @@ -72,9 +78,12 @@ def __str__(self): REQUIRED = _REQUIRED() + _schema = {} + def __init__(self, **kwargs): # pylint: disable=super-init-not-called - for k, v in list(kwargs.items()): + # Make sure arguments have valid names + for k in kwargs: # pylint: disable=no-member k_in_propnames = k in self._prop_names k_in_wildcards = any([k.startswith(w) @@ -88,6 +97,8 @@ def __init__(self, **kwargs): ', '.join(sorted(self._prop_names)) ) ) + + for k, v in list(kwargs.items()): setattr(self, k, v) def to_plotly_json(self): @@ -211,7 +222,7 @@ def traverse_with_paths(self): """Yield each item with its path in the tree.""" children = getattr(self, 'children', None) children_type = type(children).__name__ - children_id = "(id={:s})".format(children.id) \ + children_id = "(id={})".format(children.id) \ if getattr(children, 'id', False) else '' children_string = children_type + ' ' + children_id @@ -224,10 +235,10 @@ def traverse_with_paths(self): # children is a list of components elif isinstance(children, (tuple, collections.MutableSequence)): for idx, i in enumerate(children): - list_path = "[{:d}] {:s} {}".format( + list_path = "[{}] {} {}".format( idx, type(i).__name__, - "(id={:s})".format(i.id) if getattr(i, 'id', False) else '' + "(id={})".format(i.id) if getattr(i, 'id', False) else '' ) yield list_path, i @@ -235,6 +246,61 @@ 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, + ) + args = { + k: self.__dict__[k] + for k in self.__dict__['_prop_names'] + if k in self.__dict__.keys() + } + valid = validator.validate(args) + if not valid: + # pylint: disable=protected-access + error_message = dedent("""\ + + 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: + + + """).format( + component_name=self.__class__.__name__, + component_args=pprint.pformat(args), + component_schema=pprint.pformat(self.__class__._schema) + ) + + error_message = generate_validation_error_message( + validator.errors, + 0, + error_message + ) + dedent(""" + You can turn off these validation exceptions by setting + `app.config.suppress_validation_exceptions=True` + """) + + # pylint: disable=protected-access + raise dash.exceptions.ComponentInitializationValidationError( + error_message + ) + def __iter__(self): """Yield IDs in the tree of children.""" for t in self.traverse(): @@ -266,6 +332,141 @@ def __len__(self): return length +def schema_is_nullable(type_object): + if type_object: + if type_object.get('name', None) == 'enum': + values = type_object['value'] + for v in values: + value = v['value'] + if value == 'null': + return True + if type_object.get('name', None) == '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 _merge(x, y): + z = x.copy() + z.update(y) + return z + + def _enum(x): + schema = {'allowed': [], + 'type': ('string', 'number')} + values = x['value'] + for v in values: + value = v['value'] + if value == 'null': + schema.update({'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: { + 'type': ('boolean', + 'number', + 'string', + 'dict', + 'list') + }, + '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'])} + ) + }, + '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) + if schema_is_nullable(type_object): + schema.update({'nullable': True}) + if required: + schema.update({'required': True}) + return schema + + # pylint: disable=unused-argument def generate_class_string(typename, props, description, namespace): """ @@ -302,8 +503,13 @@ 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): + # pylint: disable=too-many-locals + c = ''' +schema = {schema} + +class {typename}(Component): """{docstring}""" + _schema = schema @_explicitize_args def __init__(self, {default_argtext}): self._prop_names = {list_of_valid_keys} @@ -319,12 +525,13 @@ def __init__(self, {default_argtext}): _explicit_args = kwargs.pop('_explicit_args') _locals = locals() _locals.update(kwargs) # For wildcard attrs - args = {{k: _locals[k] for k in _explicit_args if k != 'children'}} + args = {{k: _locals[k] for k in _explicit_args}} for k in {required_args}: if k not in args: raise TypeError( 'Required argument `' + k + '` was not specified.') + args.pop('children', None) super({typename}, self).__init__({argtext}) def __repr__(self): @@ -367,23 +574,26 @@ def __repr__(self): events = '[' + ', '.join(parse_events(props)) + ']' prop_keys = list(props.keys()) if 'children' in props: - prop_keys.remove('children') - default_argtext = "children=None, " - # pylint: disable=unused-variable - argtext = 'children=children, **args' + default_argtext = 'children=None, ' + argtext = 'children=children, **args' # Children will be popped before else: - default_argtext = "" + default_argtext = '' argtext = '**args' - default_argtext += ", ".join( - [('{:s}=Component.REQUIRED'.format(p) - if props[p]['required'] else - '{:s}=Component.UNDEFINED'.format(p)) - for p in prop_keys - if not p.endswith("-*") and - p not in keyword.kwlist and - p not in ['dashEvents', 'fireEvent', 'setProps']] + ['**kwargs'] - ) - + for p in list(props.keys()): + if ( + not p.endswith("-*") and # Not a wildcard attribute + p not in keyword.kwlist and # Not a protected keyword + p not in ['dashEvents', 'fireEvent', 'setProps'] and + p != 'children' # Already accounted for + ): + default_argtext += ('{:s}=Component.REQUIRED, '.format(p) + if props[p]['required'] else + '{:s}=Component.UNDEFINED, '.format(p)) + default_argtext += '**kwargs' + schema = { + k: generate_property_schema(v) + for k, v in props.items() if not k.endswith("-*") + } required_args = required_props(props) return c.format(**locals()) diff --git a/dash/development/component_loader.py b/dash/development/component_loader.py index 74d2e557d4..0d91a65b4d 100644 --- a/dash/development/component_loader.py +++ b/dash/development/component_loader.py @@ -1,16 +1,28 @@ -import collections -import json import os +import json +import collections from .base_component import generate_class from .base_component import generate_class_file +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..ca49451bf7 --- /dev/null +++ b/dash/development/validator.py @@ -0,0 +1,107 @@ +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: Not a dict") + 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 dict!") + 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): + return True + elif isinstance(value, (self.component_class, str)): + return False + try: + value_list = list(value) + if not isinstance(value_list, list): + return False + except (ValueError, TypeError): + return False + return True + + # 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 + try: + int(value) + return True + except (ValueError, TypeError): + pass + try: + float(value) + return True + except (ValueError, TypeError): + 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 generate_validation_error_message(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 = generate_validation_error_message( + 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 = generate_validation_error_message( + error_tuple[0], + level + 1, + error_message + "\n") + return error_message diff --git a/dash/exceptions.py b/dash/exceptions.py index 5ec2779afb..253af107cb 100644 --- a/dash/exceptions.py +++ b/dash/exceptions.py @@ -62,5 +62,13 @@ 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 bee83d7eb7..c328906119 100644 --- a/setup.py +++ b/setup.py @@ -21,6 +21,7 @@ 'flask-compress', 'plotly', 'dash_renderer', + 'Cerberus' ], url='https://plot.ly/dash', classifiers=[ diff --git a/tests/development/TestReactComponent.react.js b/tests/development/TestReactComponent.react.js index 5c45fed8c6..a58269e85d 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' + @@ -93,13 +97,29 @@ ReactComponent.propTypes = { // special dash events - 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]) + ]) + ) + ]), - id: React.PropTypes.string, + in: PropTypes.string, + id: PropTypes.string, // dashEvents is a special prop that is used to events validation - dashEvents: React.PropTypes.oneOf([ + dashEvents: PropTypes.oneOf([ 'restyle', 'relayout', 'click' 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 1da85ba814..97e8a3b26a 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 1074ff0e51..647613ec2e 100644 --- a/tests/development/metadata_test.py +++ b/tests/development/metadata_test.py @@ -3,13 +3,16 @@ from dash.development.base_component import Component, _explicitize_args + +schema = {'customArrayProp': {'type': 'list', 'schema': {'nullable': False}}, 'optionalObjectWithShapeAndNestedDescription': {'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}}}, 'optionalBool': {'type': 'boolean'}, 'optionalFunc': {}, 'optionalSymbol': {}, 'in': {'type': 'string'}, 'customProp': {}, 'children': {'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}, 'optionalMessage': {}, 'optionalNumber': {'type': 'number'}, 'optionalObject': {'type': 'dict'}, 'dashEvents': {'type': ('string', 'number'), 'allowed': ['restyle', 'relayout', 'click']}, 'id': {'type': 'string'}, 'optionalString': {'type': 'string'}, 'optionalElement': {'type': 'component'}, 'optionalArray': {'type': 'list'}, 'optionalNode': {'anyof': [{'type': 'component'}, {'type': 'boolean'}, {'type': 'number'}, {'type': 'string'}, {'type': 'list', 'schema': {'type': ('component', 'boolean', 'number', 'string')}}]}, 'optionalObjectOf': {'type': 'dict', 'valueschema': {'type': 'number'}, 'nullable': False}, 'optionalEnum': {'type': ('string', 'number'), 'allowed': ['News', 'Photos', '1', 1, 1.0, '2', 2, 2.0, False, True]}, 'optionalArrayOf': {'type': 'list', 'schema': {'type': 'number', 'nullable': False}}, 'optionalUnion': {'anyof': [{'type': 'string'}, {'type': 'number'}, {}]}, 'optionalAny': {'type': ('boolean', 'number', 'string', 'dict', 'list')}} + 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 +20,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,33 +33,35 @@ 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) Available events: 'restyle', 'relayout', 'click'""" + _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_events = ['restyle', 'relayout', 'click'] - 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 - args = {k: _locals[k] for k in _explicit_args if k != 'children'} + args = {k: _locals[k] for k in _explicit_args} for k in []: if k not in args: raise TypeError( 'Required argument `' + k + '` was not specified.') + args.pop('children', None) super(Table, self).__init__(children=children, **args) def __repr__(self): diff --git a/tests/development/test_base_component.py b/tests/development/test_base_component.py index a43be1b898..15a02c5a13 100644 --- a/tests/development/test_base_component.py +++ b/tests/development/test_base_component.py @@ -7,6 +7,7 @@ import unittest import plotly +from dash.development.component_loader import _get_metadata from dash.development.base_component import ( generate_class, generate_class_string, @@ -505,12 +506,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') @@ -549,6 +545,14 @@ 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[:6] + tmp[7:]) + 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') @@ -568,12 +572,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', @@ -619,14 +618,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': [] } }) @@ -741,6 +740,12 @@ def test_call_signature(self): ['None'] + ['undefined'] * 19 ) + def test_schema_generation(self): + self.assertEqual( + self.ComponentClass._schema, + {'customArrayProp': {'type': 'list', 'schema': {'nullable': False}}, 'optionalObjectWithShapeAndNestedDescription': {'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}}}, 'optionalBool': {'type': 'boolean'}, 'optionalFunc': {}, 'optionalSymbol': {}, 'in': {'type': 'string'}, 'customProp': {}, 'children': {'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}, 'optionalMessage': {}, 'optionalNumber': {'type': 'number'}, 'optionalObject': {'type': 'dict'}, 'dashEvents': {'type': ('string', 'number'), 'allowed': ['restyle', 'relayout', 'click']}, 'id': {'type': 'string'}, 'optionalString': {'type': 'string'}, 'optionalElement': {'type': 'component'}, 'optionalArray': {'type': 'list'}, 'optionalNode': {'anyof': [{'type': 'component'}, {'type': 'boolean'}, {'type': 'number'}, {'type': 'string'}, {'type': 'list', 'schema': {'type': ('component', 'boolean', 'number', 'string')}}]}, 'optionalObjectOf': {'type': 'dict', 'valueschema': {'type': 'number'}, 'nullable': False}, 'optionalEnum': {'type': ('string', 'number'), 'allowed': ['News', 'Photos', '1', 1, 1.0, '2', 2, 2.0, False, True]}, 'optionalArrayOf': {'type': 'list', 'schema': {'type': 'number', 'nullable': False}}, 'optionalUnion': {'anyof': [{'type': 'string'}, {'type': 'number'}, {}]}, 'optionalAny': {'type': ('boolean', 'number', 'string', 'dict', 'list')}} + ) + def test_required_props(self): with self.assertRaises(Exception): self.ComponentClassRequired() @@ -763,7 +768,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'], @@ -786,7 +791,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'], @@ -853,7 +858,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)", @@ -864,7 +869,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)", @@ -893,10 +898,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 b0f826625e..17929a2eb5 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 ( generate_class, 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', @@ -218,4 +221,4 @@ def test_loadcomponents(self): self.assertEqual( repr(A_runtime(**AKwargs)), repr(A_buildtime(**AKwargs)) - ) + ) \ No newline at end of file diff --git a/tests/development/test_component_validation.py b/tests/development/test_component_validation.py new file mode 100644 index 0000000000..d1f2ce9a79 --- /dev/null +++ b/tests/development/test_component_validation.py @@ -0,0 +1,511 @@ +import os +import json +import unittest +import collections +import numpy as np +import pandas as pd +import plotly.graph_objs as go +import dash +import dash_html_components as html +from importlib import import_module +from dash.development.component_loader import _get_metadata +from dash.development.base_component import generate_class, Component +from dash.development.validator import DashValidator + +# 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): + 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.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.assertFalse(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'} + ] + })) diff --git a/tests/test_integration.py b/tests/test_integration.py index c507b80a07..cd9ad48e2d 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -2,7 +2,6 @@ from multiprocessing import Value import datetime import itertools -import re import dash_html_components as html import dash_core_components as dcc import dash_flow_example @@ -138,6 +137,7 @@ def test_aborted_callback(self): html.Div(initial_output, id='output1'), html.Div(initial_output, id='output2'), ]) + app.config.suppress_validation_exceptions = True callback1_count = Value('i', 0) callback2_count = Value('i', 0) @@ -183,6 +183,8 @@ def test_wildcard_data_attributes(self): app.layout = html.Div([ html.Div( id="inner-element", + n_clicks=0, + n_clicks_timestamp=-1, **{ 'data-string': 'multiple words', 'data-number': 512, @@ -197,39 +199,21 @@ def test_wildcard_data_attributes(self): div = self.wait_for_element_by_id('data-element') - # React wraps text and numbers with e.g. - # Remove those - comment_regex = '' - - # Somehow the html attributes are unordered. - # Try different combinations (they're all valid html) - permutations = itertools.permutations([ + attributes = [ 'id="inner-element"', 'data-string="multiple words"', 'data-number="512"', 'data-date="%s"' % test_date, - 'aria-progress="5"' - ], 5) - passed = False - for permutation in permutations: - actual_cleaned = re.sub(comment_regex, '', - div.get_attribute('innerHTML')) - expected_cleaned = re.sub( - comment_regex, - '', - "
" - .replace('PERMUTE', ' '.join(list(permutation))) - ) - passed = passed or (actual_cleaned == expected_cleaned) - if passed: - break - if not passed: - raise Exception( - 'HTML does not match\nActual:\n{}\n\nExpected:\n{}'.format( - actual_cleaned, - expected_cleaned + 'aria-progress="5"', + ] + actual = div.get_attribute('innerHTML') + for attr in attributes: + if attr not in actual: + raise Exception( + 'Attribute {}\nnot in actual HTML\n{}'.format( + attr, actual + ) ) - ) assert_clean_console(self)