From 9d1087110618655592306f03980ca3b188b4bff0 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Mon, 16 Aug 2021 18:24:06 -0400 Subject: [PATCH] Refactor JSON encoding to use plotly.py JSON engine (#1514) * Use plotly.io.json.to_json_plotly for JSON serialization * Require plotly.py v5 * Run a few tests with both json and orjson encoders * CHANGELOG entry --- CHANGELOG.md | 5 + dash/_utils.py | 7 ++ dash/dash.py | 12 +-- requires-dev.txt | 2 + requires-install.txt | 2 +- .../callbacks/test_basic_callback.py | 91 ++++++++++--------- .../callbacks/test_malformed_request.py | 2 +- tests/integration/utils.py | 13 +++ 8 files changed, 81 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97f1c471a6..b3451a4c7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Dash and Dash Renderer +### Added +- [#1514](https://github.com/plotly/dash/pull/1514) Perform json encoding using the active plotly JSON engine. This will default to the faster orjson encoder if the `orjson` package is installed. + + + ### Changed - [#1707](https://github.com/plotly/dash/pull/1707) Change the default value of the `compress` argument to the `dash.Dash` constructor to `False`. This change reduces CPU usage, and was made in recognition of the fact that many deployment platforms (e.g. Dash Enterprise) already apply their own compression. If deploying to an environment that does not already provide compression, the Dash 1 behavior may be restored by adding `compress=True` to the `dash.Dash` constructor. diff --git a/dash/_utils.py b/dash/_utils.py index d073bf6e6d..8478670ca5 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -20,6 +20,13 @@ _strings = (type(""), type(utils.bytes_to_native_str(b""))) +def to_json(value): + # pylint: disable=import-outside-toplevel + from plotly.io.json import to_json_plotly + + return to_json_plotly(value) + + def interpolate_str(template, **data): s = template for k, v in data.items(): diff --git a/dash/dash.py b/dash/dash.py index 87e0955974..36464b7069 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -4,7 +4,6 @@ import sys import collections import importlib -import json import pkgutil import threading import re @@ -22,8 +21,6 @@ from werkzeug.debug.tbtools import get_current_traceback from pkg_resources import get_distribution, parse_version -import plotly - from .fingerprint import build_fingerprint, check_fingerprint from .resources import Scripts, Css from .dependencies import ( @@ -49,6 +46,7 @@ split_callback_id, stringify_id, strip_relative_path, + to_json, ) from . import _dash_renderer from . import _validate @@ -556,7 +554,7 @@ def serve_layout(self): # TODO - Set browser cache limit - pass hash into frontend return flask.Response( - json.dumps(layout, cls=plotly.utils.PlotlyJSONEncoder), + to_json(layout), mimetype="application/json", ) @@ -714,7 +712,7 @@ def _generate_scripts_html(self): def _generate_config_html(self): return ''.format( - json.dumps(self._config(), cls=plotly.utils.PlotlyJSONEncoder) + to_json(self._config()) ) def _generate_renderer(self): @@ -1109,9 +1107,7 @@ def add_context(*args, **kwargs): response = {"response": component_ids, "multi": True} try: - jsonResponse = json.dumps( - response, cls=plotly.utils.PlotlyJSONEncoder - ) + jsonResponse = to_json(response) except TypeError: _validate.fail_callback_output(output_value, output) diff --git a/requires-dev.txt b/requires-dev.txt index 30cfb2f517..5491728835 100644 --- a/requires-dev.txt +++ b/requires-dev.txt @@ -10,3 +10,5 @@ black==21.6b0 fire==0.4.0 coloredlogs==15.0.1 flask-talisman==0.8.1 +orjson==3.3.1;python_version<"3.7" +orjson==3.6.1;python_version>="3.7" diff --git a/requires-install.txt b/requires-install.txt index 64209d539c..861c95d5c4 100644 --- a/requires-install.txt +++ b/requires-install.txt @@ -1,6 +1,6 @@ Flask>=1.0.4 flask-compress -plotly +plotly>=5.0.0 dash-core-components==1.17.1 dash-html-components==1.1.4 dash-table==4.12.0 diff --git a/tests/integration/callbacks/test_basic_callback.py b/tests/integration/callbacks/test_basic_callback.py index 12c2b2f356..d1a8f89086 100644 --- a/tests/integration/callbacks/test_basic_callback.py +++ b/tests/integration/callbacks/test_basic_callback.py @@ -16,6 +16,7 @@ from dash.dependencies import Input, Output, State from dash.exceptions import PreventUpdate from dash.testing import wait +from tests.integration.utils import json_engine def test_cbsc001_simple_callback(dash_duo): @@ -248,57 +249,61 @@ def update_out2(n_clicks, data): assert dash_duo.get_logs() == [] -def test_cbsc005_children_types(dash_duo): - app = dash.Dash() - app.layout = html.Div([html.Button(id="btn"), html.Div("init", id="out")]) - - outputs = [ - [None, ""], - ["a string", "a string"], - [123, "123"], - [123.45, "123.45"], - [[6, 7, 8], "678"], - [["a", "list", "of", "strings"], "alistofstrings"], - [["strings", 2, "numbers"], "strings2numbers"], - [["a string", html.Div("and a div")], "a string\nand a div"], - ] - - @app.callback(Output("out", "children"), [Input("btn", "n_clicks")]) - def set_children(n): - if n is None or n > len(outputs): - return dash.no_update - return outputs[n - 1][0] +@pytest.mark.parametrize("engine", ["json", "orjson"]) +def test_cbsc005_children_types(dash_duo, engine): + with json_engine(engine): + app = dash.Dash() + app.layout = html.Div([html.Button(id="btn"), html.Div("init", id="out")]) + + outputs = [ + [None, ""], + ["a string", "a string"], + [123, "123"], + [123.45, "123.45"], + [[6, 7, 8], "678"], + [["a", "list", "of", "strings"], "alistofstrings"], + [["strings", 2, "numbers"], "strings2numbers"], + [["a string", html.Div("and a div")], "a string\nand a div"], + ] - dash_duo.start_server(app) - dash_duo.wait_for_text_to_equal("#out", "init") + @app.callback(Output("out", "children"), [Input("btn", "n_clicks")]) + def set_children(n): + if n is None or n > len(outputs): + return dash.no_update + return outputs[n - 1][0] - for children, text in outputs: - dash_duo.find_element("#btn").click() - dash_duo.wait_for_text_to_equal("#out", text) + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#out", "init") + for children, text in outputs: + dash_duo.find_element("#btn").click() + dash_duo.wait_for_text_to_equal("#out", text) -def test_cbsc006_array_of_objects(dash_duo): - app = dash.Dash() - app.layout = html.Div( - [html.Button(id="btn"), dcc.Dropdown(id="dd"), html.Div(id="out")] - ) - @app.callback(Output("dd", "options"), [Input("btn", "n_clicks")]) - def set_options(n): - return [{"label": "opt{}".format(i), "value": i} for i in range(n or 0)] +@pytest.mark.parametrize("engine", ["json", "orjson"]) +def test_cbsc006_array_of_objects(dash_duo, engine): + with json_engine(engine): + app = dash.Dash() + app.layout = html.Div( + [html.Button(id="btn"), dcc.Dropdown(id="dd"), html.Div(id="out")] + ) - @app.callback(Output("out", "children"), [Input("dd", "options")]) - def set_out(opts): - print(repr(opts)) - return len(opts) + @app.callback(Output("dd", "options"), [Input("btn", "n_clicks")]) + def set_options(n): + return [{"label": "opt{}".format(i), "value": i} for i in range(n or 0)] - dash_duo.start_server(app) + @app.callback(Output("out", "children"), [Input("dd", "options")]) + def set_out(opts): + print(repr(opts)) + return len(opts) - dash_duo.wait_for_text_to_equal("#out", "0") - for i in range(5): - dash_duo.find_element("#btn").click() - dash_duo.wait_for_text_to_equal("#out", str(i + 1)) - dash_duo.select_dcc_dropdown("#dd", "opt{}".format(i)) + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#out", "0") + for i in range(5): + dash_duo.find_element("#btn").click() + dash_duo.wait_for_text_to_equal("#out", str(i + 1)) + dash_duo.select_dcc_dropdown("#dd", "opt{}".format(i)) @pytest.mark.parametrize("refresh", [False, True]) diff --git a/tests/integration/callbacks/test_malformed_request.py b/tests/integration/callbacks/test_malformed_request.py index c815fc1b9d..e0cbe24c9b 100644 --- a/tests/integration/callbacks/test_malformed_request.py +++ b/tests/integration/callbacks/test_malformed_request.py @@ -33,7 +33,7 @@ def update_output(value): ), ) assert response.status_code == 200 - assert '"o1": {"children": 9}' in response.text + assert '"o1":{"children":9}' in response.text # now some bad ones outspecs = [ diff --git a/tests/integration/utils.py b/tests/integration/utils.py index 43dc61974f..7b0a967cf2 100644 --- a/tests/integration/utils.py +++ b/tests/integration/utils.py @@ -1,4 +1,5 @@ import time +import contextlib TIMEOUT = 5 # Seconds @@ -61,3 +62,15 @@ def wrapped_condition_function(): time.sleep(0.5) raise WaitForTimeout(get_message()) + + +@contextlib.contextmanager +def json_engine(engine): + import plotly.io as pio + + original_engine = pio.json.config.default_engine + try: + pio.json.config.default_engine = engine + yield + finally: + pio.json.config.default_engine = original_engine