Repository: plotly/dash

Issue:
DashR transpiler: type and default information in help strings
Let's make sure that the R equivalent of docstrings has the same type/default information as the Python docstrings for components.

Comments:

Patch:
diff --git a/dash/development/_py_components_generation.py b/dash/development/_py_components_generation.py
index 66db6a12..1b5e2d52 100644
--- a/dash/development/_py_components_generation.py
+++ b/dash/development/_py_components_generation.py
@@ -488,11 +488,12 @@ def map_js_to_py_types_prop_types(type_object):
                 if js_to_py_type(subType) != '')),
 
         # React's PropTypes.arrayOf
-        arrayOf=lambda: 'list'.format(  # pylint: disable=too-many-format-args
-            ' of {}s'.format(
-                js_to_py_type(type_object['value']))
-            if js_to_py_type(type_object['value']) != ''
-            else ''),
+        arrayOf=lambda: (
+            "list" + ((" of {}s").format(
+                js_to_py_type(type_object["value"]))
+                      if js_to_py_type(type_object["value"]) != ""
+                      else "")
+        ),
 
         # React's PropTypes.objectOf
         objectOf=lambda: (
diff --git a/dash/development/_r_components_generation.py b/dash/development/_r_components_generation.py
index 616f1b66..bbb8315f 100644
--- a/dash/development/_r_components_generation.py
+++ b/dash/development/_r_components_generation.py
@@ -6,6 +6,7 @@ import sys
 import shutil
 import glob
 import importlib
+import textwrap
 import re
 
 from ._all_keywords import r_keywords
@@ -41,15 +42,15 @@ deps_metadata <- list("""
 frame_element_template = """`{dep_name}` = structure(list(name = "{dep_name}",
 version = "{project_ver}", src = list(href = NULL,
 file = "deps"), meta = NULL,
-script = "{dep_rpp}",
-stylesheet = NULL, head = NULL, attachment = NULL, package = "{rpkgname}",
-all_files = FALSE), class = "html_dependency")"""
+script = {script_name},
+stylesheet = {css_name}, head = NULL, attachment = NULL, package = "{rpkgname}",
+all_files = FALSE), class = "html_dependency")"""   # noqa:E501
 
 frame_body_template = """`{project_shortname}` = structure(list(name = "{project_shortname}",
 version = "{project_ver}", src = list(href = NULL,
 file = "deps"), meta = NULL,
-script = "{dep_rpp}",
-stylesheet = NULL, head = NULL, attachment = NULL, package = "{rpkgname}",
+script = {script_name},
+stylesheet = {css_name}, head = NULL, attachment = NULL, package = "{rpkgname}",
 all_files = FALSE), class = "html_dependency")"""  # noqa:E501
 
 frame_close_template = """)
@@ -70,7 +71,7 @@ help_string = """% Auto-generated: do not edit by hand
 }}
 
 \\usage{{
-{prefix}{name}({default_argtext})
+{argtext}
 }}
 
 \\arguments{{
@@ -237,7 +238,8 @@ def generate_js_metadata(pkg_data, project_shortname):
     # import component library module into sys
     mod = sys.modules[project_shortname]
 
-    jsdist = getattr(mod, "_js_dist", [])
+    alldist = getattr(mod, "_js_dist", []) + getattr(mod, "_css_dist", [])
+
     project_ver = pkg_data.get("version")
 
     rpkgname = snake_case_to_camel_case(project_shortname)
@@ -253,29 +255,45 @@ def generate_js_metadata(pkg_data, project_shortname):
     function_frame_body = []
 
     # pylint: disable=consider-using-enumerate
-    if len(jsdist) > 1:
-        for dep in range(len(jsdist)):
-            if "dash_" in jsdist[dep]["relative_package_path"]:
-                dep_name = jsdist[dep]["relative_package_path"].split(".")[0]
+    if len(alldist) > 1:
+        for dep in range(len(alldist)):
+            rpp = alldist[dep]["relative_package_path"]
+            if "dash_" in rpp:
+                dep_name = rpp.split(".")[0]
             else:
                 dep_name = "{}_{}".format(project_shortname, str(dep))
                 project_ver = str(dep)
+            if "css" in rpp:
+                css_name = "'{}'".format(rpp)
+                script_name = 'NULL'
+            else:
+                script_name = "'{}'".format(rpp)
+                css_name = 'NULL'
             function_frame += [
                 frame_element_template.format(
                     dep_name=dep_name,
                     project_ver=project_ver,
                     rpkgname=rpkgname,
                     project_shortname=project_shortname,
-                    dep_rpp=jsdist[dep]["relative_package_path"],
+                    script_name=script_name,
+                    css_name=css_name,
                 )
             ]
             function_frame_body = ",\n".join(function_frame)
-    elif len(jsdist) == 1:
+    elif len(alldist) == 1:
+        rpp = alldist[0]["relative_package_path"]
+        if "css" in rpp:
+            css_name = rpp
+            script_name = "NULL"
+        else:
+            script_name = rpp
+            css_name = "NULL"
         function_frame_body = frame_body_template.format(
             project_shortname=project_shortname,
             project_ver=project_ver,
             rpkgname=rpkgname,
-            dep_rpp=jsdist[0]["relative_package_path"],
+            script_name=script_name,
+            css_name=css_name,
         )
 
     function_string = "".join(
@@ -318,42 +336,45 @@ def write_help_file(name, props, description, prefix):
     default_argtext += ", ".join("{}=NULL".format(p) for p in prop_keys)
 
     item_text += "\n\n".join(
-        "\\item{{{}}}{{{}}}".format(p, props[p]["description"])
+        "\\item{{{}}}{{{}{}}}".format(p,
+                                      print_r_type(
+                                          props[p]["type"]
+                                      ),
+                                      props[p]["description"])
         for p in prop_keys
     )
 
     if has_wildcards:
-        item_text += "\n\n\\item{...}{wildcards: `data-*` or `aria-*`}"
-        default_argtext += ", ..."
-
-    file_path = os.path.join("man", file_name)
-    with open(file_path, "w") as f:
-        f.write(
-            help_string.format(
-                prefix=prefix,
-                name=name,
-                default_argtext=default_argtext,
-                item_text=item_text,
-                description=description.replace("\n", " "),
-            )
-        )
+        item_text += '\n\n\\item{...}{wildcards: `data-*` or `aria-*`}'
+        default_argtext += ', ...'
+
+    # in R, the online help viewer does not properly wrap lines for
+    # the usage string -- we will hard wrap at 80 characters using
+    # textwrap.fill, starting from the beginning of the usage string
+    argtext = prefix + name + "({})".format(default_argtext)
+
+    file_path = os.path.join('man', file_name)
+    with open(file_path, 'w') as f:
+        f.write(help_string.format(
+            prefix=prefix,
+            name=name,
+            argtext=textwrap.fill(argtext,
+                                  width=80,
+                                  break_long_words=False),
+            item_text=item_text,
+            description=description.replace('\n', ' ')
+        ))
 
 
 def write_class_file(name, props, description, project_shortname, prefix=None):
     props = reorder_props(props=props)
 
-    # generate the R help pages for each of the Dash components that we
-    # are transpiling -- this is done to avoid using Roxygen2 syntax,
-    # we may eventually be able to generate similar documentation using
-    # doxygen and an R plugin, but for now we'll just do it on our own
-    # from within Python
-    write_help_file(name, props, description, prefix)
-
     import_string = "# AUTO GENERATED FILE - DO NOT EDIT\n\n"
     class_string = generate_class_string(name,
                                          props,
                                          project_shortname,
                                          prefix)
+
     file_name = "{}{}.R".format(prefix, name)
 
     file_path = os.path.join("R", file_name)
@@ -361,6 +382,18 @@ def write_class_file(name, props, description, project_shortname, prefix=None):
         f.write(import_string)
         f.write(class_string)
 
+    # generate the R help pages for each of the Dash components that we
+    # are transpiling -- this is done to avoid using Roxygen2 syntax,
+    # we may eventually be able to generate similar documentation using
+    # doxygen and an R plugin, but for now we'll just do it on our own
+    # from within Python
+    write_help_file(
+        name,
+        props,
+        description,
+        prefix
+    )
+
     print("Generated {}".format(file_name))
 
 
@@ -625,3 +658,154 @@ def generate_exports(
         package_imports,
         package_suggests,
     )
+
+
+def get_r_prop_types(type_object):
+    """Mapping from the PropTypes js type object to the R type"""
+
+    def shape_or_exact():
+        return 'lists containing elements {}.\n{}'.format(
+            ', '.join(
+                "'{}'".format(t) for t in list(type_object['value'].keys())
+            ),
+            'Those elements have the following types:\n{}'.format(
+                '\n'.join(
+                    create_prop_docstring_r(
+                        prop_name=prop_name,
+                        type_object=prop,
+                        required=prop['required'],
+                        description=prop.get('description', ''),
+                        indent_num=1
+                    ) for prop_name, prop in
+                    list(type_object['value'].items())))
+            )
+
+    return dict(
+        array=lambda: "unnamed list",
+        bool=lambda: "logical",
+        number=lambda: "numeric",
+        string=lambda: "character",
+        object=lambda: "named list",
+        any=lambda: "logical | numeric | character | "
+                    "named list | unnamed list",
+        element=lambda: "dash component",
+        node=lambda: "a list of or a singular dash "
+                     "component, string or number",
+        # React's PropTypes.oneOf
+        enum=lambda: "a value equal to: {}".format(
+            ", ".join("{}".format(str(t["value"]))
+                      for t in type_object["value"])
+        ),
+        # React's PropTypes.oneOfType
+        union=lambda: "{}".format(
+            " | ".join(
+                "{}".format(get_r_type(subType))
+                for subType in type_object["value"]
+                if get_r_type(subType) != ""
+            )
+        ),
+        # React's PropTypes.arrayOf
+        arrayOf=lambda: (
+            "list" + ((" of {}s").format(
+                get_r_type(type_object["value"]))
+                      if get_r_type(type_object["value"]) != ""
+                      else "")
+        ),
+        # React's PropTypes.objectOf
+        objectOf=lambda: (
+            "list with named elements and values of type {}"
+            ).format(
+                get_r_type(type_object["value"])
+            ),
+
+        # React's PropTypes.shape
+        shape=shape_or_exact,
+        # React's PropTypes.exact
+        exact=shape_or_exact
+    )
+
+
+def get_r_type(type_object, is_flow_type=False, indent_num=0):
+    """
+    Convert JS types to R types for the component definition
+    Parameters
+    ----------
+    type_object: dict
+        react-docgen-generated prop type dictionary
+
+    indent_num: int
+        Number of indents to use for the docstring for the prop
+    Returns
+    -------
+    str
+        Python type string
+    """
+    js_type_name = type_object["name"]
+    js_to_r_types = get_r_prop_types(type_object=type_object)
+    if (
+            "computed" in type_object
+            and type_object["computed"]
+            or type_object.get("type", "") == "function"
+    ):
+        return ""
+    elif js_type_name in js_to_r_types:
+        prop_type = js_to_r_types[js_type_name]()
+        return prop_type
+    return ""
+
+
+def print_r_type(typedata):
+    typestring = get_r_type(typedata).capitalize()
+    if typestring:
+        typestring += ". "
+    return typestring
+
+
+# pylint: disable=too-many-arguments
+def create_prop_docstring_r(prop_name, type_object, required, description,
+                            indent_num, is_flow_type=False):
+    """
+    Create the Dash component prop docstring
+    Parameters
+    ----------
+    prop_name: str
+        Name of the Dash component prop
+    type_object: dict
+        react-docgen-generated prop type dictionary
+    required: bool
+        Component is required?
+    description: str
+        Dash component description
+    indent_num: int
+        Number of indents to use for the context block
+        (creates 2 spaces for every indent)
+    is_flow_type: bool
+        Does the prop use Flow types? Otherwise, uses PropTypes
+    Returns
+    -------
+    str
+        Dash component prop docstring
+    """
+    r_type_name = get_r_type(
+        type_object=type_object,
+        is_flow_type=is_flow_type,
+        indent_num=indent_num + 1)
+
+    indent_spacing = '  ' * indent_num
+    if '\n' in r_type_name:
+        return '{indent_spacing}- {name} ({is_required}): {description}. ' \
+               '{name} has the following type: {type}'.format(
+                   indent_spacing=indent_spacing,
+                   name=prop_name,
+                   type=r_type_name,
+                   description=description,
+                   is_required='required' if required else 'optional')
+    return '{indent_spacing}- {name} ({type}' \
+           '{is_required}){description}'.format(
+               indent_spacing=indent_spacing,
+               name=prop_name,
+               type='{}; '.format(r_type_name) if r_type_name else '',
+               description=(
+                   ': {}'.format(description) if description != '' else ''
+               ),
+               is_required='required' if required else 'optional')


Test patch:
diff --git a/tests/unit/dash/development/metadata_test.py b/tests/unit/dash/development/metadata_test.py
index 785feae6..edb67126 100644
--- a/tests/unit/dash/development/metadata_test.py
+++ b/tests/unit/dash/development/metadata_test.py
@@ -19,7 +19,7 @@ Keyword arguments:
 - optionalElement (dash component; optional)
 - optionalEnum (a value equal to: 'News', 'Photos'; optional)
 - optionalUnion (string | number; optional)
-- optionalArrayOf (list; optional)
+- optionalArrayOf (list of numbers; optional)
 - optionalObjectOf (dict with strings as keys and values of type number; optional)
 - optionalObjectWithExactAndNestedDescription (optional): . optionalObjectWithExactAndNestedDescription has the following type: dict containing keys 'color', 'fontSize', 'figure'.
 Those keys have the following types:
@@ -27,7 +27,7 @@ Those keys have the following types:
   - 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
+  - data (list of dicts; optional): data is a collection of traces
   - layout (dict; optional): layout describes the rest of the figure
 - optionalObjectWithShapeAndNestedDescription (optional): . optionalObjectWithShapeAndNestedDescription has the following type: dict containing keys 'color', 'fontSize', 'figure'.
 Those keys have the following types:
@@ -35,7 +35,7 @@ Those keys have the following types:
   - 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
+  - data (list of dicts; 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)
diff --git a/tests/unit/dash/development/test_base_component.py b/tests/unit/dash/development/test_base_component.py
index 739590cc..9040a446 100644
--- a/tests/unit/dash/development/test_base_component.py
+++ b/tests/unit/dash/development/test_base_component.py
@@ -540,15 +540,19 @@ class TestGenerateClassFile(unittest.TestCase):
         for line in s.split('\n'):
             self.assertEqual(line, line.rstrip())
 
+    def match_lines(self, val, expected):
+        for val1, exp1 in zip(val.splitlines(), expected.splitlines()):
+            assert val1 == exp1
+
     def test_class_string(self):
-        self.assertEqual(
+        self.match_lines(
             self.expected_class_string,
             self.component_class_string
         )
         self.assert_no_trailing_spaces(self.component_class_string)
 
     def test_class_file(self):
-        self.assertEqual(
+        self.match_lines(
             self.expected_class_string,
             self.written_class_string
         )
@@ -801,7 +805,7 @@ class TestMetaDataConversions(unittest.TestCase):
 
             ['optionalUnion', 'string | number'],
 
-            ['optionalArrayOf', 'list'],
+            ['optionalArrayOf', 'list of numbers'],
 
             ['optionalObjectOf',
              'dict with strings as keys and values of type number'],
@@ -814,7 +818,7 @@ class TestMetaDataConversions(unittest.TestCase):
                 "  - fontSize (number; optional)",
                 "  - figure (optional): Figure is a plotly graph object. figure has the following type: dict containing keys 'data', 'layout'.",  # noqa: E501
                 "Those keys have the following types:",
-                "  - data (list; optional): data is a collection of traces",
+                "  - data (list of dicts; optional): data is a collection of traces",
                 "  - layout (dict; optional): layout describes the rest of the figure"  # noqa: E501
 
             ])],
@@ -827,7 +831,7 @@ class TestMetaDataConversions(unittest.TestCase):
                 "  - fontSize (number; optional)",
                 "  - figure (optional): Figure is a plotly graph object. figure has the following type: dict containing keys 'data', 'layout'.",  # noqa: E501
                 "Those keys have the following types:",
-                "  - data (list; optional): data is a collection of traces",
+                "  - data (list of dicts; optional): data is a collection of traces",
                 "  - layout (dict; optional): layout describes the rest of the figure"  # noqa: E501
 
             ])],
@@ -888,7 +892,7 @@ def assert_docstring(assertEqual, docstring):
             "- optionalElement (dash component; optional)",
             "- optionalEnum (a value equal to: 'News', 'Photos'; optional)",
             "- optionalUnion (string | number; optional)",
-            "- optionalArrayOf (list; optional)",
+            "- optionalArrayOf (list of numbers; optional)",
 
             "- optionalObjectOf (dict with strings as keys and values "
             "of type number; optional)",
@@ -907,7 +911,7 @@ def assert_docstring(assertEqual, docstring):
             "keys 'data', 'layout'.",
 
             "Those keys have the following types:",
-            "  - data (list; optional): data is a collection of traces",
+            "  - data (list of dicts; optional): data is a collection of traces",
 
             "  - layout (dict; optional): layout describes "
             "the rest of the figure",
@@ -926,7 +930,7 @@ def assert_docstring(assertEqual, docstring):
             "keys 'data', 'layout'.",
 
             "Those keys have the following types:",
-            "  - data (list; optional): data is a collection of traces",
+            "  - data (list of dicts; optional): data is a collection of traces",
 
             "  - layout (dict; optional): layout describes "
             "the rest of the figure",