From 3c3a3becad2633571f48a7e5348da6420cb32fae Mon Sep 17 00:00:00 2001 From: Mike Baker Date: Wed, 25 Mar 2026 11:18:18 +0900 Subject: [PATCH 1/6] added `typed_callback` --- dash/__init__.py | 3 +- dash/_callback.py | 153 ++++++++++- .../compliance/test_typed_callback_typing.py | 255 +++++++++++++++++ .../callbacks/test_typed_callback.py | 260 ++++++++++++++++++ tests/unit/test_typed_callback_unit.py | 111 ++++++++ 5 files changed, 776 insertions(+), 6 deletions(-) create mode 100644 tests/compliance/test_typed_callback_typing.py create mode 100644 tests/integration/callbacks/test_typed_callback.py create mode 100644 tests/unit/test_typed_callback_unit.py diff --git a/dash/__init__.py b/dash/__init__.py index 6f16a068aa..e67fe4671d 100644 --- a/dash/__init__.py +++ b/dash/__init__.py @@ -19,7 +19,7 @@ from . import dash_table # noqa: F401,E402 from .version import __version__ # noqa: F401,E402 from ._callback_context import callback_context, set_props # noqa: F401,E402 -from ._callback import callback, clientside_callback # noqa: F401,E402 +from ._callback import callback, clientside_callback, typed_callback # noqa: F401,E402 from ._get_app import get_app # noqa: F401,E402 from ._get_paths import ( # noqa: F401,E402 get_asset_url, @@ -77,6 +77,7 @@ def _jupyter_nbextension_paths(): "callback_context", "set_props", "callback", + "typed_callback", "get_app", "get_asset_url", "get_relative_path", diff --git a/dash/_callback.py b/dash/_callback.py index 0b63f17740..68ced8fe21 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -3,7 +3,13 @@ import inspect from functools import wraps -from typing import Callable, Optional, Any, List, Tuple, Union, Dict +import sys +from typing import Callable, Optional, Any, List, Tuple, Union, Dict, TypeVar, cast + +if sys.version_info >= (3, 10): + from typing import ParamSpec +else: + from typing_extensions import ParamSpec import flask @@ -234,15 +240,152 @@ def callback( ) +Params = ParamSpec("Params") +ReturnVar = TypeVar("ReturnVar") + + +# pylint: disable=too-many-arguments +def typed_callback( + *_args, + background: bool = False, + interval: int = 1000, + progress: Optional[Union[List[Output], Output]] = None, + progress_default: Any = None, + running: Optional[List[Tuple[Output, Any, Any]]] = None, + cancel: Optional[Union[List[Input], Input]] = None, + manager: Optional[BaseBackgroundCallbackManager] = None, + cache_args_to_ignore: Optional[list] = None, + cache_ignore_triggered=True, + on_error: Optional[Callable[[Exception], Any]] = None, + api_endpoint: Optional[str] = None, + optional: Optional[bool] = False, + hidden: Optional[bool] = None, + **_kwargs, +) -> Callable[[Callable[Params, ReturnVar]], Callable[Params, ReturnVar]]: + """Decorator factory for Dash callbacks with full type preservation. + + Centralizes the typing gap in Dash's untyped decorator, satisfying + disallow_untyped_decorators while preserving callback function signatures. + + This is a type-safe wrapper around `dash.callback` that preserves the + exact signature of the decorated function. Use this when working with + strict Mypy settings like `disallow_untyped_decorators`. + + :Usage: + ```python + @dash.typed_callback( + Output('output-id', 'children'), + Input('input-id', 'value') + ) + def my_callback(value: str) -> str: + return f"You entered: {value}" + ``` + + :Keyword Arguments: + :param background: + Mark the callback as a background callback to execute in a manager for + callbacks that take a long time without locking up the Dash app + or timing out. + :param manager: + A background callback manager instance. Currently, an instance of one of + `DiskcacheManager` or `CeleryManager`. + Defaults to the `background_callback_manager` instance provided to the + `dash.Dash` constructor. + - A diskcache manager (`DiskcacheManager`) that runs callback + logic in a separate process and stores the results to disk using the + diskcache library. This is the easiest backend to use for local + development. + - A Celery manager (`CeleryManager`) that runs callback logic + in a celery worker and returns results to the Dash app through a Celery + broker like RabbitMQ or Redis. + :param running: + A list of 3-element tuples. The first element of each tuple should be + an `Output` dependency object referencing a property of a component in + the app layout. The second element is the value that the property + should be set to while the callback is running, and the third element + is the value the property should be set to when the callback completes. + :param cancel: + A list of `Input` dependency objects that reference a property of a + component in the app's layout. When the value of this property changes + while a callback is running, the callback is canceled. + Note that the value of the property is not significant, any change in + value will result in the cancellation of the running job (if any). + This parameter only applies to background callbacks (`background=True`). + :param progress: + An `Output` dependency grouping that references properties of + components in the app's layout. When provided, the decorated function + will be called with an extra argument as the first argument to the + function. This argument, is a function handle that the decorated + function should call in order to provide updates to the app on its + current progress. This function accepts a single argument, which + correspond to the grouping of properties specified in the provided + `Output` dependency grouping. This parameter only applies to background + callbacks (`background=True`). + :param progress_default: + A grouping of values that should be assigned to the components + specified by the `progress` argument when the callback is not in + progress. If `progress_default` is not provided, all the dependency + properties specified in `progress` will be set to `None` when the + callback is not running. This parameter only applies to background + callbacks (`background=True`). + :param cache_args_to_ignore: + Arguments to ignore when caching is enabled. If callback is configured + with keyword arguments (Input/State provided in a dict), + this should be a list of argument names as strings. Otherwise, + this should be a list of argument indices as integers. + This parameter only applies to background callbacks (`background=True`). + :param cache_ignore_triggered: + Whether to ignore which inputs triggered the callback when creating + the cache. This parameter only applies to background callbacks + (`background=True`). + :param interval: + Time to wait between the background callback update requests. + :param on_error: + Function to call when the callback raises an exception. Receives the + exception object as first argument. The callback_context can be used + to access the original callback inputs, states and output. + :param optional: + Mark all dependencies as not required on the initial layout checks. + :param hidden: + Hide the callback from the devtools callbacks tab. + :param api_endpoint: + If provided, the callback will be available at the given API endpoint. + This allows you to call the callback directly through HTTP requests + instead of through the Dash front-end. The endpoint should be a string + that starts with a forward slash (e.g. `/my_callback`). + The endpoint is relative to the Dash app's base URL. + Note that the endpoint will not appear in the list of registered + callbacks in the Dash devtools. + """ + raw = callback( + *_args, + background=background, + interval=interval, + progress=progress, + progress_default=progress_default, + running=running, + cancel=cancel, + manager=manager, + cache_args_to_ignore=cache_args_to_ignore, + cache_ignore_triggered=cache_ignore_triggered, + on_error=on_error, + api_endpoint=api_endpoint, + optional=optional, + hidden=hidden, + **_kwargs, + ) # type: ignore[no-untyped-call] + return cast( + Callable[[Callable[Params, ReturnVar]], Callable[Params, ReturnVar]], raw + ) + + def validate_background_inputs(deps): for dep in deps: if dep.has_wildcard(): - raise WildcardInLongCallback( - f""" + raise WildcardInLongCallback(f""" background callbacks does not support dependencies with pattern-matching ids - Received: {repr(dep)}\n""" - ) + Received: {repr(dep)}\n""") ClientsideFuncType = Union[str, ClientsideFunction] diff --git a/tests/compliance/test_typed_callback_typing.py b/tests/compliance/test_typed_callback_typing.py new file mode 100644 index 0000000000..b5a85dc461 --- /dev/null +++ b/tests/compliance/test_typed_callback_typing.py @@ -0,0 +1,255 @@ +"""Type compliance tests for typed_callback with strict mypy/pyright settings.""" +import os +import sys +import pytest + +# Import the testing utilities from the main test_typing.py +from .test_typing import run_module, format_template_and_save + + +# Template for testing typed_callback with strict type checking +typed_callback_template = """ +from dash import Dash, html, dcc, typed_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} +""" + +# Template for testing with strict mypy settings +strict_mypy_template = """# mypy: disallow-untyped-defs +# mypy: disallow-untyped-calls +# mypy: disallow-untyped-decorators +from dash import Dash, html, dcc, typed_callback, Input, Output, State + +app = Dash(__name__) + +app.layout = html.Div([ + dcc.Input(id='input', value=''), + html.Div(id='output'), +]) + +{0} +""" + + +valid_typed_callback_single = """ +@typed_callback(Output('output1', 'children'), Input('input1', 'value')) +def update_output(value: str) -> str: + return f"You typed: {value}" +""" + +valid_typed_callback_multi_input = """ +@typed_callback( + Output('output1', 'children'), + Input('input1', 'value'), + Input('input2', 'value') +) +def update_output(val1: str, val2: str) -> str: + return f"{val1} and {val2}" +""" + +valid_typed_callback_with_state = """ +@typed_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_typed_callback_multi_output = """ +@typed_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}" +""" + +# This should pass with typed_callback but fail with regular callback in strict mode +strict_mode_typed_callback = """ +@typed_callback(Output('output', 'children'), Input('input', 'value')) +def my_callback(value: str) -> str: + '''Fully typed callback function.''' + return f"Result: {value}" +""" + +# Regular callback would fail in strict mode (for comparison) +strict_mode_regular_callback = """ +@app.callback(Output('output', 'children'), Input('input', 'value')) +def my_callback(value: str) -> str: + '''This should fail with disallow-untyped-decorators.''' + return f"Result: {value}" +""" + +# Test with complex return types +complex_return_types = """ +from typing import Union + +@typed_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_typed_callback_single, 0), + (valid_typed_callback_multi_input, 0), + (valid_typed_callback_with_state, 0), + (valid_typed_callback_multi_output, 0), + (complex_return_types, 0), + ], +) +def test_typi_typed_callback_basic( + typing_module, callback_code, expected_status, tmp_path +): + """Test that typed_callback passes type checking in normal mode.""" + codefile = os.path.join(tmp_path, "code.py") + code = format_template_and_save( + typed_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_typed_callback_strict_mode(typing_module, tmp_path): + """Test that typed_callback works with strict mypy/pyright settings. + + This is the main purpose of typed_callback - to satisfy + disallow_untyped_decorators and similar strict settings. + """ + codefile = os.path.join(tmp_path, "code.py") + code = format_template_and_save( + strict_mypy_template, codefile, strict_mode_typed_callback + ) + + output, error, status = run_module(codefile, typing_module) + assert status == 0, ( + f"typed_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_regular_callback_strict_mode_fails(typing_module, tmp_path): + """Verify that regular callback fails in strict mode (comparison test). + + This demonstrates why typed_callback is needed. + """ + codefile = os.path.join(tmp_path, "code.py") + code = format_template_and_save( + strict_mypy_template, codefile, strict_mode_regular_callback + ) + + output, error, status = run_module(codefile, typing_module) + + # Regular callback should fail in strict mode + # (This test validates that our strict mode setup is actually strict) + if typing_module == "mypy": + # Mypy should report untyped decorator error + assert status != 0 or "Untyped decorator" in output or "untyped" in error.lower(), ( + f"Regular callback should fail with disallow-untyped-decorators.\n" + f"Status: {status}\nOutput: {output}\nError: {error}\n" + f"Module: {typing_module}" + ) + elif typing_module == "pyright": + # Pyright might or might not catch this depending on strict settings + # The important thing is that typed_callback passes when regular might not + pass + + +@pytest.mark.parametrize("typing_module", typing_modules) +def test_typi_typed_callback_preserves_signature(typing_module, tmp_path): + """Test that typed_callback preserves function signatures for type inference.""" + code = """ +from typing import reveal_type # type: ignore +from dash import typed_callback, Input, Output, html, Dash + +app = Dash(__name__) +app.layout = html.Div([html.Div(id='in'), html.Div(id='out')]) + +@typed_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) + + # Should pass type checking - the function signature is preserved + assert status == 0, ( + f"typed_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_typed_callback_with_none_values(typing_module, tmp_path): + """Test typed_callback with Optional types.""" + code = """ +from dash import Dash, html, dcc, typed_callback, Input, Output + +app = Dash(__name__) +app.layout = html.Div([ + dcc.Input(id='input', value=''), + html.Button('Click', id='btn'), + html.Div(id='output'), +]) + +@typed_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"typed_callback should handle Optional types.\n" + f"Status: {status}\nOutput: {output}\nError: {error}\n" + f"Module: {typing_module}" + ) diff --git a/tests/integration/callbacks/test_typed_callback.py b/tests/integration/callbacks/test_typed_callback.py new file mode 100644 index 0000000000..e3f096f99d --- /dev/null +++ b/tests/integration/callbacks/test_typed_callback.py @@ -0,0 +1,260 @@ +"""Integration tests for typed_callback functionality.""" +import dash +from dash import Input, Output, State, dcc, html, typed_callback + + +def test_tcb001_basic_typed_callback(dash_duo): + """Test that typed_callback works identically to callback for basic usage.""" + app = dash.Dash(__name__) + + app.layout = html.Div( + [ + dcc.Input(id="input", value="initial"), + html.Div(id="output-typed"), + html.Div(id="output-regular"), + ] + ) + + @typed_callback(Output("output-typed", "children"), Input("input", "value")) + def update_typed(value): + return f"Typed: {value}" + + @dash.callback(Output("output-regular", "children"), Input("input", "value")) + def update_regular(value): + return f"Regular: {value}" + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#output-typed", "Typed: initial") + dash_duo.wait_for_text_to_equal("#output-regular", "Regular: 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-typed", "Typed: test") + dash_duo.wait_for_text_to_equal("#output-regular", "Regular: test") + + assert not dash_duo.get_logs() + + +def test_tcb002_typed_callback_multi_input(dash_duo): + """Test typed_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"), + ] + ) + + @typed_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_tcb003_typed_callback_with_state(dash_duo): + """Test typed_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"), + ] + ) + + @typed_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_tcb004_typed_callback_multi_output(dash_duo): + """Test typed_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"), + ] + ) + + @typed_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_tcb005_typed_callback_prevent_initial_call(dash_duo): + """Test typed_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"), + ] + ) + + @typed_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) + # Should remain "default" because prevent_initial_call=True + 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_tcb006_typed_callback_mixed_with_regular(dash_duo): + """Test that typed_callback and regular 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"), + ] + ) + + # Using typed_callback (global style) + @typed_callback(Output("output1", "children"), Input("input", "value")) + def update_1(value): + return f"Typed global: {value}" + + # Using dash.callback (global style) + @dash.callback(Output("output2", "children"), Input("input", "value")) + def update_2(value): + return f"Regular global: {value}" + + # Using app.callback (app instance style) + @app.callback(Output("output3", "children"), Input("input", "value")) + def update_3(value): + return f"App callback: {value}" + + # Another typed_callback + @typed_callback(Output("output4", "children"), Input("input", "value")) + def update_4(value): + return f"Typed 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", "Typed global: test") + dash_duo.wait_for_text_to_equal("#output2", "Regular global: test") + dash_duo.wait_for_text_to_equal("#output3", "App callback: test") + dash_duo.wait_for_text_to_equal("#output4", "Typed global 2: test") + + assert not dash_duo.get_logs() + + +def test_tcb007_typed_callback_with_no_update(dash_duo): + """Test typed_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"), + ] + ) + + @typed_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 + else: + 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_typed_callback_unit.py b/tests/unit/test_typed_callback_unit.py new file mode 100644 index 0000000000..2e5210b084 --- /dev/null +++ b/tests/unit/test_typed_callback_unit.py @@ -0,0 +1,111 @@ +"""Unit tests for typed_callback - no browser required.""" +import dash +from dash import Input, Output, State, typed_callback, callback + + +def test_typed_callback_returns_callable(): + """Test that typed_callback returns a callable decorator.""" + decorator = typed_callback(Output("output", "children"), Input("input", "value")) + assert callable(decorator) + + +def test_typed_callback_decorates_function(): + """Test that typed_callback can decorate a function.""" + @typed_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_typed_callback_signature_matches_callback(): + """Test that typed_callback has the same signature as callback.""" + import inspect + + typed_sig = inspect.signature(typed_callback) + callback_sig = inspect.signature(callback) + + # Both should have the same parameters + assert typed_sig.parameters.keys() == callback_sig.parameters.keys() + + +def test_typed_callback_with_multiple_inputs(): + """Test typed_callback with multiple inputs.""" + @typed_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_typed_callback_with_state(): + """Test typed_callback with State.""" + @typed_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_typed_callback_with_multiple_outputs(): + """Test typed_callback with multiple outputs.""" + @typed_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_typed_callback_preserves_docstring(): + """Test that typed_callback preserves the wrapped function's docstring.""" + @typed_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_typed_callback_with_prevent_initial_call(): + """Test typed_callback with prevent_initial_call parameter.""" + @typed_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_typed_callback_with_background_params(): + """Test that typed_callback accepts background callback parameters.""" + # Just test that the decorator accepts these parameters + # Full background callback testing requires integration tests + decorator = typed_callback( + Output("output", "children"), + Input("input", "value"), + background=False, + interval=1000, + ) + assert callable(decorator) + + +def test_typed_callback_module_export(): + """Test that typed_callback is properly exported from dash module.""" + assert hasattr(dash, "typed_callback") + assert dash.typed_callback is typed_callback From 5746c1eb81dab8d4818a23c7ada9ff9ddd5b1839 Mon Sep 17 00:00:00 2001 From: Mike Baker Date: Wed, 15 Apr 2026 10:44:44 +0900 Subject: [PATCH 2/6] Update: Merged callback and callback_typed. callback now behaves as callback_typed did. Update: Rewrote tests to match above change. --- dash/__init__.py | 3 +- dash/_callback.py | 143 +--------- tests/compliance/test_callback_typing.py | 203 ++++++++++++++ .../compliance/test_typed_callback_typing.py | 255 ------------------ ...callback.py => test_callback_decorator.py} | 90 +++---- tests/unit/test_callback_unit.py | 128 +++++++++ tests/unit/test_typed_callback_unit.py | 111 -------- 7 files changed, 380 insertions(+), 553 deletions(-) create mode 100644 tests/compliance/test_callback_typing.py delete mode 100644 tests/compliance/test_typed_callback_typing.py rename tests/integration/callbacks/{test_typed_callback.py => test_callback_decorator.py} (68%) create mode 100644 tests/unit/test_callback_unit.py delete mode 100644 tests/unit/test_typed_callback_unit.py diff --git a/dash/__init__.py b/dash/__init__.py index e67fe4671d..6f16a068aa 100644 --- a/dash/__init__.py +++ b/dash/__init__.py @@ -19,7 +19,7 @@ from . import dash_table # noqa: F401,E402 from .version import __version__ # noqa: F401,E402 from ._callback_context import callback_context, set_props # noqa: F401,E402 -from ._callback import callback, clientside_callback, typed_callback # noqa: F401,E402 +from ._callback import callback, clientside_callback # noqa: F401,E402 from ._get_app import get_app # noqa: F401,E402 from ._get_paths import ( # noqa: F401,E402 get_asset_url, @@ -77,7 +77,6 @@ def _jupyter_nbextension_paths(): "callback_context", "set_props", "callback", - "typed_callback", "get_app", "get_asset_url", "get_relative_path", diff --git a/dash/_callback.py b/dash/_callback.py index 68ced8fe21..c8f98a82e9 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -70,6 +70,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, @@ -87,7 +91,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 @@ -224,7 +228,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, @@ -239,141 +243,6 @@ def callback( hidden=hidden, ) - -Params = ParamSpec("Params") -ReturnVar = TypeVar("ReturnVar") - - -# pylint: disable=too-many-arguments -def typed_callback( - *_args, - background: bool = False, - interval: int = 1000, - progress: Optional[Union[List[Output], Output]] = None, - progress_default: Any = None, - running: Optional[List[Tuple[Output, Any, Any]]] = None, - cancel: Optional[Union[List[Input], Input]] = None, - manager: Optional[BaseBackgroundCallbackManager] = None, - cache_args_to_ignore: Optional[list] = None, - cache_ignore_triggered=True, - on_error: Optional[Callable[[Exception], Any]] = None, - api_endpoint: Optional[str] = None, - optional: Optional[bool] = False, - hidden: Optional[bool] = None, - **_kwargs, -) -> Callable[[Callable[Params, ReturnVar]], Callable[Params, ReturnVar]]: - """Decorator factory for Dash callbacks with full type preservation. - - Centralizes the typing gap in Dash's untyped decorator, satisfying - disallow_untyped_decorators while preserving callback function signatures. - - This is a type-safe wrapper around `dash.callback` that preserves the - exact signature of the decorated function. Use this when working with - strict Mypy settings like `disallow_untyped_decorators`. - - :Usage: - ```python - @dash.typed_callback( - Output('output-id', 'children'), - Input('input-id', 'value') - ) - def my_callback(value: str) -> str: - return f"You entered: {value}" - ``` - - :Keyword Arguments: - :param background: - Mark the callback as a background callback to execute in a manager for - callbacks that take a long time without locking up the Dash app - or timing out. - :param manager: - A background callback manager instance. Currently, an instance of one of - `DiskcacheManager` or `CeleryManager`. - Defaults to the `background_callback_manager` instance provided to the - `dash.Dash` constructor. - - A diskcache manager (`DiskcacheManager`) that runs callback - logic in a separate process and stores the results to disk using the - diskcache library. This is the easiest backend to use for local - development. - - A Celery manager (`CeleryManager`) that runs callback logic - in a celery worker and returns results to the Dash app through a Celery - broker like RabbitMQ or Redis. - :param running: - A list of 3-element tuples. The first element of each tuple should be - an `Output` dependency object referencing a property of a component in - the app layout. The second element is the value that the property - should be set to while the callback is running, and the third element - is the value the property should be set to when the callback completes. - :param cancel: - A list of `Input` dependency objects that reference a property of a - component in the app's layout. When the value of this property changes - while a callback is running, the callback is canceled. - Note that the value of the property is not significant, any change in - value will result in the cancellation of the running job (if any). - This parameter only applies to background callbacks (`background=True`). - :param progress: - An `Output` dependency grouping that references properties of - components in the app's layout. When provided, the decorated function - will be called with an extra argument as the first argument to the - function. This argument, is a function handle that the decorated - function should call in order to provide updates to the app on its - current progress. This function accepts a single argument, which - correspond to the grouping of properties specified in the provided - `Output` dependency grouping. This parameter only applies to background - callbacks (`background=True`). - :param progress_default: - A grouping of values that should be assigned to the components - specified by the `progress` argument when the callback is not in - progress. If `progress_default` is not provided, all the dependency - properties specified in `progress` will be set to `None` when the - callback is not running. This parameter only applies to background - callbacks (`background=True`). - :param cache_args_to_ignore: - Arguments to ignore when caching is enabled. If callback is configured - with keyword arguments (Input/State provided in a dict), - this should be a list of argument names as strings. Otherwise, - this should be a list of argument indices as integers. - This parameter only applies to background callbacks (`background=True`). - :param cache_ignore_triggered: - Whether to ignore which inputs triggered the callback when creating - the cache. This parameter only applies to background callbacks - (`background=True`). - :param interval: - Time to wait between the background callback update requests. - :param on_error: - Function to call when the callback raises an exception. Receives the - exception object as first argument. The callback_context can be used - to access the original callback inputs, states and output. - :param optional: - Mark all dependencies as not required on the initial layout checks. - :param hidden: - Hide the callback from the devtools callbacks tab. - :param api_endpoint: - If provided, the callback will be available at the given API endpoint. - This allows you to call the callback directly through HTTP requests - instead of through the Dash front-end. The endpoint should be a string - that starts with a forward slash (e.g. `/my_callback`). - The endpoint is relative to the Dash app's base URL. - Note that the endpoint will not appear in the list of registered - callbacks in the Dash devtools. - """ - raw = callback( - *_args, - background=background, - interval=interval, - progress=progress, - progress_default=progress_default, - running=running, - cancel=cancel, - manager=manager, - cache_args_to_ignore=cache_args_to_ignore, - cache_ignore_triggered=cache_ignore_triggered, - on_error=on_error, - api_endpoint=api_endpoint, - optional=optional, - hidden=hidden, - **_kwargs, - ) # type: ignore[no-untyped-call] return cast( Callable[[Callable[Params, ReturnVar]], Callable[Params, ReturnVar]], raw ) diff --git a/tests/compliance/test_callback_typing.py b/tests/compliance/test_callback_typing.py new file mode 100644 index 0000000000..cf13f0d617 --- /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 .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_typed_callback_typing.py b/tests/compliance/test_typed_callback_typing.py deleted file mode 100644 index b5a85dc461..0000000000 --- a/tests/compliance/test_typed_callback_typing.py +++ /dev/null @@ -1,255 +0,0 @@ -"""Type compliance tests for typed_callback with strict mypy/pyright settings.""" -import os -import sys -import pytest - -# Import the testing utilities from the main test_typing.py -from .test_typing import run_module, format_template_and_save - - -# Template for testing typed_callback with strict type checking -typed_callback_template = """ -from dash import Dash, html, dcc, typed_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} -""" - -# Template for testing with strict mypy settings -strict_mypy_template = """# mypy: disallow-untyped-defs -# mypy: disallow-untyped-calls -# mypy: disallow-untyped-decorators -from dash import Dash, html, dcc, typed_callback, Input, Output, State - -app = Dash(__name__) - -app.layout = html.Div([ - dcc.Input(id='input', value=''), - html.Div(id='output'), -]) - -{0} -""" - - -valid_typed_callback_single = """ -@typed_callback(Output('output1', 'children'), Input('input1', 'value')) -def update_output(value: str) -> str: - return f"You typed: {value}" -""" - -valid_typed_callback_multi_input = """ -@typed_callback( - Output('output1', 'children'), - Input('input1', 'value'), - Input('input2', 'value') -) -def update_output(val1: str, val2: str) -> str: - return f"{val1} and {val2}" -""" - -valid_typed_callback_with_state = """ -@typed_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_typed_callback_multi_output = """ -@typed_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}" -""" - -# This should pass with typed_callback but fail with regular callback in strict mode -strict_mode_typed_callback = """ -@typed_callback(Output('output', 'children'), Input('input', 'value')) -def my_callback(value: str) -> str: - '''Fully typed callback function.''' - return f"Result: {value}" -""" - -# Regular callback would fail in strict mode (for comparison) -strict_mode_regular_callback = """ -@app.callback(Output('output', 'children'), Input('input', 'value')) -def my_callback(value: str) -> str: - '''This should fail with disallow-untyped-decorators.''' - return f"Result: {value}" -""" - -# Test with complex return types -complex_return_types = """ -from typing import Union - -@typed_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_typed_callback_single, 0), - (valid_typed_callback_multi_input, 0), - (valid_typed_callback_with_state, 0), - (valid_typed_callback_multi_output, 0), - (complex_return_types, 0), - ], -) -def test_typi_typed_callback_basic( - typing_module, callback_code, expected_status, tmp_path -): - """Test that typed_callback passes type checking in normal mode.""" - codefile = os.path.join(tmp_path, "code.py") - code = format_template_and_save( - typed_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_typed_callback_strict_mode(typing_module, tmp_path): - """Test that typed_callback works with strict mypy/pyright settings. - - This is the main purpose of typed_callback - to satisfy - disallow_untyped_decorators and similar strict settings. - """ - codefile = os.path.join(tmp_path, "code.py") - code = format_template_and_save( - strict_mypy_template, codefile, strict_mode_typed_callback - ) - - output, error, status = run_module(codefile, typing_module) - assert status == 0, ( - f"typed_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_regular_callback_strict_mode_fails(typing_module, tmp_path): - """Verify that regular callback fails in strict mode (comparison test). - - This demonstrates why typed_callback is needed. - """ - codefile = os.path.join(tmp_path, "code.py") - code = format_template_and_save( - strict_mypy_template, codefile, strict_mode_regular_callback - ) - - output, error, status = run_module(codefile, typing_module) - - # Regular callback should fail in strict mode - # (This test validates that our strict mode setup is actually strict) - if typing_module == "mypy": - # Mypy should report untyped decorator error - assert status != 0 or "Untyped decorator" in output or "untyped" in error.lower(), ( - f"Regular callback should fail with disallow-untyped-decorators.\n" - f"Status: {status}\nOutput: {output}\nError: {error}\n" - f"Module: {typing_module}" - ) - elif typing_module == "pyright": - # Pyright might or might not catch this depending on strict settings - # The important thing is that typed_callback passes when regular might not - pass - - -@pytest.mark.parametrize("typing_module", typing_modules) -def test_typi_typed_callback_preserves_signature(typing_module, tmp_path): - """Test that typed_callback preserves function signatures for type inference.""" - code = """ -from typing import reveal_type # type: ignore -from dash import typed_callback, Input, Output, html, Dash - -app = Dash(__name__) -app.layout = html.Div([html.Div(id='in'), html.Div(id='out')]) - -@typed_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) - - # Should pass type checking - the function signature is preserved - assert status == 0, ( - f"typed_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_typed_callback_with_none_values(typing_module, tmp_path): - """Test typed_callback with Optional types.""" - code = """ -from dash import Dash, html, dcc, typed_callback, Input, Output - -app = Dash(__name__) -app.layout = html.Div([ - dcc.Input(id='input', value=''), - html.Button('Click', id='btn'), - html.Div(id='output'), -]) - -@typed_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"typed_callback should handle Optional types.\n" - f"Status: {status}\nOutput: {output}\nError: {error}\n" - f"Module: {typing_module}" - ) diff --git a/tests/integration/callbacks/test_typed_callback.py b/tests/integration/callbacks/test_callback_decorator.py similarity index 68% rename from tests/integration/callbacks/test_typed_callback.py rename to tests/integration/callbacks/test_callback_decorator.py index e3f096f99d..fd81eafe1e 100644 --- a/tests/integration/callbacks/test_typed_callback.py +++ b/tests/integration/callbacks/test_callback_decorator.py @@ -1,44 +1,44 @@ -"""Integration tests for typed_callback functionality.""" +"""Integration tests for callback decorator behavior.""" import dash -from dash import Input, Output, State, dcc, html, typed_callback +from dash import Input, Output, State, callback, dcc, html -def test_tcb001_basic_typed_callback(dash_duo): - """Test that typed_callback works identically to callback for basic usage.""" +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-typed"), - html.Div(id="output-regular"), + html.Div(id="output-callback"), + html.Div(id="output-dash-callback"), ] ) - @typed_callback(Output("output-typed", "children"), Input("input", "value")) - def update_typed(value): - return f"Typed: {value}" + @callback(Output("output-callback", "children"), Input("input", "value")) + def update_callback(value): + return f"Callback: {value}" - @dash.callback(Output("output-regular", "children"), Input("input", "value")) - def update_regular(value): - return f"Regular: {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-typed", "Typed: initial") - dash_duo.wait_for_text_to_equal("#output-regular", "Regular: initial") + 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-typed", "Typed: test") - dash_duo.wait_for_text_to_equal("#output-regular", "Regular: 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_tcb002_typed_callback_multi_input(dash_duo): - """Test typed_callback with multiple inputs.""" +def test_cb002_callback_multi_input(dash_duo): + """Test callback with multiple inputs.""" app = dash.Dash(__name__) app.layout = html.Div( @@ -49,7 +49,7 @@ def test_tcb002_typed_callback_multi_input(dash_duo): ] ) - @typed_callback( + @callback( Output("output", "children"), Input("input1", "value"), Input("input2", "value"), @@ -69,8 +69,8 @@ def combine_inputs(val1, val2): assert not dash_duo.get_logs() -def test_tcb003_typed_callback_with_state(dash_duo): - """Test typed_callback with State dependencies.""" +def test_cb003_callback_with_state(dash_duo): + """Test callback with State dependencies.""" app = dash.Dash(__name__) app.layout = html.Div( @@ -82,7 +82,7 @@ def test_tcb003_typed_callback_with_state(dash_duo): ] ) - @typed_callback( + @callback( Output("output", "children"), Input("button", "n_clicks"), State("input1", "value"), @@ -105,8 +105,8 @@ def update_with_state(n_clicks, state1, state2): assert not dash_duo.get_logs() -def test_tcb004_typed_callback_multi_output(dash_duo): - """Test typed_callback with multiple outputs.""" +def test_cb004_callback_multi_output(dash_duo): + """Test callback with multiple outputs.""" app = dash.Dash(__name__) app.layout = html.Div( @@ -118,7 +118,7 @@ def test_tcb004_typed_callback_multi_output(dash_duo): ] ) - @typed_callback( + @callback( Output("output1", "children"), Output("output2", "children"), Output("output3", "children"), @@ -143,8 +143,8 @@ def update_multiple(value): assert not dash_duo.get_logs() -def test_tcb005_typed_callback_prevent_initial_call(dash_duo): - """Test typed_callback with prevent_initial_call parameter.""" +def test_cb005_callback_prevent_initial_call(dash_duo): + """Test callback with prevent_initial_call parameter.""" app = dash.Dash(__name__) app.layout = html.Div( @@ -154,7 +154,7 @@ def test_tcb005_typed_callback_prevent_initial_call(dash_duo): ] ) - @typed_callback( + @callback( Output("output", "children"), Input("input", "value"), prevent_initial_call=True, @@ -163,7 +163,6 @@ def update_no_initial(value): return f"Updated: {value}" dash_duo.start_server(app) - # Should remain "default" because prevent_initial_call=True dash_duo.wait_for_text_to_equal("#output", "default") input_element = dash_duo.find_element("#input") @@ -174,8 +173,8 @@ def update_no_initial(value): assert not dash_duo.get_logs() -def test_tcb006_typed_callback_mixed_with_regular(dash_duo): - """Test that typed_callback and regular callback can coexist.""" +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( @@ -188,41 +187,37 @@ def test_tcb006_typed_callback_mixed_with_regular(dash_duo): ] ) - # Using typed_callback (global style) - @typed_callback(Output("output1", "children"), Input("input", "value")) + @callback(Output("output1", "children"), Input("input", "value")) def update_1(value): - return f"Typed global: {value}" + return f"Callback global: {value}" - # Using dash.callback (global style) @dash.callback(Output("output2", "children"), Input("input", "value")) def update_2(value): - return f"Regular global: {value}" + return f"Dash callback global: {value}" - # Using app.callback (app instance style) @app.callback(Output("output3", "children"), Input("input", "value")) def update_3(value): return f"App callback: {value}" - # Another typed_callback - @typed_callback(Output("output4", "children"), Input("input", "value")) + @callback(Output("output4", "children"), Input("input", "value")) def update_4(value): - return f"Typed global 2: {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", "Typed global: test") - dash_duo.wait_for_text_to_equal("#output2", "Regular global: 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", "Typed global 2: test") + dash_duo.wait_for_text_to_equal("#output4", "Callback global 2: test") assert not dash_duo.get_logs() -def test_tcb007_typed_callback_with_no_update(dash_duo): - """Test typed_callback with no_update.""" +def test_cb007_callback_with_no_update(dash_duo): + """Test callback with no_update.""" app = dash.Dash(__name__) app.layout = html.Div( @@ -233,7 +228,7 @@ def test_tcb007_typed_callback_with_no_update(dash_duo): ] ) - @typed_callback( + @callback( Output("output1", "children"), Output("output2", "children"), Input("input", "value"), @@ -242,8 +237,7 @@ 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 - else: - return dash.no_update, f"Odd: {num}" + return dash.no_update, f"Odd: {num}" dash_duo.start_server(app) dash_duo.wait_for_text_to_equal("#output1", "Even: 0") 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 diff --git a/tests/unit/test_typed_callback_unit.py b/tests/unit/test_typed_callback_unit.py deleted file mode 100644 index 2e5210b084..0000000000 --- a/tests/unit/test_typed_callback_unit.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Unit tests for typed_callback - no browser required.""" -import dash -from dash import Input, Output, State, typed_callback, callback - - -def test_typed_callback_returns_callable(): - """Test that typed_callback returns a callable decorator.""" - decorator = typed_callback(Output("output", "children"), Input("input", "value")) - assert callable(decorator) - - -def test_typed_callback_decorates_function(): - """Test that typed_callback can decorate a function.""" - @typed_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_typed_callback_signature_matches_callback(): - """Test that typed_callback has the same signature as callback.""" - import inspect - - typed_sig = inspect.signature(typed_callback) - callback_sig = inspect.signature(callback) - - # Both should have the same parameters - assert typed_sig.parameters.keys() == callback_sig.parameters.keys() - - -def test_typed_callback_with_multiple_inputs(): - """Test typed_callback with multiple inputs.""" - @typed_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_typed_callback_with_state(): - """Test typed_callback with State.""" - @typed_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_typed_callback_with_multiple_outputs(): - """Test typed_callback with multiple outputs.""" - @typed_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_typed_callback_preserves_docstring(): - """Test that typed_callback preserves the wrapped function's docstring.""" - @typed_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_typed_callback_with_prevent_initial_call(): - """Test typed_callback with prevent_initial_call parameter.""" - @typed_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_typed_callback_with_background_params(): - """Test that typed_callback accepts background callback parameters.""" - # Just test that the decorator accepts these parameters - # Full background callback testing requires integration tests - decorator = typed_callback( - Output("output", "children"), - Input("input", "value"), - background=False, - interval=1000, - ) - assert callable(decorator) - - -def test_typed_callback_module_export(): - """Test that typed_callback is properly exported from dash module.""" - assert hasattr(dash, "typed_callback") - assert dash.typed_callback is typed_callback From 8ac7fba07b433f322a350e571b992836056436ad Mon Sep 17 00:00:00 2001 From: Mike Baker Date: Wed, 15 Apr 2026 10:57:42 +0900 Subject: [PATCH 3/6] Fix callback typing test import path --- tests/compliance/test_callback_typing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/compliance/test_callback_typing.py b/tests/compliance/test_callback_typing.py index cf13f0d617..aae5b77d86 100644 --- a/tests/compliance/test_callback_typing.py +++ b/tests/compliance/test_callback_typing.py @@ -4,7 +4,7 @@ import pytest # type: ignore -from .test_typing import format_template_and_save, run_module +from tests.compliance.test_typing import format_template_and_save, run_module callback_template = """ From 73a19e20a64a99db89075276f9906f1d2b4e741c Mon Sep 17 00:00:00 2001 From: Mike Baker Date: Wed, 15 Apr 2026 14:27:32 +0900 Subject: [PATCH 4/6] Update: Modified tests to work correctly in build environment (includes AI generated code) --- tests/compliance/test_typing.py | 46 +++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 14 deletions(-) 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, From 41f9896ae7d30d17a856f51203a5bffc58305b29 Mon Sep 17 00:00:00 2001 From: Mike Baker Date: Wed, 15 Apr 2026 15:54:39 +0900 Subject: [PATCH 5/6] Updated: Added changes to CHANGELOG.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) 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 From bbb91c7e5547dc8762093ed083cd8be0ca8ab905 Mon Sep 17 00:00:00 2001 From: Mike Baker Date: Fri, 17 Apr 2026 14:35:54 +0900 Subject: [PATCH 6/6] Fix: `test_callback_typing.py` not running on ci Fix: import order failing pylint --- .github/workflows/testing.yml | 4 +++- dash/_callback.py | 14 +++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) 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/dash/_callback.py b/dash/_callback.py index e748cbbebb..3475ad517c 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -2,14 +2,12 @@ import hashlib import inspect from functools import wraps - -import sys from typing import Callable, Optional, Any, List, Tuple, Union, Dict, TypeVar, cast -if sys.version_info >= (3, 10): +try: from typing import ParamSpec -else: - from typing_extensions import ParamSpec +except ImportError: # Assume Python < 3.10 + from typing_extensions import ParamSpec # type: ignore import flask @@ -251,10 +249,12 @@ def callback( def validate_background_inputs(deps): for dep in deps: if dep.has_wildcard(): - raise WildcardInLongCallback(f""" + raise WildcardInLongCallback( + f""" background callbacks does not support dependencies with pattern-matching ids - Received: {repr(dep)}\n""") + Received: {repr(dep)}\n""" + ) ClientsideFuncType = Union[str, ClientsideFunction]