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
18 changes: 14 additions & 4 deletions dash/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,9 @@ def first(self, *names):


def create_callback_id(output, inputs, no_output=False):
# A single dot within a dict id key or value is OK
# but in case of multiple dots together escape each dot
# with `\` so we don't mistake it for multi-outputs
# Dots within a string ID are escaped with \. to distinguish them
# from the separator between component-id and property.
# For dict IDs (JSON strings) we use \u002e instead - see _concat.
hashed_inputs = None

def _hash_inputs():
Expand All @@ -156,7 +156,17 @@ def _hash_inputs():

def _concat(x):
nonlocal hashed_inputs
_id = x.component_id_str().replace(".", "\\.") + "." + x.component_property
id_str = x.component_id_str()
if isinstance(x.component_id, dict):
# Dict IDs are serialized as JSON strings. Using \. to escape
# dots produces an invalid JSON escape sequence that causes
# JSON.parse to throw SyntaxError in the frontend (see #3480).
# \u002e is the valid JSON Unicode escape for "." and is
# transparently decoded back to "." by JSON.parse.
escaped = id_str.replace(".", "\\u002e")
else:
escaped = id_str.replace(".", "\\.")
_id = escaped + "." + x.component_property
if x.allow_duplicate:
if not hashed_inputs:
hashed_inputs = _hash_inputs()
Expand Down
293 changes: 165 additions & 128 deletions tests/unit/test_callback_unit.py
Original file line number Diff line number Diff line change
@@ -1,128 +1,165 @@
"""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
"""Unit tests for callback decorator behavior - no browser required."""
import inspect
import json

import dash
from dash import Input, Output, State, callback
from dash._utils import create_callback_id


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


def test_create_callback_id_escapes_dots_in_string_id():
"""A dot in a plain string component id is escaped with a backslash."""
output = Output("my.component", "children")
callback_id = create_callback_id(output, [])

assert callback_id == "my\\.component.children"


def test_create_callback_id_escapes_dots_in_dict_id_as_json_unicode():
"""A dot in a dict id must use the JSON \\u002e escape, not \\.,
otherwise the frontend's JSON.parse throws a SyntaxError when it
un-escapes the id portion of the callback id string (see #3480)."""
output = Output({"type": "my.type", "index": 1}, "children")
callback_id = create_callback_id(output, [])

id_part, prop_part = callback_id.rsplit(".", 1)
assert prop_part == "children"
# The escaped id must not contain a raw backslash-dot sequence...
assert "\\." not in id_part
# ...and must be valid JSON once the . escape is present verbatim.
assert "\\u002e" in id_part
parsed = json.loads(id_part)
assert parsed == {"type": "my.type", "index": 1}


def test_create_callback_id_dict_id_without_dots_unaffected():
"""Dict ids with no dots in their values still round-trip through JSON."""
output = Output({"type": "widget", "index": 2}, "value")
callback_id = create_callback_id(output, [])

id_part, prop_part = callback_id.rsplit(".", 1)
assert prop_part == "value"
assert json.loads(id_part) == {"type": "widget", "index": 2}