Skip to content

Commit efab3a2

Browse files
committed
refactor: rename _as_dict→raw, add update_traces/update_* fast paths
- Rename _as_dict to raw (public API per maintainer feedback) - Add fast paths: update_traces, select_traces, for_each_trace - Add fast path: _select_annotations_like (enables update_annotations, update_shapes, update_images, update_selections) - Extract _recursive_update to module-level helper - Rename test file to test_raw_mode.py, add 5 new tests
1 parent e24fcbb commit efab3a2

File tree

3 files changed

+113
-70
lines changed

3 files changed

+113
-70
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
55
## Unreleased
66

77
### Added
8-
- Add `_as_dict=True` parameter to graph object constructors and `go.Figure` for high-performance figure construction, bypassing validation and object creation [[#5514](https://github.com/plotly/plotly.py/issues/5514)].
8+
- Add `raw=True` parameter to graph object constructors and `go.Figure` for high-performance figure construction, bypassing validation and object creation. Includes fast paths for `update_traces`, `for_each_trace`, `select_traces`, `update_annotations`, `update_shapes`, and other `update_*` methods [[#5514](https://github.com/plotly/plotly.py/issues/5514)].
99
Benchmarks show significant speedups:
1010
- Trace creation: ~26x faster
1111
- Figure creation: ~52x faster

plotly/basedatatypes.py

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,15 @@ def _generator(i):
386386
yield x
387387

388388

389+
def _recursive_update(d, u):
390+
"""Recursively update dict d with values from dict u."""
391+
for k, v in u.items():
392+
if isinstance(v, dict) and k in d and isinstance(d[k], dict):
393+
_recursive_update(d[k], v)
394+
else:
395+
d[k] = v
396+
397+
389398
def _set_property_provided_value(obj, name, arg, provided):
390399
"""
391400
Initialize a property of this object using the provided value
@@ -478,9 +487,9 @@ class is a subclass of both BaseFigure and widgets.DOMWidget.
478487

479488
# Initialize validation
480489
self._validate = kwargs.pop("_validate", True)
481-
self._as_dict_mode = kwargs.pop("_as_dict", False)
490+
self._raw_mode = kwargs.pop("raw", False)
482491

483-
if self._as_dict_mode:
492+
if self._raw_mode:
484493
# Fast path: minimal init for to_dict()/show()/to_json() to work.
485494
self._grid_str = None
486495
self._grid_ref = None
@@ -1002,7 +1011,7 @@ def data(self):
10021011
-------
10031012
tuple[BaseTraceType]
10041013
"""
1005-
if getattr(self, "_as_dict_mode", False):
1014+
if getattr(self, "_raw_mode", False):
10061015
return tuple(self._data)
10071016
return self["data"]
10081017

@@ -1147,6 +1156,8 @@ def select_traces(self, selector=None, row=None, col=None, secondary_y=None):
11471156
Select traces from a particular subplot cell and/or traces
11481157
that satisfy custom selection criteria.
11491158
1159+
In raw mode, row/col/secondary_y filtering is skipped.
1160+
11501161
Parameters
11511162
----------
11521163
selector: dict, function, int, str or None (default None)
@@ -1225,6 +1236,11 @@ def select_traces(self, selector=None, row=None, col=None, secondary_y=None):
12251236
)
12261237

12271238
def _perform_select_traces(self, filter_by_subplot, grid_subplot_refs, selector):
1239+
if getattr(self, "_raw_mode", False):
1240+
return _generator(
1241+
t for t in self._data if self._selector_matches(t, selector)
1242+
)
1243+
12281244
from plotly._subplots import _get_subplot_ref_for_trace
12291245

12301246
# functions for filtering
@@ -1412,6 +1428,16 @@ def update_traces(
14121428
self
14131429
Returns the Figure object that the method was called on
14141430
"""
1431+
if getattr(self, "_raw_mode", False):
1432+
updates = {**(patch or {}), **kwargs}
1433+
for trace in self._data:
1434+
if self._selector_matches(trace, selector):
1435+
if overwrite:
1436+
trace.update(updates)
1437+
else:
1438+
_recursive_update(trace, updates)
1439+
return self
1440+
14151441
for trace in self.select_traces(
14161442
selector=selector, row=row, col=col, secondary_y=secondary_y
14171443
):
@@ -1442,15 +1468,7 @@ def update_layout(self, dict1=None, overwrite=False, **kwargs):
14421468
BaseFigure
14431469
The Figure object that the update_layout method was called on
14441470
"""
1445-
if getattr(self, "_as_dict_mode", False):
1446-
1447-
def _recursive_update(d, u):
1448-
for k, v in u.items():
1449-
if isinstance(v, dict) and k in d and isinstance(d[k], dict):
1450-
_recursive_update(d[k], v)
1451-
else:
1452-
d[k] = v
1453-
1471+
if getattr(self, "_raw_mode", False):
14541472
if overwrite:
14551473
if dict1:
14561474
self._layout.update(dict1)
@@ -1523,6 +1541,10 @@ def _select_annotations_like(
15231541
Helper to select annotation-like elements from a layout object array.
15241542
Compatible with layout.annotations, layout.shapes, and layout.images
15251543
"""
1544+
if getattr(self, "_raw_mode", False):
1545+
objs = self._layout.get(prop, [])
1546+
return _generator(o for o in objs if self._selector_matches(o, selector))
1547+
15261548
xref_to_col = {}
15271549
yref_to_row = {}
15281550
yref_to_secondary_y = {}
@@ -1573,7 +1595,7 @@ def _add_annotation_like(
15731595
secondary_y=None,
15741596
exclude_empty_subplots=False,
15751597
):
1576-
if getattr(self, "_as_dict_mode", False):
1598+
if getattr(self, "_raw_mode", False):
15771599
if hasattr(new_obj, "to_plotly_json"):
15781600
obj_dict = new_obj.to_plotly_json()
15791601
elif isinstance(new_obj, dict):
@@ -2266,7 +2288,7 @@ def add_traces(
22662288
Figure(...)
22672289
"""
22682290

2269-
if getattr(self, "_as_dict_mode", False):
2291+
if getattr(self, "_raw_mode", False):
22702292
if not isinstance(data, (list, tuple)):
22712293
data = [data]
22722294
self._data.extend(data)
@@ -2626,7 +2648,7 @@ def layout(self):
26262648
-------
26272649
plotly.graph_objs.Layout
26282650
"""
2629-
if getattr(self, "_as_dict_mode", False):
2651+
if getattr(self, "_raw_mode", False):
26302652
return self._layout
26312653
return self["layout"]
26322654

@@ -4138,7 +4160,7 @@ def _process_multiple_axis_spanning_shapes(
41384160
Add a shape or multiple shapes and call _make_axis_spanning_layout_object on
41394161
all the new shapes.
41404162
"""
4141-
if getattr(self, "_as_dict_mode", False):
4163+
if getattr(self, "_raw_mode", False):
41424164
shape_kwargs, annotation_kwargs = shapeannotation.split_dict_by_key_prefix(
41434165
kwargs, "annotation_"
41444166
)
@@ -4427,7 +4449,7 @@ class BasePlotlyType(object):
44274449
_valid_props = set()
44284450

44294451
def __new__(cls, *args, **kwargs):
4430-
if kwargs.pop("_as_dict", False):
4452+
if kwargs.pop("raw", False):
44314453
kwargs.pop("skip_invalid", None)
44324454
kwargs.pop("_validate", None)
44334455
return kwargs
@@ -4444,8 +4466,8 @@ def __init__(self, plotly_name, **kwargs):
44444466
kwargs : dict
44454467
Invalid props/values to raise on
44464468
"""
4447-
# Remove _as_dict if it was passed (handled by __new__)
4448-
kwargs.pop("_as_dict", None)
4469+
# Remove raw if it was passed (handled by __new__)
4470+
kwargs.pop("raw", None)
44494471

44504472
# ### _skip_invalid ##
44514473
# If True, then invalid properties should be skipped, if False then
@@ -4551,7 +4573,7 @@ def _process_kwargs(self, **kwargs):
45514573
"""
45524574
Process any extra kwargs that are not predefined as constructor params
45534575
"""
4554-
kwargs.pop("_as_dict", None)
4576+
kwargs.pop("raw", None)
45554577
for k, v in kwargs.items():
45564578
err = _check_path_in_prop_tree(self, k, error_cast=ValueError)
45574579
if err is None:
@@ -6129,7 +6151,7 @@ class BaseTraceType(BaseTraceHierarchyType):
61296151
"""
61306152

61316153
def __new__(cls, *args, **kwargs):
6132-
if kwargs.pop("_as_dict", False):
6154+
if kwargs.pop("raw", False):
61336155
kwargs.pop("skip_invalid", None)
61346156
kwargs.pop("_validate", None)
61356157
kwargs["type"] = cls._path_str
Original file line numberDiff line numberDiff line change
@@ -1,111 +1,132 @@
11
import plotly.graph_objects as go
22

33

4-
class TestTraceAsDict:
5-
"""Test _as_dict=True on trace constructors."""
4+
class TestTraceRaw:
5+
"""Test raw=True on trace constructors."""
66

77
def test_returns_dict_with_type(self):
8-
result = go.Scatter(x=[1, 2], y=[3, 4], mode="lines", _as_dict=True)
8+
result = go.Scatter(x=[1, 2], y=[3, 4], mode="lines", raw=True)
99
assert isinstance(result, dict)
1010
assert result == {"type": "scatter", "x": [1, 2], "y": [3, 4], "mode": "lines"}
1111

1212
def test_preserves_nested_dicts(self):
13-
result = go.Scatter(
14-
x=[1], y=[2], line=dict(color="red", width=2), _as_dict=True
15-
)
13+
result = go.Scatter(x=[1], y=[2], line=dict(color="red", width=2), raw=True)
1614
assert result["line"] == {"color": "red", "width": 2}
1715

1816
def test_default_unchanged(self):
19-
"""Without _as_dict, constructors must return graph objects as before."""
17+
"""Without raw, constructors must return graph objects as before."""
2018
assert not isinstance(go.Scatter(x=[1], y=[2]), dict)
21-
assert not isinstance(go.Scatter(x=[1], y=[2], _as_dict=False), dict)
19+
assert not isinstance(go.Scatter(x=[1], y=[2], raw=False), dict)
2220

2321

24-
class TestFigureAsDict:
25-
"""Test _as_dict=True on go.Figure construction and methods."""
22+
class TestFigureRaw:
23+
"""Test raw=True on go.Figure construction and methods."""
2624

2725
def test_construction(self):
2826
fig = go.Figure(
29-
data=[go.Scatter(x=[1], y=[2], _as_dict=True)],
27+
data=[go.Scatter(x=[1], y=[2], raw=True)],
3028
layout={"title": "T"},
31-
_as_dict=True,
29+
raw=True,
3230
)
3331
assert isinstance(fig, go.Figure)
34-
assert fig._as_dict_mode is True
35-
# data stored as list of dicts, layout as raw dict
32+
assert fig._raw_mode is True
3633
assert fig._data == [{"type": "scatter", "x": [1], "y": [2]}]
3734
assert fig._layout == {"title": "T"}
38-
# Property getters return raw containers
3935
assert isinstance(fig.data, tuple)
4036
assert fig.layout is fig._layout
4137

4238
def test_construction_from_dict(self):
4339
"""Accept figure-like dict input (e.g. from to_dict())."""
4440
fig = go.Figure(
4541
data={"data": [{"type": "bar", "x": [1]}], "layout": {"height": 400}},
46-
_as_dict=True,
42+
raw=True,
4743
)
4844
assert fig._data == [{"type": "bar", "x": [1]}]
4945
assert fig._layout == {"height": 400}
5046

5147
def test_add_traces(self):
52-
fig = go.Figure(_as_dict=True)
53-
# Single item (not list)
54-
fig.add_traces(go.Scatter(x=[1], y=[2], _as_dict=True))
55-
# List of items
56-
fig.add_traces([go.Bar(x=["a"], y=[1], _as_dict=True)])
48+
fig = go.Figure(raw=True)
49+
fig.add_traces(go.Scatter(x=[1], y=[2], raw=True))
50+
fig.add_traces([go.Bar(x=["a"], y=[1], raw=True)])
5751
assert len(fig._data) == 2
5852
assert fig._data[0]["type"] == "scatter"
5953
assert fig._data[1]["type"] == "bar"
6054

6155
def test_update_layout(self):
62-
fig = go.Figure(_as_dict=True)
63-
# kwargs path
56+
fig = go.Figure(raw=True)
6457
fig.update_layout(title={"text": "Hello"})
65-
# dict1 path — recursive merge
6658
fig.update_layout({"title": {"font": {"size": 20}}})
6759
assert fig._layout["title"] == {"text": "Hello", "font": {"size": 20}}
68-
# overwrite=True
6960
fig.update_layout(title={"text": "New"}, overwrite=True)
7061
assert fig._layout["title"] == {"text": "New"}
7162

63+
def test_update_traces(self):
64+
fig = go.Figure(raw=True)
65+
fig.add_traces(
66+
[
67+
go.Scatter(x=[1], y=[2], mode="lines", raw=True),
68+
go.Bar(x=["a"], y=[1], raw=True),
69+
]
70+
)
71+
fig.update_traces(patch={"opacity": 0.5})
72+
assert fig._data[0]["opacity"] == 0.5
73+
assert fig._data[1]["opacity"] == 0.5
74+
75+
def test_update_traces_with_selector(self):
76+
fig = go.Figure(raw=True)
77+
fig.add_traces(
78+
[
79+
go.Scatter(x=[1], y=[2], raw=True),
80+
go.Bar(x=["a"], y=[1], raw=True),
81+
]
82+
)
83+
fig.update_traces(patch={"opacity": 0.5}, selector={"type": "scatter"})
84+
assert fig._data[0]["opacity"] == 0.5
85+
assert "opacity" not in fig._data[1]
86+
87+
def test_for_each_trace(self):
88+
fig = go.Figure(raw=True)
89+
fig.add_traces([go.Scatter(x=[1], y=[2], raw=True)])
90+
visited = []
91+
fig.for_each_trace(lambda t: visited.append(t["type"]))
92+
assert visited == ["scatter"]
93+
7294
def test_add_annotation_and_shape(self):
73-
fig = go.Figure(_as_dict=True)
95+
fig = go.Figure(raw=True)
7496
fig.add_annotation(text="Note", x=1, y=2, showarrow=False)
7597
fig.add_shape(type="rect", x0=0, y0=0, x1=1, y1=1)
7698
assert fig._layout["annotations"][0]["text"] == "Note"
7799
assert fig._layout["shapes"][0]["type"] == "rect"
78100

101+
def test_update_annotations(self):
102+
fig = go.Figure(raw=True)
103+
fig.add_annotation(text="A", x=0, y=0, showarrow=False)
104+
fig.add_annotation(text="B", x=1, y=1, showarrow=False)
105+
fig.update_annotations(patch={"font": {"size": 14}})
106+
for ann in fig._layout["annotations"]:
107+
assert ann["font"] == {"size": 14}
108+
109+
def test_update_shapes(self):
110+
fig = go.Figure(raw=True)
111+
fig.add_shape(type="rect", x0=0, y0=0, x1=1, y1=1)
112+
fig.update_shapes(patch={"opacity": 0.3})
113+
assert fig._layout["shapes"][0]["opacity"] == 0.3
114+
79115
def test_add_vline_and_hline(self):
80-
fig = go.Figure(_as_dict=True)
116+
fig = go.Figure(raw=True)
81117
fig.add_vline(x=5)
82118
fig.add_hline(y=3)
83119
shapes = fig._layout["shapes"]
84120
assert len(shapes) == 2
85-
# vline: x fixed, yref is domain
86121
assert shapes[0]["x0"] == 5 and shapes[0]["x1"] == 5
87122
assert "y domain" in shapes[0]["yref"]
88-
# hline: y fixed, xref is domain
89123
assert shapes[1]["y0"] == 3 and shapes[1]["y1"] == 3
90124
assert "x domain" in shapes[1]["xref"]
91125

92-
def test_add_vline_with_annotation(self):
93-
fig = go.Figure(_as_dict=True)
94-
fig.add_vline(x=5, annotation=dict(text="Limit"))
95-
assert len(fig._layout["shapes"]) == 1
96-
assert fig._layout["annotations"][0]["text"] == "Limit"
97-
98-
def test_bulk_annotations(self):
99-
"""Annotations use list.append (O(N)), not tuple concat."""
100-
fig = go.Figure(_as_dict=True)
101-
for i in range(100):
102-
fig.add_annotation(text=f"L{i}", x=i, y=i, showarrow=False)
103-
assert len(fig._layout["annotations"]) == 100
104-
105126
def test_serialization(self):
106127
fig = go.Figure(
107-
data=[go.Scatter(x=[1, 2], y=[3, 4], _as_dict=True)],
108-
_as_dict=True,
128+
data=[go.Scatter(x=[1, 2], y=[3, 4], raw=True)],
129+
raw=True,
109130
)
110131
fig.update_layout(title="Test")
111132
d = fig.to_dict()
@@ -115,24 +136,24 @@ def test_serialization(self):
115136
assert isinstance(j, str) and '"scatter"' in j
116137

117138
def test_default_figure_unchanged(self):
118-
"""Without _as_dict, Figure must behave exactly as before."""
139+
"""Without raw, Figure must behave exactly as before."""
119140
fig = go.Figure(data=[go.Scatter(x=[1], y=[2])])
120141
assert hasattr(fig.data[0], "to_plotly_json")
121-
assert not getattr(fig, "_as_dict_mode", False)
142+
assert not getattr(fig, "_raw_mode", False)
122143

123144

124145
class TestOutputEquivalence:
125146
"""Fast-path output must match standard path for explicit properties."""
126147

127148
def test_trace_equivalence(self):
128149
default = go.Scatter(x=[1], y=[2], mode="lines")
129-
fast = go.Scatter(x=[1], y=[2], mode="lines", _as_dict=True)
150+
fast = go.Scatter(x=[1], y=[2], mode="lines", raw=True)
130151
for key in ["x", "y", "mode", "type"]:
131152
assert default.to_plotly_json()[key] == fast[key]
132153

133154
def test_figure_data_equivalence(self):
134155
default = go.Figure(data=[go.Scatter(x=[1], y=[2])])
135-
fast = go.Figure(data=[go.Scatter(x=[1], y=[2], _as_dict=True)], _as_dict=True)
156+
fast = go.Figure(data=[go.Scatter(x=[1], y=[2], raw=True)], raw=True)
136157
dd, fd = default.to_dict()["data"][0], fast.to_dict()["data"][0]
137158
for key in ["type", "x", "y"]:
138159
assert dd[key] == fd[key]

0 commit comments

Comments
 (0)