diff --git a/CHANGELOG.md b/CHANGELOG.md index e98c86fad0..358f89975f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [3.0.0-rc2] - UNRELEASED +## Added + +- [#3152](https://github.com/plotly/dash/pull/3152) Custom Python prop typing for component library. + - Added `-t`, `--custom-typing-module` argument to `dash-generate-components` CLI, default to `dash_prop_typing` and can contains definitions in variables: + - `custom_imports: dict[ComponentName, list[str]]` import statement to be copied at the top of the component class definition. + - `custom_props: dict[ComponentName, dict[PropName, function]]` for custom props. The function signature is: `def generate_type(type_info, component_name, prop_name) -> str` + ## Fixed - [#3142](https://github.com/plotly/dash/pull/3142) Fix typing generation for id and dates props. diff --git a/components/dash-core-components/dash_prop_typing.py b/components/dash-core-components/dash_prop_typing.py new file mode 100644 index 0000000000..a0f4fdcb41 --- /dev/null +++ b/components/dash-core-components/dash_prop_typing.py @@ -0,0 +1,40 @@ +# This file is automatically loaded on build time to generate types. + +def generate_plotly_figure(*_): + return "typing.Union[Figure, dict]" + + +def generate_datetime_prop(array=False): + + def generator(*_): + datetime_type = "typing.Union[str, datetime.datetime]" + if array: + datetime_type = f"typing.Sequence[{datetime_type}]" + return datetime_type + + return generator + + +custom_imports = { + "Graph": ["from plotly.graph_objects import Figure"], + "DatePickerRange": ["import datetime"], + "DatePickerSingle": ["import datetime"], +} + +custom_props = { + "Graph": {"figure": generate_plotly_figure}, + "DatePickerRange": { + "start_date": generate_datetime_prop(), + "end_date": generate_datetime_prop(), + "min_date_allowed": generate_datetime_prop(), + "max_date_allowed": generate_datetime_prop(), + "disabled_days": generate_datetime_prop(True), + }, + "DatePickerSingle": { + "date": generate_datetime_prop(), + "min_date_allowed": generate_datetime_prop(), + "max_date_allowed": generate_datetime_prop(), + "disabled_days": generate_datetime_prop(True), + "initial_visible_month": generate_datetime_prop(), + }, +} diff --git a/dash/dash.py b/dash/dash.py index b9c62c705a..efdf7a2727 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -16,7 +16,7 @@ import base64 import traceback from urllib.parse import urlparse -from typing import Any, Callable, Dict, Optional, Union, List +from typing import Any, Callable, Dict, Optional, Union, Sequence import flask @@ -397,14 +397,14 @@ def __init__( # pylint: disable=too-many-statements routes_pathname_prefix: Optional[str] = None, serve_locally: bool = True, compress: Optional[bool] = None, - meta_tags: Optional[List[Dict[str, Any]]] = None, + meta_tags: Optional[Sequence[Dict[str, Any]]] = None, index_string: str = _default_index, - external_scripts: Optional[List[Union[str, Dict[str, Any]]]] = None, - external_stylesheets: Optional[List[Union[str, Dict[str, Any]]]] = None, + external_scripts: Optional[Sequence[Union[str, Dict[str, Any]]]] = None, + external_stylesheets: Optional[Sequence[Union[str, Dict[str, Any]]]] = None, suppress_callback_exceptions: Optional[bool] = None, prevent_initial_callbacks: bool = False, show_undo_redo: bool = False, - extra_hot_reload_paths: Optional[List[str]] = None, + extra_hot_reload_paths: Optional[Sequence[str]] = None, plugins: Optional[list] = None, title: str = "Dash", update_title: str = "Updating...", diff --git a/dash/development/_py_components_generation.py b/dash/development/_py_components_generation.py index 6fa88bb396..26cd796bb2 100644 --- a/dash/development/_py_components_generation.py +++ b/dash/development/_py_components_generation.py @@ -10,7 +10,12 @@ 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_prop_typing, shapes, custom_imports +from ._py_prop_typing import ( + get_custom_props, + get_prop_typing, + shapes, + get_custom_imports, +) from .base_component import Component, ComponentType import_string = """# AUTO GENERATED FILE - DO NOT EDIT @@ -36,6 +41,7 @@ def generate_class_string( 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. @@ -162,7 +168,14 @@ def __init__( type_name = type_info.get("name") - typed = get_prop_typing(type_name, typename, prop_key, type_info, namespace) + custom_props = get_custom_props(custom_typing_module) + typed = get_prop_typing( + type_name, + typename, + prop_key, + type_info, + custom_props=custom_props, + ) arg_value = f"{prop_key}: typing.Optional[{typed}] = None" @@ -208,6 +221,7 @@ def generate_class_file( 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 @@ -223,10 +237,16 @@ def generate_class_file( imports = import_string class_string = generate_class_string( - typename, props, description, namespace, prop_reorder_exceptions, max_props + typename, + props, + description, + namespace, + prop_reorder_exceptions, + max_props, + custom_typing_module, ) - custom_imp = custom_imports[namespace][typename] + custom_imp = get_custom_imports(custom_typing_module).get(typename) if custom_imp: imports += "\n".join(custom_imp) imports += "\n\n" diff --git a/dash/development/_py_prop_typing.py b/dash/development/_py_prop_typing.py index 3ace0edd4a..7c93d555a1 100644 --- a/dash/development/_py_prop_typing.py +++ b/dash/development/_py_prop_typing.py @@ -2,6 +2,7 @@ import json import string import textwrap +import importlib import stringcase @@ -15,6 +16,24 @@ custom_imports = collections.defaultdict(lambda: collections.defaultdict(list)) +def _get_custom(module_name, prop, default): + if not module_name: + return default + try: + module = importlib.import_module(module_name) + return getattr(module, prop, default) + except ImportError: + return default + + +def get_custom_imports(module_name): + return _get_custom(module_name, "custom_imports", {}) + + +def get_custom_props(module_name): + return _get_custom(module_name, "custom_props", {}) + + def _clean_key(key): k = "" for ch in key: @@ -118,17 +137,18 @@ def generate_enum(type_info, *_): def get_prop_typing( - type_name: str, component_name: str, prop_name: str, type_info, namespace=None + type_name: str, + component_name: str, + prop_name: str, + type_info, + custom_props=None, ): if prop_name == "id": # Id is always the same either a string or a dict for pattern matching. return "typing.Union[str, dict]" - if namespace: - # Only check the namespace once - special = ( - special_cases.get(namespace, {}).get(component_name, {}).get(prop_name) - ) + if custom_props: + special = custom_props.get(component_name, {}).get(prop_name) if special: return special(type_info, component_name, prop_name) @@ -158,27 +178,6 @@ def generator(*_): return generator -special_cases = { - "dash_core_components": { - "Graph": {"figure": generate_plotly_figure}, - "DatePickerRange": { - "start_date": generate_datetime_prop("DatePickerRange"), - "end_date": generate_datetime_prop("DatePickerRange"), - "min_date_allowed": generate_datetime_prop("DatePickerRange"), - "max_date_allowed": generate_datetime_prop("DatePickerRange"), - "disabled_days": generate_datetime_prop("DatePickerRange", True), - }, - "DatePickerSingle": { - "date": generate_datetime_prop("DatePickerSingle"), - "min_date_allowed": generate_datetime_prop("DatePickerSingle"), - "max_date_allowed": generate_datetime_prop("DatePickerSingle"), - "disabled_days": generate_datetime_prop("DatePickerSingle", True), - "initial_visible_month": generate_datetime_prop("DatePickerSingle"), - }, - } -} - - PROP_TYPING = { "array": generate_type("typing.Sequence"), "arrayOf": generate_array_of, diff --git a/dash/development/component_generator.py b/dash/development/component_generator.py index 352ce9f663..c7f407aeb8 100644 --- a/dash/development/component_generator.py +++ b/dash/development/component_generator.py @@ -50,6 +50,7 @@ def generate_components( metadata=None, keep_prop_order=None, max_props=None, + custom_typing_module=None, ): project_shortname = project_shortname.replace("-", "_").rstrip("/\\") @@ -99,7 +100,9 @@ def generate_components( metadata = safe_json_loads(out.decode("utf-8")) - py_generator_kwargs = {} + py_generator_kwargs = { + "custom_typing_module": custom_typing_module, + } if keep_prop_order is not None: keep_prop_order = [ component.strip(" ") for component in keep_prop_order.split(",") @@ -239,10 +242,22 @@ def component_build_arg_parser(): "but you may also want to reduce further for improved readability at the " "expense of auto-completion for the later props. Use 0 to include all props.", ) + parser.add_argument( + "-t", + "--custom-typing-module", + type=str, + default="dash_prop_typing", + help=" Module containing custom typing definition for components." + "Can contains two variables:\n" + " - custom_imports: dict[ComponentName, list[str]].\n" + " - custom_props: dict[ComponentName, dict[PropName, function]].\n", + ) return parser def cli(): + # Add current path for loading modules. + sys.path.insert(0, ".") args = component_build_arg_parser().parse_args() generate_components( args.components_source, @@ -256,6 +271,7 @@ def cli(): jlprefix=args.jl_prefix, keep_prop_order=args.keep_prop_order, max_props=args.max_props, + custom_typing_module=args.custom_typing_module, )