Skip to content

Commit 0fa346d

Browse files
committed
RFC: implement attr function and "magic" underscore behavior for PlotlyDict.update
1 parent 1bf7bc2 commit 0fa346d

File tree

6 files changed

+369
-3
lines changed

6 files changed

+369
-3
lines changed

plotly/graph_objs/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,5 @@
1212
from __future__ import absolute_import
1313

1414
from plotly.graph_objs.graph_objs import * # this is protected with __all__
15+
16+
from plotly.graph_objs.graph_objs_tools import attr

plotly/graph_objs/graph_objs.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,12 @@ def update(self, dict1=None, **dict2):
612612
else:
613613
self[key] = val
614614
else:
615-
self[key] = val
615+
# don't have this key -- might be using underscore magic
616+
graph_objs_tools._underscore_magic(key, val, self)
617+
618+
# return self so we can chain this method (e.g. Scatter().update(**)
619+
# returns an instance of Scatter)
620+
return self
616621

617622
def strip_style(self):
618623
"""

plotly/graph_objs/graph_objs_tools.py

+115
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from __future__ import absolute_import
2+
import re
23
import textwrap
34
import six
45

@@ -268,3 +269,117 @@ def sort_keys(key):
268269
"""
269270
is_special = key in 'rtxyz'
270271
return not is_special, key
272+
273+
274+
_underscore_attr_regex = re.compile(
275+
"(" + "|".join(graph_reference.UNDERSCORE_ATTRS) + ")"
276+
)
277+
278+
279+
def _key_parts(key):
280+
if "_" in key:
281+
match = _underscore_attr_regex.search(key)
282+
if match is not None:
283+
if key in graph_reference.UNDERSCORE_ATTRS:
284+
# we have _exactly_ one of the underscore
285+
# attrs
286+
return [key]
287+
else:
288+
# have one underscore in the UNDERSCORE_ATTR
289+
# and then at least one underscore not part
290+
# of the attr. Need to break out the attr
291+
# and then split the other parts
292+
parts = []
293+
if match.start() == 0:
294+
# UNDERSCORE_ATTR is at start of key
295+
parts.append(match.group(1))
296+
else:
297+
# something comes first
298+
before = key[0:match.start()-1]
299+
parts.extend(before.split("_"))
300+
parts.append(match.group(1))
301+
302+
# now take care of anything that might come
303+
# after the underscore attr
304+
if match.end() < len(key):
305+
parts.extend(key[match.end()+1:].split("_"))
306+
307+
return parts
308+
else: # no underscore attributes. just split on `_`
309+
return key.split("_")
310+
311+
else:
312+
return [key]
313+
314+
315+
def _underscore_magic(parts, val, obj=None, skip_dict_check=False):
316+
if obj is None:
317+
obj = {}
318+
319+
if isinstance(parts, str):
320+
return _underscore_magic(_key_parts(parts), val, obj)
321+
322+
if isinstance(val, dict) and not skip_dict_check:
323+
return _underscore_magic_dict(parts, val, obj)
324+
325+
if len(parts) == 1:
326+
obj[parts[0]] = val
327+
328+
if len(parts) == 2:
329+
k1, k2 = parts
330+
d1 = obj.get(k1, dict())
331+
d1[k2] = val
332+
obj[k1] = d1
333+
334+
if len(parts) == 3:
335+
k1, k2, k3 = parts
336+
d1 = obj.get(k1, dict())
337+
d2 = d1.get(k2, dict())
338+
d2[k3] = val
339+
d1[k2] = d2
340+
obj[k1] = d1
341+
342+
if len(parts) == 4:
343+
k1, k2, k3, k4 = parts
344+
d1 = obj.get(k1, dict())
345+
d2 = d1.get(k2, dict())
346+
d3 = d2.get(k3, dict())
347+
d3[k4] = val
348+
d2[k3] = d3
349+
d1[k2] = d2
350+
obj[k1] = d1
351+
352+
if len(parts) > 4:
353+
msg = (
354+
"The plotly schema shouldn't have any attributes nested"
355+
" beyond level 4. Check that you are setting a valid attribute"
356+
)
357+
raise ValueError(msg)
358+
359+
return obj
360+
361+
362+
def _underscore_magic_dict(parts, val, obj=None):
363+
if obj is None:
364+
obj = {}
365+
if not isinstance(val, dict):
366+
msg = "This function is only meant to be called when val is a dict"
367+
raise ValueError(msg)
368+
369+
# make sure obj has the key all the way up to parts
370+
_underscore_magic(parts, {}, obj, True)
371+
372+
for key, val2 in val.items():
373+
_underscore_magic(parts + [key], val2, obj)
374+
375+
return obj
376+
377+
378+
def attr(obj=None, **kwargs):
379+
if obj is None:
380+
obj = dict()
381+
382+
for k, v in kwargs.items():
383+
_underscore_magic(k, v, obj)
384+
385+
return obj

plotly/graph_reference.py

+22
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,26 @@ def _get_classes():
574574
return classes
575575

576576

577+
def _get_underscore_attrs():
578+
579+
nms = set()
580+
581+
def extract_keys(x):
582+
if isinstance(x, dict):
583+
for val in x.values():
584+
if isinstance(val, dict):
585+
extract_keys(val)
586+
list(map(extract_keys, x.keys()))
587+
elif isinstance(x, str):
588+
nms.add(x)
589+
else:
590+
pass
591+
592+
extract_keys(GRAPH_REFERENCE["layout"]["layoutAttributes"])
593+
extract_keys(GRAPH_REFERENCE["traces"])
594+
return list(filter(lambda x: "_" in x and x[0] != "_", nms))
595+
596+
577597
# The ordering here is important.
578598
GRAPH_REFERENCE = get_graph_reference()
579599

@@ -592,3 +612,5 @@ def _get_classes():
592612
OBJECT_NAME_TO_CLASS_NAME = {class_dict['object_name']: class_name
593613
for class_name, class_dict in CLASSES.items()
594614
if class_dict['object_name'] is not None}
615+
616+
UNDERSCORE_ATTRS = _get_underscore_attrs()

plotly/tests/test_core/test_graph_objs/test_graph_objs_tools.py

+181
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from plotly import graph_reference as gr
66
from plotly.graph_objs import graph_objs_tools as got
7+
from plotly import graph_objs as go
78

89

910
class TestGetHelp(TestCase):
@@ -30,3 +31,183 @@ def test_get_help_does_not_raise(self):
3031
got.get_help(object_name, attribute=fake_attribute)
3132
except:
3233
self.fail(msg=msg)
34+
35+
36+
class TestKeyParts(TestCase):
37+
def test_without_underscore_attr(self):
38+
assert got._key_parts("foo") == ["foo"]
39+
assert got._key_parts("foo_bar") == ["foo", "bar"]
40+
assert got._key_parts("foo_bar_baz") == ["foo", "bar", "baz"]
41+
42+
def test_traililng_underscore_attr(self):
43+
assert got._key_parts("foo_error_x") == ["foo", "error_x"]
44+
assert got._key_parts("foo_bar_error_x") == ["foo", "bar", "error_x"]
45+
assert got._key_parts("foo_bar_baz_error_x") == ["foo", "bar", "baz", "error_x"]
46+
47+
def test_leading_underscore_attr(self):
48+
assert got._key_parts("error_x_foo") == ["error_x", "foo"]
49+
assert got._key_parts("error_x_foo_bar") == ["error_x", "foo", "bar"]
50+
assert got._key_parts("error_x_foo_bar_baz") == ["error_x", "foo", "bar", "baz"]
51+
52+
53+
class TestUnderscoreMagicDictObj(TestCase):
54+
55+
def test_can_split_string_key_into_parts(self):
56+
obj1 = {}
57+
obj2 = {}
58+
got._underscore_magic("marker_line_width", 42, obj1)
59+
got._underscore_magic(["marker", "line", "width"], 42, obj2)
60+
want = {"marker": {"line": {"width": 42}}}
61+
assert obj1 == obj2 == want
62+
63+
def test_will_make_tree_with_empty_dict_val(self):
64+
obj = {}
65+
got._underscore_magic("marker_colorbar_tickfont", {}, obj)
66+
assert obj == {"marker": {"colorbar": {"tickfont": {}}}}
67+
68+
def test_can_set_at_depths_1to4(self):
69+
# 1 level
70+
obj = {}
71+
got._underscore_magic("opacity", 0.9, obj)
72+
assert obj == {"opacity": 0.9}
73+
74+
# 2 levels
75+
got._underscore_magic("line_width", 10, obj)
76+
assert obj == {"opacity": 0.9, "line": {"width": 10}}
77+
78+
# 3 levels
79+
got._underscore_magic("hoverinfo_font_family", "Times", obj)
80+
want = {
81+
"opacity": 0.9,
82+
"line": {"width": 10},
83+
"hoverinfo": {"font": {"family": "Times"}}
84+
}
85+
assert obj == want
86+
87+
# 4 levels
88+
got._underscore_magic("marker_colorbar_tickfont_family", "Times", obj)
89+
want = {
90+
"opacity": 0.9,
91+
"line": {"width": 10},
92+
"hoverinfo": {"font": {"family": "Times"}},
93+
"marker": {"colorbar": {"tickfont": {"family": "Times"}}},
94+
}
95+
assert obj == want
96+
97+
def test_does_not_displace_existing_fields(self):
98+
obj = {}
99+
got._underscore_magic("marker_size", 10, obj)
100+
got._underscore_magic("marker_line_width", 0.4, obj)
101+
assert obj == {"marker": {"size": 10, "line": {"width": 0.4}}}
102+
103+
def test_doesnt_mess_up_underscore_attrs(self):
104+
obj = {}
105+
got._underscore_magic("error_x_color", "red", obj)
106+
got._underscore_magic("error_x_width", 4, obj)
107+
assert obj == {"error_x": {"color": "red", "width": 4}}
108+
109+
110+
class TestUnderscoreMagicPlotlyDictObj(TestCase):
111+
112+
def test_can_split_string_key_into_parts(self):
113+
obj1 = go.Scatter()
114+
obj2 = go.Scatter()
115+
got._underscore_magic("marker_line_width", 42, obj1)
116+
got._underscore_magic(["marker", "line", "width"], 42, obj2)
117+
want = go.Scatter({"marker": {"line": {"width": 42}}})
118+
assert obj1 == obj2 == want
119+
120+
def test_will_make_tree_with_empty_dict_val(self):
121+
obj = go.Scatter()
122+
got._underscore_magic("marker_colorbar_tickfont", {}, obj)
123+
want = go.Scatter({"marker": {"colorbar": {"tickfont": {}}}})
124+
assert obj == want
125+
126+
def test_can_set_at_depths_1to4(self):
127+
# 1 level
128+
obj = go.Scatter()
129+
got._underscore_magic("opacity", 0.9, obj)
130+
assert obj == go.Scatter({"type": "scatter", "opacity": 0.9})
131+
132+
# 2 levels
133+
got._underscore_magic("line_width", 10, obj)
134+
assert obj == go.Scatter({"opacity": 0.9, "line": {"width": 10}})
135+
136+
# 3 levels
137+
got._underscore_magic("hoverinfo_font_family", "Times", obj)
138+
want = go.Scatter({
139+
"opacity": 0.9,
140+
"line": {"width": 10},
141+
"hoverinfo": {"font": {"family": "Times"}}
142+
})
143+
assert obj == want
144+
145+
# 4 levels
146+
got._underscore_magic("marker_colorbar_tickfont_family", "Times", obj)
147+
want = go.Scatter({
148+
"opacity": 0.9,
149+
"line": {"width": 10},
150+
"hoverinfo": {"font": {"family": "Times"}},
151+
"marker": {"colorbar": {"tickfont": {"family": "Times"}}},
152+
})
153+
assert obj == want
154+
155+
def test_does_not_displace_existing_fields(self):
156+
obj = go.Scatter()
157+
got._underscore_magic("marker_size", 10, obj)
158+
got._underscore_magic("marker_line_width", 0.4, obj)
159+
assert obj == go.Scatter({"marker": {"size": 10, "line": {"width": 0.4}}})
160+
161+
def test_doesnt_mess_up_underscore_attrs(self):
162+
obj = go.Scatter()
163+
got._underscore_magic("error_x_color", "red", obj)
164+
got._underscore_magic("error_x_width", 4, obj)
165+
assert obj == go.Scatter({"error_x": {"color": "red", "width": 4}})
166+
167+
168+
class TestAttr(TestCase):
169+
def test_with_no_positional_argument(self):
170+
have = got.attr(
171+
opacity=0.9, line_width=10,
172+
hoverinfo_font_family="Times",
173+
marker_colorbar_tickfont_size=10
174+
)
175+
want = {
176+
"opacity": 0.9,
177+
"line": {"width": 10},
178+
"hoverinfo": {"font": {"family": "Times"}},
179+
"marker": {"colorbar": {"tickfont": {"size": 10}}},
180+
}
181+
assert have == want
182+
183+
def test_with_dict_positional_argument(self):
184+
have = {"x": [1, 2, 3, 4, 5]}
185+
got.attr(have,
186+
opacity=0.9, line_width=10,
187+
hoverinfo_font_family="Times",
188+
marker_colorbar_tickfont_size=10
189+
)
190+
want = {
191+
"x": [1, 2, 3, 4, 5],
192+
"opacity": 0.9,
193+
"line": {"width": 10},
194+
"hoverinfo": {"font": {"family": "Times"}},
195+
"marker": {"colorbar": {"tickfont": {"size": 10}}},
196+
}
197+
assert have == want
198+
199+
def test_with_PlotlyDict_positional_argument(self):
200+
have = go.Scatter({"x": [1, 2, 3, 4, 5]})
201+
got.attr(have,
202+
opacity=0.9, line_width=10,
203+
hoverinfo_font_family="Times",
204+
marker_colorbar_tickfont_size=10
205+
)
206+
want = go.Scatter({
207+
"x": [1, 2, 3, 4, 5],
208+
"opacity": 0.9,
209+
"line": {"width": 10},
210+
"hoverinfo": {"font": {"family": "Times"}},
211+
"marker": {"colorbar": {"tickfont": {"size": 10}}},
212+
})
213+
assert have == want

0 commit comments

Comments
 (0)