diff --git a/.pylintrc b/.pylintrc index fcf0968f10..a9688e4e7a 100644 --- a/.pylintrc +++ b/.pylintrc @@ -58,7 +58,9 @@ disable=fixme, missing-docstring, invalid-name, too-many-lines, - old-style-class + old-style-class, + superfluous-parens + # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option # multiple time (only on the command line, not in the configuration file where diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e82d00ea4..fcf11203b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.31.0 - 2018-11-26 +## Added +- Combined `extract-meta` and python component files generation in a cli [#451](https://github.com/plotly/dash/pull/451) + ## 0.30.0 - 2018-11-14 ## Added - Hot reload from the browser [#362](https://github.com/plotly/dash/pull/362) diff --git a/MANIFEST.in b/MANIFEST.in index af967cd137..caa2d28999 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,4 @@ include README.md include LICENSE include dash/favicon.ico +include dash/extract-meta.js diff --git a/dash/development/_py_components_generation.py b/dash/development/_py_components_generation.py new file mode 100644 index 0000000000..ba17ccd84d --- /dev/null +++ b/dash/development/_py_components_generation.py @@ -0,0 +1,615 @@ +import collections +import copy +import os +import textwrap + +from dash.development.base_component import _explicitize_args +from ._all_keywords import kwlist +from .base_component import Component + + +# 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, available_events, + # 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. + c = '''class {typename}(Component): + """{docstring}""" + @_explicitize_args + def __init__(self, {default_argtext}): + self._prop_names = {list_of_valid_keys} + self._type = '{typename}' + self._namespace = '{namespace}' + self._valid_wildcard_attributes =\ + {list_of_valid_wildcard_attr_prefixes} + self.available_events = {events} + self.available_properties = {list_of_valid_keys} + self.available_wildcard_properties =\ + {list_of_valid_wildcard_attr_prefixes} + + _explicit_args = kwargs.pop('_explicit_args') + _locals = locals() + _locals.update(kwargs) # For wildcard attrs + args = {{k: _locals[k] for k in _explicit_args if k != 'children'}} + + for k in {required_args}: + if k not in args: + raise TypeError( + 'Required argument `' + k + '` was not specified.') + super({typename}, self).__init__({argtext}) + + def __repr__(self): + if(any(getattr(self, c, None) is not None + for c in self._prop_names + if c is not self._prop_names[0]) + or any(getattr(self, c, None) is not None + for c in self.__dict__.keys() + if any(c.startswith(wc_attr) + for wc_attr in self._valid_wildcard_attributes))): + props_string = ', '.join([c+'='+repr(getattr(self, c, None)) + for c in self._prop_names + if getattr(self, c, None) is not None]) + wilds_string = ', '.join([c+'='+repr(getattr(self, c, None)) + for c in self.__dict__.keys() + if any([c.startswith(wc_attr) + for wc_attr in + self._valid_wildcard_attributes])]) + return ('{typename}(' + props_string + + (', ' + wilds_string if wilds_string != '' else '') + ')') + else: + return ( + '{typename}(' + + repr(getattr(self, self._prop_names[0], None)) + ')') +''' + + filtered_props = reorder_props(filter_props(props)) + # pylint: disable=unused-variable + list_of_valid_wildcard_attr_prefixes = repr(parse_wildcards(props)) + # pylint: disable=unused-variable + list_of_valid_keys = repr(list(map(str, filtered_props.keys()))) + # pylint: disable=unused-variable + docstring = create_docstring( + component_name=typename, + props=filtered_props, + events=parse_events(props), + description=description).replace('\r\n', '\n') + + # pylint: disable=unused-variable + events = '[' + ', '.join(parse_events(props)) + ']' + prop_keys = list(props.keys()) + if 'children' in props: + prop_keys.remove('children') + default_argtext = "children=None, " + # pylint: disable=unused-variable + argtext = 'children=children, **args' + else: + default_argtext = "" + argtext = '**args' + default_argtext += ", ".join( + [('{:s}=Component.REQUIRED'.format(p) + if props[p]['required'] else + '{:s}=Component.UNDEFINED'.format(p)) + for p in prop_keys + if not p.endswith("-*") and + p not in kwlist and + p not in ['dashEvents', 'fireEvent', 'setProps']] + ['**kwargs'] + ) + + required_args = required_props(props) + return c.format(**locals()) + + +def generate_class_file(typename, props, description, namespace): + """ + Generate a python class file (.py) given a class string + + Parameters + ---------- + typename + props + description + namespace + + Returns + ------- + + """ + import_string =\ + "# AUTO GENERATED FILE - DO NOT EDIT\n\n" + \ + "from dash.development.base_component import " + \ + "Component, _explicitize_args\n\n\n" + class_string = generate_class_string( + typename, + props, + description, + namespace + ) + file_name = "{:s}.py".format(typename) + + file_path = os.path.join(namespace, file_name) + with open(file_path, 'w') as f: + f.write(import_string) + f.write(class_string) + + print('Generated {}'.format(file_name)) + + +def generate_imports(project_shortname, components): + with open(os.path.join(project_shortname, '_imports_.py'), 'w') as f: + f.write(textwrap.dedent( + ''' + {} + + __all__ = [ + {} + ] + '''.format( + '\n'.join( + 'from .{0} import {0}'.format(x) for x in components), + ',\n'.join(' "{}"'.format(x) for x in components) + ) + ).lstrip()) + + +def generate_classes_files(project_shortname, metadata, *component_generators): + components = [] + for component_path, component_data in metadata.items(): + component_name = component_path.split('/')[-1].split('.')[0] + components.append(component_name) + + for generator in component_generators: + generator( + component_name, + component_data['props'], + component_data['description'], + project_shortname + ) + + return components + + +def generate_class(typename, props, description, namespace): + """ + Generate a python class object given a class string + + Parameters + ---------- + typename + props + description + namespace + + Returns + ------- + + """ + string = generate_class_string(typename, props, description, namespace) + scope = {'Component': Component, '_explicitize_args': _explicitize_args} + # pylint: disable=exec-used + exec(string, scope) + result = scope[typename] + return result + + +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, events, description): + """ + Create the Dash component docstring + + Parameters + ---------- + component_name: str + Component name + props: dict + Dictionary with {propName: propMetadata} structure + events: list + List of Dash events + description: str + Component description + + Returns + ------- + str + Dash component docstring + """ + # Ensure props are ordered with children first + props = reorder_props(props=props) + + return ( + """A {name} component.\n{description} + +Keyword arguments:\n{args} + +Available events: {events}""" + ).format( + 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'], + indent_num=0, + is_flow_type='flowType' in prop and 'type' not in prop) + for p, prop in list(filter_props(props).items())), + events=', '.join(events)) + + +def parse_events(props): + """ + Pull out the dashEvents from the Component props + + Parameters + ---------- + props: dict + Dictionary with {propName: propMetadata} structure + + Returns + ------- + list + List of Dash event strings + """ + if 'dashEvents' in props and props['dashEvents']['type']['name'] == 'enum': + events = [v['value'] for v in props['dashEvents']['type']['value']] + else: + events = [] + + return events + + +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.keys(): + 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: + props = collections.OrderedDict( + [('children', props.pop('children'),)] + + list(zip(list(props.keys()), list(props.values())))) + + 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'} + - dashEvents as a name + + 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 + + # dashEvents are a special oneOf property that is used for subscribing + # to events but it's never set as a property + if arg_name in ['dashEvents']: + filtered_props.pop(arg_name) + return filtered_props + + +# pylint: disable=too-many-arguments +def create_prop_docstring(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 + """ + 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 '\n' in py_type_name: + return '{indent_spacing}- {name} ({is_required}): {description}. ' \ + '{name} has the following type: {type}'.format( + indent_spacing=indent_spacing, + name=prop_name, + type=py_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(py_type_name) if py_type_name else '', + description=( + ': {}'.format(description) if description != '' else '' + ), + is_required='required' if required else 'optional') + + +def map_js_to_py_types_prop_types(type_object): + """Mapping from the PropTypes js type object to the Python type""" + 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'.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 ''), + + # 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=lambda: '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', ''), + indent_num=1) + for prop_name, prop in + list(type_object['value'].items())))), + ) + + +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', ''), + 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 '' + elif 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 '' diff --git a/dash/development/base_component.py b/dash/development/base_component.py index 789253a3f2..ce6aec0741 100644 --- a/dash/development/base_component.py +++ b/dash/development/base_component.py @@ -1,12 +1,9 @@ import collections -import copy -import os -import inspect import abc +import inspect import sys -import six -from ._all_keywords import kwlist +import six # pylint: disable=no-init,too-few-public-methods @@ -66,37 +63,6 @@ def _check_if_has_indexable_children(item): raise KeyError -def _explicitize_args(func): - # Python 2 - if hasattr(func, 'func_code'): - varnames = func.func_code.co_varnames - # Python 3 - else: - varnames = func.__code__.co_varnames - - def wrapper(*args, **kwargs): - if '_explicit_args' in kwargs.keys(): - raise Exception('Variable _explicit_args should not be set.') - kwargs['_explicit_args'] = \ - list( - set( - list(varnames[:len(args)]) + [k for k, _ in kwargs.items()] - ) - ) - if 'self' in kwargs['_explicit_args']: - kwargs['_explicit_args'].remove('self') - return func(*args, **kwargs) - - # If Python 3, we can set the function signature to be correct - if hasattr(inspect, 'signature'): - # pylint: disable=no-member - new_sig = inspect.signature(wrapper).replace( - parameters=inspect.signature(func).parameters.values() - ) - wrapper.__signature__ = new_sig - return wrapper - - @six.add_metaclass(ComponentMeta) class Component(collections.MutableMapping): class _UNDEFINED(object): @@ -311,574 +277,32 @@ def __len__(self): return length -# 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, available_events, - # 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. - c = '''class {typename}(Component): - """{docstring}""" - @_explicitize_args - def __init__(self, {default_argtext}): - self._prop_names = {list_of_valid_keys} - self._type = '{typename}' - self._namespace = '{namespace}' - self._valid_wildcard_attributes =\ - {list_of_valid_wildcard_attr_prefixes} - self.available_events = {events} - self.available_properties = {list_of_valid_keys} - self.available_wildcard_properties =\ - {list_of_valid_wildcard_attr_prefixes} - - _explicit_args = kwargs.pop('_explicit_args') - _locals = locals() - _locals.update(kwargs) # For wildcard attrs - args = {{k: _locals[k] for k in _explicit_args if k != 'children'}} - - for k in {required_args}: - if k not in args: - raise TypeError( - 'Required argument `' + k + '` was not specified.') - super({typename}, self).__init__({argtext}) - - def __repr__(self): - if(any(getattr(self, c, None) is not None - for c in self._prop_names - if c is not self._prop_names[0]) - or any(getattr(self, c, None) is not None - for c in self.__dict__.keys() - if any(c.startswith(wc_attr) - for wc_attr in self._valid_wildcard_attributes))): - props_string = ', '.join([c+'='+repr(getattr(self, c, None)) - for c in self._prop_names - if getattr(self, c, None) is not None]) - wilds_string = ', '.join([c+'='+repr(getattr(self, c, None)) - for c in self.__dict__.keys() - if any([c.startswith(wc_attr) - for wc_attr in - self._valid_wildcard_attributes])]) - return ('{typename}(' + props_string + - (', ' + wilds_string if wilds_string != '' else '') + ')') - else: - return ( - '{typename}(' + - repr(getattr(self, self._prop_names[0], None)) + ')') -''' - - filtered_props = reorder_props(filter_props(props)) - # pylint: disable=unused-variable - list_of_valid_wildcard_attr_prefixes = repr(parse_wildcards(props)) - # pylint: disable=unused-variable - list_of_valid_keys = repr(list(map(str, filtered_props.keys()))) - # pylint: disable=unused-variable - docstring = create_docstring( - component_name=typename, - props=filtered_props, - events=parse_events(props), - description=description).replace('\r\n', '\n') - - # pylint: disable=unused-variable - events = '[' + ', '.join(parse_events(props)) + ']' - prop_keys = list(props.keys()) - if 'children' in props: - prop_keys.remove('children') - default_argtext = "children=None, " - # pylint: disable=unused-variable - argtext = 'children=children, **args' - else: - default_argtext = "" - argtext = '**args' - default_argtext += ", ".join( - [('{:s}=Component.REQUIRED'.format(p) - if props[p]['required'] else - '{:s}=Component.UNDEFINED'.format(p)) - for p in prop_keys - if not p.endswith("-*") and - p not in kwlist and - p not in ['dashEvents', 'fireEvent', 'setProps']] + ['**kwargs'] - ) - - required_args = required_props(props) - return c.format(**locals()) - - -# pylint: disable=unused-argument -def generate_class_file(typename, props, description, namespace): - """ - Generate a python class file (.py) given a class string - - Parameters - ---------- - typename - props - description - namespace - - Returns - ------- - - """ - import_string =\ - "# AUTO GENERATED FILE - DO NOT EDIT\n\n" + \ - "from dash.development.base_component import " + \ - "Component, _explicitize_args\n\n\n" - class_string = generate_class_string( - typename, - props, - description, - namespace - ) - file_name = "{:s}.py".format(typename) - - file_path = os.path.join(namespace, file_name) - with open(file_path, 'w') as f: - f.write(import_string) - f.write(class_string) - - -# pylint: disable=unused-argument -def generate_class(typename, props, description, namespace): - """ - Generate a python class object given a class string - - Parameters - ---------- - typename - props - description - namespace - - Returns - ------- - - """ - string = generate_class_string(typename, props, description, namespace) - scope = {'Component': Component, '_explicitize_args': _explicitize_args} - # pylint: disable=exec-used - exec(string, scope) - result = scope[typename] - return result - - -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, events, description): - """ - Create the Dash component docstring - - Parameters - ---------- - component_name: str - Component name - props: dict - Dictionary with {propName: propMetadata} structure - events: list - List of Dash events - description: str - Component description - - Returns - ------- - str - Dash component docstring - """ - # Ensure props are ordered with children first - props = reorder_props(props=props) - - return ( - """A {name} component.\n{description} - -Keyword arguments:\n{args} - -Available events: {events}""" - ).format( - 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'], - indent_num=0, - is_flow_type='flowType' in prop and 'type' not in prop) - for p, prop in list(filter_props(props).items())), - events=', '.join(events)) - - -def parse_events(props): - """ - Pull out the dashEvents from the Component props - - Parameters - ---------- - props: dict - Dictionary with {propName: propMetadata} structure - - Returns - ------- - list - List of Dash event strings - """ - if 'dashEvents' in props and props['dashEvents']['type']['name'] == 'enum': - events = [v['value'] for v in props['dashEvents']['type']['value']] +def _explicitize_args(func): + # Python 2 + if hasattr(func, 'func_code'): + varnames = func.func_code.co_varnames + # Python 3 else: - events = [] - - return events - - -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.keys(): - 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: - props = collections.OrderedDict( - [('children', props.pop('children'),)] + - list(zip(list(props.keys()), list(props.values())))) - - 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'} - - dashEvents as a name - - 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 - - # dashEvents are a special oneOf property that is used for subscribing - # to events but it's never set as a property - if arg_name in ['dashEvents']: - filtered_props.pop(arg_name) - return filtered_props - - -# pylint: disable=too-many-arguments -def create_prop_docstring(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 - """ - 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 '\n' in py_type_name: - return '{indent_spacing}- {name} ({is_required}): {description}. ' \ - '{name} has the following type: {type}'.format( - indent_spacing=indent_spacing, - name=prop_name, - type=py_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(py_type_name) if py_type_name else '', - description=( - ': {}'.format(description) if description != '' else '' - ), - is_required='required' if required else 'optional') - - -def map_js_to_py_types_prop_types(type_object): - """Mapping from the PropTypes js type object to the Python type""" - 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'.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 ''), - - # 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=lambda: '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', ''), - indent_num=1) - for prop_name, prop in - list(type_object['value'].items())))), - ) - - -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', ''), - 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 '' - elif 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 '' + varnames = func.__code__.co_varnames + + def wrapper(*args, **kwargs): + if '_explicit_args' in kwargs.keys(): + raise Exception('Variable _explicit_args should not be set.') + kwargs['_explicit_args'] = \ + list( + set( + list(varnames[:len(args)]) + [k for k, _ in kwargs.items()] + ) + ) + if 'self' in kwargs['_explicit_args']: + kwargs['_explicit_args'].remove('self') + return func(*args, **kwargs) + + # If Python 3, we can set the function signature to be correct + if hasattr(inspect, 'signature'): + # pylint: disable=no-member + new_sig = inspect.signature(wrapper).replace( + parameters=inspect.signature(func).parameters.values() + ) + wrapper.__signature__ = new_sig + return wrapper diff --git a/dash/development/component_generator.py b/dash/development/component_generator.py new file mode 100644 index 0000000000..8e04e0eab4 --- /dev/null +++ b/dash/development/component_generator.py @@ -0,0 +1,93 @@ +from __future__ import print_function + +import json +import sys +import subprocess +import shlex +import os +import argparse +import shutil + +import pkg_resources + +from ._py_components_generation import generate_class_file +from ._py_components_generation import generate_imports +from ._py_components_generation import generate_classes_files + + +class _CombinedFormatter(argparse.ArgumentDefaultsHelpFormatter, + argparse.RawDescriptionHelpFormatter): + pass + + +# pylint: disable=too-many-locals +def generate_components(components_source, project_shortname, + package_info_filename='package.json'): + is_windows = sys.platform == 'win32' + + extract_path = pkg_resources.resource_filename('dash', 'extract-meta.js') + + os.environ['NODE_PATH'] = 'node_modules' + cmd = shlex.split('node {} {}'.format(extract_path, components_source), + posix=not is_windows) + + shutil.copyfile('package.json', + os.path.join(project_shortname, package_info_filename)) + + proc = subprocess.Popen(cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + shell=is_windows) + out, err = proc.communicate() + status = proc.poll() + + if err: + print(err.decode(), file=sys.stderr) + + if not out: + print( + 'Error generating metadata in {} (status={})'.format( + project_shortname, status), + file=sys.stderr) + sys.exit(1) + + metadata = json.loads(out.decode()) + + components = generate_classes_files( + project_shortname, + metadata, + generate_class_file + ) + + with open(os.path.join(project_shortname, 'metadata.json'), 'w') as f: + json.dump(metadata, f) + + generate_imports(project_shortname, components) + + +def cli(): + parser = argparse.ArgumentParser( + prog='dash-generate-components', + formatter_class=_CombinedFormatter, + description='Generate dash components by extracting the metadata ' + 'using react-docgen. Then map the metadata to python classes.' + ) + parser.add_argument('components_source', + help='React components source directory.') + parser.add_argument( + 'project_shortname', + help='Name of the project to export the classes files.' + ) + parser.add_argument( + '-p', '--package-info-filename', + default='package.json', + help='The filename of the copied `package.json` to `project_shortname`' + ) + + args = parser.parse_args() + generate_components(args.components_source, args.project_shortname, + package_info_filename=args.package_info_filename) + + +if __name__ == '__main__': + cli() diff --git a/dash/development/component_loader.py b/dash/development/component_loader.py index 2b5e70b10f..968a3fa1d1 100644 --- a/dash/development/component_loader.py +++ b/dash/development/component_loader.py @@ -1,8 +1,13 @@ import collections import json import os -from .base_component import generate_class -from .base_component import generate_class_file + +from ._py_components_generation import ( + generate_class_file, + generate_imports, + generate_classes_files, + generate_class +) from .base_component import ComponentRegistry @@ -81,32 +86,7 @@ def generate_classes(namespace, metadata_path='lib/metadata.json'): if os.path.exists(imports_path): os.remove(imports_path) - # 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] - generate_class_file( - name, - componentData['props'], - componentData['description'], - namespace - ) - - # Add an import statement for this component - with open(imports_path, 'a') as f: - f.write('from .{0:s} import {0:s}\n'.format(name)) + components = generate_classes_files(namespace, data, generate_class_file) # Add the __all__ value so we can import * from _imports_ - all_imports = [p.split('/').pop().split('.')[0] for p in data] - with open(imports_path, 'a') as f: - array_string = '[\n' - for a in all_imports: - array_string += ' "{:s}",\n'.format(a) - array_string += ']\n' - f.write('\n\n__all__ = {:s}'.format(array_string)) + generate_imports(namespace, components) diff --git a/dash/extract-meta.js b/dash/extract-meta.js new file mode 100644 index 0000000000..8394bcfeed --- /dev/null +++ b/dash/extract-meta.js @@ -0,0 +1,93 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const reactDocs = require('react-docgen'); + +const componentPaths = process.argv.slice(2); +if (!componentPaths.length) { + help(); + process.exit(1); +} + +const metadata = Object.create(null); +componentPaths.forEach(componentPath => + collectMetadataRecursively(componentPath) +); +writeOut(metadata); + +function help() { + console.error('usage: '); + console.error( + 'extract-meta path/to/component(s) ' + + ' [path/to/more/component(s), ...] > metadata.json' + ); +} + +function writeError(msg, filePath) { + if (filePath) { + process.stderr.write(`Error with path ${filePath}`); + } + + process.stderr.write(msg + '\n'); + if (msg instanceof Error) { + process.stderr.write(msg.stack + '\n'); + } +} + +function checkWarn(name, value) { + if (value.length < 1) { + process.stderr.write(`\nDescription for ${name} is missing!\n`) + } +} + +function docstringWarning(doc) { + checkWarn(doc.displayName, doc.description); + + Object.entries(doc.props).forEach( + ([name, p]) => checkWarn(`${doc.displayName}.${name}`, p.description) + ); +} + + +function parseFile(filepath) { + const urlpath = filepath.split(path.sep).join('/'); + let src; + + if (!['.jsx', '.js'].includes(path.extname(filepath))) { + return; + } + + try { + src = fs.readFileSync(filepath); + const doc = metadata[urlpath] = reactDocs.parse(src); + docstringWarning(doc); + } catch (error) { + writeError(error, filepath); + } +} + +function collectMetadataRecursively(componentPath) { + if (fs.lstatSync(componentPath).isDirectory()) { + let dirs; + try { + dirs = fs.readdirSync(componentPath); + } catch (error) { + writeError(error, componentPath); + } + dirs.forEach(filename => { + const filepath = path.join(componentPath, filename); + if (fs.lstatSync(filepath).isDirectory()) { + collectMetadataRecursively(filepath); + } else { + parseFile(filepath); + } + }); + } else { + parseFile(componentPath); + } +} + +function writeOut(result) { + console.log(JSON.stringify(result, '\t', 2)); +} diff --git a/dash/version.py b/dash/version.py index e187e0aa61..c3d10d7c49 100644 --- a/dash/version.py +++ b/dash/version.py @@ -1 +1 @@ -__version__ = '0.30.0' +__version__ = '0.31.0' diff --git a/setup.py b/setup.py index bee83d7eb7..2182122ced 100644 --- a/setup.py +++ b/setup.py @@ -22,6 +22,12 @@ 'plotly', 'dash_renderer', ], + entry_points={ + 'console_scripts': [ + 'dash-generate-components =' + ' dash.development.component_generator:cli' + ] + }, url='https://plot.ly/dash', classifiers=[ 'Development Status :: 5 - Production/Stable', diff --git a/tests/development/test_base_component.py b/tests/development/test_base_component.py index a43be1b898..9e725f5903 100644 --- a/tests/development/test_base_component.py +++ b/tests/development/test_base_component.py @@ -8,15 +8,10 @@ import plotly from dash.development.base_component import ( - generate_class, - generate_class_string, - generate_class_file, Component, - _explicitize_args, - js_to_py_type, - create_docstring, - parse_events -) + _explicitize_args) +from dash.development._py_components_generation import generate_class_string, generate_class_file, generate_class, \ + create_docstring, parse_events, js_to_py_type Component._prop_names = ('id', 'a', 'children', 'style', ) Component._type = 'TestComponent' diff --git a/tests/development/test_component_loader.py b/tests/development/test_component_loader.py index a4fb4423e1..7f3ce871fb 100644 --- a/tests/development/test_component_loader.py +++ b/tests/development/test_component_loader.py @@ -5,9 +5,9 @@ import unittest from dash.development.component_loader import load_components, generate_classes from dash.development.base_component import ( - generate_class, Component ) +from dash.development._py_components_generation import generate_class METADATA_PATH = 'metadata.json' diff --git a/tests/test_resources.py b/tests/test_resources.py index a25553abe5..f0214f45b2 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -1,7 +1,7 @@ import unittest import warnings from dash.resources import Scripts, Css -from dash.development.base_component import generate_class +from dash.development._py_components_generation import generate_class def generate_components():