Skip to content

Commit 38f26fb

Browse files
authored
Merge pull request #3287 from plotly/fix/typing-component-gen
Fix typing component generation & explicitize_args
2 parents 17fca44 + 8b3a96a commit 38f26fb

File tree

8 files changed

+185
-51
lines changed

8 files changed

+185
-51
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
88
- [#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)
99
- [#3280](https://github.com/plotly/dash/pull/3280) Remove flask typing import not available in earlier versions.
1010
- [#3284](https://github.com/plotly/dash/pull/3284) Fix component as props having the same key when used in the same container.
11+
- [#3287](https://github.com/plotly/dash/pull/3287) Fix typing component generation & explicitize_args.
1112

1213
## [3.0.3] - 2025-04-14
1314

dash/development/_py_components_generation.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,21 @@
2222
import_string = """# AUTO GENERATED FILE - DO NOT EDIT
2323
2424
import typing # noqa: F401
25-
import numbers # noqa: F401
2625
from typing_extensions import TypedDict, NotRequired, Literal # noqa: F401
27-
from dash.development.base_component import Component
28-
try:
29-
from dash.development.base_component import ComponentType # noqa: F401
30-
except ImportError:
31-
ComponentType = typing.TypeVar("ComponentType", bound=Component)
26+
from dash.development.base_component import Component, _explicitize_args
27+
{custom_imports}
28+
ComponentType = typing.Union[
29+
str,
30+
int,
31+
float,
32+
Component,
33+
None,
34+
typing.Sequence[typing.Union[str, int, float, Component, None]],
35+
]
36+
37+
NumberType = typing.Union[
38+
typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex
39+
]
3240
3341
3442
"""
@@ -80,7 +88,6 @@ def generate_class_string(
8088
_namespace = '{namespace}'
8189
_type = '{typename}'
8290
{shapes}
83-
_explicitize_dash_init = True
8491
8592
def __init__(
8693
self,
@@ -98,6 +105,8 @@ def __init__(
98105
args = {args}
99106
{required_validation}
100107
super({typename}, self).__init__({argtext})
108+
109+
setattr({typename}, "__init__", _explicitize_args({typename}.__init__))
101110
'''
102111

103112
filtered_props = (
@@ -239,7 +248,6 @@ def generate_class_file(
239248
Returns
240249
-------
241250
"""
242-
imports = import_string
243251

244252
class_string = generate_class_string(
245253
typename,
@@ -255,8 +263,11 @@ def generate_class_file(
255263
custom_imp = custom_imp.get(typename) or custom_imp.get("*")
256264

257265
if custom_imp:
258-
imports += "\n".join(custom_imp)
259-
imports += "\n\n"
266+
imports = import_string.format(
267+
custom_imports="\n" + "\n".join(custom_imp) + "\n\n"
268+
)
269+
else:
270+
imports = import_string.format(custom_imports="")
260271

261272
file_name = f"{typename:s}.py"
262273

@@ -321,6 +332,9 @@ def generate_class(
321332
"TypedDict": TypedDict,
322333
"NotRequired": NotRequired,
323334
"Literal": Literal,
335+
"NumberType": typing.Union[
336+
typing.SupportsFloat, typing.SupportsComplex, typing.SupportsInt
337+
],
324338
}
325339
# pylint: disable=exec-used
326340
exec(string, scope)

dash/development/_py_prop_typing.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -183,16 +183,10 @@ def get_prop_typing(
183183
"exact": generate_shape,
184184
"string": generate_type("str"),
185185
"bool": generate_type("bool"),
186-
"number": generate_type(
187-
"typing.Union[typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex]"
188-
),
189-
"node": generate_type(
190-
"typing.Union[str, int, float, ComponentType,"
191-
" typing.Sequence[typing.Union"
192-
"[str, int, float, ComponentType]]]"
193-
),
186+
"number": generate_type("NumberType"),
187+
"node": generate_type("ComponentType"),
194188
"func": generate_any,
195-
"element": generate_type("ComponentType"),
189+
"element": generate_type("Component"),
196190
"union": generate_union,
197191
"any": generate_any,
198192
"custom": generate_any,

dash/development/base_component.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ def __new__(mcs, name, bases, attributes):
5757
# We only want to patch the new generated component without
5858
# the `@_explicitize_args` decorator for mypy support
5959
# See issue: https://github.com/plotly/dash/issues/3226
60+
# Only for component that were generated by 3.0.3
61+
# Better to setattr on the component afterwards to ensure
62+
# backward compatibility.
6063
attributes["__init__"] = _explicitize_args(attributes["__init__"])
6164

6265
_component = abc.ABCMeta.__new__(mcs, name, bases, attributes)
@@ -441,7 +444,15 @@ def _validate_deprecation(self):
441444
warnings.warn(DeprecationWarning(textwrap.dedent(deprecation_message)))
442445

443446

444-
ComponentType = typing.TypeVar("ComponentType", bound=Component)
447+
# Renderable node type.
448+
ComponentType = typing.Union[
449+
str,
450+
int,
451+
float,
452+
Component,
453+
None,
454+
typing.Sequence[typing.Union[str, int, float, Component, None]],
455+
]
445456

446457
ComponentTemplate = typing.TypeVar("ComponentTemplate")
447458

requirements/ci.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ xlrd>=2.0.1
1919
pytest-rerunfailures
2020
jupyterlab<4.0.0
2121
pyright==1.1.398;python_version>="3.7"
22+
mypy==1.15.0;python_version>="3.12"

tests/integration/test_typing.py

Lines changed: 118 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,55 @@
1111
t = TypeScriptComponent({0})
1212
"""
1313

14+
basic_app_template = """
15+
from dash import Dash, html, dcc, callback, Input, Output
1416
15-
def run_pyright(codefile: str):
17+
app = Dash()
18+
19+
{0}
20+
app.layout = {1}
21+
22+
@callback(Output("out", "children"), Input("btn", "n_clicks"))
23+
def on_click() -> html.Div:
24+
return {2}
25+
"""
26+
27+
valid_layout = """html.Div([
28+
html.H2('Valid'),
29+
'String in middle',
30+
123,
31+
404.4,
32+
dcc.Input(value='', id='in')
33+
])
34+
"""
35+
valid_layout_list = """[
36+
html.H2('Valid'),
37+
'String in middle',
38+
123,
39+
404.4,
40+
dcc.Input(value='', id='in')
41+
]
42+
"""
43+
valid_layout_function = """
44+
def layout() -> html.Div:
45+
return html.Div(["hello layout"])
46+
47+
"""
48+
49+
invalid_layout = """html.Div([
50+
{"invalid": "dictionary in children"}
51+
])
52+
"""
53+
# There is not invalid layout for function & list as explicitly typed as Any to avoid special cases.
54+
55+
valid_callback = "html.Div('Valid')"
56+
invalid_callback = "[]"
57+
58+
59+
def run_module(codefile: str, module: str, extra: str = ""):
1660

1761
cmd = shlex.split(
18-
f"pyright {codefile}",
62+
f"{sys.executable} -m {module} {codefile}{extra}",
1963
posix=sys.platform != "win32",
2064
comments=True,
2165
)
@@ -32,17 +76,51 @@ def run_pyright(codefile: str):
3276
return out.decode(), err.decode(), proc.poll()
3377

3478

35-
def assert_pyright_output(
36-
codefile: str, expected_outputs=tuple(), expected_errors=tuple(), expected_status=0
79+
def assert_output(
80+
codefile: str,
81+
code: str,
82+
expected_outputs=tuple(),
83+
expected_errors=tuple(),
84+
expected_status=0,
85+
module="pyright",
3786
):
38-
output, error, status = run_pyright(codefile)
87+
output, error, status = run_module(codefile, module)
3988
assert (
4089
status == expected_status
41-
), f"Status: {status}\nOutput: {output}\nError: {error}"
90+
), f"Status: {status}\nOutput: {output}\nError: {error}\nCode: {code}"
4291
for ex_out in expected_outputs:
43-
assert ex_out in output, f"Invalid output:\n {output}"
44-
for ex_err in expected_errors:
45-
assert ex_err in error
92+
assert ex_out in output, f"Invalid output:\n {output}\n\nCode: {code}"
93+
94+
95+
def format_template_and_save(template, filename, *args):
96+
formatted = template.format(*args)
97+
with open(filename, "w") as f:
98+
f.write(formatted)
99+
return formatted
100+
101+
102+
def expect(status=None, outputs=None, modular=False):
103+
data = {}
104+
if status is not None:
105+
data["expected_status"] = status
106+
if outputs is not None:
107+
data["expected_outputs"] = outputs
108+
if modular:
109+
# The expectations are per module.
110+
data["modular"] = modular
111+
return data
112+
113+
114+
@pytest.fixture()
115+
def change_dir():
116+
original_dir = os.getcwd()
117+
118+
def change(dirname):
119+
os.chdir(dirname)
120+
121+
yield change
122+
123+
os.chdir(original_dir)
46124

47125

48126
@pytest.mark.parametrize(
@@ -205,7 +283,7 @@ def assert_pyright_output(
205283
"expected_status": 1,
206284
"expected_outputs": [
207285
'Argument of type "tuple[Literal[1], Literal[2]]" cannot be assigned '
208-
'to parameter "a_tuple" of type "Tuple[SupportsFloat | SupportsInt | SupportsComplex, str] | None'
286+
'to parameter "a_tuple" of type "Tuple[NumberType, str] | None'
209287
],
210288
},
211289
),
@@ -247,9 +325,35 @@ def assert_pyright_output(
247325
),
248326
],
249327
)
250-
def test_component_typing(arguments, assertions, tmp_path):
328+
def test_typi001_component_typing(arguments, assertions, tmp_path):
251329
codefile = os.path.join(tmp_path, "code.py")
252-
with open(codefile, "w") as f:
253-
f.write(component_template.format(arguments))
330+
code = format_template_and_save(component_template, codefile, arguments)
331+
assert_output(codefile, code, module="pyright", **assertions)
332+
333+
334+
typing_modules = ["pyright"]
335+
336+
if sys.version_info.minor >= 10:
337+
typing_modules.append("mypy")
254338

255-
assert_pyright_output(codefile, **assertions)
339+
340+
@pytest.mark.parametrize("typing_module", typing_modules)
341+
@pytest.mark.parametrize(
342+
"prelayout, layout, callback_return, assertions",
343+
[
344+
("", valid_layout, valid_callback, expect(status=0)),
345+
("", valid_layout_list, valid_callback, expect(status=0)),
346+
(valid_layout_function, "layout", valid_callback, expect(status=0)),
347+
("", valid_layout, invalid_callback, expect(status=1)),
348+
("", invalid_layout, valid_callback, expect(status=1)),
349+
],
350+
)
351+
def test_typi002_typing_compliance(
352+
typing_module, prelayout, layout, callback_return, assertions, tmp_path, change_dir
353+
):
354+
codefile = os.path.join(tmp_path, "code.py")
355+
os.chdir(tmp_path)
356+
code = format_template_and_save(
357+
basic_app_template, codefile, prelayout, layout, callback_return
358+
)
359+
assert_output(codefile, code, module=typing_module, **assertions)

tests/unit/development/metadata_test.py

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
# AUTO GENERATED FILE - DO NOT EDIT
22

33
import typing # noqa: F401
4-
import numbers # noqa: F401
54
from typing_extensions import TypedDict, NotRequired, Literal # noqa: F401
6-
from dash.development.base_component import Component
7-
try:
8-
from dash.development.base_component import ComponentType # noqa: F401
9-
except ImportError:
10-
ComponentType = typing.TypeVar("ComponentType", bound=Component)
5+
from dash.development.base_component import Component, _explicitize_args
6+
7+
ComponentType = typing.Union[
8+
str,
9+
int,
10+
float,
11+
Component,
12+
None,
13+
typing.Sequence[typing.Union[str, int, float, Component, None]],
14+
]
15+
16+
NumberType = typing.Union[
17+
typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex
18+
]
1119

1220

1321
class Table(Component):
@@ -109,7 +117,7 @@ class Table(Component):
109117
"OptionalObjectWithExactAndNestedDescription",
110118
{
111119
"color": NotRequired[str],
112-
"fontSize": NotRequired[typing.Union[typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex]],
120+
"fontSize": NotRequired[NumberType],
113121
"figure": NotRequired["OptionalObjectWithExactAndNestedDescriptionFigure"]
114122
}
115123
)
@@ -126,30 +134,29 @@ class Table(Component):
126134
"OptionalObjectWithShapeAndNestedDescription",
127135
{
128136
"color": NotRequired[str],
129-
"fontSize": NotRequired[typing.Union[typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex]],
137+
"fontSize": NotRequired[NumberType],
130138
"figure": NotRequired["OptionalObjectWithShapeAndNestedDescriptionFigure"]
131139
}
132140
)
133141

134-
_explicitize_dash_init = True
135142

136143
def __init__(
137144
self,
138-
children: typing.Optional[typing.Union[str, int, float, ComponentType, typing.Sequence[typing.Union[str, int, float, ComponentType]]]] = None,
145+
children: typing.Optional[ComponentType] = None,
139146
optionalArray: typing.Optional[typing.Sequence] = None,
140147
optionalBool: typing.Optional[bool] = None,
141148
optionalFunc: typing.Optional[typing.Any] = None,
142-
optionalNumber: typing.Optional[typing.Union[typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex]] = None,
149+
optionalNumber: typing.Optional[NumberType] = None,
143150
optionalObject: typing.Optional[dict] = None,
144151
optionalString: typing.Optional[str] = None,
145152
optionalSymbol: typing.Optional[typing.Any] = None,
146-
optionalNode: typing.Optional[typing.Union[str, int, float, ComponentType, typing.Sequence[typing.Union[str, int, float, ComponentType]]]] = None,
147-
optionalElement: typing.Optional[ComponentType] = None,
153+
optionalNode: typing.Optional[ComponentType] = None,
154+
optionalElement: typing.Optional[Component] = None,
148155
optionalMessage: typing.Optional[typing.Any] = None,
149156
optionalEnum: typing.Optional[Literal["News", "Photos"]] = None,
150-
optionalUnion: typing.Optional[typing.Union[str, typing.Union[typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex], typing.Any]] = None,
151-
optionalArrayOf: typing.Optional[typing.Sequence[typing.Union[typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex]]] = None,
152-
optionalObjectOf: typing.Optional[typing.Dict[typing.Union[str, float, int], typing.Union[typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex]]] = None,
157+
optionalUnion: typing.Optional[typing.Union[str, NumberType, typing.Any]] = None,
158+
optionalArrayOf: typing.Optional[typing.Sequence[NumberType]] = None,
159+
optionalObjectOf: typing.Optional[typing.Dict[typing.Union[str, float, int], NumberType]] = None,
153160
optionalObjectWithExactAndNestedDescription: typing.Optional["OptionalObjectWithExactAndNestedDescription"] = None,
154161
optionalObjectWithShapeAndNestedDescription: typing.Optional["OptionalObjectWithShapeAndNestedDescription"] = None,
155162
optionalAny: typing.Optional[typing.Any] = None,
@@ -168,3 +175,5 @@ def __init__(
168175
args = {k: _locals[k] for k in _explicit_args if k != 'children'}
169176

170177
super(Table, self).__init__(children=children, **args)
178+
179+
setattr(Table, "__init__", _explicitize_args(Table.__init__))

tests/unit/development/test_generate_class_file.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def expected_class_string():
3333

3434
@pytest.fixture
3535
def component_class_string(make_component_dir):
36-
return import_string + generate_class_string(
36+
return import_string.format(custom_imports="") + generate_class_string(
3737
typename="Table",
3838
props=make_component_dir["props"],
3939
description=make_component_dir["description"],

0 commit comments

Comments
 (0)