Skip to content

Commit 8ade05e

Browse files
authored
Merge pull request #3746 from odorikakeru/baker/typed-callback
Improved static typing in dash.callback
2 parents 2117781 + 9291c79 commit 8ade05e

7 files changed

Lines changed: 639 additions & 19 deletions

File tree

.github/workflows/testing.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,9 @@ jobs:
236236

237237
- name: Run typing tests
238238
run: |
239-
pytest tests/compliance/test_typing.py
239+
pytest \
240+
tests/compliance/test_typing.py \
241+
tests/compliance/test_callback_typing.py
240242
241243
background-callbacks:
242244
name: Run Background & Async Callback Tests (Python ${{ matrix.python-version }})

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ This project adheres to [Semantic Versioning](https://semver.org/).
3636
## Added
3737
- [#3742](https://github.com/plotly/dash/pull/3742) Add websocket callbacks to fastapi and quart backends.
3838

39+
## Changed
40+
- [#3691] Improve static typing for `dash.callback` by preserving wrapped callback signatures, and add callback typing coverage in compliance plus new callback decorator unit and integration tests.
41+
3942
## [4.1.0] - 2026-03-23
4043

4144
## Added

dash/_callback.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
from typing import Callable, Optional, Any, List, Tuple, Union, Dict
2-
from functools import wraps
31
import collections
42
import hashlib
53
import inspect
4+
from functools import wraps
5+
from typing import Callable, Optional, Any, List, Tuple, Union, Dict, TypeVar, cast
6+
7+
from typing_extensions import ParamSpec
68

79
from .dependencies import (
810
handle_callback_args,
@@ -61,6 +63,10 @@ def _invoke_callback(func, *args, **kwargs): # used to mark the frame for the d
6163
GLOBAL_API_PATHS: Dict[str, Any] = {}
6264

6365

66+
Params = ParamSpec("Params")
67+
ReturnVar = TypeVar("ReturnVar")
68+
69+
6470
# pylint: disable=too-many-locals,too-many-arguments
6571
def callback(
6672
*_args,
@@ -80,7 +86,7 @@ def callback(
8086
websocket: Optional[bool] = False,
8187
persistent: Optional[bool] = False,
8288
**_kwargs,
83-
) -> Callable[..., Any]:
89+
) -> Callable[[Callable[Params, ReturnVar]], Callable[Params, ReturnVar]]:
8490
"""
8591
Normally used as a decorator, `@dash.callback` provides a server-side
8692
callback relating the values of one or more `Output` items to one or
@@ -221,7 +227,7 @@ def callback(
221227

222228
background_spec["cache_ignore_triggered"] = cache_ignore_triggered
223229

224-
return register_callback(
230+
raw = register_callback(
225231
callback_list,
226232
callback_map,
227233
config_prevent_initial_callbacks,
@@ -238,6 +244,10 @@ def callback(
238244
persistent=persistent,
239245
)
240246

247+
return cast(
248+
Callable[[Callable[Params, ReturnVar]], Callable[Params, ReturnVar]], raw
249+
)
250+
241251

242252
def validate_background_inputs(deps):
243253
for dep in deps:
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
"""Type compliance tests for callback with strict mypy/pyright settings."""
2+
import os
3+
import sys
4+
5+
import pytest # type: ignore
6+
7+
from tests.compliance.test_typing import format_template_and_save, run_module
8+
9+
10+
callback_template = """
11+
from dash import Dash, html, dcc, callback, Input, Output, State
12+
13+
app = Dash()
14+
15+
app.layout = html.Div([
16+
dcc.Input(id='input1', value=''),
17+
dcc.Input(id='input2', value=''),
18+
html.Button('Click', id='btn'),
19+
html.Div(id='output1'),
20+
html.Div(id='output2'),
21+
])
22+
23+
{0}
24+
"""
25+
26+
strict_mypy_template = """# mypy: disallow-untyped-defs
27+
# mypy: disallow-untyped-calls
28+
# mypy: disallow-untyped-decorators
29+
from dash import Dash, html, dcc, callback, Input, Output, State
30+
31+
app = Dash(__name__)
32+
33+
app.layout = html.Div([
34+
dcc.Input(id='input', value=''),
35+
html.Div(id='output'),
36+
])
37+
38+
{0}
39+
"""
40+
41+
42+
valid_callback_single = """
43+
@callback(Output('output1', 'children'), Input('input1', 'value'))
44+
def update_output(value: str) -> str:
45+
return f"You typed: {value}"
46+
"""
47+
48+
valid_callback_multi_input = """
49+
@callback(
50+
Output('output1', 'children'),
51+
Input('input1', 'value'),
52+
Input('input2', 'value')
53+
)
54+
def update_output(val1: str, val2: str) -> str:
55+
return f"{val1} and {val2}"
56+
"""
57+
58+
valid_callback_with_state = """
59+
@callback(
60+
Output('output1', 'children'),
61+
Input('btn', 'n_clicks'),
62+
State('input1', 'value')
63+
)
64+
def update_output(n_clicks: int | None, state_value: str) -> str:
65+
if n_clicks is None:
66+
return "Not clicked"
67+
return f"Clicked {n_clicks} times with {state_value}"
68+
"""
69+
70+
valid_callback_multi_output = """
71+
@callback(
72+
Output('output1', 'children'),
73+
Output('output2', 'children'),
74+
Input('input1', 'value')
75+
)
76+
def update_outputs(value: str) -> tuple[str, str]:
77+
return f"First: {value}", f"Second: {value}"
78+
"""
79+
80+
strict_mode_callback = """
81+
@callback(Output('output', 'children'), Input('input', 'value'))
82+
def my_callback(value: str) -> str:
83+
'''Fully typed callback function.'''
84+
return f"Result: {value}"
85+
"""
86+
87+
complex_return_types = """
88+
from typing import Union
89+
90+
@callback(
91+
Output('output1', 'children'),
92+
Output('output2', 'children'),
93+
Input('input1', 'value')
94+
)
95+
def complex_callback(value: str) -> tuple[Union[str, int], list[str]]:
96+
return len(value), [value, value.upper()]
97+
"""
98+
99+
100+
typing_modules = ["pyright"]
101+
if sys.version_info.minor >= 10:
102+
typing_modules.append("mypy")
103+
104+
105+
@pytest.mark.parametrize("typing_module", typing_modules)
106+
@pytest.mark.parametrize(
107+
"callback_code, expected_status",
108+
[
109+
(valid_callback_single, 0),
110+
(valid_callback_multi_input, 0),
111+
(valid_callback_with_state, 0),
112+
(valid_callback_multi_output, 0),
113+
(complex_return_types, 0),
114+
],
115+
)
116+
def test_typi_callback_basic(typing_module, callback_code, expected_status, tmp_path):
117+
"""Test that callback passes type checking in normal mode."""
118+
codefile = os.path.join(tmp_path, "code.py")
119+
code = format_template_and_save(callback_template, codefile, callback_code)
120+
121+
output, error, status = run_module(codefile, typing_module)
122+
assert (
123+
status == expected_status
124+
), f"Status: {status}\nOutput: {output}\nError: {error}\nCode: {code}\nModule: {typing_module}"
125+
126+
127+
@pytest.mark.parametrize("typing_module", typing_modules)
128+
def test_typi_callback_strict_mode(typing_module, tmp_path):
129+
"""Test that callback works with strict mypy/pyright settings."""
130+
codefile = os.path.join(tmp_path, "code.py")
131+
code = format_template_and_save(
132+
strict_mypy_template, codefile, strict_mode_callback
133+
)
134+
135+
output, error, status = run_module(codefile, typing_module)
136+
assert status == 0, (
137+
f"callback should pass strict type checking.\n"
138+
f"Status: {status}\nOutput: {output}\nError: {error}\n"
139+
f"Code: {code}\nModule: {typing_module}"
140+
)
141+
142+
143+
@pytest.mark.parametrize("typing_module", typing_modules)
144+
def test_typi_callback_preserves_signature(typing_module, tmp_path):
145+
"""Test that callback preserves function signatures for type inference."""
146+
code = """
147+
from dash import callback, Input, Output, html, Dash
148+
149+
app = Dash(__name__)
150+
app.layout = html.Div([html.Div(id='in'), html.Div(id='out')])
151+
152+
@callback(Output('out', 'children'), Input('in', 'children'))
153+
def my_func(value: str) -> int:
154+
return len(value)
155+
156+
# The decorated function should still have its original signature
157+
result = my_func("test") # Should return int
158+
"""
159+
160+
codefile = os.path.join(tmp_path, "code.py")
161+
with open(codefile, "w") as f:
162+
f.write(code)
163+
164+
output, error, status = run_module(codefile, typing_module)
165+
166+
assert status == 0, (
167+
f"callback should preserve function signature.\n"
168+
f"Status: {status}\nOutput: {output}\nError: {error}\n"
169+
f"Module: {typing_module}"
170+
)
171+
172+
173+
@pytest.mark.parametrize("typing_module", typing_modules)
174+
def test_typi_callback_with_none_values(typing_module, tmp_path):
175+
"""Test callback with Optional types."""
176+
code = """
177+
from dash import Dash, html, dcc, callback, Input, Output
178+
179+
app = Dash(__name__)
180+
app.layout = html.Div([
181+
dcc.Input(id='input', value=''),
182+
html.Button('Click', id='btn'),
183+
html.Div(id='output'),
184+
])
185+
186+
@callback(
187+
Output('output', 'children'),
188+
Input('btn', 'n_clicks')
189+
)
190+
def handle_optional(n_clicks: int | None) -> str:
191+
if n_clicks is None:
192+
return "Not clicked yet"
193+
return f"Clicked {n_clicks} times"
194+
"""
195+
196+
codefile = os.path.join(tmp_path, "code.py")
197+
with open(codefile, "w") as f:
198+
f.write(code)
199+
200+
output, error, status = run_module(codefile, typing_module)
201+
assert status == 0, (
202+
f"callback should handle Optional types.\n"
203+
f"Status: {status}\nOutput: {output}\nError: {error}\n"
204+
f"Module: {typing_module}"
205+
)

tests/compliance/test_typing.py

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ def layout() -> html.Div:
5858
invalid_callback = "[]"
5959

6060

61+
def _has_built_dash_components(project_root: str) -> bool:
62+
return all(
63+
os.path.exists(os.path.join(project_root, "dash", package, "__init__.py"))
64+
for package in ("html", "dcc", "dash_table")
65+
)
66+
67+
6168
def run_module(codefile: str, module: str, extra: str = ""):
6269
config_file_to_cleanup = None
6370

@@ -76,19 +83,32 @@ def run_module(codefile: str, module: str, extra: str = ""):
7683
# Get the site-packages directory for standard packages
7784
site_packages = sysconfig.get_path("purelib")
7885

79-
# Check if dash is installed as editable or regular install
80-
# If editable, we need project root first; if regular, site-packages first
86+
# Include the directory containing the test file
87+
test_file_dir = os.path.dirname(codefile)
88+
89+
# Check if dash is installed as editable or regular install.
90+
# If the editable source tree is unbuilt, prefer the installed package.
8191
import dash
8292

8393
dash_file = dash.__file__
8494
is_editable = project_root in dash_file
95+
source_tree_is_built = _has_built_dash_components(project_root)
8596

86-
if is_editable:
97+
if is_editable and source_tree_is_built:
8798
# Editable install: prioritize project root
8899
extra_paths = [project_root, site_packages]
100+
execution_environments = [
101+
{"root": project_root, "extraPaths": extra_paths},
102+
{"root": test_file_dir, "extraPaths": extra_paths},
103+
]
89104
else:
90-
# Regular install (CI): prioritize site-packages
105+
# Regular installs and unbuilt editable checkouts should resolve the
106+
# installed package first so generated component modules are present.
91107
extra_paths = [site_packages, project_root]
108+
execution_environments = [
109+
{"root": site_packages, "extraPaths": extra_paths},
110+
{"root": test_file_dir, "extraPaths": extra_paths},
111+
]
92112

93113
# Add the test component source directories
94114
# They are in the @plotly subdirectory of the project root
@@ -100,17 +120,10 @@ def run_module(codefile: str, module: str, extra: str = ""):
100120
if os.path.isdir(component_path):
101121
extra_paths.append(component_path)
102122

103-
# For files in /tmp (component tests), we need a different approach
104-
# Include the directory containing the test file
105-
test_file_dir = os.path.dirname(codefile)
106-
107123
config = {
108124
"pythonVersion": f"{sys.version_info.major}.{sys.version_info.minor}",
109125
"pythonPlatform": sys.platform,
110-
"executionEnvironments": [
111-
{"root": project_root, "extraPaths": extra_paths},
112-
{"root": test_file_dir, "extraPaths": extra_paths},
113-
],
126+
"executionEnvironments": execution_environments,
114127
}
115128

116129
# Write config to project root instead of test directory
@@ -142,14 +155,19 @@ def run_module(codefile: str, module: str, extra: str = ""):
142155
)
143156
test_components_dir = os.path.join(project_root, "@plotly")
144157

145-
mypy_paths = [project_root]
158+
source_tree_is_built = _has_built_dash_components(project_root)
159+
160+
mypy_paths = [project_root] if source_tree_is_built else []
146161
if os.path.exists(test_components_dir):
147162
for component in os.listdir(test_components_dir):
148163
component_path = os.path.join(test_components_dir, component)
149164
if os.path.isdir(component_path):
150165
mypy_paths.append(component_path)
151166

152-
env["MYPYPATH"] = os.pathsep.join(mypy_paths)
167+
if mypy_paths:
168+
env["MYPYPATH"] = os.pathsep.join(mypy_paths)
169+
else:
170+
env.pop("MYPYPATH", None)
153171

154172
proc = subprocess.Popen(
155173
cmd,

0 commit comments

Comments
 (0)