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
1 change: 1 addition & 0 deletions .ai/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ app.layout = html.Div([
- **Static layout**: Assigned directly as a component tree
- **Dynamic layout**: Assigned as a function that returns components (called on each page load, useful for per-session state)
- Layout is serialized to JSON and sent to the React frontend via `/_dash-layout`
- Every payload (layout, callback responses, stream frames) goes through `dash._utils.to_json` (`dash/_json.py`): orjson with a `default=` hook when installed, otherwise msgspec (a hard dependency), so only values the encoder can't take (components, figures, pandas/numpy values) are converted. Anything it fails on goes to plotly's `to_json_plotly`, keeping plotly's errors. Without orjson, msgspec is more permissive than orjson and plotly: a timezone-aware UTC `datetime` comes out as `...Z`, not `+00:00`, and sets, bytes (base64), `timedelta` and `np.timedelta64` serialize instead of raising, so `_validate` no longer flags them. A `go.Figure` still pays `to_dict()`'s deepcopy (#4065).
- Components can contain other components via `children` prop
- Component IDs can be strings or dicts (for pattern-matching callbacks)

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
- [#3986](https://github.com/plotly/dash/pull/3986) Adjust `_run_before_hooks` in the `fastapi` backend to honor a response returned by a `before_request` function, matching the `flask` backend's behavior.

### Fixed
- [#4067](https://github.com/plotly/dash/pull/4067) Fix slow serialization of callback responses and layouts that hold a component, figure or `pd.Timestamp`, and use msgspec when orjson is not installed. Fixes [#4065](https://github.com/plotly/dash/issues/4065).
- [#4027](https://github.com/plotly/dash/pull/4027) Support `async def` hook routes (`dash.hooks.route`) on all backends, stop hook routes from leaking the app context to the caller, and let hook routes and MCP read the request on the `fastapi` backend.
- [#4053](https://github.com/plotly/dash/pull/4053) Fix callbacks being registered twice under a reloader (e.g. `uvicorn --reload`) when `Dash()` is given an explicit `name` or is built by an app factory called from the main module. Fixes [#3912](https://github.com/plotly/dash/issues/3912).
- [#3980](https://github.com/plotly/dash/pull/3980) Fix the three `before_request` hooks (`Dash._setup_server` and the pages `router_sync` / `router_async`) publishing their "already done" guard flag before the setup work behind it had run. Under a multi-threaded WSGI worker such as `gunicorn -k gthread` (or under an ASGI worker for the async router), a second request arriving mid-setup could observe the flag already set, skip setup, then read `registered_paths` / `callback_map` / the pages router callback while they were still being registered - causing the first burst of component bundle requests after a restart to 500 with `Error loading dependency. "<lib>" is not a registered library`, or the pages router to hit `DuplicateCallback` when two workers raced past the guard. Each hook body now runs under a lock (`threading.Lock` for the two sync hooks, an `asyncio.Lock` bound to the running loop for the async router) and only publishes the flag after all work completes. Fixes [#3971](https://github.com/plotly/dash/issues/3971).
Expand Down
101 changes: 101 additions & 0 deletions dash/_json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""JSON for callback responses, layouts and the other payloads Dash sends.

plotly's ``to_json_plotly`` hands the whole payload to orjson, and when one
value is not orjson-native (a component, a figure, a ``pd.Timestamp``) it
walks every value in Python and dumps again. Here the encoder converts only
the values it cannot take itself: orjson when installed, otherwise msgspec,
which Dash already depends on. Anything either fails on goes to plotly, so the
output and the errors stay plotly's.
"""
import msgspec

# pylint: disable-next=import-error
from _plotly_utils.optional_imports import get_module # type: ignore[import-untyped]
from plotly.io._json import ( # type: ignore[import-untyped]
_safe,
_swap_orjson,
clean_to_json_compatible,
config,
to_json_plotly,
)


def _modules():
return {
"sage_all": get_module("sage.all", should_load=False),
"np": get_module("numpy", should_load=False),
"pd": get_module("pandas", should_load=False),
"image": get_module("PIL.Image", should_load=False),
}


def _convert(value, native):
if isinstance(value, (dict, list, tuple)):
# the encoder only asks about these when they sit where JSON can't
# hold them (a tuple dict key); converting would loop forever
raise TypeError(f"Type is not JSON serializable: {type(value).__name__}")
to_plotly_json = getattr(value, "to_plotly_json", None)
if to_plotly_json is not None:
return to_plotly_json()
cleaned = clean_to_json_compatible(
value, numpy_allowed=native, datetime_allowed=native, modules=_modules()
)
if cleaned is value:
raise TypeError(f"Type is not JSON serializable: {type(value).__name__}")
return cleaned


def _orjson_default(value):
return _convert(value, native=True)


def _is_nat(value):
np = get_module("numpy", should_load=False)
pd = get_module("pandas", should_load=False)
return (pd is not None and value is pd.NaT) or (
np is not None and isinstance(value, np.datetime64) and np.isnat(value)
)


def _msgspec_hook(value):
# numpy scalars subclass float and int, but msgspec takes only exact types
if isinstance(value, float):
return float(value)
if isinstance(value, int):
return int(value)
if _is_nat(value):
return None
pd = get_module("pandas", should_load=False)
if pd is not None:
# isoformat keeps nanoseconds, which to_pydatetime drops
if isinstance(value, pd.Timestamp):
return value.isoformat()
if isinstance(value, (pd.Series, pd.Index)) and value.dtype.kind == "M":
return value.tolist()
return _convert(value, native=False)


_msgspec_encoder = msgspec.json.Encoder(enc_hook=_msgspec_hook, decimal_format="number")


def to_json(value):
orjson = (
get_module("orjson", should_load=True)
if config.default_engine != "json"
else None
)
try:
if orjson is not None:
out = orjson.dumps(
value,
default=_orjson_default,
option=orjson.OPT_NON_STR_KEYS | orjson.OPT_SERIALIZE_NUMPY,
)
else:
out = _msgspec_encoder.encode(value)
# msgspec writes Decimal as is, so a NaN or Infinity value or a
# Decimal dict key gives invalid JSON. Checking the syntax is cheap.
msgspec.json.decode(out, type=msgspec.Raw)
except (TypeError, ValueError, OverflowError, RecursionError, msgspec.MsgspecError):
return to_json_plotly(value)
return _safe(out.decode("utf8"), _swap_orjson)
6 changes: 3 additions & 3 deletions dash/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@


def to_json(value):
# pylint: disable=import-outside-toplevel
from plotly.io.json import to_json_plotly # type: ignore[import-untyped]
# pylint: disable-next=import-outside-toplevel
from ._json import to_json as _to_json

return to_json_plotly(value)
return _to_json(value)


def interpolate_str(template, **data):
Expand Down
55 changes: 55 additions & 0 deletions tests/integration/callbacks/test_serialization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import datetime

import pandas as pd
import plotly.graph_objects as go
import pytest

from dash import Dash, Input, Output, State, dcc, html
from tests.integration.utils import json_engine


@pytest.mark.parametrize("engine", ["json", "orjson"])
def test_cbsr001_figure_component_and_timestamp_in_one_response(dash_duo, engine):
with json_engine(engine):
app = Dash()
app.layout = html.Div(
[
html.Button("go", id="btn"),
dcc.Graph(id="graph"),
html.Div(id="status"),
dcc.Store(id="store"),
html.Div(id="check"),
]
)
start = datetime.datetime(2024, 1, 1)
x = [start + datetime.timedelta(minutes=i) for i in range(2000)]

@app.callback(
Output("graph", "figure"),
Output("status", "children"),
Output("store", "data"),
Input("btn", "n_clicks"),
prevent_initial_call=True,
)
def update(_):
fig = go.Figure(go.Scatter(x=x, y=[float(i) for i in range(2000)]))
return fig, html.B("ok"), {"at": pd.Timestamp("2024-01-01 12:00")}

@app.callback(
Output("check", "children"),
Input("store", "data"),
State("graph", "figure"),
prevent_initial_call=True,
)
def check(data, figure):
trace = figure["data"][0]
return f"{data['at']} {len(trace['x'])} {trace['x'][-1]} {trace['y'][-1]}"

dash_duo.start_server(app)
dash_duo.find_element("#btn").click()
dash_duo.wait_for_text_to_equal("#status", "ok")
dash_duo.wait_for_text_to_equal(
"#check", "2024-01-01T12:00:00 2000 2024-01-02T09:19:00 1999"
)
dash_duo.wait_for_element("#graph .scatterlayer .trace")
assert dash_duo.get_logs() == []
114 changes: 114 additions & 0 deletions tests/unit/test_json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import datetime
import decimal
import json

import numpy as np
import pandas as pd
import plotly.graph_objects as go
import plotly.io as pio
import pytest
from plotly.io.json import to_json_plotly

from dash import dcc, html
from dash._utils import to_json


@pytest.fixture(params=["orjson", "json"])
def engine(request):
original = pio.json.config.default_engine
pio.json.config.default_engine = request.param
yield request.param
pio.json.config.default_engine = original


VALUES = {
"component": html.Div([html.P("a"), dcc.Graph(figure=go.Figure(go.Bar(y=[1, 2])))]),
"figure": go.Figure(go.Scatter(x=[1, 2], y=np.array([1.5, 2.5]))),
"trace": go.Scatter(y=np.arange(3)),
"timestamp": pd.Timestamp("2024-01-01 12:00"),
"timestamp_utc": pd.Timestamp("2024-01-01", tz="UTC"),
"timestamp_ns": pd.Timestamp("2024-01-01 00:00:00.123456789"),
"nat": pd.NaT,
"na": pd.NA,
"datetime_index": pd.date_range("2024", periods=3),
"series": pd.Series([1.5, np.nan]),
"numpy_float": np.float64(1.5),
"numpy_int": np.int64(3),
"noncontiguous": np.arange(10.0)[::2],
"object_array": np.array([1, "a", None], dtype=object),
"masked": np.ma.masked,
"decimal": decimal.Decimal("1.5"),
"date": datetime.date(2024, 1, 1),
"datetimes": [datetime.datetime(2024, 1, 1, 5)],
"nan": [float("nan"), float("inf")],
"int_keys": {1: "a"},
"unsafe": "</script>
",
}


@pytest.mark.parametrize("name", list(VALUES))
def test_json001_matches_plotly(engine, name):
payload = {"multi": True, "response": {"a": {"v": VALUES[name]}}}
assert to_json(payload) == to_json_plotly(payload)
with_div = {"v": VALUES[name], "d": html.Div("ok")}
assert json.loads(to_json(with_div)) == json.loads(to_json_plotly(with_div))


def test_json002_converts_only_the_values_it_cannot_take(engine, monkeypatch):
visited = []
walk = pio._json.clean_to_json_compatible

def counting(obj, **kwargs):
visited.append(obj)
return walk(obj, **kwargs)

monkeypatch.setattr("plotly.io._json.clean_to_json_compatible", counting)
monkeypatch.setattr("dash._json.clean_to_json_compatible", counting)
points = [float(i) for i in range(10_000)]
figure = go.Figure(go.Scatter(y=points)).to_plotly_json()
response = {
"multi": True,
"response": {
"graph": {"figure": figure},
"status": {"children": html.Div("ok")},
"store": {"data": pd.Timestamp("2024-01-01")},
},
}
out = json.loads(to_json(response))
assert out["response"]["graph"]["figure"]["data"][0]["y"] == points
assert out["response"]["store"]["data"] == "2024-01-01T00:00:00"
assert len(visited) < 10


def test_json003_datetime_series_with_nat(engine):
# plotly's orjson path raises on this one
series = pd.Series([pd.Timestamp("2024-01-01"), pd.NaT])
assert json.loads(to_json({"v": series})) == {"v": ["2024-01-01T00:00:00", None]}


@pytest.mark.parametrize(
"value",
[object(), 1j, {(1, 2): 1}, {"a": [{(1,): 2}]}, {decimal.Decimal("1.5"): 1}],
)
def test_json004_unserializable_raises_type_error(engine, value):
with pytest.raises(TypeError):
to_json({"v": value})


@pytest.mark.parametrize(
"value",
[decimal.Decimal("NaN"), decimal.Decimal("-Infinity"), np.datetime64("NaT")],
)
def test_json005_non_finite_values_are_null(engine, value):
if engine == "orjson" and isinstance(value, np.datetime64):
pytest.skip("orjson writes NaT, as plotly does")
assert json.loads(to_json({"v": [value]})) == {"v": [None]}


def test_json006_circular_values_raise_value_error(engine):
if engine == "orjson":
pytest.skip("orjson raises RecursionError, as plotly does")
circular = []
circular.append(circular)
with pytest.raises(ValueError):
to_json(circular)
Loading