diff --git a/dash/development/_all_keywords.py b/dash/development/_all_keywords.py index 76842b56af..550c1d9fa0 100644 --- a/dash/development/_all_keywords.py +++ b/dash/development/_all_keywords.py @@ -3,7 +3,7 @@ # >>> import keyword # >>> keyword.kwlist -kwlist = set([ +python_keywords = set([ 'and', 'elif', 'is', @@ -42,3 +42,31 @@ 'def', 'lambda' ]) + +# This is a set of R reserved words that cannot be used as function argument names. +# +# Reserved words can be obtained from R's help pages by executing the statement below: +# > ?reserved + +r_keywords = set([ + 'if', + 'else', + 'repeat', + 'while', + 'function', + 'for', + 'in', + 'next', + 'break', + 'TRUE', + 'FALSE', + 'NULL', + 'Inf', + 'NaN', + 'NA', + 'NA_integer_', + 'NA_real_', + 'NA_complex_', + 'NA_character_', + '...' +]) diff --git a/dash/development/base_component.py b/dash/development/base_component.py index 789253a3f2..e98dd2b86d 100644 --- a/dash/development/base_component.py +++ b/dash/development/base_component.py @@ -6,8 +6,7 @@ import sys import six -from ._all_keywords import kwlist - +from ._all_keywords import python_keywords, r_keywords # pylint: disable=no-init,too-few-public-methods class ComponentRegistry: @@ -425,13 +424,231 @@ def __repr__(self): '{:s}=Component.UNDEFINED'.format(p)) for p in prop_keys if not p.endswith("-*") and - p not in kwlist and + p not in python_keywords and p not in ['dashEvents', 'fireEvent', 'setProps']] + ['**kwargs'] ) required_args = required_props(props) - return c.format(**locals()) + return c.format(typename=typename, + docstring=docstring, + default_argtext=default_argtext, + list_of_valid_keys=list_of_valid_keys, + namespace=namespace, + list_of_valid_wildcard_attr_prefixes=list_of_valid_wildcard_attr_prefixes, + events=events, + required_args=required_args, + argtext=argtext) + +# This is an initial attempt at resolving type inconsistencies +# between R and JSON. +def json_to_r_type(current_prop): + object_type = current_prop['type'].values() + if 'defaultValue' in current_prop and object_type == ['string']: + if current_prop['defaultValue']['value'].__contains__('\''): + argument = current_prop['defaultValue']['value'] + else: + argument = "\'{:s}\'".format(current_prop['defaultValue']['value']) + elif 'defaultValue' in current_prop and object_type == ['object']: + argument = 'list()' + elif 'defaultValue' in current_prop and \ + current_prop['defaultValue']['value'] == '[]': + argument = 'list()' + else: + argument = 'NULL' + return argument + +# pylint: disable=R0914 +def generate_class_string_r(typename, props, namespace, prefix): + """ + Dynamically generate class strings to have nicely formatted documentation, + and function arguments + + Inspired by http://jameso.be/2013/08/06/namedtuple.html + + Parameters + ---------- + typename + props + namespace + prefix + + Returns + ------- + string + + """ + c = '''{prefix}{typename} <- function(..., {default_argtext}) {{ + + component <- list( + props = list( + {default_paramtext} + ), + type = '{typename}', + namespace = '{namespace}', + propNames = c({prop_names}), + package = '{package_name}' + ) + + component$props <- filter_null(component$props) + component <- append_wildcard_props(component, wildcards = {default_wildcards}, ...) + + structure(component, class = c('dash_component', 'list')) + }} +''' + + # Here we convert from snake case to camel case + package_name = make_package_name_r(namespace) + + prop_keys = list(props.keys()) + + default_paramtext = '' + default_argtext = '' + default_wildcards = '' + + # Produce a string with all property names other than WCs + prop_names = ", ".join( + ('\'{:s}\''.format(p)) + for p in prop_keys + if '*' not in p and + p not in ['setProps', 'dashEvents', 'fireEvent'] + ) + + # in R, we set parameters with no defaults to NULL + # Here we'll do that if no default value exists + default_wildcards += ", ".join( + ('\'{:s}\''.format(p)) + for p in prop_keys + if '*' in p + ) + + if default_wildcards == '': + default_wildcards = 'NULL' + else: + default_wildcards = 'c({:s})'.format(default_wildcards) + + default_argtext += ", ".join( + ('{:s}={}'.format(p, json_to_r_type(props[p])) + if 'defaultValue' in props[p] else + '{:s}=NULL'.format(p)) + for p in prop_keys + if not p.endswith("-*") and + p not in r_keywords and + p not in ['setProps', 'dashEvents', 'fireEvent'] + ) + + if 'children' in props: + prop_keys.remove('children') + + # pylint: disable=C0301 + default_paramtext += ", ".join( + ('{:s}={:s}'.format(p, p) + if p != "children" else + '{:s}=c(children, assert_valid_children(..., wildcards = {:s}))'.format(p, default_wildcards)) + for p in props.keys() + if not p.endswith("-*") and + p not in r_keywords and + p not in ['setProps', 'dashEvents', 'fireEvent'] + ) + return c.format(prefix=prefix, + typename=typename, + default_argtext=default_argtext, + default_paramtext=default_paramtext, + namespace=namespace, + prop_names=prop_names, + package_name=package_name, + default_wildcards=default_wildcards) + +# pylint: disable=R0914 +def generate_js_metadata_r(namespace): + """ + Dynamically generate R function to supply JavaScript + dependency information required by htmltools package, + which is loaded by dashR. + + Inspired by http://jameso.be/2013/08/06/namedtuple.html + + Parameters + ---------- + namespace + + Returns + ------- + function_string + """ + + project_shortname = namespace.replace('-', '_') + + import importlib + + # import component library module + importlib.import_module(project_shortname) + + # import component library module into sys + mod = sys.modules[project_shortname] + + jsdist = getattr(mod, '_js_dist', []) + project_ver = getattr(mod, '__version__', []) + + rpkgname = make_package_name_r(project_shortname) + + # since _js_dist may suggest more than one dependency, need + # a way to iterate over all dependencies for a given set. + # here we define an opening, element, and closing string -- + # if the total number of dependencies > 1, we can string each + # together and write a list object in R with multiple elements + + function_frame_open = '''.{rpkgname}_js_metadata <- function() {{ + deps_metadata <- list( + '''.format(rpkgname=rpkgname) + + function_frame = [] + + # the following string represents all the elements in an object + # of the html_dependency class, which will be propagated by + # iterating over _js_dist in __init__.py + function_frame_element = '''`{dep_name}` = structure(list(name = "{dep_name}", + version = "{project_ver}", src = list(href = NULL, + file = "lib/"), meta = NULL, + script = "{dep_rpp}", + stylesheet = NULL, head = NULL, attachment = NULL, package = "{rpkgname}", + all_files = FALSE), class = "html_dependency")''' + + if len(jsdist) > 1: + for dep in range(len(jsdist)): + if jsdist[dep]['relative_package_path'].__contains__('dash_'): + dep_name = jsdist[dep]['relative_package_path'].split('.')[0] + else: + dep_name = '{:s}_{:s}'.format(project_shortname, str(dep)) + project_ver = str(dep) + function_frame += [function_frame_element.format(dep_name=dep_name, + project_ver=project_ver, + rpkgname=rpkgname, + project_shortname=project_shortname, + dep_rpp=jsdist[dep]['relative_package_path']) + ] + function_frame_body = ',\n'.join(function_frame) + elif len(jsdist) == 1: + function_frame_body = '''`{project_shortname}` = structure(list(name = "{project_shortname}", + version = "{project_ver}", src = list(href = NULL, + file = "lib/"), meta = NULL, + script = "{dep_rpp}", + stylesheet = NULL, head = NULL, attachment = NULL, package = "{rpkgname}", + all_files = FALSE), class = "html_dependency")'''.format(project_shortname=project_shortname, + project_ver=project_ver, + rpkgname=rpkgname, + dep_rpp=jsdist[0]['relative_package_path']) + + function_frame_close = ''') + return(deps_metadata) + }''' + + function_string = ''.join([function_frame_open, + function_frame_body, + function_frame_close] + ) + + return function_string # pylint: disable=unused-argument def generate_class_file(typename, props, description, namespace): @@ -466,6 +683,253 @@ def generate_class_file(typename, props, description, namespace): f.write(import_string) f.write(class_string) +def write_help_file_r(typename, props, prefix): + """ + Write R documentation file (.Rd) given component name and properties + + Parameters + ---------- + typename + props + prefix + + Returns + ------- + + + """ + file_name = '{:s}{:s}.Rd'.format(prefix, typename) + prop_keys = list(props.keys()) + + default_argtext = '' + item_text = '' + + # Ensure props are ordered with children first + props = reorder_props(props=props) + + default_argtext += ", ".join( + ('{:s}={}'.format(p, json_to_r_type(props[p])) + if 'defaultValue' in props[p] else + '{:s}=NULL'.format(p)) + for p in prop_keys + if not p.endswith("-*") and + p not in r_keywords and + p not in ['setProps', 'dashEvents', 'fireEvent'] + ) + + item_text += "\n\n".join( + ('\\item{{{:s}}}{{{:s}}}'.format(p, props[p]['description'])) + for p in prop_keys + if not p.endswith("-*") and + p not in r_keywords and + p not in ['setProps', 'dashEvents', 'fireEvent'] + ) + + help_string = '''% Auto-generated: do not edit by hand +\\name{{{prefix}{typename}}} +\\alias{{{prefix}{typename}}} +\\title{{{typename} component}} +\\usage{{ +{prefix}{typename}(..., {default_argtext}) +}} +\\arguments{{ +{item_text} +}} +\\description{{ +{typename} component +}} + ''' + if not os.path.exists('man'): + os.makedirs('man') + + file_path = os.path.join('man', file_name) + with open(file_path, 'w') as f: + f.write(help_string.format( + prefix=prefix, + typename=typename, + default_argtext=default_argtext, + item_text=item_text + )) + +def write_class_file_r(typename, props, description, namespace, prefix): + """ + Generate a R class file (.R) given a class string + + Parameters + ---------- + typename + props + description + namespace + prefix + + Returns + ------- + + """ + import_string =\ + "# AUTO GENERATED FILE - DO NOT EDIT\n\n" + class_string = generate_class_string_r( + typename, + props, + namespace, + prefix + ) + file_name = "{:s}{:s}.R".format(prefix, typename) + + if not os.path.exists('R'): + os.makedirs('R') + + file_path = os.path.join('R', file_name) + with open(file_path, 'w') as f: + f.write(import_string) + f.write(class_string) + +# pylint: disable=unused-variable +def generate_export_string_r(name, prefix): + if not name.endswith('-*') and \ + str(name) not in r_keywords and \ + str(name) not in ['setProps', 'children', 'dashEvents']: + return 'export({:s}{:s})\n'.format(prefix, name) + +def write_js_metadata_r(namespace): + """ + Write an internal (not exported) function to return all JS + dependencies as required by htmlDependency package given a + function string + + Parameters + ---------- + namespace + + Returns + ------- + + """ + function_string = generate_js_metadata_r( + namespace + ) + file_name = "internal.R" + + if not os.path.exists('R'): + os.makedirs('R') + + file_path = os.path.join('R', file_name) + with open(file_path, 'w') as f: + f.write(function_string) + + # now copy over all the JS dependencies from the (Python) + # components directory + import shutil, glob + + for file in glob.glob('{}/*.js'.format(namespace.replace('-', '_'))): + shutil.copy(file, 'inst/lib/') + +# pylint: disable=R0914 +def generate_rpkg(pkg_data, + namespace, + export_string): + ''' + Generate documents for R package creation + + Parameters + ---------- + name + pkg_data + namespace + + Returns + ------- + + ''' + # Leverage package.json to import specifics which are also applicable + # to R package that we're generating here + package_name = make_package_name_r(namespace) + package_description = pkg_data['description'] + package_version = pkg_data['version'] + package_issues = pkg_data['bugs']['url'] + package_url = pkg_data['homepage'] + + package_author = pkg_data['author'] + + package_author_no_email = package_author.split(" <")[0] + ' [aut]' + + if not (os.path.isfile('LICENSE') or os.path.isfile('LICENSE.txt')): + package_license = pkg_data['license'] + else: + package_license = pkg_data['license'] + ' + file LICENSE' + # R requires that the LICENSE.txt file be named LICENSE + if not os.path.isfile('LICENSE'): + os.symlink("LICENSE.txt", "LICENSE") + + import_string =\ + '# AUTO GENERATED FILE - DO NOT EDIT\n\n' + + description_string = \ + '''Package: {package_name} +Title: {package_description} +Version: {package_version} +Authors @R: as.person(c({package_author})) +Description: {package_description} +Suggests: testthat, roxygen2 +License: {package_license} +URL: {package_url} +BugReports: {package_issues} +Encoding: UTF-8 +LazyData: true +Author: {package_author_no_email} +Maintainer: {package_author} +''' + + description_string = description_string.format(package_name=package_name, + package_description=package_description, + package_version=package_version, + package_author=package_author, + package_license=package_license, + package_url=package_url, + package_issues=package_issues, + package_author_no_email=package_author_no_email) + + rbuild_ignore_string = r'''# ignore JS config files/folders +node_modules/ +coverage/ +src/ +lib/ +.babelrc +.builderrc +.eslintrc +.npmignore + +# demo folder has special meaning in R +# this should hopefully make it still +# allow for the possibility to make R demos +demo/*.js +demo/*.html +demo/*.css + +# ignore python files/folders +setup.py +usage.py +setup.py +requirements.txt +MANIFEST.in +CHANGELOG.md +test/ +# CRAN has weird LICENSE requirements +LICENSE.txt +^.*\.Rproj$ +^\.Rproj\.user$ +''' + + with open('NAMESPACE', 'w') as f: + f.write(import_string) + f.write(export_string) + + with open('DESCRIPTION', 'w') as f2: + f2.write(description_string) + + with open('.Rbuildignore', 'w') as f3: + f3.write(rbuild_ignore_string) # pylint: disable=unused-argument def generate_class(typename, props, description, namespace): @@ -552,7 +1016,6 @@ def create_docstring(component_name, props, events, description): for p, prop in list(filter_props(props).items())), events=', '.join(events)) - def parse_events(props): """ Pull out the dashEvents from the Component props @@ -882,3 +1345,10 @@ def js_to_py_type(type_object, is_flow_type=False, indent_num=0): # All other types return js_to_py_types[js_type_name]() return '' + +# This converts a string from snake case to camel case +# Not required for R package name to be in camel case, +# but probably more conventional this way +def make_package_name_r(namestring): + first, rest = namestring.split('_')[0], namestring.split('_')[1:] + return first + ''.join(word.capitalize() for word in rest) diff --git a/dash/development/component_loader.py b/dash/development/component_loader.py index 2b5e70b10f..7a345f77c2 100644 --- a/dash/development/component_loader.py +++ b/dash/development/component_loader.py @@ -1,10 +1,17 @@ import collections import json import os + +from .base_component import ComponentRegistry + from .base_component import generate_class from .base_component import generate_class_file -from .base_component import ComponentRegistry +from .base_component import generate_export_string_r +from .base_component import generate_rpkg +from .base_component import write_class_file_r +from .base_component import write_help_file_r +from .base_component import write_js_metadata_r def _get_metadata(metadata_path): # Start processing @@ -110,3 +117,90 @@ def generate_classes(namespace, metadata_path='lib/metadata.json'): array_string += ' "{:s}",\n'.format(a) array_string += ']\n' f.write('\n\n__all__ = {:s}'.format(array_string)) + +def generate_classes_r(namespace, metadata_path='lib/metadata.json', pkgjson_path='package.json'): + """Load React component metadata into a format Dash can parse, + then create python class files. + + Usage: generate_classes_r() + + 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). + + pkgjson_path -- a path to a JSON file created by + [`cookiecutter`](https://github.com/audreyr/cookiecutter). + + Returns: + """ + + data = _get_metadata(metadata_path) + pkg_data = _get_metadata(pkgjson_path) + imports_path = os.path.join(namespace, '_imports_.py') + export_string = '' + + if namespace == 'dash_html_components': + prefix = 'html' + elif namespace == 'dash_core_components': + prefix = 'core' + else: + prefix = '' + + # Remove the R NAMESPACE file if it exists, this will be repopulated + if os.path.isfile('NAMESPACE'): + os.remove('NAMESPACE') + + # 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] + + export_string += generate_export_string_r(name, prefix) + + # generate and write out R functions which will serve an analogous + # purpose to the classes in Python which interface with the + # Dash components + write_class_file_r( + name, + componentData['props'], + componentData['description'], + namespace, + prefix + ) + + # generate the internal (not exported to the user) functions which + # supply the JavaScript dependencies to the htmlDependency package, + # which is required by DashR (this avoids having to generate an + # RData file from within Python, given the current package generation + # workflow) + write_js_metadata_r( + namespace + ) + + # 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_r( + name, + componentData['props'], + prefix + ) + + # 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, + namespace, + export_string + )