from collections import OrderedDict import copy import os import sys import shutil import importlib import glob import json from dash.development.base_component import _explicitize_args from dash.exceptions import NonExistentEventException from ._all_keywords import python_keywords from .base_component import Component # TODO Follow the same structure of R package generation # pylint: disable=unused-argument def generate_class_string(typename, props, description, namespace): """Dynamically generate class strings to have nicely formatted docstrings, keyword arguments, and repr. Inspired by http://jameso.be/2013/08/06/namedtuple.html Parameters ---------- typename props description namespace Returns ------- string """ # TODO _prop_names, _type, _namespace, and available_properties # can be modified by a Dash JS developer via setattr # TODO - Tab out the repr for the repr of these components to make it # look more like a hierarchical tree # TODO - Include "description" "defaultValue" in the repr and docstring # # TODO - Handle "required" # # TODO - How to handle user-given `null` values? I want to include # an expanded docstring like Dropdown(value=None, id=None) # but by templating in those None values, I have no way of knowing # whether a property is None because the user explicitly wanted # it to be `null` or whether that was just the default value. # The solution might be to deal with default values better although # not all component authors will supply those. filtered_props = filter_props(props) prop_keys = list(filtered_props.keys()) string_attributes = "" for p in prop_keys: # TODO support wildcard attributes if p[-1] != "*": string_attributes += "has '{}' => (\n is => 'rw'\n);\n".format(p) perl_assets_package_name = _perl_assets_package_name_from_shortname(namespace) common = "my $dash_namespace = '" + namespace + "';\n\nsub DashNamespace {\n return $dash_namespace;\n}\nsub _js_dist {\n return " + perl_assets_package_name + "::_js_dist;\n}\n" return string_attributes + common # TODO Refactor this methods to a class def _perl_package_name_from_shortname(shortname, suffix=''): namespace_components = shortname.split("_") package_name = "" for namespace_component in namespace_components: if (len(package_name) > 0): package_name += "::" package_name += namespace_component.title() return package_name + suffix def _perl_file_name_from_shortname(shortname, suffix=''): namespace_components = shortname.split("_") return namespace_components[-1].title() + suffix + ".pm" def _perl_assets_package_name_from_shortname(shortname): return _perl_package_name_from_shortname(shortname, "Assets") def generate_perl_package_file(typename, props, description, namespace): """Generate a python class file (.py) given a class string. Parameters ---------- typename props description namespace Returns ------- """ perl_base_package = _perl_package_name_from_shortname(namespace) package_name = perl_base_package + "::" + typename perl_assets_package = _perl_assets_package_name_from_shortname(namespace) import_string =\ "# AUTO GENERATED FILE - DO NOT EDIT\n\n" + \ "package " + package_name + ";\n\n" + \ "use Moo;\n" + \ "use strictures 2;\n" + \ "use " + perl_assets_package + ";\n" + \ "use namespace::clean;\n\n" + \ "extends 'Dash::BaseComponent';\n\n" class_string = generate_class_string( typename, props, description, namespace ) file_name = "{:s}.pm".format(typename) directory = os.path.join('Perl', namespace) if not os.path.exists(directory): os.makedirs(directory) file_path = os.path.join(directory, file_name) with open(file_path, 'w') as f: f.write(import_string) f.write(class_string) f.write("\n1;\n"); print('Generated {}'.format(file_name)) 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. Parameters ---------- project_shortname = hyphenated string, e.g. dash-html-components Returns ------- """ file_name = "js_deps.json" sys.path.insert(0, os.getcwd()) mod = importlib.import_module(project_shortname) alldist = { "_js_dist": getattr(mod, "_js_dist", []) , "_css_dist": getattr(mod, "_css_dist", []) } # the Perl source directory for the package won't exist on first call # create the Perl directory if it is missing if not os.path.exists("Perl"): os.makedirs("Perl") file_path = os.path.join("Perl", file_name) with open(file_path, "w") as f: #f.write(function_string) f.write(json.dumps(alldist, indent=4)) 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 # create this directory if it is missing deps_output_path = os.path.join('Perl', 'deps') if os.path.exists(deps_output_path): shutil.rmtree(deps_output_path) os.makedirs(deps_output_path) for javascript in glob.glob("{}/*.js".format(project_shortname)): shutil.copy(javascript, deps_output_path) for css in glob.glob("{}/*.css".format(project_shortname)): shutil.copy(css, deps_output_path) for sourcemap in glob.glob("{}/*.map".format(project_shortname)): shutil.copy(sourcemap, deps_output_path) def generate_perl_imports(project_shortname, components): # the Perl source directory for the package won't exist on first call # create the Perl directory if it is missing if not os.path.exists("Perl"): os.makedirs("Perl") file_path = os.path.join("Perl", _perl_file_name_from_shortname(project_shortname)) children_omit_support = ' if ({}->can("children")) {{\n if (((scalar @_) % 2)) {{\n unshift @_, "children";\n }}\n }}\n' with open(file_path, 'w') as f: base_package_name = _perl_package_name_from_shortname(project_shortname) header_string = 'package ' + base_package_name + ';\nuse strict;\nuse warnings;\nuse Module::Load;\n\n' f.write(header_string) imports_string = '\n'.join( ('sub {} {{\n shift @_;\n load {};\n' + children_omit_support + ' return {}->new(@_);\n}}\n').format( x, base_package_name + "::" + x, base_package_name + "::" + x, base_package_name + "::" + x ) for x in components) f.write(imports_string) f.write("1;\n"); functions_suffix = 'Functions' file_path = os.path.join("Perl", _perl_file_name_from_shortname(project_shortname, functions_suffix)) with open(file_path, 'w') as f: base_package_name = _perl_package_name_from_shortname(project_shortname) functions_package_name = _perl_package_name_from_shortname(project_shortname, functions_suffix) header_string = 'package ' + functions_package_name + ';\nuse strict;\nuse warnings;\nuse Module::Load;\nuse Exporter::Auto;\n\n' f.write(header_string) imports_string = '\n'.join( ('sub {} {{\n load {};\n' + children_omit_support + ' return {}->new(@_);\n}}\n').format( x, base_package_name + "::" + x, base_package_name + "::" + x, base_package_name + "::" + x ) for x in components) f.write(imports_string) f.write("1;\n"); file_path = os.path.join("Perl", _perl_file_name_from_shortname(project_shortname, 'Assets')) with open(file_path, 'w') as f: content = 'package ' + _perl_assets_package_name_from_shortname(project_shortname) + ';\n\nuse strict;\nuse warnings;\nuse JSON;\nuse File::ShareDir;\nuse Path::Tiny;\n\n' + \ 'my $_deps;\nsub _deps {\n my $kind = shift;\n if (!defined $_deps) {\n $_deps = from_json(Path::Tiny::path(File::ShareDir::dist_file("Dash", Path::Tiny::path("assets", "' + project_shortname + '", "js_deps.json" )->canonpath ))->slurp_utf8);\n }\n' + \ ' if (defined $kind) {\n return $_deps->{$kind};\n }\n return $_deps;\n}\n\n' + \ 'sub _js_dist {\n return _deps("_js_dist");\n}\nsub _css_dist {\n return _deps("_css_dist");\n}\n\n' + \ '1;\n' f.write(content); def required_props(props): """Pull names of required props from the props object. Parameters ---------- props: dict Returns ------- list List of prop names (str) that are required for the Component """ return [prop_name for prop_name, prop in list(props.items()) if prop['required']] def create_docstring(component_name, props, description): """Create the Dash component docstring. Parameters ---------- component_name: str Component name props: dict Dictionary with {propName: propMetadata} structure description: str Component description Returns ------- str Dash component docstring """ # Ensure props are ordered with children first props = reorder_props(props=props) return ( """A{n} {name} component.\n{description} Keyword arguments:\n{args}""" ).format( n='n' if component_name[0].lower() in ['a', 'e', 'i', 'o', 'u'] else '', name=component_name, description=description, args='\n'.join( create_prop_docstring( prop_name=p, type_object=prop['type'] if 'type' in prop else prop['flowType'], required=prop['required'], description=prop['description'], default=prop.get('defaultValue'), indent_num=0, is_flow_type='flowType' in prop and 'type' not in prop) for p, prop in list(filter_props(props).items()))) def prohibit_events(props): """Events have been removed. Raise an error if we see dashEvents or fireEvents. Parameters ---------- props: dict Dictionary with {propName: propMetadata} structure Raises ------- ? """ if 'dashEvents' in props or 'fireEvents' in props: raise NonExistentEventException( 'Events are no longer supported by dash. Use properties instead, ' 'eg `n_clicks` instead of a `click` event.') def parse_wildcards(props): """Pull out the wildcard attributes from the Component props. Parameters ---------- props: dict Dictionary with {propName: propMetadata} structure Returns ------- list List of Dash valid wildcard prefixes """ list_of_valid_wildcard_attr_prefixes = [] for wildcard_attr in ["data-*", "aria-*"]: if wildcard_attr in props: list_of_valid_wildcard_attr_prefixes.append(wildcard_attr[:-1]) return list_of_valid_wildcard_attr_prefixes def reorder_props(props): """If "children" is in props, then move it to the front to respect dash convention. Parameters ---------- props: dict Dictionary with {propName: propMetadata} structure Returns ------- dict Dictionary with {propName: propMetadata} structure """ if 'children' in props: # Constructing an OrderedDict with duplicate keys, you get the order # from the first one but the value from the last. # Doing this to avoid mutating props, which can cause confusion. props = OrderedDict([('children', '')] + list(props.items())) return props def filter_props(props): """Filter props from the Component arguments to exclude: - Those without a "type" or a "flowType" field - Those with arg.type.name in {'func', 'symbol', 'instanceOf'} Parameters ---------- props: dict Dictionary with {propName: propMetadata} structure Returns ------- dict Filtered dictionary with {propName: propMetadata} structure Examples -------- ```python prop_args = { 'prop1': { 'type': {'name': 'bool'}, 'required': False, 'description': 'A description', 'flowType': {}, 'defaultValue': {'value': 'false', 'computed': False}, }, 'prop2': {'description': 'A prop without a type'}, 'prop3': { 'type': {'name': 'func'}, 'description': 'A function prop', }, } # filtered_prop_args is now # { # 'prop1': { # 'type': {'name': 'bool'}, # 'required': False, # 'description': 'A description', # 'flowType': {}, # 'defaultValue': {'value': 'false', 'computed': False}, # }, # } filtered_prop_args = filter_props(prop_args) ``` """ filtered_props = copy.deepcopy(props) for arg_name, arg in list(filtered_props.items()): if 'type' not in arg and 'flowType' not in arg: filtered_props.pop(arg_name) continue # Filter out functions and instances -- # these cannot be passed from Python if 'type' in arg: # These come from PropTypes arg_type = arg['type']['name'] if arg_type in {'func', 'symbol', 'instanceOf'}: filtered_props.pop(arg_name) elif 'flowType' in arg: # These come from Flow & handled differently arg_type_name = arg['flowType']['name'] if arg_type_name == 'signature': # This does the same as the PropTypes filter above, but "func" # is under "type" if "name" is "signature" vs just in "name" if 'type' not in arg['flowType'] \ or arg['flowType']['type'] != 'object': filtered_props.pop(arg_name) else: raise ValueError return filtered_props # pylint: disable=too-many-arguments def create_prop_docstring(prop_name, type_object, required, description, default, 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 default: dict Either None if a default value is not defined, or dict containing the key 'value' that defines a default value for the prop 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 """ py_type_name = js_to_py_type( type_object=type_object, is_flow_type=is_flow_type, indent_num=indent_num + 1) indent_spacing = ' ' * indent_num if default is None: default = '' else: default = default['value'] if default in ['true', 'false']: default = default.title() is_required = 'optional' if required: is_required = 'required' elif default and default not in ['null', '{}', '[]']: is_required = 'default {}'.format( default.replace('\n', '\n' + indent_spacing) ) if '\n' in py_type_name: return '{indent_spacing}- {name} (dict; {is_required}): ' \ '{description}{period}' \ '{name} has the following type: {type}'.format( indent_spacing=indent_spacing, name=prop_name, type=py_type_name, description=description.strip().strip('.'), period='. ' if description else '', is_required=is_required) return '{indent_spacing}- {name} ({type}' \ '{is_required}){description}'.format( indent_spacing=indent_spacing, name=prop_name, type='{}; '.format(py_type_name) if py_type_name else '', description=( ': {}'.format(description) if description != '' else '' ), is_required=is_required) def map_js_to_py_types_prop_types(type_object): """Mapping from the PropTypes js type object to the Python type.""" def shape_or_exact(): return 'dict containing keys {}.\n{}'.format( ', '.join( "'{}'".format(t) for t in list(type_object['value'].keys()) ), 'Those keys have the following types:\n{}'.format( '\n'.join( create_prop_docstring( prop_name=prop_name, type_object=prop, required=prop['required'], description=prop.get('description', ''), default=prop.get('defaultValue'), indent_num=1 ) for prop_name, prop in list(type_object['value'].items()))) ) return dict( array=lambda: 'list', bool=lambda: 'boolean', number=lambda: 'number', string=lambda: 'string', object=lambda: 'dict', any=lambda: 'boolean | number | string | dict | 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(js_to_py_type(subType)) for subType in type_object['value'] if js_to_py_type(subType) != '')), # React's PropTypes.arrayOf arrayOf=lambda: ( "list" + (" of {}".format( js_to_py_type(type_object["value"]) + 's' if js_to_py_type(type_object["value"]).split(' ')[0] != 'dict' else js_to_py_type(type_object["value"]).replace( 'dict', 'dicts', 1 ) ) if js_to_py_type(type_object["value"]) != "" else "") ), # React's PropTypes.objectOf objectOf=lambda: ( 'dict with strings as keys and values of type {}' ).format( js_to_py_type(type_object['value'])), # React's PropTypes.shape shape=shape_or_exact, # React's PropTypes.exact exact=shape_or_exact ) def map_js_to_py_types_flow_types(type_object): """Mapping from the Flow js types to the Python type.""" return dict( array=lambda: 'list', boolean=lambda: 'boolean', number=lambda: 'number', string=lambda: 'string', Object=lambda: 'dict', any=lambda: 'bool | number | str | dict | list', Element=lambda: 'dash component', Node=lambda: 'a list of or a singular dash ' 'component, string or number', # React's PropTypes.oneOfType union=lambda: '{}'.format( ' | '.join( '{}'.format(js_to_py_type(subType)) for subType in type_object['elements'] if js_to_py_type(subType) != '')), # Flow's Array type Array=lambda: 'list{}'.format( ' of {}s'.format( js_to_py_type(type_object['elements'][0])) if js_to_py_type(type_object['elements'][0]) != '' else ''), # React's PropTypes.shape signature=lambda indent_num: 'dict containing keys {}.\n{}'.format( ', '.join("'{}'".format(d['key']) for d in type_object['signature']['properties']), '{}Those keys have the following types:\n{}'.format( ' ' * indent_num, '\n'.join( create_prop_docstring( prop_name=prop['key'], type_object=prop['value'], required=prop['value']['required'], description=prop['value'].get('description', ''), default=prop.get('defaultValue'), indent_num=indent_num, is_flow_type=True) for prop in type_object['signature']['properties']))), ) def js_to_py_type(type_object, is_flow_type=False, indent_num=0): """Convert JS types to Python types for the component definition. Parameters ---------- type_object: dict react-docgen-generated prop type dictionary is_flow_type: bool Does the prop use Flow types? Otherwise, uses PropTypes 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_py_types = map_js_to_py_types_flow_types(type_object=type_object) \ if is_flow_type \ else map_js_to_py_types_prop_types(type_object=type_object) if 'computed' in type_object and type_object['computed'] \ or type_object.get('type', '') == 'function': return '' if js_type_name in js_to_py_types: if js_type_name == 'signature': # This is a Flow object w/ signature return js_to_py_types[js_type_name](indent_num) # 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 snake_case_to_camel_case(namestring): s = namestring.split("_") return s[0] + "".join(w.capitalize() for w in s[1:])