Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }})
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions dash/_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down
203 changes: 203 additions & 0 deletions tests/compliance/test_callback_typing.py
Original file line number Diff line number Diff line change
@@ -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}"
)
46 changes: 32 additions & 14 deletions tests/compliance/test_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading