Skip to content

Fix typing component generation & explicitize_args #3287

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Apr 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 24 additions & 10 deletions dash/development/_py_components_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment on lines -28 to -30
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: types defined in try except don't always resolve.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that makes sense now that you've said it but damn - I'm liking Python's type system less and less as time goes by…

ComponentType = typing.TypeVar("ComponentType", bound=Component)
from dash.development.base_component import Component, _explicitize_args
{custom_imports}
ComponentType = typing.Union[
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect this will eventually grow to Any :-)

str,
int,
float,
Component,
None,
typing.Sequence[typing.Union[str, int, float, Component, None]],
]

NumberType = typing.Union[
typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex
]


"""
Expand Down Expand Up @@ -80,7 +88,6 @@ def generate_class_string(
_namespace = '{namespace}'
_type = '{typename}'
{shapes}
_explicitize_dash_init = True

def __init__(
self,
Expand All @@ -98,6 +105,8 @@ def __init__(
args = {args}
{required_validation}
super({typename}, self).__init__({argtext})

setattr({typename}, "__init__", _explicitize_args({typename}.__init__))
'''

filtered_props = (
Expand Down Expand Up @@ -239,7 +248,6 @@ def generate_class_file(
Returns
-------
"""
imports = import_string

class_string = generate_class_string(
typename,
Expand All @@ -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"

Expand Down Expand Up @@ -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)
Expand Down
12 changes: 3 additions & 9 deletions dash/development/_py_prop_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

"union": generate_union,
"any": generate_any,
"custom": generate_any,
Expand Down
13 changes: 12 additions & 1 deletion dash/development/base_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")

Expand Down
1 change: 1 addition & 0 deletions requirements/ci.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
132 changes: 118 additions & 14 deletions tests/integration/test_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

posix=sys.platform != "win32",
comments=True,
)
Expand All @@ -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(
Expand Down Expand Up @@ -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'
],
},
),
Expand Down Expand Up @@ -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)
41 changes: 25 additions & 16 deletions tests/unit/development/metadata_test.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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"]
}
)
Expand All @@ -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,
Expand All @@ -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__))
2 changes: 1 addition & 1 deletion tests/unit/development/test_generate_class_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down