diff --git a/CHANGELOG.md b/CHANGELOG.md index c695e8e935..a4d5a172f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#3278](https://github.com/plotly/dash/pull/3278) Fix loading selector with children starting at the same digit. Fix [#3276](https://github.com/plotly/dash/issues/3276) - [#3280](https://github.com/plotly/dash/pull/3280) Remove flask typing import not available in earlier versions. - [#3284](https://github.com/plotly/dash/pull/3284) Fix component as props having the same key when used in the same container. +- [#3287](https://github.com/plotly/dash/pull/3287) Fix typing component generation & explicitize_args. ## [3.0.3] - 2025-04-14 diff --git a/dash/development/_py_components_generation.py b/dash/development/_py_components_generation.py index 87aab47c5f..7b23066faf 100644 --- a/dash/development/_py_components_generation.py +++ b/dash/development/_py_components_generation.py @@ -22,13 +22,21 @@ import_string = """# AUTO GENERATED FILE - DO NOT EDIT import typing # noqa: F401 -import numbers # noqa: F401 from typing_extensions import TypedDict, NotRequired, Literal # noqa: F401 -from dash.development.base_component import Component -try: - from dash.development.base_component import ComponentType # noqa: F401 -except ImportError: - ComponentType = typing.TypeVar("ComponentType", bound=Component) +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 +] """ @@ -80,7 +88,6 @@ def generate_class_string( _namespace = '{namespace}' _type = '{typename}' {shapes} - _explicitize_dash_init = True def __init__( self, @@ -98,6 +105,8 @@ def __init__( args = {args} {required_validation} super({typename}, self).__init__({argtext}) + +setattr({typename}, "__init__", _explicitize_args({typename}.__init__)) ''' filtered_props = ( @@ -239,7 +248,6 @@ def generate_class_file( Returns ------- """ - imports = import_string class_string = generate_class_string( typename, @@ -255,8 +263,11 @@ def generate_class_file( custom_imp = custom_imp.get(typename) or custom_imp.get("*") if custom_imp: - imports += "\n".join(custom_imp) - imports += "\n\n" + 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" @@ -321,6 +332,9 @@ def generate_class( "TypedDict": TypedDict, "NotRequired": NotRequired, "Literal": Literal, + "NumberType": typing.Union[ + typing.SupportsFloat, typing.SupportsComplex, typing.SupportsInt + ], } # pylint: disable=exec-used exec(string, scope) diff --git a/dash/development/_py_prop_typing.py b/dash/development/_py_prop_typing.py index 5b9c0f264b..96b2053a1c 100644 --- a/dash/development/_py_prop_typing.py +++ b/dash/development/_py_prop_typing.py @@ -183,16 +183,10 @@ def get_prop_typing( "exact": generate_shape, "string": generate_type("str"), "bool": generate_type("bool"), - "number": generate_type( - "typing.Union[typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex]" - ), - "node": generate_type( - "typing.Union[str, int, float, ComponentType," - " typing.Sequence[typing.Union" - "[str, int, float, ComponentType]]]" - ), + "number": generate_type("NumberType"), + "node": generate_type("ComponentType"), "func": generate_any, - "element": generate_type("ComponentType"), + "element": generate_type("Component"), "union": generate_union, "any": generate_any, "custom": generate_any, diff --git a/dash/development/base_component.py b/dash/development/base_component.py index a54a0a9f42..975acfd537 100644 --- a/dash/development/base_component.py +++ b/dash/development/base_component.py @@ -57,6 +57,9 @@ def __new__(mcs, name, bases, attributes): # We only want to patch the new generated component without # the `@_explicitize_args` decorator for mypy support # See issue: https://github.com/plotly/dash/issues/3226 + # Only for component that were generated by 3.0.3 + # Better to setattr on the component afterwards to ensure + # backward compatibility. attributes["__init__"] = _explicitize_args(attributes["__init__"]) _component = abc.ABCMeta.__new__(mcs, name, bases, attributes) @@ -441,7 +444,15 @@ def _validate_deprecation(self): warnings.warn(DeprecationWarning(textwrap.dedent(deprecation_message))) -ComponentType = typing.TypeVar("ComponentType", bound=Component) +# Renderable node type. +ComponentType = typing.Union[ + str, + int, + float, + Component, + None, + typing.Sequence[typing.Union[str, int, float, Component, None]], +] ComponentTemplate = typing.TypeVar("ComponentTemplate") diff --git a/requirements/ci.txt b/requirements/ci.txt index 96495aa4f9..a2d56c0a8e 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -19,3 +19,4 @@ xlrd>=2.0.1 pytest-rerunfailures jupyterlab<4.0.0 pyright==1.1.398;python_version>="3.7" +mypy==1.15.0;python_version>="3.12" diff --git a/tests/integration/test_typing.py b/tests/integration/test_typing.py index d2894d75ca..58c81ab5c2 100644 --- a/tests/integration/test_typing.py +++ b/tests/integration/test_typing.py @@ -11,11 +11,55 @@ t = TypeScriptComponent({0}) """ +basic_app_template = """ +from dash import Dash, html, dcc, callback, Input, Output -def run_pyright(codefile: str): +app = Dash() + +{0} +app.layout = {1} + +@callback(Output("out", "children"), Input("btn", "n_clicks")) +def on_click() -> html.Div: + return {2} +""" + +valid_layout = """html.Div([ + html.H2('Valid'), + 'String in middle', + 123, + 404.4, + dcc.Input(value='', id='in') +]) +""" +valid_layout_list = """[ + html.H2('Valid'), + 'String in middle', + 123, + 404.4, + dcc.Input(value='', id='in') +] +""" +valid_layout_function = """ +def layout() -> html.Div: + return html.Div(["hello layout"]) + +""" + +invalid_layout = """html.Div([ + {"invalid": "dictionary in children"} +]) +""" +# There is not invalid layout for function & list as explicitly typed as Any to avoid special cases. + +valid_callback = "html.Div('Valid')" +invalid_callback = "[]" + + +def run_module(codefile: str, module: str, extra: str = ""): cmd = shlex.split( - f"pyright {codefile}", + f"{sys.executable} -m {module} {codefile}{extra}", posix=sys.platform != "win32", comments=True, ) @@ -32,17 +76,51 @@ def run_pyright(codefile: str): return out.decode(), err.decode(), proc.poll() -def assert_pyright_output( - codefile: str, expected_outputs=tuple(), expected_errors=tuple(), expected_status=0 +def assert_output( + codefile: str, + code: str, + expected_outputs=tuple(), + expected_errors=tuple(), + expected_status=0, + module="pyright", ): - output, error, status = run_pyright(codefile) + output, error, status = run_module(codefile, module) assert ( status == expected_status - ), f"Status: {status}\nOutput: {output}\nError: {error}" + ), f"Status: {status}\nOutput: {output}\nError: {error}\nCode: {code}" for ex_out in expected_outputs: - assert ex_out in output, f"Invalid output:\n {output}" - for ex_err in expected_errors: - assert ex_err in error + assert ex_out in output, f"Invalid output:\n {output}\n\nCode: {code}" + + +def format_template_and_save(template, filename, *args): + formatted = template.format(*args) + with open(filename, "w") as f: + f.write(formatted) + return formatted + + +def expect(status=None, outputs=None, modular=False): + data = {} + if status is not None: + data["expected_status"] = status + if outputs is not None: + data["expected_outputs"] = outputs + if modular: + # The expectations are per module. + data["modular"] = modular + return data + + +@pytest.fixture() +def change_dir(): + original_dir = os.getcwd() + + def change(dirname): + os.chdir(dirname) + + yield change + + os.chdir(original_dir) @pytest.mark.parametrize( @@ -205,7 +283,7 @@ def assert_pyright_output( "expected_status": 1, "expected_outputs": [ 'Argument of type "tuple[Literal[1], Literal[2]]" cannot be assigned ' - 'to parameter "a_tuple" of type "Tuple[SupportsFloat | SupportsInt | SupportsComplex, str] | None' + 'to parameter "a_tuple" of type "Tuple[NumberType, str] | None' ], }, ), @@ -247,9 +325,35 @@ def assert_pyright_output( ), ], ) -def test_component_typing(arguments, assertions, tmp_path): +def test_typi001_component_typing(arguments, assertions, tmp_path): codefile = os.path.join(tmp_path, "code.py") - with open(codefile, "w") as f: - f.write(component_template.format(arguments)) + code = format_template_and_save(component_template, codefile, arguments) + assert_output(codefile, code, module="pyright", **assertions) + + +typing_modules = ["pyright"] + +if sys.version_info.minor >= 10: + typing_modules.append("mypy") - assert_pyright_output(codefile, **assertions) + +@pytest.mark.parametrize("typing_module", typing_modules) +@pytest.mark.parametrize( + "prelayout, layout, callback_return, assertions", + [ + ("", valid_layout, valid_callback, expect(status=0)), + ("", valid_layout_list, valid_callback, expect(status=0)), + (valid_layout_function, "layout", valid_callback, expect(status=0)), + ("", valid_layout, invalid_callback, expect(status=1)), + ("", invalid_layout, valid_callback, expect(status=1)), + ], +) +def test_typi002_typing_compliance( + typing_module, prelayout, layout, callback_return, assertions, tmp_path, change_dir +): + codefile = os.path.join(tmp_path, "code.py") + os.chdir(tmp_path) + code = format_template_and_save( + basic_app_template, codefile, prelayout, layout, callback_return + ) + assert_output(codefile, code, module=typing_module, **assertions) diff --git a/tests/unit/development/metadata_test.py b/tests/unit/development/metadata_test.py index 439ba0f3c3..0ee96efdeb 100644 --- a/tests/unit/development/metadata_test.py +++ b/tests/unit/development/metadata_test.py @@ -1,13 +1,21 @@ # AUTO GENERATED FILE - DO NOT EDIT import typing # noqa: F401 -import numbers # noqa: F401 from typing_extensions import TypedDict, NotRequired, Literal # noqa: F401 -from dash.development.base_component import Component -try: - from dash.development.base_component import ComponentType # noqa: F401 -except ImportError: - ComponentType = typing.TypeVar("ComponentType", bound=Component) +from dash.development.base_component import Component, _explicitize_args + +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 +] class Table(Component): @@ -109,7 +117,7 @@ class Table(Component): "OptionalObjectWithExactAndNestedDescription", { "color": NotRequired[str], - "fontSize": NotRequired[typing.Union[typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex]], + "fontSize": NotRequired[NumberType], "figure": NotRequired["OptionalObjectWithExactAndNestedDescriptionFigure"] } ) @@ -126,30 +134,29 @@ class Table(Component): "OptionalObjectWithShapeAndNestedDescription", { "color": NotRequired[str], - "fontSize": NotRequired[typing.Union[typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex]], + "fontSize": NotRequired[NumberType], "figure": NotRequired["OptionalObjectWithShapeAndNestedDescriptionFigure"] } ) - _explicitize_dash_init = True def __init__( self, - children: typing.Optional[typing.Union[str, int, float, ComponentType, typing.Sequence[typing.Union[str, int, float, ComponentType]]]] = None, + children: typing.Optional[ComponentType] = None, optionalArray: typing.Optional[typing.Sequence] = None, optionalBool: typing.Optional[bool] = None, optionalFunc: typing.Optional[typing.Any] = None, - optionalNumber: typing.Optional[typing.Union[typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex]] = None, + optionalNumber: typing.Optional[NumberType] = None, optionalObject: typing.Optional[dict] = None, optionalString: typing.Optional[str] = None, optionalSymbol: typing.Optional[typing.Any] = None, - optionalNode: typing.Optional[typing.Union[str, int, float, ComponentType, typing.Sequence[typing.Union[str, int, float, ComponentType]]]] = None, - optionalElement: typing.Optional[ComponentType] = None, + optionalNode: typing.Optional[ComponentType] = None, + optionalElement: typing.Optional[Component] = None, optionalMessage: typing.Optional[typing.Any] = None, optionalEnum: typing.Optional[Literal["News", "Photos"]] = None, - optionalUnion: typing.Optional[typing.Union[str, typing.Union[typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex], typing.Any]] = None, - optionalArrayOf: typing.Optional[typing.Sequence[typing.Union[typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex]]] = None, - optionalObjectOf: typing.Optional[typing.Dict[typing.Union[str, float, int], typing.Union[typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex]]] = None, + optionalUnion: typing.Optional[typing.Union[str, NumberType, typing.Any]] = None, + optionalArrayOf: typing.Optional[typing.Sequence[NumberType]] = None, + optionalObjectOf: typing.Optional[typing.Dict[typing.Union[str, float, int], NumberType]] = None, optionalObjectWithExactAndNestedDescription: typing.Optional["OptionalObjectWithExactAndNestedDescription"] = None, optionalObjectWithShapeAndNestedDescription: typing.Optional["OptionalObjectWithShapeAndNestedDescription"] = None, optionalAny: typing.Optional[typing.Any] = None, @@ -168,3 +175,5 @@ def __init__( args = {k: _locals[k] for k in _explicit_args if k != 'children'} super(Table, self).__init__(children=children, **args) + +setattr(Table, "__init__", _explicitize_args(Table.__init__)) diff --git a/tests/unit/development/test_generate_class_file.py b/tests/unit/development/test_generate_class_file.py index f79121bf01..7269670c94 100644 --- a/tests/unit/development/test_generate_class_file.py +++ b/tests/unit/development/test_generate_class_file.py @@ -33,7 +33,7 @@ def expected_class_string(): @pytest.fixture def component_class_string(make_component_dir): - return import_string + generate_class_string( + return import_string.format(custom_imports="") + generate_class_string( typename="Table", props=make_component_dir["props"], description=make_component_dir["description"],