diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index ac31ffa6dc..be106a8151 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -136,7 +136,9 @@ jobs: - name: Run typing tests run: | - pytest tests/compliance/test_typing.py + pytest \ + tests/compliance/test_typing.py \ + tests/compliance/test_callback_typing.py background-callbacks: name: Run Background & Async Callback Tests (Python ${{ matrix.python-version }}) diff --git a/CHANGELOG.md b/CHANGELOG.md index a78eabf786..b071f3ebed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#3690](https://github.com/plotly/dash/pull/3690) Fixes Input when min or max is set to None - [#3723](https://github.com/plotly/dash/pull/3723) Fix misaligned `dcc.Slider` marks when some labels are empty strings +## Changed +- [#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. + ## [4.1.0] - 2026-03-23 ## Added diff --git a/dash/_callback.py b/dash/_callback.py index 3785df7166..3475ad517c 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -2,8 +2,12 @@ import hashlib import inspect from functools import wraps +from typing import Callable, Optional, Any, List, Tuple, Union, Dict, TypeVar, cast -from typing import Callable, Optional, Any, List, Tuple, Union, Dict +try: + from typing import ParamSpec +except ImportError: # Assume Python < 3.10 + from typing_extensions import ParamSpec # type: ignore import flask @@ -64,6 +68,10 @@ def _invoke_callback(func, *args, **kwargs): # used to mark the frame for the d GLOBAL_API_PATHS: Dict[str, Any] = {} +Params = ParamSpec("Params") +ReturnVar = TypeVar("ReturnVar") + + # pylint: disable=too-many-locals,too-many-arguments def callback( *_args, @@ -81,7 +89,7 @@ def callback( optional: Optional[bool] = False, hidden: Optional[bool] = None, **_kwargs, -) -> Callable[..., Any]: +) -> Callable[[Callable[Params, ReturnVar]], Callable[Params, ReturnVar]]: """ Normally used as a decorator, `@dash.callback` provides a server-side callback relating the values of one or more `Output` items to one or @@ -218,7 +226,7 @@ def callback( background_spec["cache_ignore_triggered"] = cache_ignore_triggered - return register_callback( + raw = register_callback( callback_list, callback_map, config_prevent_initial_callbacks, @@ -233,6 +241,10 @@ def callback( hidden=hidden, ) + return cast( + Callable[[Callable[Params, ReturnVar]], Callable[Params, ReturnVar]], raw + ) + def validate_background_inputs(deps): for dep in deps: diff --git a/tests/compliance/test_callback_typing.py b/tests/compliance/test_callback_typing.py new file mode 100644 index 0000000000..aae5b77d86 --- /dev/null +++ b/tests/compliance/test_callback_typing.py @@ -0,0 +1,203 @@ +"""Type compliance tests for callback with strict mypy/pyright settings.""" +import os +import sys + +import pytest # type: ignore + +from tests.compliance.test_typing import format_template_and_save, run_module + + +callback_template = """ +from dash import Dash, html, dcc, callback, Input, Output, State + +app = Dash() + +app.layout = html.Div([ + dcc.Input(id='input1', value=''), + dcc.Input(id='input2', value=''), + html.Button('Click', id='btn'), + html.Div(id='output1'), + html.Div(id='output2'), +]) + +{0} +""" + +strict_mypy_template = """# mypy: disallow-untyped-defs +# mypy: disallow-untyped-calls +# mypy: disallow-untyped-decorators +from dash import Dash, html, dcc, callback, Input, Output, State + +app = Dash(__name__) + +app.layout = html.Div([ + dcc.Input(id='input', value=''), + html.Div(id='output'), +]) + +{0} +""" + + +valid_callback_single = """ +@callback(Output('output1', 'children'), Input('input1', 'value')) +def update_output(value: str) -> str: + return f"You typed: {value}" +""" + +valid_callback_multi_input = """ +@callback( + Output('output1', 'children'), + Input('input1', 'value'), + Input('input2', 'value') +) +def update_output(val1: str, val2: str) -> str: + return f"{val1} and {val2}" +""" + +valid_callback_with_state = """ +@callback( + Output('output1', 'children'), + Input('btn', 'n_clicks'), + State('input1', 'value') +) +def update_output(n_clicks: int | None, state_value: str) -> str: + if n_clicks is None: + return "Not clicked" + return f"Clicked {n_clicks} times with {state_value}" +""" + +valid_callback_multi_output = """ +@callback( + Output('output1', 'children'), + Output('output2', 'children'), + Input('input1', 'value') +) +def update_outputs(value: str) -> tuple[str, str]: + return f"First: {value}", f"Second: {value}" +""" + +strict_mode_callback = """ +@callback(Output('output', 'children'), Input('input', 'value')) +def my_callback(value: str) -> str: + '''Fully typed callback function.''' + return f"Result: {value}" +""" + +complex_return_types = """ +from typing import Union + +@callback( + Output('output1', 'children'), + Output('output2', 'children'), + Input('input1', 'value') +) +def complex_callback(value: str) -> tuple[Union[str, int], list[str]]: + return len(value), [value, value.upper()] +""" + + +typing_modules = ["pyright"] +if sys.version_info.minor >= 10: + typing_modules.append("mypy") + + +@pytest.mark.parametrize("typing_module", typing_modules) +@pytest.mark.parametrize( + "callback_code, expected_status", + [ + (valid_callback_single, 0), + (valid_callback_multi_input, 0), + (valid_callback_with_state, 0), + (valid_callback_multi_output, 0), + (complex_return_types, 0), + ], +) +def test_typi_callback_basic(typing_module, callback_code, expected_status, tmp_path): + """Test that callback passes type checking in normal mode.""" + codefile = os.path.join(tmp_path, "code.py") + code = format_template_and_save(callback_template, codefile, callback_code) + + output, error, status = run_module(codefile, typing_module) + assert ( + status == expected_status + ), f"Status: {status}\nOutput: {output}\nError: {error}\nCode: {code}\nModule: {typing_module}" + + +@pytest.mark.parametrize("typing_module", typing_modules) +def test_typi_callback_strict_mode(typing_module, tmp_path): + """Test that callback works with strict mypy/pyright settings.""" + codefile = os.path.join(tmp_path, "code.py") + code = format_template_and_save(strict_mypy_template, codefile, strict_mode_callback) + + output, error, status = run_module(codefile, typing_module) + assert status == 0, ( + f"callback should pass strict type checking.\n" + f"Status: {status}\nOutput: {output}\nError: {error}\n" + f"Code: {code}\nModule: {typing_module}" + ) + + +@pytest.mark.parametrize("typing_module", typing_modules) +def test_typi_callback_preserves_signature(typing_module, tmp_path): + """Test that callback preserves function signatures for type inference.""" + code = """ +from dash import callback, Input, Output, html, Dash + +app = Dash(__name__) +app.layout = html.Div([html.Div(id='in'), html.Div(id='out')]) + +@callback(Output('out', 'children'), Input('in', 'children')) +def my_func(value: str) -> int: + return len(value) + +# The decorated function should still have its original signature +result = my_func("test") # Should return int +""" + + codefile = os.path.join(tmp_path, "code.py") + with open(codefile, "w") as f: + f.write(code) + + output, error, status = run_module(codefile, typing_module) + + assert status == 0, ( + f"callback should preserve function signature.\n" + f"Status: {status}\nOutput: {output}\nError: {error}\n" + f"Module: {typing_module}" + ) + + +@pytest.mark.parametrize("typing_module", typing_modules) +def test_typi_callback_with_none_values(typing_module, tmp_path): + """Test callback with Optional types.""" + code = """ +from dash import Dash, html, dcc, callback, Input, Output + +app = Dash(__name__) +app.layout = html.Div([ + dcc.Input(id='input', value=''), + html.Button('Click', id='btn'), + html.Div(id='output'), +]) + +@callback( + Output('output', 'children'), + Input('btn', 'n_clicks') +) +def handle_optional(n_clicks: int | None) -> str: + if n_clicks is None: + return "Not clicked yet" + return f"Clicked {n_clicks} times" +""" + + codefile = os.path.join(tmp_path, "code.py") + with open(codefile, "w") as f: + f.write(code) + + output, error, status = run_module(codefile, typing_module) + assert status == 0, ( + f"callback should handle Optional types.\n" + f"Status: {status}\nOutput: {output}\nError: {error}\n" + f"Module: {typing_module}" + ) diff --git a/tests/compliance/test_typing.py b/tests/compliance/test_typing.py index 896bd638c3..a1d66a781d 100644 --- a/tests/compliance/test_typing.py +++ b/tests/compliance/test_typing.py @@ -58,6 +58,13 @@ def layout() -> html.Div: invalid_callback = "[]" +def _has_built_dash_components(project_root: str) -> bool: + return all( + os.path.exists(os.path.join(project_root, "dash", package, "__init__.py")) + for package in ("html", "dcc", "dash_table") + ) + + def run_module(codefile: str, module: str, extra: str = ""): config_file_to_cleanup = None @@ -76,19 +83,32 @@ def run_module(codefile: str, module: str, extra: str = ""): # Get the site-packages directory for standard packages site_packages = sysconfig.get_path("purelib") - # Check if dash is installed as editable or regular install - # If editable, we need project root first; if regular, site-packages first + # Include the directory containing the test file + test_file_dir = os.path.dirname(codefile) + + # Check if dash is installed as editable or regular install. + # If the editable source tree is unbuilt, prefer the installed package. import dash dash_file = dash.__file__ is_editable = project_root in dash_file + source_tree_is_built = _has_built_dash_components(project_root) - if is_editable: + if is_editable and source_tree_is_built: # Editable install: prioritize project root extra_paths = [project_root, site_packages] + execution_environments = [ + {"root": project_root, "extraPaths": extra_paths}, + {"root": test_file_dir, "extraPaths": extra_paths}, + ] else: - # Regular install (CI): prioritize site-packages + # Regular installs and unbuilt editable checkouts should resolve the + # installed package first so generated component modules are present. extra_paths = [site_packages, project_root] + execution_environments = [ + {"root": site_packages, "extraPaths": extra_paths}, + {"root": test_file_dir, "extraPaths": extra_paths}, + ] # Add the test component source directories # They are in the @plotly subdirectory of the project root @@ -100,17 +120,10 @@ def run_module(codefile: str, module: str, extra: str = ""): if os.path.isdir(component_path): extra_paths.append(component_path) - # For files in /tmp (component tests), we need a different approach - # Include the directory containing the test file - test_file_dir = os.path.dirname(codefile) - config = { "pythonVersion": f"{sys.version_info.major}.{sys.version_info.minor}", "pythonPlatform": sys.platform, - "executionEnvironments": [ - {"root": project_root, "extraPaths": extra_paths}, - {"root": test_file_dir, "extraPaths": extra_paths}, - ], + "executionEnvironments": execution_environments, } # Write config to project root instead of test directory @@ -142,14 +155,19 @@ def run_module(codefile: str, module: str, extra: str = ""): ) test_components_dir = os.path.join(project_root, "@plotly") - mypy_paths = [project_root] + source_tree_is_built = _has_built_dash_components(project_root) + + mypy_paths = [project_root] if source_tree_is_built else [] if os.path.exists(test_components_dir): for component in os.listdir(test_components_dir): component_path = os.path.join(test_components_dir, component) if os.path.isdir(component_path): mypy_paths.append(component_path) - env["MYPYPATH"] = os.pathsep.join(mypy_paths) + if mypy_paths: + env["MYPYPATH"] = os.pathsep.join(mypy_paths) + else: + env.pop("MYPYPATH", None) proc = subprocess.Popen( cmd, diff --git a/tests/integration/callbacks/test_callback_decorator.py b/tests/integration/callbacks/test_callback_decorator.py new file mode 100644 index 0000000000..fd81eafe1e --- /dev/null +++ b/tests/integration/callbacks/test_callback_decorator.py @@ -0,0 +1,254 @@ +"""Integration tests for callback decorator behavior.""" +import dash +from dash import Input, Output, State, callback, dcc, html + + +def test_cb001_callback_and_dash_callback_equivalence(dash_duo): + """Test that callback and dash.callback behave equivalently for basic usage.""" + app = dash.Dash(__name__) + + app.layout = html.Div( + [ + dcc.Input(id="input", value="initial"), + html.Div(id="output-callback"), + html.Div(id="output-dash-callback"), + ] + ) + + @callback(Output("output-callback", "children"), Input("input", "value")) + def update_callback(value): + return f"Callback: {value}" + + @dash.callback(Output("output-dash-callback", "children"), Input("input", "value")) + def update_dash_callback(value): + return f"Dash callback: {value}" + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#output-callback", "Callback: initial") + dash_duo.wait_for_text_to_equal("#output-dash-callback", "Dash callback: initial") + + input_element = dash_duo.find_element("#input") + dash_duo.clear_input(input_element) + input_element.send_keys("test") + + dash_duo.wait_for_text_to_equal("#output-callback", "Callback: test") + dash_duo.wait_for_text_to_equal("#output-dash-callback", "Dash callback: test") + + assert not dash_duo.get_logs() + + +def test_cb002_callback_multi_input(dash_duo): + """Test callback with multiple inputs.""" + app = dash.Dash(__name__) + + app.layout = html.Div( + [ + dcc.Input(id="input1", value="Hello"), + dcc.Input(id="input2", value="World"), + html.Div(id="output"), + ] + ) + + @callback( + Output("output", "children"), + Input("input1", "value"), + Input("input2", "value"), + ) + def combine_inputs(val1, val2): + return f"{val1} {val2}" + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#output", "Hello World") + + input1 = dash_duo.find_element("#input1") + dash_duo.clear_input(input1) + input1.send_keys("Goodbye") + + dash_duo.wait_for_text_to_equal("#output", "Goodbye World") + + assert not dash_duo.get_logs() + + +def test_cb003_callback_with_state(dash_duo): + """Test callback with State dependencies.""" + app = dash.Dash(__name__) + + app.layout = html.Div( + [ + dcc.Input(id="input1", value="stored"), + dcc.Input(id="input2", value="trigger"), + html.Button("Submit", id="button"), + html.Div(id="output"), + ] + ) + + @callback( + Output("output", "children"), + Input("button", "n_clicks"), + State("input1", "value"), + State("input2", "value"), + ) + def update_with_state(n_clicks, state1, state2): + if n_clicks is None: + return "Not clicked" + return f"Click {n_clicks}: {state1} + {state2}" + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#output", "Not clicked") + + dash_duo.find_element("#button").click() + dash_duo.wait_for_text_to_equal("#output", "Click 1: stored + trigger") + + dash_duo.find_element("#button").click() + dash_duo.wait_for_text_to_equal("#output", "Click 2: stored + trigger") + + assert not dash_duo.get_logs() + + +def test_cb004_callback_multi_output(dash_duo): + """Test callback with multiple outputs.""" + app = dash.Dash(__name__) + + app.layout = html.Div( + [ + dcc.Input(id="input", value="test"), + html.Div(id="output1"), + html.Div(id="output2"), + html.Div(id="output3"), + ] + ) + + @callback( + Output("output1", "children"), + Output("output2", "children"), + Output("output3", "children"), + Input("input", "value"), + ) + def update_multiple(value): + return f"First: {value}", f"Second: {value}", f"Third: {value}" + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#output1", "First: test") + dash_duo.wait_for_text_to_equal("#output2", "Second: test") + dash_duo.wait_for_text_to_equal("#output3", "Third: test") + + input_element = dash_duo.find_element("#input") + dash_duo.clear_input(input_element) + input_element.send_keys("changed") + + dash_duo.wait_for_text_to_equal("#output1", "First: changed") + dash_duo.wait_for_text_to_equal("#output2", "Second: changed") + dash_duo.wait_for_text_to_equal("#output3", "Third: changed") + + assert not dash_duo.get_logs() + + +def test_cb005_callback_prevent_initial_call(dash_duo): + """Test callback with prevent_initial_call parameter.""" + app = dash.Dash(__name__) + + app.layout = html.Div( + [ + dcc.Input(id="input", value="initial"), + html.Div(id="output", children="default"), + ] + ) + + @callback( + Output("output", "children"), + Input("input", "value"), + prevent_initial_call=True, + ) + def update_no_initial(value): + return f"Updated: {value}" + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#output", "default") + + input_element = dash_duo.find_element("#input") + input_element.send_keys("x") + + dash_duo.wait_for_text_to_equal("#output", "Updated: initialx") + + assert not dash_duo.get_logs() + + +def test_cb006_callback_global_and_app_callback_mix(dash_duo): + """Test that callback, dash.callback, and app.callback can coexist.""" + app = dash.Dash(__name__) + + app.layout = html.Div( + [ + dcc.Input(id="input"), + html.Div(id="output1"), + html.Div(id="output2"), + html.Div(id="output3"), + html.Div(id="output4"), + ] + ) + + @callback(Output("output1", "children"), Input("input", "value")) + def update_1(value): + return f"Callback global: {value}" + + @dash.callback(Output("output2", "children"), Input("input", "value")) + def update_2(value): + return f"Dash callback global: {value}" + + @app.callback(Output("output3", "children"), Input("input", "value")) + def update_3(value): + return f"App callback: {value}" + + @callback(Output("output4", "children"), Input("input", "value")) + def update_4(value): + return f"Callback global 2: {value}" + + dash_duo.start_server(app) + + input_element = dash_duo.find_element("#input") + input_element.send_keys("test") + + dash_duo.wait_for_text_to_equal("#output1", "Callback global: test") + dash_duo.wait_for_text_to_equal("#output2", "Dash callback global: test") + dash_duo.wait_for_text_to_equal("#output3", "App callback: test") + dash_duo.wait_for_text_to_equal("#output4", "Callback global 2: test") + + assert not dash_duo.get_logs() + + +def test_cb007_callback_with_no_update(dash_duo): + """Test callback with no_update.""" + app = dash.Dash(__name__) + + app.layout = html.Div( + [ + dcc.Input(id="input", value="0"), + html.Div(id="output1"), + html.Div(id="output2"), + ] + ) + + @callback( + Output("output1", "children"), + Output("output2", "children"), + Input("input", "value"), + ) + def selective_update(value): + num = int(value) if value and value.isdigit() else 0 + if num % 2 == 0: + return f"Even: {num}", dash.no_update + return dash.no_update, f"Odd: {num}" + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#output1", "Even: 0") + + input_element = dash_duo.find_element("#input") + dash_duo.clear_input(input_element) + input_element.send_keys("1") + dash_duo.wait_for_text_to_equal("#output2", "Odd: 1") + + dash_duo.clear_input(input_element) + input_element.send_keys("2") + dash_duo.wait_for_text_to_equal("#output1", "Even: 2") + + assert not dash_duo.get_logs() diff --git a/tests/unit/test_callback_unit.py b/tests/unit/test_callback_unit.py new file mode 100644 index 0000000000..a44336cbe5 --- /dev/null +++ b/tests/unit/test_callback_unit.py @@ -0,0 +1,128 @@ +"""Unit tests for callback decorator behavior - no browser required.""" +import inspect + +import dash +from dash import Input, Output, State, callback + + +def test_callback_returns_callable(): + """Test that callback returns a callable decorator.""" + decorator = callback(Output("output", "children"), Input("input", "value")) + assert callable(decorator) + + +def test_callback_decorates_function(): + """Test that callback can decorate a function.""" + + @callback(Output("output", "children"), Input("input", "value")) + def my_callback(value): + return f"Value: {value}" + + assert callable(my_callback) + assert my_callback.__name__ == "my_callback" + + +def test_callback_signature_includes_typed_options(): + """Test that callback exposes the expected decorator keyword arguments.""" + sig = inspect.signature(callback) + + expected = { + "background", + "interval", + "progress", + "progress_default", + "running", + "cancel", + "manager", + "cache_args_to_ignore", + "cache_ignore_triggered", + "on_error", + "api_endpoint", + "optional", + "hidden", + } + assert expected.issubset(set(sig.parameters)) + + +def test_callback_with_multiple_inputs(): + """Test callback with multiple inputs.""" + + @callback( + Output("output", "children"), + Input("input1", "value"), + Input("input2", "value"), + ) + def multi_input_callback(val1, val2): + return f"{val1} + {val2}" + + assert callable(multi_input_callback) + + +def test_callback_with_state(): + """Test callback with State.""" + + @callback( + Output("output", "children"), + Input("input", "value"), + State("state", "value"), + ) + def callback_with_state(input_val, state_val): + return f"{input_val} - {state_val}" + + assert callable(callback_with_state) + + +def test_callback_with_multiple_outputs(): + """Test callback with multiple outputs.""" + + @callback( + Output("output1", "children"), + Output("output2", "children"), + Input("input", "value"), + ) + def multi_output_callback(value): + return value, f"Copy: {value}" + + assert callable(multi_output_callback) + + +def test_callback_preserves_docstring(): + """Test that callback preserves the wrapped function's docstring.""" + + @callback(Output("output", "children"), Input("input", "value")) + def documented_callback(value): + """This is a documented callback.""" + return value + + assert documented_callback.__doc__ == "This is a documented callback." + + +def test_callback_with_prevent_initial_call(): + """Test callback with prevent_initial_call parameter.""" + + @callback( + Output("output", "children"), + Input("input", "value"), + prevent_initial_call=True, + ) + def callback_no_initial(value): + return value + + assert callable(callback_no_initial) + + +def test_callback_with_background_params(): + """Test that callback accepts background callback parameters.""" + decorator = callback( + Output("output", "children"), + Input("input", "value"), + background=False, + interval=1000, + ) + assert callable(decorator) + + +def test_callback_module_export(): + """Test that callback is properly exported from dash module.""" + assert hasattr(dash, "callback") + assert dash.callback is callback