from collections import OrderedDict import copy import numbers import os import typing from textwrap import fill, dedent from typing_extensions import TypedDict, NotRequired, Literal from dash.development.base_component import _explicitize_args from dash.exceptions import NonExistentEventException from ._all_keywords import python_keywords from ._collect_nodes import collect_nodes, filter_base_nodes from ._py_prop_typing import ( get_custom_ignore, get_custom_props, get_prop_typing, shapes, get_custom_imports, ) from .base_component import Component, ComponentType import_string = """# AUTO GENERATED FILE - DO NOT EDIT import typing # noqa: F401 from typing_extensions import TypedDict, NotRequired, Literal # noqa: F401 from dash.development.base_component import Component, _explicitize_args {custom_imports} ComponentType = typing.Union[ str, int, float, Component, None, typing.Sequence[typing.Union[str, int, float, Component, None]], ] NumberType = typing.Union[ typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex ] """ # pylint: disable=unused-argument,too-many-locals,too-many-branches def generate_class_string( typename, props, description, namespace, prop_reorder_exceptions=None, max_props=None, custom_typing_module=None, ): """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 prop_reorder_exceptions 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. c = '''class {typename}(Component): """{docstring}""" _children_props = {children_props} _base_nodes = {base_nodes} _namespace = '{namespace}' _type = '{typename}' {shapes} def __init__( self, {default_argtext} ): self._prop_names = {list_of_valid_keys} self._valid_wildcard_attributes =\ {list_of_valid_wildcard_attr_prefixes} 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 and excess named props args = {args} {required_validation} super({typename}, self).__init__({argtext}) setattr({typename}, "__init__", _explicitize_args({typename}.__init__)) ''' filtered_props = ( filter_props(props) if (prop_reorder_exceptions is not None and typename in prop_reorder_exceptions) or (prop_reorder_exceptions is not None and "ALL" in prop_reorder_exceptions) else reorder_props(filter_props(props)) ) wildcard_prefixes = repr(parse_wildcards(props)) list_of_valid_keys = repr(list(map(str, filtered_props.keys()))) custom_ignore = get_custom_ignore(custom_typing_module) docstring = create_docstring( component_name=typename, props=filtered_props, description=description, prop_reorder_exceptions=prop_reorder_exceptions, ignored_props=custom_ignore, ).replace("\r\n", "\n") required_args = required_props(filtered_props) is_children_required = "children" in required_args required_args = [arg for arg in required_args if arg != "children"] prohibit_events(props) # pylint: disable=unused-variable prop_keys = list(props.keys()) if "children" in props and "children" in list_of_valid_keys: prop_keys.remove("children") # TODO For dash 3.0, remove the Optional and = None for proper typing. # Also add the other required props after children. default_argtext = f"children: typing.Optional[{get_prop_typing('node', '', '', {})}] = None,\n " args = "{k: _locals[k] for k in _explicit_args if k != 'children'}" argtext = "children=children, **args" else: default_argtext = "" args = "{k: _locals[k] for k in _explicit_args}" argtext = "**args" if len(required_args) == 0: required_validation = "" else: required_validation = f""" for k in {required_args}: if k not in args: raise TypeError( 'Required argument `' + k + '` was not specified.') """ if is_children_required: required_validation += """ if 'children' not in _explicit_args: raise TypeError('Required argument children was not specified.') """ default_arglist = [] for prop_key in prop_keys: prop = props[prop_key] if ( prop_key.endswith("-*") or prop_key in python_keywords or prop_key == "setProps" ): continue type_info = prop.get("type") if not type_info: print(f"Invalid prop type for typing: {prop_key}") default_arglist.append(f"{prop_key} = None") continue type_name = type_info.get("name") custom_props = get_custom_props(custom_typing_module) typed = get_prop_typing( type_name, typename, prop_key, type_info, custom_props=custom_props, custom_ignore=custom_ignore, ) arg_value = f"{prop_key}: typing.Optional[{typed}] = None" default_arglist.append(arg_value) if max_props: final_max_props = max_props - (1 if "children" in props else 0) if len(default_arglist) > final_max_props: default_arglist = default_arglist[:final_max_props] docstring += ( "\n\n" "Note: due to the large number of props for this component,\n" "not all of them appear in the constructor signature, but\n" "they may still be used as keyword arguments." ) default_argtext += ",\n ".join(default_arglist + ["**kwargs"]) nodes = collect_nodes({k: v for k, v in props.items() if k != "children"}) return dedent( c.format( typename=typename, namespace=namespace, filtered_props=filtered_props, list_of_valid_wildcard_attr_prefixes=wildcard_prefixes, list_of_valid_keys=list_of_valid_keys, docstring=docstring, default_argtext=default_argtext, args=args, argtext=argtext, required_validation=required_validation, children_props=nodes, base_nodes=filter_base_nodes(nodes) + ["children"], shapes="\n".join(shapes.get(typename, {}).values()), ) ) def generate_class_file( typename, props, description, namespace, prop_reorder_exceptions=None, max_props=None, custom_typing_module="dash_prop_typing", ): """Generate a Python class file (.py) given a class string. Parameters ---------- typename props description namespace prop_reorder_exceptions Returns ------- """ class_string = generate_class_string( typename, props, description, namespace, prop_reorder_exceptions, max_props, custom_typing_module, ) custom_imp = get_custom_imports(custom_typing_module) custom_imp = custom_imp.get(typename) or custom_imp.get("*") if custom_imp: imports = import_string.format( custom_imports="\n" + "\n".join(custom_imp) + "\n\n" ) else: imports = import_string.format(custom_imports="") file_name = f"{typename:s}.py" file_path = os.path.join(namespace, file_name) with open(file_path, "w", encoding="utf-8") as f: f.write(imports) f.write(class_string) print(f"Generated {file_name}") def generate_imports(project_shortname, components): with open( os.path.join(project_shortname, "_imports_.py"), "w", encoding="utf-8" ) as f: component_imports = "\n".join(f"from .{x} import {x}" for x in components) all_list = ",\n".join(f' "{x}"' for x in components) imports_string = f"{component_imports}\n\n__all__ = [\n{all_list}\n]" f.write(imports_string) 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, prop_reorder_exceptions=None ): """Generate a Python class object given a class string. Parameters ---------- typename props description namespace Returns ------- """ string = generate_class_string( typename, props, description, namespace, prop_reorder_exceptions ) scope = { "Component": Component, "ComponentType": ComponentType, "_explicitize_args": _explicitize_args, "typing": typing, "numbers": numbers, "TypedDict": TypedDict, "NotRequired": NotRequired, "Literal": Literal, "NumberType": typing.Union[ typing.SupportsFloat, typing.SupportsComplex, typing.SupportsInt ], } # 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, description, prop_reorder_exceptions=None, ignored_props=tuple(), ): """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 = ( props if ( prop_reorder_exceptions is not None and component_name in prop_reorder_exceptions ) or (prop_reorder_exceptions is not None and "ALL" in prop_reorder_exceptions) else reorder_props(props) ) n = "n" if component_name[0].lower() in "aeiou" else "" 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 filter_props(props, ignored_props).items() ) return ( f"A{n} {component_name} component.\n{description}\n\nKeyword arguments:\n{args}" ) 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, then 'id', then the remaining props sorted by prop name Parameters ---------- props: dict Dictionary with {propName: propMetadata} structure Returns ------- dict Dictionary with {propName: propMetadata} structure """ # 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. props1 = [("children", "")] if "children" in props else [] props2 = [("id", "")] if "id" in props else [] return OrderedDict(props1 + props2 + sorted(list(props.items()))) def filter_props(props, ignored_props=tuple()): """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 arg_name in ignored_props or ("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 def fix_keywords(txt): """ replaces javascript keywords true, false, null with Python keywords """ fix_word = {"true": "True", "false": "False", "null": "None"} for js_keyword, python_keyword in fix_word.items(): txt = txt.replace(js_keyword, python_keyword) return txt # pylint: disable=too-many-arguments # pylint: disable=too-many-locals 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 ) indent_spacing = " " * indent_num default = default["value"] if default else "" default = fix_keywords(default) is_required = "optional" if required: is_required = "required" elif default and default not in ["None", "{}", "[]"]: is_required = "default " + default.replace("\n", "") # formats description period = "." if description else "" description = description.strip().strip(".").replace('"', r"\"") + period desc_indent = indent_spacing + " " description = fill( description, initial_indent=desc_indent, subsequent_indent=desc_indent, break_long_words=False, break_on_hyphens=False, ) description = f"\n{description}" if description else "" colon = ":" if description else "" description = fix_keywords(description) if "\n" in py_type_name: # corrects the type dict_or_list = "list of dicts" if py_type_name.startswith("list") else "dict" # format and rewrite the intro to the nested dicts intro1, intro2, dict_descr = py_type_name.partition("with keys:") intro = f"`{prop_name}` is a {intro1}{intro2}" intro = fill( intro, initial_indent=desc_indent, subsequent_indent=desc_indent, break_long_words=False, break_on_hyphens=False, ) # captures optional nested dict description and puts the "or" condition on a new line if "| dict with keys:" in dict_descr: dict_part1, dict_part2 = dict_descr.split(" |", 1) dict_part2 = "".join([desc_indent, "Or", dict_part2]) dict_descr = f"{dict_part1}\n\n {dict_part2}" # ensures indent is correct if there is a second nested list of dicts current_indent = dict_descr.lstrip("\n").find("-") if current_indent == len(indent_spacing): dict_descr = "".join( "\n\n " + line for line in dict_descr.splitlines() if line != "" ) return ( f"\n{indent_spacing}- {prop_name} ({dict_or_list}; {is_required}){colon}" f"{description}" f"\n\n{intro}{dict_descr}" ) tn = f"{py_type_name}; " if py_type_name else "" return f"\n{indent_spacing}- {prop_name} ({tn}{is_required}){colon}{description}" def map_js_to_py_types_prop_types(type_object, indent_num): """Mapping from the PropTypes js type object to the Python type.""" def shape_or_exact(): return "dict with keys:\n" + "\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=indent_num + 2, ) for prop_name, prop in type_object["value"].items() ) def array_of(): inner = js_to_py_type(type_object["value"]) if inner: return "list of " + ( inner + "s" if inner.split(" ")[0] != "dict" else inner.replace("dict", "dicts", 1) ) return "list" def tuple_of(): elements = [js_to_py_type(element) for element in type_object["elements"]] return f"list of {len(elements)} elements: [{', '.join(elements)}]" 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: " + ", ".join(str(t["value"]) for t in type_object["value"]) ), # React's PropTypes.oneOfType union=lambda: " | ".join( js_to_py_type(subType) for subType in type_object["value"] if js_to_py_type(subType) != "" ), # React's PropTypes.arrayOf arrayOf=array_of, # React's PropTypes.objectOf objectOf=lambda: ( "dict with strings as keys and values of type " + js_to_py_type(type_object["value"]) ), # React's PropTypes.shape shape=shape_or_exact, # React's PropTypes.exact exact=shape_or_exact, tuple=tuple_of, ) 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: " | ".join( js_to_py_type(subType) for subType in type_object["elements"] if js_to_py_type(subType) != "" ), # Flow's Array type Array=lambda: "list" + ( f' of {js_to_py_type(type_object["elements"][0])}s' if js_to_py_type(type_object["elements"][0]) != "" else "" ), # React's PropTypes.shape signature=lambda indent_num: ( "dict with keys:\n" + "\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 + 2, 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, indent_num=indent_num ) ) 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) # type: ignore[reportCallIssue] # All other types return js_to_py_types[js_type_name]() # type: ignore[reportCallIssue] return ""