Repository: plotly/dash
Pull request #276 (2018-06-28 23:50:50)

IDE support
This builds on https://github.com/plotly/dash/pull/271 and adds explicit arguments to generated dash component python class `__init__` functions (rather than `**kwargs`), which improves experience in an IDE.

### Atom example
![atom](https://user-images.githubusercontent.com/10272301/42038964-3c418344-7aba-11e8-99d2-171bea28366d.gif)

### Pycharm example
![pycharm](https://user-images.githubusercontent.com/10272301/42039009-5ad7064e-7aba-11e8-9884-19d598e9e093.gif)

### Jupyter Notebook example
![jupyter](https://user-images.githubusercontent.com/10272301/42040884-b0dde5a4-7abe-11e8-8384-e8ed7407fede.gif)

##### Notes
* If `dash_html_components` or `dash_core_components` are updated build using this version of the `dash` module, a user must also update `dash` since a new function `_explicitize_args` is added to `dash.development.base_component`. There are workarounds for this (putting the new function in the archetype generator or into the __init__.py file, or any shared file in a component module), but I think they are messy since it separates base component code into multiple repositories. I will add an exception to both of the `dash-*-components` packages that say to download the new `dash` version.
* Autocomplete functions which rely on the `inspect` would not work for python2. They work for python 3 since we are able to change the function signature of `Component.__init__` to be correct even though it is actually `(*args, **kwargs)` after the `_explicitize_args` decorator is applied. This did not matter for Atom and PyCharm, but it seems Jupyter uses the inspect module to perform autocomplete.
* Installing this new `dash` version and keeping old `dash_*_components` modules auto-magically adds the auto-complete functionality to Jupyter notebooks, but not Atom or PyCharm.






Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf921670b..519e2f0fe 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 2b3ad778a..274d10414 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 @@ class Component(collections.MutableMapping):
 
 
 # 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 generate_class(typename, props, description, namespace):
         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 generate_class(typename, props, description, namespace):
             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 generate_class(typename, props, description, namespace):
 
     # 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 cb82a609e..74d2e557d 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 66d9d1e39..08a9dbff6 100644
--- a/dash/version.py
+++ b/dash/version.py
@@ -1,1 +1,1 @@
-__version__ = '0.22.1'
+__version__ = '0.23.0'


Test patch:
diff --git a/tests/development/metadata_test.py b/tests/development/metadata_test.py
new file mode 100644
index 000000000..de56679fc
--- /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 9b4b3b609..625e5329c 100644
--- a/tests/development/test_base_component.py
+++ b/tests/development/test_base_component.py
@@ -3,12 +3,16 @@ import collections
 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 @@ class TestComponent(unittest.TestCase):
         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 @@ class TestGenerateClass(unittest.TestCase):
             ['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 3748705a9..6e2de8f60 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 @@ class TestLoadComponents(unittest.TestCase):
             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))
+        )