diff --git a/.ai/ARCHITECTURE.md b/.ai/ARCHITECTURE.md index fbb06653f4..8ff39935db 100644 --- a/.ai/ARCHITECTURE.md +++ b/.ai/ARCHITECTURE.md @@ -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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7103f5644c..ee7e37ccfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. "" 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). diff --git a/dash/_json.py b/dash/_json.py new file mode 100644 index 0000000000..ee8c1a3e13 --- /dev/null +++ b/dash/_json.py @@ -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) diff --git a/dash/_utils.py b/dash/_utils.py index 3a3f6ab4d6..8dc010766a 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -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): diff --git a/tests/integration/callbacks/test_serialization.py b/tests/integration/callbacks/test_serialization.py new file mode 100644 index 0000000000..8ff5691b84 --- /dev/null +++ b/tests/integration/callbacks/test_serialization.py @@ -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() == [] diff --git a/tests/unit/test_json.py b/tests/unit/test_json.py new file mode 100644 index 0000000000..03b5f9926f --- /dev/null +++ b/tests/unit/test_json.py @@ -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": "
", +} + + +@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)