diff --git a/dash/development/_all_keywords.py b/dash/development/_all_keywords.py index 75d2c99974..f84f7a4314 100644 --- a/dash/development/_all_keywords.py +++ b/dash/development/_all_keywords.py @@ -68,3 +68,38 @@ "NA_character_", "...", } + +# This is a set of Julia reserved words that cannot be used as function +# argument names. + +julia_keywords = { + "baremodule", + "begin", + "break", + "catch", + "const", + "continue", + "do", + "else", + "elseif", + "end", + "export", + "false", + "finally", + "for", + "function", + "global", + "if", + "import", + "let", + "local", + "macro", + "module", + "quote", + "return", + "struct", + "true", + "try", + "using", + "while", +} diff --git a/dash/development/_jl_components_generation.py b/dash/development/_jl_components_generation.py new file mode 100644 index 0000000000..7b16deef20 --- /dev/null +++ b/dash/development/_jl_components_generation.py @@ -0,0 +1,527 @@ +from __future__ import absolute_import +from __future__ import print_function + +import copy +import os +import shutil +import glob +import warnings +import sys +import importlib +import uuid + +from ._all_keywords import julia_keywords +from ._py_components_generation import reorder_props + +# uuid of DashBase Julia package. +jl_dash_base_uuid = "03207cf0-e2b3-4b91-9ca8-690cf0fb507e" + +# uuid of Dash Julia package. Used as base for component package uuid +jl_dash_uuid = "1b08a953-4be3-4667-9a23-3db579824955" + +# Declaring longer string templates as globals to improve +# readability, make method logic clearer to anyone inspecting +# code below +jl_component_string = ''' +export {funcname} + +""" + {funcname}(;kwargs...){children_signatures} + +{docstring} +""" +function {funcname}(; kwargs...) + available_props = Symbol[{component_props}] + wild_props = Symbol[{wildcard_symbols}] + return Component("{funcname}", "{element_name}", "{module_name}", available_props, wild_props; kwargs...) +end +{children_definitions} +''' # noqa:E501 + +jl_children_signatures = """ + {funcname}(children::Any;kwargs...) + {funcname}(children_maker::Function;kwargs...) +""" + +jl_children_definitions = """ +{funcname}(children::Any; kwargs...) = {funcname}(;kwargs..., children = children) +{funcname}(children_maker::Function; kwargs...) = {funcname}(children_maker(); kwargs...) +""" + +jl_package_file_string = """ +module {package_name} +using {base_package} + +const resources_path = realpath(joinpath( @__DIR__, "..", "deps")) +const version = "{version}" + +{component_includes} + +function __init__() + DashBase.register_package( + DashBase.ResourcePkg( + "{project_shortname}", + resources_path, + version = version, + [ + {resources_dist} + ] + ) + + ) +end +end +""" + +jl_projecttoml_string = """ +name = "{package_name}" +uuid = "{package_uuid}" +{authors}version = "{version}" + +[deps] +{base_package} = "{dash_uuid}" + +[compact] +julia = "1.2" +{base_package} = ">=0.1" +""" + +jl_component_include_string = 'include("{name}.jl")' + +jl_resource_tuple_string = """DashBase.Resource( + relative_package_path = {relative_package_path}, + external_url = {external_url}, + dynamic = {dynamic}, + async = {async_string}, + type = :{type} +)""" + +core_packages = ["dash_html_components", "dash_core_components", "dash_table"] + + +def jl_package_name(namestring): + s = namestring.split("_") + return "".join(w.capitalize() for w in s) + + +def stringify_wildcards(wclist, no_symbol=False): + if no_symbol: + wcstring = "|".join("{}-".format(item) for item in wclist) + else: + wcstring = ", ".join('Symbol("{}-")'.format(item) for item in wclist) + return wcstring + + +def get_wildcards_jl(props): + return [key.replace("-*", "") for key in props if key.endswith("-*")] + + +def get_jl_prop_types(type_object): + """Mapping from the PropTypes js type object to the Julia type.""" + + def shape_or_exact(): + return "lists containing elements {}.\n{}".format( + ", ".join("'{}'".format(t) for t in type_object["value"]), + "Those elements have the following types:\n{}".format( + "\n".join( + create_prop_docstring_jl( + prop_name=prop_name, + type_object=prop, + required=prop["required"], + description=prop.get("description", ""), + indent_num=1, + ) + for prop_name, prop in type_object["value"].items() + ) + ), + ) + + return dict( + array=lambda: "Array", + bool=lambda: "Bool", + number=lambda: "Real", + string=lambda: "String", + object=lambda: "Dict", + any=lambda: "Bool | Real | String | Dict | Array", + 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(get_jl_type(subType)) + for subType in type_object["value"] + if get_jl_type(subType) != "" + ) + ), + # React's PropTypes.arrayOf + arrayOf=lambda: ( + "Array" + + ( + " of {}s".format(get_jl_type(type_object["value"])) + if get_jl_type(type_object["value"]) != "" + else "" + ) + ), + # React's PropTypes.objectOf + objectOf=lambda: ("Dict with Strings as keys and values of type {}").format( + get_jl_type(type_object["value"]) + ), + # React's PropTypes.shape + shape=shape_or_exact, + # React's PropTypes.exact + exact=shape_or_exact, + ) + + +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 + """ + 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 -- + 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 get_jl_type(type_object): + """ + Convert JS types to Julia types for the component definition + Parameters + ---------- + type_object: dict + react-docgen-generated prop type dictionary + Returns + ------- + str + Julia type string + """ + js_type_name = type_object["name"] + js_to_jl_types = get_jl_prop_types(type_object=type_object) + if js_type_name in js_to_jl_types: + prop_type = js_to_jl_types[js_type_name]() + return prop_type + return "" + + +def print_jl_type(typedata): + typestring = get_jl_type(typedata).capitalize() + if typestring: + typestring += ". " + return typestring + + +def create_docstring_jl(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}\nKeyword arguments:\n{args}").format( + n="n" if component_name[0].lower() in "aeiou" else "", + name=component_name, + description=description, + args="\n".join( + create_prop_docstring_jl( + prop_name=p, + type_object=prop["type"] if "type" in prop else prop["flowType"], + required=prop["required"], + description=prop["description"], + indent_num=0, + ) + for p, prop in filter_props(props).items() + ), + ) + + +def create_prop_docstring_jl( + prop_name, type_object, required, description, indent_num, +): + """ + 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 + """ + jl_type_name = get_jl_type(type_object=type_object) + + indent_spacing = " " * indent_num + if "\n" in jl_type_name: + return ( + "{indent_spacing}- `{name}` ({is_required}): {description}. " + "{name} has the following type: {type}".format( + indent_spacing=indent_spacing, + name=prop_name, + type=jl_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(jl_type_name) if jl_type_name else "", + description=(": {}".format(description) if description != "" else ""), + is_required="required" if required else "optional", + ) + + +# this logic will permit passing blank Julia prefixes to +# dash-generate-components, while also enforcing +# lower case names for the resulting functions; if a prefix +# is supplied, leave it as-is +def format_fn_name(prefix, name): + if prefix: + return "{}_{}".format(prefix, name.lower()) + return name.lower() + + +def generate_metadata_strings(resources, metatype): + def nothing_or_string(v): + return '"{}"'.format(v) if v else "nothing" + + return [ + jl_resource_tuple_string.format( + relative_package_path=nothing_or_string( + resource.get("relative_package_path", "") + ), + external_url=nothing_or_string(resource.get("external_url", "")), + dynamic=str(resource.get("dynamic", "nothing")).lower(), + type=metatype, + async_string=":{}".format(str(resource.get("async")).lower()) + if "async" in resource.keys() + else "nothing", + ) + for resource in resources + ] + + +def is_core_package(project_shortname): + return project_shortname in core_packages + + +def base_package_name(project_shortname): + return "DashBase" if is_core_package(project_shortname) else "Dash" + + +def base_package_uid(project_shortname): + return jl_dash_base_uuid if is_core_package(project_shortname) else jl_dash_uuid + + +def generate_package_file(project_shortname, components, pkg_data, prefix): + package_name = jl_package_name(project_shortname) + + sys.path.insert(0, os.getcwd()) + mod = importlib.import_module(project_shortname) + js_dist = getattr(mod, "_js_dist", []) + css_dist = getattr(mod, "_css_dist", []) + project_ver = pkg_data.get("version") + + resources_dist = ",\n".join( + generate_metadata_strings(js_dist, "js") + + generate_metadata_strings(css_dist, "css") + ) + + package_string = jl_package_file_string.format( + package_name=package_name, + component_includes="\n".join( + [ + jl_component_include_string.format( + name=format_fn_name(prefix, comp_name) + ) + for comp_name in components + ] + ), + resources_dist=resources_dist, + version=project_ver, + project_shortname=project_shortname, + base_package=base_package_name(project_shortname), + ) + file_path = os.path.join("src", package_name + ".jl") + with open(file_path, "w") as f: + f.write(package_string) + print("Generated {}".format(file_path)) + + +def generate_toml_file(project_shortname, pkg_data): + package_author = pkg_data.get("author", "") + project_ver = pkg_data.get("version") + package_name = jl_package_name(project_shortname) + u = uuid.UUID(jl_dash_uuid) + package_uuid = uuid.UUID(hex=u.hex[:-12] + hex(hash(package_name))[-12:]) + + authors_string = ( + 'authors = ["{}"]\n'.format(package_author) if package_author else "" + ) + + toml_string = jl_projecttoml_string.format( + package_name=package_name, + package_uuid=package_uuid, + version=project_ver, + authors=authors_string, + base_package=base_package_name(project_shortname), + dash_uuid=base_package_uid(project_shortname), + ) + file_path = "Project.toml" + with open(file_path, "w") as f: + f.write(toml_string) + print("Generated {}".format(file_path)) + + +def generate_class_string(name, props, description, project_shortname, prefix): + # Ensure props are ordered with children first + filtered_props = reorder_props(filter_props(props)) + + prop_keys = list(filtered_props.keys()) + + docstring = ( + create_docstring_jl( + component_name=name, props=filtered_props, description=description + ) + .replace("\r\n", "\n") + .replace("$", "\\$") + ) + + wclist = get_wildcards_jl(props) + default_paramtext = "" + + # Filter props to remove those we don't want to expose + for item in prop_keys[:]: + if item.endswith("-*") or item == "setProps": + prop_keys.remove(item) + elif item in julia_keywords: + prop_keys.remove(item) + warnings.warn( + ( + 'WARNING: prop "{}" in component "{}" is a Julia keyword' + " - REMOVED FROM THE JULIA COMPONENT" + ).format(item, name) + ) + + default_paramtext += ", ".join(":{}".format(p) for p in prop_keys) + + has_children = "children" in prop_keys + funcname = format_fn_name(prefix, name) + children_signatures = ( + jl_children_signatures.format(funcname=funcname) if has_children else "" + ) + children_definitions = ( + jl_children_definitions.format(funcname=funcname) if has_children else "" + ) + return jl_component_string.format( + funcname=format_fn_name(prefix, name), + docstring=docstring, + component_props=default_paramtext, + wildcard_symbols=stringify_wildcards(wclist, no_symbol=False), + wildcard_names=stringify_wildcards(wclist, no_symbol=True), + element_name=name, + module_name=project_shortname, + children_signatures=children_signatures, + children_definitions=children_definitions, + ) + + +def generate_struct_file(name, props, description, project_shortname, prefix): + props = reorder_props(props=props) + import_string = "# AUTO GENERATED FILE - DO NOT EDIT\n" + class_string = generate_class_string( + name, props, description, project_shortname, prefix + ) + + file_name = format_fn_name(prefix, name) + ".jl" + + file_path = os.path.join("src", file_name) + with open(file_path, "w") as f: + f.write(import_string) + f.write(class_string) + + print("Generated {}".format(file_name)) + + +# pylint: disable=unused-argument +def generate_module( + project_shortname, components, metadata, pkg_data, prefix, **kwargs +): + # the Julia source directory for the package won't exist on first call + # create the Julia directory if it is missing + if not os.path.exists("src"): + os.makedirs("src") + + # 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 + if os.path.exists("deps"): + shutil.rmtree("deps") + + os.makedirs("deps") + + for javascript in glob.glob("{}/*.js".format(project_shortname)): + shutil.copy(javascript, "deps/") + + for css in glob.glob("{}/*.css".format(project_shortname)): + shutil.copy(css, "deps/") + + for sourcemap in glob.glob("{}/*.map".format(project_shortname)): + shutil.copy(sourcemap, "deps/") + + generate_package_file(project_shortname, components, pkg_data, prefix) + generate_toml_file(project_shortname, pkg_data) diff --git a/dash/development/_py_components_generation.py b/dash/development/_py_components_generation.py index c6aa769eaf..24928d1fcb 100644 --- a/dash/development/_py_components_generation.py +++ b/dash/development/_py_components_generation.py @@ -225,11 +225,9 @@ def create_docstring(component_name, props, description): props = reorder_props(props=props) return ( - """A{n} {name} component.\n{description} - -Keyword arguments:\n{args}""" + "A{n} {name} component.\n{description}\n\nKeyword arguments:\n{args}" ).format( - n="n" if component_name[0].lower() in ["a", "e", "i", "o", "u"] else "", + n="n" if component_name[0].lower() in "aeiou" else "", name=component_name, description=description, args="\n".join( @@ -242,7 +240,7 @@ def create_docstring(component_name, props, description): indent_num=0, is_flow_type="flowType" in prop and "type" not in prop, ) - for p, prop in list(filter_props(props).items()) + for p, prop in filter_props(props).items() ), ) diff --git a/dash/development/_r_components_generation.py b/dash/development/_r_components_generation.py index e1e4344d36..e06600ee85 100644 --- a/dash/development/_r_components_generation.py +++ b/dash/development/_r_components_generation.py @@ -846,7 +846,7 @@ def get_r_prop_types(type_object): def shape_or_exact(): return "lists containing elements {}.\n{}".format( - ", ".join("'{}'".format(t) for t in list(type_object["value"].keys())), + ", ".join("'{}'".format(t) for t in type_object["value"]), "Those elements have the following types:\n{}".format( "\n".join( create_prop_docstring_r( @@ -856,7 +856,7 @@ def shape_or_exact(): description=prop.get("description", ""), indent_num=1, ) - for prop_name, prop in list(type_object["value"].items()) + for prop_name, prop in type_object["value"].items() ) ), ) diff --git a/dash/development/component_generator.py b/dash/development/component_generator.py index ac85e9e98b..a74261752c 100644 --- a/dash/development/component_generator.py +++ b/dash/development/component_generator.py @@ -18,6 +18,8 @@ from ._py_components_generation import generate_class_file from ._py_components_generation import generate_imports from ._py_components_generation import generate_classes_files +from ._jl_components_generation import generate_struct_file +from ._jl_components_generation import generate_module reserved_words = [ @@ -46,6 +48,7 @@ def generate_components( rdepends="", rimports="", rsuggests="", + jlprefix=None ): project_shortname = project_shortname.replace("-", "_").rstrip("/\\") @@ -91,6 +94,10 @@ def generate_components( generator_methods = [generate_class_file] + if rprefix is not None or jlprefix is not None: + with open("package.json", "r") as f: + pkg_data = safe_json_loads(f.read()) + if rprefix is not None: if not os.path.exists("man"): os.makedirs("man") @@ -101,12 +108,15 @@ def generate_components( rpkg_data = yaml.safe_load(yamldata) else: rpkg_data = None - with open("package.json", "r") as f: - pkg_data = safe_json_loads(f.read()) generator_methods.append( functools.partial(write_class_file, prefix=rprefix, rpkg_data=rpkg_data) ) + if jlprefix is not None: + generator_methods.append( + functools.partial(generate_struct_file, prefix=jlprefix) + ) + components = generate_classes_files(project_shortname, metadata, *generator_methods) with open(os.path.join(project_shortname, "metadata.json"), "w") as f: @@ -127,6 +137,15 @@ def generate_components( rsuggests, ) + if jlprefix is not None: + generate_module( + project_shortname, + components, + metadata, + pkg_data, + jlprefix + ) + def safe_json_loads(s): jsondata_unicode = json.loads(s, object_pairs_hook=OrderedDict) @@ -181,6 +200,11 @@ def cli(): help="Specify a comma-separated list of R packages to be " "inserted into the Suggests field of the DESCRIPTION file.", ) + parser.add_argument( + "--jl-prefix", + help="Specify a prefix for Dash for R component names, write " + "components to R dir, create R package.", + ) args = parser.parse_args() generate_components( @@ -192,6 +216,7 @@ def cli(): rdepends=args.r_depends, rimports=args.r_imports, rsuggests=args.r_suggests, + jlprefix=args.jl_prefix, ) @@ -201,9 +226,9 @@ def byteify(input_object): return OrderedDict( [(byteify(key), byteify(value)) for key, value in input_object.iteritems()] ) - elif isinstance(input_object, list): + if isinstance(input_object, list): return [byteify(element) for element in input_object] - elif isinstance(input_object, unicode): # noqa:F821 + if isinstance(input_object, unicode): # noqa:F821 return input_object.encode("utf-8") return input_object