Skip to content

Commit e197f57

Browse files
authored
Add deepcopy and pickle support to figures and graph objects (#1191)
* Allow kwargs to override properties in codegen validator subclasses This will make it easier to implement generic validator deepcopy support * Make all validators support deepcopy * Avoid infinite loops when checking for existence of _subplotid_props in Layout * Add __reduce__ methods to BaseFigure and BasePlotlyType This method provides pickle and deep copy support * Add tests for deepcopy and pickle support
1 parent fac00f1 commit e197f57

File tree

5,565 files changed

+18859
-16791
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

5,565 files changed

+18859
-16791
lines changed

_plotly_utils/basevalidators.py

+13
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import textwrap
44
import uuid
55
from importlib import import_module
6+
import copy
67

78
import io
89
from copy import deepcopy
@@ -373,6 +374,7 @@ def __init__(self,
373374
self.array_ok = array_ok
374375
# coerce_number is rarely used and not implemented
375376
self.coerce_number = coerce_number
377+
self.kwargs = kwargs
376378

377379
# Handle regular expressions
378380
# --------------------------
@@ -398,6 +400,17 @@ def __init__(self,
398400
self.val_regexs.append(None)
399401
self.regex_replacements.append(None)
400402

403+
def __deepcopy__(self, memodict={}):
404+
"""
405+
A custom deepcopy method is needed here because compiled regex
406+
objects don't support deepcopy
407+
"""
408+
cls = self.__class__
409+
return cls(
410+
self.plotly_name,
411+
self.parent_name,
412+
values=self.values)
413+
401414
@staticmethod
402415
def build_regex_replacement(regex_str):
403416
# Example: regex_str == r"^y([2-9]|[1-9][0-9]+)?$"

codegen/validators.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def __init__(self, plotly_name={params['plotly_name']},
6363
continue
6464

6565
buffer.write(f""",
66-
{attr_name}={attr_val}""")
66+
{attr_name}=kwargs.pop('{attr_name}', {attr_val})""")
6767

6868
buffer.write(f""",
6969
**kwargs""")

plotly/animation.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33

44
class EasingValidator(EnumeratedValidator):
55

6-
def __init__(self, plotly_name='easing'):
7-
super(EasingValidator, self).__init__(plotly_name=plotly_name,
8-
parent_name='batch_animate',
6+
def __init__(self, plotly_name='easing', parent_name='batch_animate', **_):
7+
super(EasingValidator, self).__init__(
8+
plotly_name=plotly_name,
9+
parent_name=parent_name,
910
values=[
1011
"linear",
1112
"quad",

plotly/basedatatypes.py

+30-3
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,9 @@ class is a subclass of both BaseFigure and widgets.DOMWidget.
9999
# ------------------
100100
# These properties are used by the tools.make_subplots logic.
101101
# We initialize them to None here, before checking if the input data
102-
# object is a BaseFigure, in which case we bring over the _grid*
103-
# properties of the input BaseFigure
102+
# object is a BaseFigure, or a dict with _grid_str and _grid_ref
103+
# properties, in which case we bring over the _grid* properties of
104+
# the input
104105
self._grid_str = None
105106
self._grid_ref = None
106107

@@ -116,6 +117,12 @@ class is a subclass of both BaseFigure and widgets.DOMWidget.
116117

117118
elif (isinstance(data, dict)
118119
and ('data' in data or 'layout' in data or 'frames' in data)):
120+
121+
# Bring over subplot fields
122+
self._grid_str = data.get('_grid_str', None)
123+
self._grid_ref = data.get('_grid_ref', None)
124+
125+
# Extract data, layout, and frames
119126
data, layout, frames = (data.get('data', None),
120127
data.get('layout', None),
121128
data.get('frames', None))
@@ -230,6 +237,17 @@ class is a subclass of both BaseFigure and widgets.DOMWidget.
230237

231238
# Magic Methods
232239
# -------------
240+
def __reduce__(self):
241+
"""
242+
Custom implementation of reduce is used to support deep copying
243+
and pickling
244+
"""
245+
props = self.to_dict()
246+
props['_grid_str'] = self._grid_str
247+
props['_grid_ref'] = self._grid_ref
248+
return (self.__class__,
249+
(props,))
250+
233251
def __setitem__(self, prop, value):
234252

235253
# Normalize prop
@@ -2594,6 +2612,15 @@ def figure(self):
25942612

25952613
# Magic Methods
25962614
# -------------
2615+
def __reduce__(self):
2616+
"""
2617+
Custom implementation of reduce is used to support deep copying
2618+
and pickling
2619+
"""
2620+
props = self.to_plotly_json()
2621+
return (self.__class__,
2622+
(props,))
2623+
25972624
def __getitem__(self, prop):
25982625
"""
25992626
Get item or nested item from object
@@ -3623,7 +3650,7 @@ def __getattr__(self, prop):
36233650
Custom __getattr__ that handles dynamic subplot properties
36243651
"""
36253652
prop = self._strip_subplot_suffix_of_1(prop)
3626-
if prop in self._subplotid_props:
3653+
if prop != '_subplotid_props' and prop in self._subplotid_props:
36273654
validator = self._validators[prop]
36283655
return validator.present(self._compound_props[prop])
36293656
else:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import pytest
2+
import copy
3+
import pickle
4+
5+
from plotly.tools import make_subplots
6+
import plotly.graph_objs as go
7+
import plotly.io as pio
8+
9+
10+
# fixtures
11+
# --------
12+
@pytest.fixture
13+
def fig1(request):
14+
return go.Figure(data=[{'type': 'scattergl',
15+
'marker': {'color': 'green'}},
16+
{'type': 'parcoords',
17+
'dimensions': [{'values': [1, 2, 3]},
18+
{'values': [3, 2, 1]}],
19+
'line': {'color': 'blue'}}],
20+
layout={'title': 'Figure title'})
21+
22+
23+
@pytest.fixture
24+
def fig_subplots(request):
25+
fig = make_subplots(3, 2)
26+
fig.add_scatter(y=[2, 1, 3], row=1, col=1)
27+
fig.add_scatter(y=[1, 3, 3], row=2, col=2)
28+
return fig
29+
30+
31+
# Deep copy
32+
# ---------
33+
def test_deepcopy_figure(fig1):
34+
fig_copied = copy.deepcopy(fig1)
35+
36+
# Contents should be equal
37+
assert pio.to_json(fig_copied) == pio.to_json(fig1)
38+
39+
# Identities should be distinct
40+
assert fig_copied is not fig1
41+
assert fig_copied.layout is not fig1.layout
42+
assert fig_copied.data is not fig1.data
43+
44+
45+
def test_deepcopy_figure_subplots(fig_subplots):
46+
fig_copied = copy.deepcopy(fig_subplots)
47+
48+
# Contents should be equal
49+
assert pio.to_json(fig_copied) == pio.to_json(fig_subplots)
50+
51+
# Subplot metadata should be equal
52+
assert fig_subplots._grid_ref == fig_copied._grid_ref
53+
assert fig_subplots._grid_str == fig_copied._grid_str
54+
55+
# Identities should be distinct
56+
assert fig_copied is not fig_subplots
57+
assert fig_copied.layout is not fig_subplots.layout
58+
assert fig_copied.data is not fig_subplots.data
59+
60+
# Should be possible to add new trace to subplot location
61+
fig_subplots.add_bar(y=[0, 0, 1], row=1, col=2)
62+
fig_copied.add_bar(y=[0, 0, 1], row=1, col=2)
63+
64+
# And contents should be still equal
65+
assert pio.to_json(fig_copied) == pio.to_json(fig_subplots)
66+
67+
68+
def test_deepcopy_layout(fig1):
69+
copied_layout = copy.deepcopy(fig1.layout)
70+
71+
# Contents should be equal
72+
assert copied_layout == fig1.layout
73+
74+
# Identities should not
75+
assert copied_layout is not fig1.layout
76+
77+
# Original layout should still have fig1 as parent
78+
assert fig1.layout.parent is fig1
79+
80+
# Copied layout should have no parent
81+
assert copied_layout.parent is None
82+
83+
84+
# Pickling
85+
# --------
86+
def test_pickle_figure_round_trip(fig1):
87+
fig_copied = pickle.loads(pickle.dumps(fig1))
88+
89+
# Contents should be equal
90+
assert pio.to_json(fig_copied) == pio.to_json(fig1)
91+
92+
93+
def test_pickle_figure_subplots_round_trip(fig_subplots):
94+
fig_copied = pickle.loads(pickle.dumps(fig_subplots))
95+
96+
# Contents should be equal
97+
assert pio.to_json(fig_copied) == pio.to_json(fig_subplots)
98+
99+
# Should be possible to add new trace to subplot location
100+
fig_subplots.add_bar(y=[0, 0, 1], row=1, col=2)
101+
fig_copied.add_bar(y=[0, 0, 1], row=1, col=2)
102+
103+
# And contents should be still equal
104+
assert pio.to_json(fig_copied) == pio.to_json(fig_subplots)
105+
106+
107+
def test_pickle_layout(fig1):
108+
copied_layout = pickle.loads(pickle.dumps(fig1.layout))
109+
110+
# Contents should be equal
111+
assert copied_layout == fig1.layout
112+
113+
# Identities should not
114+
assert copied_layout is not fig1.layout
115+
116+
# Original layout should still have fig1 as parent
117+
assert fig1.layout.parent is fig1
118+
119+
# Copied layout should have no parent
120+
assert copied_layout.parent is None

plotly/validators/_area.py

+5-3
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ def __init__(self, plotly_name='area', parent_name='', **kwargs):
77
super(AreaValidator, self).__init__(
88
plotly_name=plotly_name,
99
parent_name=parent_name,
10-
data_class_str='Area',
11-
data_docs="""
10+
data_class_str=kwargs.pop('data_class_str', 'Area'),
11+
data_docs=kwargs.pop(
12+
'data_docs', """
1213
customdata
1314
Assigns extra data each datum. This may be
1415
useful when listening to hover, click and
@@ -83,6 +84,7 @@ def __init__(self, plotly_name='area', parent_name='', **kwargs):
8384
visible. If "legendonly", the trace is not
8485
drawn, but can appear as a legend item
8586
(provided that the legend itself is visible).
86-
""",
87+
"""
88+
),
8789
**kwargs
8890
)

plotly/validators/_bar.py

+5-3
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ def __init__(self, plotly_name='bar', parent_name='', **kwargs):
77
super(BarValidator, self).__init__(
88
plotly_name=plotly_name,
99
parent_name=parent_name,
10-
data_class_str='Bar',
11-
data_docs="""
10+
data_class_str=kwargs.pop('data_class_str', 'Bar'),
11+
data_docs=kwargs.pop(
12+
'data_docs', """
1213
base
1314
Sets where the bar base is drawn (in position
1415
axis units). In "stack" or "relative" barmode,
@@ -210,6 +211,7 @@ def __init__(self, plotly_name='bar', parent_name='', **kwargs):
210211
data.
211212
ysrc
212213
Sets the source reference on plot.ly for y .
213-
""",
214+
"""
215+
),
214216
**kwargs
215217
)

plotly/validators/_box.py

+5-3
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ def __init__(self, plotly_name='box', parent_name='', **kwargs):
77
super(BoxValidator, self).__init__(
88
plotly_name=plotly_name,
99
parent_name=parent_name,
10-
data_class_str='Box',
11-
data_docs="""
10+
data_class_str=kwargs.pop('data_class_str', 'Box'),
11+
data_docs=kwargs.pop(
12+
'data_docs', """
1213
boxmean
1314
If True, the mean of the box(es)' underlying
1415
distribution is drawn as a dashed line inside
@@ -179,6 +180,7 @@ def __init__(self, plotly_name='box', parent_name='', **kwargs):
179180
data.
180181
ysrc
181182
Sets the source reference on plot.ly for y .
182-
""",
183+
"""
184+
),
183185
**kwargs
184186
)

plotly/validators/_candlestick.py

+5-3
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ def __init__(self, plotly_name='candlestick', parent_name='', **kwargs):
77
super(CandlestickValidator, self).__init__(
88
plotly_name=plotly_name,
99
parent_name=parent_name,
10-
data_class_str='Candlestick',
11-
data_docs="""
10+
data_class_str=kwargs.pop('data_class_str', 'Candlestick'),
11+
data_docs=kwargs.pop(
12+
'data_docs', """
1213
close
1314
Sets the close values.
1415
closesrc
@@ -129,6 +130,7 @@ def __init__(self, plotly_name='candlestick', parent_name='', **kwargs):
129130
(the default value), the y coordinates refer to
130131
`layout.yaxis`. If "y2", the y coordinates
131132
refer to `layout.yaxis2`, and so on.
132-
""",
133+
"""
134+
),
133135
**kwargs
134136
)

plotly/validators/_carpet.py

+5-3
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ def __init__(self, plotly_name='carpet', parent_name='', **kwargs):
77
super(CarpetValidator, self).__init__(
88
plotly_name=plotly_name,
99
parent_name=parent_name,
10-
data_class_str='Carpet',
11-
data_docs="""
10+
data_class_str=kwargs.pop('data_class_str', 'Carpet'),
11+
data_docs=kwargs.pop(
12+
'data_docs', """
1213
a
1314
An array containing values of the first
1415
parameter value
@@ -139,6 +140,7 @@ def __init__(self, plotly_name='carpet', parent_name='', **kwargs):
139140
refer to `layout.yaxis2`, and so on.
140141
ysrc
141142
Sets the source reference on plot.ly for y .
142-
""",
143+
"""
144+
),
143145
**kwargs
144146
)

plotly/validators/_choropleth.py

+5-3
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ def __init__(self, plotly_name='choropleth', parent_name='', **kwargs):
77
super(ChoroplethValidator, self).__init__(
88
plotly_name=plotly_name,
99
parent_name=parent_name,
10-
data_class_str='Choropleth',
11-
data_docs="""
10+
data_class_str=kwargs.pop('data_class_str', 'Choropleth'),
11+
data_docs=kwargs.pop(
12+
'data_docs', """
1213
autocolorscale
1314
Determines whether the colorscale is a default
1415
palette (`autocolorscale: true`) or the palette
@@ -150,6 +151,7 @@ def __init__(self, plotly_name='choropleth', parent_name='', **kwargs):
150151
set, `zmax` must be set as well.
151152
zsrc
152153
Sets the source reference on plot.ly for z .
153-
""",
154+
"""
155+
),
154156
**kwargs
155157
)

plotly/validators/_cone.py

+5-3
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ def __init__(self, plotly_name='cone', parent_name='', **kwargs):
77
super(ConeValidator, self).__init__(
88
plotly_name=plotly_name,
99
parent_name=parent_name,
10-
data_class_str='Cone',
11-
data_docs="""
10+
data_class_str=kwargs.pop('data_class_str', 'Cone'),
11+
data_docs=kwargs.pop(
12+
'data_docs', """
1213
anchor
1314
Sets the cones' anchor with respect to their
1415
x/y/z positions. Note that "cm" denote the
@@ -189,6 +190,7 @@ def __init__(self, plotly_name='cone', parent_name='', **kwargs):
189190
of the displayed cones.
190191
zsrc
191192
Sets the source reference on plot.ly for z .
192-
""",
193+
"""
194+
),
193195
**kwargs
194196
)

0 commit comments

Comments
 (0)