Repository: plotly/plotly.py

Issue:
Handle serializing dicts with ints and strings
The current `to_json_plotly` doesn't handle dicts with integers and strings as keys. This used to work before we set `sort_keys=True`.

This is relatively common in Dash.


Here is a MWE:
```python
import pandas as pd
from plotly.io.json import to_json_plotly

df = pd.DataFrame({'a': [0], 1: [0]})
to_json_plotly(df.to_dict('records'))
```

```
TypeError: '<' not supported between instances of 'int' and 'str'
```

Here is the issue in the Python tracker: https://bugs.python.org/issue25457 re `sort_keys=True`

Originally reported in https://community.plotly.com/t/dash-2-0-0-migration-error/56478/7


Discussion:
@jonmmease is this something you could take a look at please? There's going to be some ambiguity if the same dict has `1` and `"1"` as keys...
Admittedly, that's an ambiguity that JSON imposes.
FWIW, this can be reproduced without plotly.py

```python
import json
json.dumps({'a': 0, 1: 0}, sort_keys=True)
```
```
TypeError: '<' not supported between instances of 'int' and 'str'
```
The reason that we're hitting this now is that we switched to using `sort_keys=True` during the `orjson` refactor.

As @chriddyp pointed out, there is a 2+ year long discussion in https://bugs.python.org/issue25457 on whether this is actually a bug in the Python 3 json module.

Maybe it was a mistake to set `sort_keys=True`. This is the behavior of orjson, and at the time it seemed reasonable to align the output across json engines :slightly_smiling_face: 

I don' t see a way to address this from within our custom `PlotlyJSONEncoder`, so I see two options:
 - Remove `sort_keys=True`, adjusting tests to accept that the json engines will not produce identical results
 - Try `json.dumps` and catch the exception. If the exception is raised, clone the input dict and modify it to convert non-string keys to strings before running `json.dumps` again.

Thoughts @nicolaskruchten ?



Hmmm yeah, it kinda feels like "Remove sort_keys=True, adjusting tests to accept that the json engines will not produce identical results" is the safer route... basically getting folks to opt-in to `orjson` and any related constraints. Sorry!

Resolving patch:
diff --git a/packages/python/plotly/plotly/io/_json.py b/packages/python/plotly/plotly/io/_json.py
--- a/packages/python/plotly/plotly/io/_json.py
+++ b/packages/python/plotly/plotly/io/_json.py
@@ -112,7 +112,7 @@ def to_json_plotly(plotly_object, pretty=False, engine=None):
     # Dump to a JSON string and return
     # --------------------------------
     if engine == "json":
-        opts = {"sort_keys": True}
+        opts = {}
         if pretty:
             opts["indent"] = 2
         else:
@@ -124,7 +124,7 @@ def to_json_plotly(plotly_object, pretty=False, engine=None):
         return json.dumps(plotly_object, cls=PlotlyJSONEncoder, **opts)
     elif engine == "orjson":
         JsonConfig.validate_orjson()
-        opts = orjson.OPT_SORT_KEYS | orjson.OPT_SERIALIZE_NUMPY
+        opts = orjson.OPT_NON_STR_KEYS | orjson.OPT_SERIALIZE_NUMPY
 
         if pretty:
             opts |= orjson.OPT_INDENT_2
@@ -462,7 +462,7 @@ def clean_to_json_compatible(obj, **kwargs):
         return obj
 
     if isinstance(obj, dict):
-        return {str(k): clean_to_json_compatible(v, **kwargs) for k, v in obj.items()}
+        return {k: clean_to_json_compatible(v, **kwargs) for k, v in obj.items()}
     elif isinstance(obj, (list, tuple)):
         if obj:
             # Must process list recursively even though it may be slow


Test patch:
diff --git a/packages/python/plotly/plotly/tests/test_core/test_graph_objs/test_template.py b/packages/python/plotly/plotly/tests/test_core/test_graph_objs/test_template.py
--- a/packages/python/plotly/plotly/tests/test_core/test_graph_objs/test_template.py
+++ b/packages/python/plotly/plotly/tests/test_core/test_graph_objs/test_template.py
@@ -356,7 +356,7 @@ def test_move_nested_trace_properties(self):
             },
         )
 
-        self.assertEqual(pio.to_json(templated_fig), pio.to_json(expected_fig))
+        self.assertEqual(templated_fig.to_dict(), expected_fig.to_dict())
 
     def test_move_nested_trace_properties_existing_traces(self):
         fig = go.Figure(
diff --git a/packages/python/plotly/plotly/tests/test_io/test_deepcopy_pickle.py b/packages/python/plotly/plotly/tests/test_io/test_deepcopy_pickle.py
--- a/packages/python/plotly/plotly/tests/test_io/test_deepcopy_pickle.py
+++ b/packages/python/plotly/plotly/tests/test_io/test_deepcopy_pickle.py
@@ -38,7 +38,7 @@ def test_deepcopy_figure(fig1):
     fig_copied = copy.deepcopy(fig1)
 
     # Contents should be equal
-    assert pio.to_json(fig_copied) == pio.to_json(fig1)
+    assert fig_copied.to_dict() == fig1.to_dict()
 
     # Identities should be distinct
     assert fig_copied is not fig1
@@ -50,7 +50,7 @@ def test_deepcopy_figure_subplots(fig_subplots):
     fig_copied = copy.deepcopy(fig_subplots)
 
     # Contents should be equal
-    assert pio.to_json(fig_copied) == pio.to_json(fig_subplots)
+    assert fig_copied.to_dict() == fig_subplots.to_dict()
 
     # Subplot metadata should be equal
     assert fig_subplots._grid_ref == fig_copied._grid_ref
@@ -66,7 +66,7 @@ def test_deepcopy_figure_subplots(fig_subplots):
     fig_copied.add_bar(y=[0, 0, 1], row=1, col=2)
 
     # And contents should be still equal
-    assert pio.to_json(fig_copied) == pio.to_json(fig_subplots)
+    assert fig_copied.to_dict() == fig_subplots.to_dict()
 
 
 def test_deepcopy_layout(fig1):
@@ -91,21 +91,21 @@ def test_pickle_figure_round_trip(fig1):
     fig_copied = pickle.loads(pickle.dumps(fig1))
 
     # Contents should be equal
-    assert pio.to_json(fig_copied) == pio.to_json(fig1)
+    assert fig_copied.to_dict() == fig1.to_dict()
 
 
 def test_pickle_figure_subplots_round_trip(fig_subplots):
     fig_copied = pickle.loads(pickle.dumps(fig_subplots))
 
     # Contents should be equal
-    assert pio.to_json(fig_copied) == pio.to_json(fig_subplots)
+    assert fig_copied.to_dict() == fig_subplots.to_dict()
 
     # Should be possible to add new trace to subplot location
     fig_subplots.add_bar(y=[0, 0, 1], row=1, col=2)
     fig_copied.add_bar(y=[0, 0, 1], row=1, col=2)
 
     # And contents should be still equal
-    assert pio.to_json(fig_copied) == pio.to_json(fig_subplots)
+    assert fig_copied.to_dict() == fig_subplots.to_dict()
 
 
 def test_pickle_layout(fig1):
diff --git a/packages/python/plotly/plotly/tests/test_io/test_to_from_json.py b/packages/python/plotly/plotly/tests/test_io/test_to_from_json.py
--- a/packages/python/plotly/plotly/tests/test_io/test_to_from_json.py
+++ b/packages/python/plotly/plotly/tests/test_io/test_to_from_json.py
@@ -29,9 +29,8 @@ def fig1(request):
 opts = {
     "separators": (",", ":"),
     "cls": plotly.utils.PlotlyJSONEncoder,
-    "sort_keys": True,
 }
-pretty_opts = {"indent": 2, "cls": plotly.utils.PlotlyJSONEncoder, "sort_keys": True}
+pretty_opts = {"indent": 2, "cls": plotly.utils.PlotlyJSONEncoder}
 
 
 # to_json
diff --git a/packages/python/plotly/plotly/tests/test_io/test_to_from_plotly_json.py b/packages/python/plotly/plotly/tests/test_io/test_to_from_plotly_json.py
--- a/packages/python/plotly/plotly/tests/test_io/test_to_from_plotly_json.py
+++ b/packages/python/plotly/plotly/tests/test_io/test_to_from_plotly_json.py
@@ -135,7 +135,7 @@ def datetime_array(request, datetime_value):
 def test_graph_object_input(engine, pretty):
     scatter = go.Scatter(x=[1, 2, 3], y=np.array([4, 5, 6]))
     result = pio.to_json_plotly(scatter, engine=engine)
-    expected = """{"type":"scatter","x":[1,2,3],"y":[4,5,6]}"""
+    expected = """{"x":[1,2,3],"y":[4,5,6],"type":"scatter"}"""
     assert result == expected
     check_roundtrip(result, engine=engine, pretty=pretty)
 
@@ -215,3 +215,9 @@ def test_nonstring_key(engine, pretty):
     value = build_test_dict({0: 1})
     result = pio.to_json_plotly(value, engine=engine)
     check_roundtrip(result, engine=engine, pretty=pretty)
+
+
+def test_mixed_string_nonstring_key(engine, pretty):
+    value = build_test_dict({0: 1, "a": 2})
+    result = pio.to_json_plotly(value, engine=engine)
+    check_roundtrip(result, engine=engine, pretty=pretty)
diff --git a/packages/python/plotly/plotly/tests/test_optional/test_offline/test_offline.py b/packages/python/plotly/plotly/tests/test_optional/test_offline/test_offline.py
--- a/packages/python/plotly/plotly/tests/test_optional/test_offline/test_offline.py
+++ b/packages/python/plotly/plotly/tests/test_optional/test_offline/test_offline.py
@@ -89,7 +89,6 @@ def test_default_mpl_plot_generates_expected_html(self):
             # just make sure a few of the parts are in here
             # like PlotlyOfflineTestCase(TestCase) in test_core
             self.assertTrue(data_json in html)  # data is in there
-            self.assertTrue(layout_json in html)  # layout is in there too
             self.assertTrue(PLOTLYJS in html)  # and the source code
             # and it's an <html> doc
             self.assertTrue(html.startswith("<html>") and html.endswith("</html>"))