Repository: plotly/dash

Issue:
Generated R packages should support dynamic wildcards
In Dash for Python components, such as `html.Div`, wildcards need not be restricted to `data-*` and `aria-*`, though these are now standard in components which accept wildcards:

https://github.com/plotly/dash-html-components/blob/bf465eb83a80d6c761288e0ffce5a2f32de66f6c/dash_html_components/Div.py#L44-L50

In Dash for R, the set of wildcards is hardcoded:

https://github.com/plotly/dashR/blob/85915f8e6091f11c1da8e2e27780f7782bc5e55c/R/dash.R#L857

This line should be modified to accept dynamic/developer specified wildcards:

`grepl(c('^data-[a-zA-Z0-9]{1,}$|^aria-[a-zA-Z0-9]{1,}$'), x)`

Given that logic already exists to selectively generate wildcard props, the change required should be relatively minor:

https://github.com/plotly/dash/blob/176f2861a9c26d5e90d22501d87cba4fcdb513f7/dash/development/_r_components_generation.py#L183-L184

https://github.com/plotly/dash/blob/176f2861a9c26d5e90d22501d87cba4fcdb513f7/dash/development/_r_components_generation.py#L163-L168


Discussion:

Resolving patch:
diff --git a/dash/development/_r_components_generation.py b/dash/development/_r_components_generation.py
--- a/dash/development/_r_components_generation.py
+++ b/dash/development/_r_components_generation.py
@@ -8,6 +8,7 @@
 import importlib
 import textwrap
 import re
+import warnings
 
 from ._all_keywords import r_keywords
 from ._py_components_generation import reorder_props
@@ -18,16 +19,18 @@
 # code below
 r_component_string = """{funcname} <- function({default_argtext}{wildcards}) {{
     {wildcard_declaration}
+    props <- list({default_paramtext}{wildcards})
+    if (length(props) > 0) {{
+        props <- props[!vapply(props, is.null, logical(1))]
+    }}
     component <- list(
-        props = list({default_paramtext}{wildcards}),
+        props = props,
         type = '{name}',
         namespace = '{project_shortname}',
         propNames = c({prop_names}{wildcard_names}),
         package = '{package_name}'
         )
 
-    component$props <- filter_null(component$props)
-
     structure(component, class = c('dash_component', 'list'))
 }}
 """  # noqa:E501
@@ -83,7 +86,7 @@
 Authors @R: as.person(c({package_author}))
 Description: {package_description}
 Depends: R (>= 3.0.2){package_depends}
-Imports: dash{package_imports}
+Imports: {package_imports}
 Suggests: {package_suggests}
 License: {package_license}
 URL: {package_url}
@@ -91,7 +94,7 @@
 Encoding: UTF-8
 LazyData: true
 Author: {package_author_no_email}
-Maintainer: {package_author}
+Maintainer: {maintainer}
 """
 
 rbuild_ignore_string = r"""# ignore JS config files/folders
@@ -103,6 +106,11 @@
 .builderrc
 .eslintrc
 .npmignore
+.editorconfig
+.eslintignore
+.prettierrc
+.circleci
+.github
 
 # demo folder has special meaning in R
 # this should hopefully make it still
@@ -129,22 +137,44 @@
 \\docType{{package}}
 \\name{{{package_name}-package}}
 \\alias{{{package_name}}}
-\\title{{{pkg_help_header}}}
+\\title{{{pkg_help_title}}}
 \\description{{
-{pkg_help_desc}
-}}
-\\seealso{{
-Useful links:
-\\itemize{{
-  \\item \\url{{https://github.com/plotly/{lib_name}}}
-  \\item Report bugs at \\url{{https://github.com/plotly/{lib_name}/issues}}
-}}
+{pkg_help_description}
 }}
 \\author{{
-\\strong{{Maintainer}}: {package_author}
+\\strong{{Maintainer}}: {maintainer}
 }}
 """
 
+wildcard_helper = """
+dash_assert_valid_wildcards <- function (attrib = list("data", "aria"), ...)
+{
+    args <- list(...)
+    validation_results <- lapply(names(args), function(x) {
+        grepl(paste0("^(", paste0(attrib, collapse="|"), ")-[a-zA-Z0-9_-]+$"),
+            x)
+    })
+    if (FALSE %in% validation_results) {
+        stop(sprintf("The following props are not valid in this component: '%s'",
+            paste(names(args)[grepl(FALSE, unlist(validation_results))],
+                collapse = ", ")), call. = FALSE)
+    }
+    else {
+        return(args)
+    }
+}
+"""  # noqa:E501
+
+wildcard_template = """
+    wildcard_names = names(dash_assert_valid_wildcards(attrib = list({}), ...))
+"""
+
+wildcard_help_template = """
+
+
+\\item{{...}}{{wildcards allowed have the form: `{}`}}
+"""
+
 
 # pylint: disable=R0914
 def generate_class_string(name, props, project_shortname, prefix):
@@ -159,18 +189,18 @@ def generate_class_string(name, props, project_shortname, prefix):
     wildcards = ""
     wildcard_declaration = ""
     wildcard_names = ""
+    default_paramtext = ""
+    default_argtext = ""
+    accepted_wildcards = ""
 
-    if any("-*" in key for key in prop_keys):
+    if any(key.endswith("-*") for key in prop_keys):
+        accepted_wildcards = get_wildcards_r(prop_keys)
         wildcards = ", ..."
-        wildcard_declaration = (
-            "\n    wildcard_names = names(assert_valid_wildcards(...))\n"
+        wildcard_declaration = wildcard_template.format(
+            accepted_wildcards.replace("-*", "")
         )
         wildcard_names = ", wildcard_names"
 
-    default_paramtext = ""
-    default_argtext = ""
-    default_wildcards = ""
-
     # Produce a string with all property names other than WCs
     prop_names = ", ".join(
         "'{}'".format(p)
@@ -178,20 +208,16 @@ def generate_class_string(name, props, project_shortname, prefix):
         if "*" not in p and p not in ["setProps"]
     )
 
-    # in R, we set parameters with no defaults to NULL
-    # Here we'll do that if no default value exists
-    default_wildcards += ", ".join("'{}'".format(p)
-                                   for p in prop_keys if "*" in p)
-
-    if default_wildcards == "":
-        default_wildcards = "NULL"
-    else:
-        default_wildcards = "c({})".format(default_wildcards)
-
     # Filter props to remove those we don't want to expose
     for item in prop_keys[:]:
-        if item.endswith("-*") or item in r_keywords or item == "setProps":
+        if item.endswith("-*") or item == "setProps":
             prop_keys.remove(item)
+        elif item in r_keywords:
+            prop_keys.remove(item)
+            warnings.warn((
+                'WARNING: prop "{}" in component "{}" is an R keyword'
+                ' - REMOVED FROM THE R COMPONENT'
+            ).format(item, name))
 
     default_argtext += ", ".join("{}=NULL".format(p) for p in prop_keys)
 
@@ -256,7 +282,7 @@ def generate_js_metadata(pkg_data, project_shortname):
             if "dash_" in rpp:
                 dep_name = rpp.split(".")[0]
             else:
-                dep_name = "{}_{}".format(project_shortname, str(dep))
+                dep_name = "{}".format(project_shortname)
                 project_ver = str(dep)
             if "css" in rpp:
                 css_name = "'{}'".format(rpp)
@@ -278,10 +304,10 @@ def generate_js_metadata(pkg_data, project_shortname):
     elif len(alldist) == 1:
         rpp = alldist[0]["relative_package_path"]
         if "css" in rpp:
-            css_name = rpp
+            css_name = "'{}'".format(rpp)
             script_name = "NULL"
         else:
-            script_name = rpp
+            script_name = "'{}'".format(rpp)
             css_name = "NULL"
         function_frame_body = frame_body_template.format(
             project_shortname=project_shortname,
@@ -321,8 +347,6 @@ def write_help_file(name, props, description, prefix):
 
     prop_keys = list(props.keys())
 
-    has_wildcards = any("-*" in key for key in prop_keys)
-
     # Filter props to remove those we don't want to expose
     for item in prop_keys[:]:
         if item.endswith("-*") or item in r_keywords or item == "setProps":
@@ -331,29 +355,28 @@ 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,
-                                      print_r_type(
-                                          props[p]["type"]
-                                      ),
-                                      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-*`}'
+    if any(key.endswith("-*") for key in prop_keys):
         default_argtext += ', ...'
+        item_text += wildcard_help_template.format(get_wildcards_r(prop_keys))
 
     # 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(
             funcname=format_fn_name(prefix, name),
             name=name,
-            default_argtext=textwrap.fill(argtext,
+            default_argtext=textwrap.fill(default_argtext,
                                           width=80,
                                           break_long_words=False),
             item_text=item_text,
@@ -361,11 +384,7 @@ def write_help_file(name, props, description, prefix):
         ))
 
 
-def write_class_file(name,
-                     props,
-                     description,
-                     project_shortname,
-                     prefix=None):
+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
@@ -373,20 +392,12 @@ def write_class_file(name,
     # 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
-    )
+    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
+        name, props, project_shortname, prefix
     )
 
     file_name = format_fn_name(prefix, name) + ".R"
@@ -399,7 +410,7 @@ def write_class_file(name,
     print("Generated {}".format(file_name))
 
 
-def write_js_metadata(pkg_data, project_shortname):
+def write_js_metadata(pkg_data, project_shortname, has_wildcards):
     """
     Write an internal (not exported) R function to return all JS
     dependencies as required by dash.
@@ -425,6 +436,8 @@ def write_js_metadata(pkg_data, project_shortname):
     file_path = os.path.join("R", file_name)
     with open(file_path, "w") as f:
         f.write(function_string)
+        if has_wildcards:
+            f.write(wildcard_helper)
 
     # now copy over all JS dependencies from the (Python) components dir
     # the inst/lib directory for the package won't exist on first call
@@ -445,11 +458,13 @@ def write_js_metadata(pkg_data, project_shortname):
 # pylint: disable=R0914, R0913, R0912, R0915
 def generate_rpkg(
         pkg_data,
+        rpkg_data,
         project_shortname,
         export_string,
         package_depends,
         package_imports,
-        package_suggests
+        package_suggests,
+        has_wildcards,
 ):
     """
     Generate documents for R package creation
@@ -457,8 +472,12 @@ def generate_rpkg(
     Parameters
     ----------
     pkg_data
+    rpkg_data
     project_shortname
     export_string
+    package_depends
+    package_imports
+    package_suggests
 
     Returns
     -------
@@ -478,12 +497,12 @@ def generate_rpkg(
         package_depends = ", " + package_depends.strip(",").lstrip()
 
     if package_imports:
-        package_imports = ", " + package_imports.strip(",").lstrip()
+        package_imports = package_imports.strip(",").lstrip()
 
     if package_suggests:
         package_suggests = package_suggests.strip(",").lstrip()
 
-    if "bugs" in pkg_data.keys():
+    if "bugs" in pkg_data:
         package_issues = pkg_data["bugs"].get("url", "")
     else:
         package_issues = ""
@@ -493,7 +512,7 @@ def generate_rpkg(
             file=sys.stderr,
         )
 
-    if "homepage" in pkg_data.keys():
+    if "homepage" in pkg_data:
         package_url = pkg_data.get("homepage", "")
     else:
         package_url = ""
@@ -506,6 +525,8 @@ def generate_rpkg(
 
     package_author_no_email = package_author.split(" <")[0] + " [aut]"
 
+    maintainer = pkg_data.get("maintainer", pkg_data.get("author"))
+
     if not (os.path.isfile("LICENSE") or os.path.isfile("LICENSE.txt")):
         package_license = pkg_data.get("license", "")
     else:
@@ -529,38 +550,16 @@ def generate_rpkg(
     # generate the internal (not exported to the user) functions which
     # supply the JavaScript dependencies to the dash package.
     # this avoids having to generate an RData file from within Python.
-    write_js_metadata(pkg_data=pkg_data, project_shortname=project_shortname)
+    write_js_metadata(pkg_data, project_shortname, has_wildcards)
 
-    with open("NAMESPACE", "w") as f:
+    with open("NAMESPACE", "w+") as f:
         f.write(import_string)
         f.write(export_string)
         f.write(packages_string)
 
-    with open(".Rbuildignore", "w") as f2:
+    with open(".Rbuildignore", "w+") as f2:
         f2.write(rbuild_ignore_string)
 
-    # Write package stub files for R online help, generate if
-    # dashHtmlComponents or dashCoreComponents; makes it easy
-    # for R users to bring up main package help page
-    pkg_help_header = ""
-
-    if package_name in ["dashHtmlComponents"]:
-        pkg_help_header = "Vanilla HTML Components for Dash"
-        pkg_help_desc = "Dash is a web application framework that\n\
-provides pure Python and R abstraction around HTML, CSS, and\n\
-JavaScript. Instead of writing HTML or using an HTML\n\
-templating engine, you compose your layout using R\n\
-functions within the dashHtmlComponents package. The\n\
-source for this package is on GitHub:\n\
-plotly/dash-html-components."
-    if package_name in ["dashCoreComponents"]:
-        pkg_help_header = "Core Interactive UI Components for Dash"
-        pkg_help_desc = "Dash ships with supercharged components for\n\
-interactive user interfaces. A core set of components,\n\
-written and maintained by the Dash team, is available in\n\
-the dashCoreComponents package. The source for this package\n\
-is on GitHub: plotly/dash-core-components."
-
     description_string = description_template.format(
         package_name=package_name,
         package_description=package_description,
@@ -573,21 +572,23 @@ def generate_rpkg(
         package_url=package_url,
         package_issues=package_issues,
         package_author_no_email=package_author_no_email,
+        maintainer=maintainer,
     )
 
-    with open("DESCRIPTION", "w") as f3:
+    with open("DESCRIPTION", "w+") as f3:
         f3.write(description_string)
 
-    if pkg_help_header != "":
-        pkghelp = pkghelp_stub.format(
-            package_name=package_name,
-            pkg_help_header=pkg_help_header,
-            pkg_help_desc=pkg_help_desc,
-            lib_name=lib_name,
-            package_author=package_author,
-        )
-        with open(pkghelp_stub_path, "w") as f4:
-            f4.write(pkghelp)
+    if rpkg_data is not None:
+        if rpkg_data.get("pkg_help_description"):
+            pkghelp = pkghelp_stub.format(
+                package_name=package_name,
+                pkg_help_title=rpkg_data.get("pkg_help_title"),
+                pkg_help_description=rpkg_data.get("pkg_help_description"),
+                lib_name=lib_name,
+                maintainer=maintainer,
+            )
+            with open(pkghelp_stub_path, "w") as f4:
+                f4.write(pkghelp)
 
 
 # This converts a string from snake case to camel case
@@ -614,6 +615,7 @@ def generate_exports(
         components,
         metadata,
         pkg_data,
+        rpkg_data,
         prefix,
         package_depends,
         package_imports,
@@ -625,9 +627,7 @@ def generate_exports(
         if (
                 not component.endswith("-*")
                 and str(component) not in r_keywords
-                and str(component) not in ["setProps",
-                                           "children",
-                                           "dashEvents"]
+                and str(component) not in ["setProps", "children"]
         ):
             export_string += "export({}{})\n".format(prefix, component)
 
@@ -659,16 +659,25 @@ def generate_exports(
     export_string += "\n".join("export({})".format(function)
                                for function in fnlist)
 
+    # Look for wildcards in the metadata
+    has_wildcards = False
+    for component_data in metadata.values():
+        if any(key.endswith('-*') for key in component_data['props']):
+            has_wildcards = True
+            break
+
     # now, bundle up the package information and create all the requisite
     # elements of an R package, so that the end result is installable either
     # locally or directly from GitHub
     generate_rpkg(
         pkg_data,
+        rpkg_data,
         project_shortname,
         export_string,
         package_depends,
         package_imports,
         package_suggests,
+        has_wildcards,
     )
 
 
@@ -821,3 +830,13 @@ def create_prop_docstring_r(prop_name, type_object, required, description,
                    ': {}'.format(description) if description != '' else ''
                ),
                is_required='required' if required else 'optional')
+
+
+def get_wildcards_r(prop_keys):
+    wildcards = ""
+    wildcards += ", ".join("'{}'".format(p)
+                           for p in prop_keys if p.endswith("-*"))
+
+    if wildcards == "":
+        wildcards = "NULL"
+    return wildcards
diff --git a/dash/development/component_generator.py b/dash/development/component_generator.py
--- a/dash/development/component_generator.py
+++ b/dash/development/component_generator.py
@@ -11,6 +11,7 @@
 import functools
 
 import pkg_resources
+import yaml
 
 from ._r_components_generation import write_class_file
 from ._r_components_generation import generate_exports
@@ -50,11 +51,10 @@ def generate_components(
 
     project_shortname = project_shortname.replace("-", "_").rstrip("/\\")
 
-    if rprefix is not None:
-        prefix = rprefix
-
     is_windows = sys.platform == "win32"
 
+    yamldata = None
+
     extract_path = pkg_resources.resource_filename("dash", "extract-meta.js")
 
     reserved_patterns = '|'.join('^{}$'.format(p) for p in reserved_words)
@@ -89,12 +89,7 @@ def generate_components(
         )
         sys.exit(1)
 
-    jsondata_unicode = json.loads(out.decode(), object_pairs_hook=OrderedDict)
-
-    if sys.version_info[0] >= 3:
-        metadata = jsondata_unicode
-    else:
-        metadata = byteify(jsondata_unicode)
+    metadata = safe_json_loads(out.decode())
 
     generator_methods = [generate_class_file]
 
@@ -104,7 +99,7 @@ def generate_components(
         if not os.path.exists('R'):
             os.makedirs('R')
         generator_methods.append(
-            functools.partial(write_class_file, prefix=prefix))
+            functools.partial(write_class_file, prefix=rprefix))
 
     components = generate_classes_files(
         project_shortname,
@@ -118,25 +113,39 @@ def generate_components(
     generate_imports(project_shortname, components)
 
     if rprefix is not None:
+        if os.path.isfile("dash-info.yaml"):
+            with open("dash-info.yaml") as yamldata:
+                rpkg_data = yaml.safe_load(yamldata)
+        else:
+            rpkg_data = None
+            print(
+                "Warning: dash-info.yaml missing; package metadata not loaded",
+                file=sys.stderr
+            )
+
         with open('package.json', 'r') as f:
-            jsondata_unicode = json.load(f, object_pairs_hook=OrderedDict)
-            if sys.version_info[0] >= 3:
-                pkg_data = jsondata_unicode
-            else:
-                pkg_data = byteify(jsondata_unicode)
+            pkg_data = safe_json_loads(f.read())
 
         generate_exports(
             project_shortname,
             components,
             metadata,
             pkg_data,
-            prefix,
+            rpkg_data,
+            rprefix,
             rdepends,
             rimports,
             rsuggests,
         )
 
 
+def safe_json_loads(s):
+    jsondata_unicode = json.loads(s, object_pairs_hook=OrderedDict)
+    if sys.version_info[0] >= 3:
+        return jsondata_unicode
+    return byteify(jsondata_unicode)
+
+
 def cli():
     parser = argparse.ArgumentParser(
         prog="dash-generate-components",


Test patch: