diff --git a/CHANGELOG.md b/CHANGELOG.md index cf921670bf..519e2f0fe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.23.0 - 2018-08-01 +## Added +- Dash components are now generated at build-time and then imported rather than generated when a module is imported. This should reduce the time it takes to import Dash component libraries, and makes Dash compatible with IDEs. + ## 0.22.1 - 2018-08-01 ## Fixed - Raise a more informative error if a non JSON serializable value is returned from a callback [#273](https://github.com/plotly/dash/pull/273) @@ -7,6 +11,7 @@ - Assets files & index customization [#286](https://github.com/plotly/dash/pull/286) - Raise an error if there is no layout present when the server is running [#294](https://github.com/plotly/dash/pull/294) + ## 0.21.1 - 2018-04-10 ## Added - `aria-*` and `data-*` attributes are now supported in all dash html components. (#40) diff --git a/dash/development/base_component.py b/dash/development/base_component.py index 2b3ad778aa..274d104148 100644 --- a/dash/development/base_component.py +++ b/dash/development/base_component.py @@ -1,5 +1,7 @@ import collections import copy +import os +import inspect def is_number(s): @@ -18,7 +20,56 @@ def _check_if_has_indexable_children(item): raise KeyError +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 + + class Component(collections.MutableMapping): + class _UNDEFINED(object): + def __repr__(self): + return 'undefined' + + def __str__(self): + return 'undefined' + + UNDEFINED = _UNDEFINED() + + class _REQUIRED(object): + def __repr__(self): + return 'required' + + def __str__(self): + return 'required' + + REQUIRED = _REQUIRED() + def __init__(self, **kwargs): # pylint: disable=super-init-not-called for k, v in list(kwargs.items()): @@ -214,9 +265,9 @@ def __len__(self): # pylint: disable=unused-argument -def generate_class(typename, props, description, namespace): +def generate_class_string(typename, props, description, namespace): """ - Dynamically generate classes to have nicely formatted docstrings, + Dynamically generate class strings to have nicely formatted docstrings, keyword arguments, and repr Inspired by http://jameso.be/2013/08/06/namedtuple.html @@ -230,6 +281,7 @@ def generate_class(typename, props, description, namespace): Returns ------- + string """ # TODO _prop_names, _type, _namespace, available_events, @@ -250,7 +302,8 @@ def generate_class(typename, props, description, namespace): # not all component authors will supply those. c = '''class {typename}(Component): """{docstring}""" - def __init__(self, {default_argtext}): + @_explicitize_args + def __init__(self, {default_argtext}, **kwargs): self._prop_names = {list_of_valid_keys} self._type = '{typename}' self._namespace = '{namespace}' @@ -261,11 +314,15 @@ def __init__(self, {default_argtext}): self.available_wildcard_properties =\ {list_of_valid_wildcard_attr_prefixes} + _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'}} + for k in {required_args}: - if k not in kwargs: + if k not in args: raise TypeError( 'Required argument `' + k + '` was not specified.') - super({typename}, self).__init__({argtext}) def __repr__(self): @@ -290,13 +347,13 @@ def __repr__(self): return ( '{typename}(' + repr(getattr(self, self._prop_names[0], None)) + ')') - ''' +''' filtered_props = reorder_props(filter_props(props)) # pylint: disable=unused-variable list_of_valid_wildcard_attr_prefixes = repr(parse_wildcards(props)) # pylint: disable=unused-variable - list_of_valid_keys = repr(list(filtered_props.keys())) + list_of_valid_keys = repr(list(map(str, filtered_props.keys()))) # pylint: disable=unused-variable docstring = create_docstring( component_name=typename, @@ -306,19 +363,82 @@ def __repr__(self): # pylint: disable=unused-variable events = '[' + ', '.join(parse_events(props)) + ']' + prop_keys = list(props.keys()) if 'children' in props: - default_argtext = 'children=None, **kwargs' + prop_keys.remove('children') + default_argtext = "children=None, " # pylint: disable=unused-variable - argtext = 'children=children, **kwargs' + argtext = 'children=children, **args' else: - default_argtext = '**kwargs' - argtext = '**kwargs' + 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 ['dashEvents', 'fireEvent', 'setProps']] + ) required_args = required_props(props) + return c.format(**locals()) + + +# pylint: disable=unused-argument +def generate_class_file(typename, props, description, namespace): + """ + Generate a python class file (.py) given a class string - scope = {'Component': Component} + Parameters + ---------- + typename + props + description + namespace + + Returns + ------- + + """ + import_string =\ + "# AUTO GENERATED FILE - DO NOT EDIT\n\n" + \ + "from dash.development.base_component import " + \ + "Component, _explicitize_args\n\n\n" + class_string = generate_class_string( + typename, + props, + description, + namespace + ) + file_name = "{:s}.py".format(typename) + + file_path = os.path.join(namespace, file_name) + with open(file_path, 'w') as f: + f.write(import_string) + f.write(class_string) + + +# pylint: disable=unused-argument +def generate_class(typename, props, description, namespace): + """ + Generate a python class object given a class string + + Parameters + ---------- + typename + props + description + namespace + + Returns + ------- + + """ + string = generate_class_string(typename, props, description, namespace) + scope = {'Component': Component, '_explicitize_args': _explicitize_args} # pylint: disable=exec-used - exec(c.format(**locals()), scope) + exec(string, scope) result = scope[typename] return result diff --git a/dash/development/component_loader.py b/dash/development/component_loader.py index cb82a609e9..74d2e557d4 100644 --- a/dash/development/component_loader.py +++ b/dash/development/component_loader.py @@ -1,6 +1,18 @@ import collections import json +import os from .base_component import generate_class +from .base_component import generate_class_file + + +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)\ + .decode(json_string) + return data def load_components(metadata_path, @@ -20,12 +32,7 @@ def load_components(metadata_path, components = [] - # Start processing - with open(metadata_path) as data_file: - json_string = data_file.read() - data = json\ - .JSONDecoder(object_pairs_hook=collections.OrderedDict)\ - .decode(json_string) + data = _get_metadata(metadata_path) # Iterate over each property name (which is a path to the component) for componentPath in data: @@ -47,3 +54,56 @@ def load_components(metadata_path, components.append(component) return components + + +def generate_classes(namespace, metadata_path='lib/metadata.json'): + """Load React component metadata into a format Dash can parse, + then create python class files. + + Usage: generate_classes() + + Keyword arguments: + namespace -- name of the generated python package (also output dir) + + metadata_path -- a path to a JSON file created by + [`react-docgen`](https://github.com/reactjs/react-docgen). + + Returns: + """ + + data = _get_metadata(metadata_path) + imports_path = os.path.join(namespace, '_imports_.py') + + # Make sure the file doesn't exist, as we use append write + if os.path.exists(imports_path): + os.remove(imports_path) + + # Iterate over each property name (which is a path to the component) + for componentPath in data: + componentData = data[componentPath] + + # Extract component name from path + # e.g. src/components/MyControl.react.js + # TODO Make more robust - some folks will write .jsx and others + # will be on windows. Unfortunately react-docgen doesn't include + # the name of the component atm. + name = componentPath.split('/').pop().split('.')[0] + generate_class_file( + name, + componentData['props'], + componentData['description'], + namespace + ) + + # Add an import statement for this component + with open(imports_path, 'a') as f: + f.write('from .{0:s} import {0:s}\n'.format(name)) + + # Add the __all__ value so we can import * from _imports_ + all_imports = [p.split('/').pop().split('.')[0] for p in data] + with open(imports_path, 'a') as f: + array_string = '[\n' + for a in all_imports: + array_string += ' "{:s}",\n'.format(a) + array_string += ']\n' + f.write('\n\n__all__ = {:s}'.format(array_string)) diff --git a/dash/version.py b/dash/version.py index 66d9d1e396..08a9dbff61 100644 --- a/dash/version.py +++ b/dash/version.py @@ -1 +1 @@ -__version__ = '0.22.1' +__version__ = '0.23.0' diff --git a/tests/development/metadata_test.py b/tests/development/metadata_test.py new file mode 100644 index 0000000000..de56679fc9 --- /dev/null +++ b/tests/development/metadata_test.py @@ -0,0 +1,82 @@ +# AUTO GENERATED FILE - DO NOT EDIT + +from dash.development.base_component import Component, _explicitize_args + + +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) +- optionalArray (list; optional): Description of optionalArray +- optionalBool (boolean; optional) +- optionalNumber (number; optional) +- optionalObject (dict; optional) +- 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) +- optionalUnion (string | number; optional) +- optionalArrayOf (list; optional) +- optionalObjectOf (dict with strings as keys and values of type number; optional) +- optionalObjectWithShapeAndNestedDescription (optional): . optionalObjectWithShapeAndNestedDescription has the following type: dict containing keys 'color', 'fontSize', 'figure'. +Those keys have the following types: + - color (string; optional) + - fontSize (number; optional) + - figure (optional): Figure is a plotly graph object. figure has the following type: dict containing keys 'data', 'layout'. +Those keys have the following types: + - 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) +- id (string; optional) + +Available events: 'restyle', 'relayout', 'click'""" + @_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-*', '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-*', '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'} + + for k in []: + if k not in args: + raise TypeError( + 'Required argument `' + k + '` was not specified.') + super(Table, self).__init__(children=children, **args) + + def __repr__(self): + if(any(getattr(self, c, None) is not None + for c in self._prop_names + if c is not self._prop_names[0]) + or any(getattr(self, c, None) is not None + for c in self.__dict__.keys() + if any(c.startswith(wc_attr) + for wc_attr in self._valid_wildcard_attributes))): + props_string = ', '.join([c+'='+repr(getattr(self, c, None)) + for c in self._prop_names + if getattr(self, c, None) is not None]) + wilds_string = ', '.join([c+'='+repr(getattr(self, c, None)) + for c in self.__dict__.keys() + if any([c.startswith(wc_attr) + for wc_attr in + self._valid_wildcard_attributes])]) + return ('Table(' + props_string + + (', ' + wilds_string if wilds_string != '' else '') + ')') + else: + return ( + 'Table(' + + repr(getattr(self, self._prop_names[0], None)) + ')') diff --git a/tests/development/test_base_component.py b/tests/development/test_base_component.py index 9b4b3b609b..625e5329cb 100644 --- a/tests/development/test_base_component.py +++ b/tests/development/test_base_component.py @@ -3,12 +3,16 @@ import inspect import json import os +import shutil import unittest import plotly from dash.development.base_component import ( generate_class, + generate_class_string, + generate_class_file, Component, + _explicitize_args, js_to_py_type, create_docstring, parse_events @@ -488,6 +492,69 @@ def test_pop(self): self.assertTrue(c2_popped is c2) +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 + + # Create a folder for the new component file + os.makedirs('TableComponents') + + # Import string not included in generated class string + import_string =\ + "# AUTO GENERATED FILE - DO NOT EDIT\n\n" + \ + "from dash.development.base_component import" + \ + " Component, _explicitize_args\n\n\n" + + # Class string generated from generate_class_string + self.component_class_string = import_string + generate_class_string( + typename='Table', + props=data['props'], + description=data['description'], + namespace='TableComponents' + ) + + # Class string written to file + generate_class_file( + typename='Table', + props=data['props'], + description=data['description'], + namespace='TableComponents' + ) + written_file_path = os.path.join( + 'TableComponents', "Table.py" + ) + with open(written_file_path, 'r') as f: + self.written_class_string = f.read() + + # The expected result for both class string and class file generation + expected_string_path = os.path.join( + 'tests', 'development', 'metadata_test.py' + ) + with open(expected_string_path, 'r') as f: + self.expected_class_string = f.read() + + def tearDown(self): + shutil.rmtree('TableComponents') + + def test_class_string(self): + self.assertEqual( + self.expected_class_string, + self.component_class_string + ) + + def test_class_file(self): + self.assertEqual( + self.expected_class_string, + self.written_class_string + ) + + class TestGenerateClass(unittest.TestCase): def setUp(self): path = os.path.join('tests', 'development', 'metadata_test.json') @@ -619,17 +686,50 @@ def test_events(self): ['restyle', 'relayout', 'click'] ) + # This one is kind of pointless now def test_call_signature(self): + __init__func = self.ComponentClass.__init__ # TODO: Will break in Python 3 # http://stackoverflow.com/questions/2677185/ self.assertEqual( - inspect.getargspec(self.ComponentClass.__init__).args, - ['self', 'children'] + inspect.getargspec(__init__func).args, + ['self', + 'children', + 'optionalArray', + 'optionalBool', + 'optionalFunc', + 'optionalNumber', + 'optionalObject', + 'optionalString', + 'optionalSymbol', + 'optionalNode', + 'optionalElement', + 'optionalMessage', + 'optionalEnum', + 'optionalUnion', + 'optionalArrayOf', + 'optionalObjectOf', + 'optionalObjectWithShapeAndNestedDescription', + 'optionalAny', + 'customProp', + 'customArrayProp', + 'id'] if hasattr(inspect, 'signature') else [] + + ) self.assertEqual( - inspect.getargspec(self.ComponentClass.__init__).defaults, - (None, ) + inspect.getargspec(__init__func).varargs, + None if hasattr(inspect, 'signature') else 'args' ) + self.assertEqual( + inspect.getargspec(__init__func).keywords, + 'kwargs' + ) + if hasattr(inspect, 'signature'): + self.assertEqual( + [str(x) for x in inspect.getargspec(__init__func).defaults], + ['None'] + ['undefined'] * 19 + ) def test_required_props(self): with self.assertRaises(Exception): diff --git a/tests/development/test_component_loader.py b/tests/development/test_component_loader.py index 3748705a90..6e2de8f601 100644 --- a/tests/development/test_component_loader.py +++ b/tests/development/test_component_loader.py @@ -1,9 +1,13 @@ import collections import json import os +import shutil import unittest -from dash.development.component_loader import load_components -from dash.development.base_component import generate_class, Component +from dash.development.component_loader import load_components, generate_classes +from dash.development.base_component import ( + generate_class, + Component +) METADATA_PATH = 'metadata.json' @@ -150,3 +154,69 @@ def test_loadcomponents(self): repr(A(**AKwargs)), repr(c[1](**AKwargs)) ) + + +class TestGenerateClasses(unittest.TestCase): + def setUp(self): + with open(METADATA_PATH, 'w') as f: + f.write(METADATA_STRING) + os.makedirs('default_namespace') + + init_file_path = 'default_namespace/__init__.py' + with open(init_file_path, 'a'): + os.utime(init_file_path, None) + + def tearDown(self): + os.remove(METADATA_PATH) + shutil.rmtree('default_namespace') + + def test_loadcomponents(self): + MyComponent_runtime = generate_class( + 'MyComponent', + METADATA['MyComponent.react.js']['props'], + METADATA['MyComponent.react.js']['description'], + 'default_namespace' + ) + + A_runtime = generate_class( + 'A', + METADATA['A.react.js']['props'], + METADATA['A.react.js']['description'], + 'default_namespace' + ) + + generate_classes('default_namespace', METADATA_PATH) + from default_namespace.MyComponent import MyComponent \ + as MyComponent_buildtime + from default_namespace.A import A as A_buildtime + + MyComponentKwargs = { + 'foo': 'Hello World', + 'bar': 'Lah Lah', + 'baz': 'Lemons', + 'data-foo': 'Blah', + 'aria-bar': 'Seven', + 'baz': 'Lemons', + 'children': 'Child' + } + AKwargs = { + 'children': 'Child', + 'href': 'Hello World' + } + + self.assertTrue( + isinstance( + MyComponent_buildtime(**MyComponentKwargs), + Component + ) + ) + + self.assertEqual( + repr(MyComponent_buildtime(**MyComponentKwargs)), + repr(MyComponent_runtime(**MyComponentKwargs)), + ) + + self.assertEqual( + repr(A_runtime(**AKwargs)), + repr(A_buildtime(**AKwargs)) + )