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:])