From 1dbbfef46cc5cc18a558df53f5e4ebfdca6f95ec Mon Sep 17 00:00:00 2001 From: Spencer Lyon Date: Fri, 19 Jan 2018 01:04:56 -0500 Subject: [PATCH 1/3] RFC: implement attr function and "magic" underscore behavior for PlotlyDict.update --- plotly/graph_objs/__init__.py | 2 + plotly/graph_objs/graph_objs.py | 7 +- plotly/graph_objs/graph_objs_tools.py | 115 +++++++++++ plotly/graph_reference.py | 22 +++ .../test_graph_objs/test_graph_objs_tools.py | 181 ++++++++++++++++++ .../test_core/test_graph_objs/test_update.py | 45 ++++- 6 files changed, 369 insertions(+), 3 deletions(-) diff --git a/plotly/graph_objs/__init__.py b/plotly/graph_objs/__init__.py index cfc295feb79..0413f788b65 100644 --- a/plotly/graph_objs/__init__.py +++ b/plotly/graph_objs/__init__.py @@ -12,3 +12,5 @@ from __future__ import absolute_import from plotly.graph_objs.graph_objs import * # this is protected with __all__ + +from plotly.graph_objs.graph_objs_tools import attr diff --git a/plotly/graph_objs/graph_objs.py b/plotly/graph_objs/graph_objs.py index 9f0f6ebd08c..5d164cf4fd8 100644 --- a/plotly/graph_objs/graph_objs.py +++ b/plotly/graph_objs/graph_objs.py @@ -612,7 +612,12 @@ def update(self, dict1=None, **dict2): else: self[key] = val else: - self[key] = val + # don't have this key -- might be using underscore magic + graph_objs_tools._underscore_magic(key, val, self) + + # return self so we can chain this method (e.g. Scatter().update(**) + # returns an instance of Scatter) + return self def strip_style(self): """ diff --git a/plotly/graph_objs/graph_objs_tools.py b/plotly/graph_objs/graph_objs_tools.py index fdd4cf71304..a29ca0c79ca 100644 --- a/plotly/graph_objs/graph_objs_tools.py +++ b/plotly/graph_objs/graph_objs_tools.py @@ -1,4 +1,5 @@ from __future__ import absolute_import +import re import textwrap import six @@ -268,3 +269,117 @@ def sort_keys(key): """ is_special = key in 'rtxyz' return not is_special, key + + +_underscore_attr_regex = re.compile( + "(" + "|".join(graph_reference.UNDERSCORE_ATTRS) + ")" +) + + +def _key_parts(key): + if "_" in key: + match = _underscore_attr_regex.search(key) + if match is not None: + if key in graph_reference.UNDERSCORE_ATTRS: + # we have _exactly_ one of the underscore + # attrs + return [key] + else: + # have one underscore in the UNDERSCORE_ATTR + # and then at least one underscore not part + # of the attr. Need to break out the attr + # and then split the other parts + parts = [] + if match.start() == 0: + # UNDERSCORE_ATTR is at start of key + parts.append(match.group(1)) + else: + # something comes first + before = key[0:match.start()-1] + parts.extend(before.split("_")) + parts.append(match.group(1)) + + # now take care of anything that might come + # after the underscore attr + if match.end() < len(key): + parts.extend(key[match.end()+1:].split("_")) + + return parts + else: # no underscore attributes. just split on `_` + return key.split("_") + + else: + return [key] + + +def _underscore_magic(parts, val, obj=None, skip_dict_check=False): + if obj is None: + obj = {} + + if isinstance(parts, str): + return _underscore_magic(_key_parts(parts), val, obj) + + if isinstance(val, dict) and not skip_dict_check: + return _underscore_magic_dict(parts, val, obj) + + if len(parts) == 1: + obj[parts[0]] = val + + if len(parts) == 2: + k1, k2 = parts + d1 = obj.get(k1, dict()) + d1[k2] = val + obj[k1] = d1 + + if len(parts) == 3: + k1, k2, k3 = parts + d1 = obj.get(k1, dict()) + d2 = d1.get(k2, dict()) + d2[k3] = val + d1[k2] = d2 + obj[k1] = d1 + + if len(parts) == 4: + k1, k2, k3, k4 = parts + d1 = obj.get(k1, dict()) + d2 = d1.get(k2, dict()) + d3 = d2.get(k3, dict()) + d3[k4] = val + d2[k3] = d3 + d1[k2] = d2 + obj[k1] = d1 + + if len(parts) > 4: + msg = ( + "The plotly schema shouldn't have any attributes nested" + " beyond level 4. Check that you are setting a valid attribute" + ) + raise ValueError(msg) + + return obj + + +def _underscore_magic_dict(parts, val, obj=None): + if obj is None: + obj = {} + if not isinstance(val, dict): + msg = "This function is only meant to be called when val is a dict" + raise ValueError(msg) + + # make sure obj has the key all the way up to parts + _underscore_magic(parts, {}, obj, True) + + for key, val2 in val.items(): + _underscore_magic(parts + [key], val2, obj) + + return obj + + +def attr(obj=None, **kwargs): + if obj is None: + obj = dict() + + for k, v in kwargs.items(): + _underscore_magic(k, v, obj) + + return obj diff --git a/plotly/graph_reference.py b/plotly/graph_reference.py index dd78bb30d92..bc81caa3733 100644 --- a/plotly/graph_reference.py +++ b/plotly/graph_reference.py @@ -574,6 +574,26 @@ def _get_classes(): return classes +def _get_underscore_attrs(): + + nms = set() + + def extract_keys(x): + if isinstance(x, dict): + for val in x.values(): + if isinstance(val, dict): + extract_keys(val) + list(map(extract_keys, x.keys())) + elif isinstance(x, str): + nms.add(x) + else: + pass + + extract_keys(GRAPH_REFERENCE["layout"]["layoutAttributes"]) + extract_keys(GRAPH_REFERENCE["traces"]) + return list(filter(lambda x: "_" in x and x[0] != "_", nms)) + + # The ordering here is important. GRAPH_REFERENCE = get_graph_reference() @@ -592,3 +612,5 @@ def _get_classes(): OBJECT_NAME_TO_CLASS_NAME = {class_dict['object_name']: class_name for class_name, class_dict in CLASSES.items() if class_dict['object_name'] is not None} + +UNDERSCORE_ATTRS = _get_underscore_attrs() diff --git a/plotly/tests/test_core/test_graph_objs/test_graph_objs_tools.py b/plotly/tests/test_core/test_graph_objs/test_graph_objs_tools.py index d3a4f857e9d..08f3197ee3e 100644 --- a/plotly/tests/test_core/test_graph_objs/test_graph_objs_tools.py +++ b/plotly/tests/test_core/test_graph_objs/test_graph_objs_tools.py @@ -4,6 +4,7 @@ from plotly import graph_reference as gr from plotly.graph_objs import graph_objs_tools as got +from plotly import graph_objs as go class TestGetHelp(TestCase): @@ -30,3 +31,183 @@ def test_get_help_does_not_raise(self): got.get_help(object_name, attribute=fake_attribute) except: self.fail(msg=msg) + + +class TestKeyParts(TestCase): + def test_without_underscore_attr(self): + assert got._key_parts("foo") == ["foo"] + assert got._key_parts("foo_bar") == ["foo", "bar"] + assert got._key_parts("foo_bar_baz") == ["foo", "bar", "baz"] + + def test_traililng_underscore_attr(self): + assert got._key_parts("foo_error_x") == ["foo", "error_x"] + assert got._key_parts("foo_bar_error_x") == ["foo", "bar", "error_x"] + assert got._key_parts("foo_bar_baz_error_x") == ["foo", "bar", "baz", "error_x"] + + def test_leading_underscore_attr(self): + assert got._key_parts("error_x_foo") == ["error_x", "foo"] + assert got._key_parts("error_x_foo_bar") == ["error_x", "foo", "bar"] + assert got._key_parts("error_x_foo_bar_baz") == ["error_x", "foo", "bar", "baz"] + + +class TestUnderscoreMagicDictObj(TestCase): + + def test_can_split_string_key_into_parts(self): + obj1 = {} + obj2 = {} + got._underscore_magic("marker_line_width", 42, obj1) + got._underscore_magic(["marker", "line", "width"], 42, obj2) + want = {"marker": {"line": {"width": 42}}} + assert obj1 == obj2 == want + + def test_will_make_tree_with_empty_dict_val(self): + obj = {} + got._underscore_magic("marker_colorbar_tickfont", {}, obj) + assert obj == {"marker": {"colorbar": {"tickfont": {}}}} + + def test_can_set_at_depths_1to4(self): + # 1 level + obj = {} + got._underscore_magic("opacity", 0.9, obj) + assert obj == {"opacity": 0.9} + + # 2 levels + got._underscore_magic("line_width", 10, obj) + assert obj == {"opacity": 0.9, "line": {"width": 10}} + + # 3 levels + got._underscore_magic("hoverinfo_font_family", "Times", obj) + want = { + "opacity": 0.9, + "line": {"width": 10}, + "hoverinfo": {"font": {"family": "Times"}} + } + assert obj == want + + # 4 levels + got._underscore_magic("marker_colorbar_tickfont_family", "Times", obj) + want = { + "opacity": 0.9, + "line": {"width": 10}, + "hoverinfo": {"font": {"family": "Times"}}, + "marker": {"colorbar": {"tickfont": {"family": "Times"}}}, + } + assert obj == want + + def test_does_not_displace_existing_fields(self): + obj = {} + got._underscore_magic("marker_size", 10, obj) + got._underscore_magic("marker_line_width", 0.4, obj) + assert obj == {"marker": {"size": 10, "line": {"width": 0.4}}} + + def test_doesnt_mess_up_underscore_attrs(self): + obj = {} + got._underscore_magic("error_x_color", "red", obj) + got._underscore_magic("error_x_width", 4, obj) + assert obj == {"error_x": {"color": "red", "width": 4}} + + +class TestUnderscoreMagicPlotlyDictObj(TestCase): + + def test_can_split_string_key_into_parts(self): + obj1 = go.Scatter() + obj2 = go.Scatter() + got._underscore_magic("marker_line_width", 42, obj1) + got._underscore_magic(["marker", "line", "width"], 42, obj2) + want = go.Scatter({"marker": {"line": {"width": 42}}}) + assert obj1 == obj2 == want + + def test_will_make_tree_with_empty_dict_val(self): + obj = go.Scatter() + got._underscore_magic("marker_colorbar_tickfont", {}, obj) + want = go.Scatter({"marker": {"colorbar": {"tickfont": {}}}}) + assert obj == want + + def test_can_set_at_depths_1to4(self): + # 1 level + obj = go.Scatter() + got._underscore_magic("opacity", 0.9, obj) + assert obj == go.Scatter({"type": "scatter", "opacity": 0.9}) + + # 2 levels + got._underscore_magic("line_width", 10, obj) + assert obj == go.Scatter({"opacity": 0.9, "line": {"width": 10}}) + + # 3 levels + got._underscore_magic("hoverinfo_font_family", "Times", obj) + want = go.Scatter({ + "opacity": 0.9, + "line": {"width": 10}, + "hoverinfo": {"font": {"family": "Times"}} + }) + assert obj == want + + # 4 levels + got._underscore_magic("marker_colorbar_tickfont_family", "Times", obj) + want = go.Scatter({ + "opacity": 0.9, + "line": {"width": 10}, + "hoverinfo": {"font": {"family": "Times"}}, + "marker": {"colorbar": {"tickfont": {"family": "Times"}}}, + }) + assert obj == want + + def test_does_not_displace_existing_fields(self): + obj = go.Scatter() + got._underscore_magic("marker_size", 10, obj) + got._underscore_magic("marker_line_width", 0.4, obj) + assert obj == go.Scatter({"marker": {"size": 10, "line": {"width": 0.4}}}) + + def test_doesnt_mess_up_underscore_attrs(self): + obj = go.Scatter() + got._underscore_magic("error_x_color", "red", obj) + got._underscore_magic("error_x_width", 4, obj) + assert obj == go.Scatter({"error_x": {"color": "red", "width": 4}}) + + +class TestAttr(TestCase): + def test_with_no_positional_argument(self): + have = got.attr( + opacity=0.9, line_width=10, + hoverinfo_font_family="Times", + marker_colorbar_tickfont_size=10 + ) + want = { + "opacity": 0.9, + "line": {"width": 10}, + "hoverinfo": {"font": {"family": "Times"}}, + "marker": {"colorbar": {"tickfont": {"size": 10}}}, + } + assert have == want + + def test_with_dict_positional_argument(self): + have = {"x": [1, 2, 3, 4, 5]} + got.attr(have, + opacity=0.9, line_width=10, + hoverinfo_font_family="Times", + marker_colorbar_tickfont_size=10 + ) + want = { + "x": [1, 2, 3, 4, 5], + "opacity": 0.9, + "line": {"width": 10}, + "hoverinfo": {"font": {"family": "Times"}}, + "marker": {"colorbar": {"tickfont": {"size": 10}}}, + } + assert have == want + + def test_with_PlotlyDict_positional_argument(self): + have = go.Scatter({"x": [1, 2, 3, 4, 5]}) + got.attr(have, + opacity=0.9, line_width=10, + hoverinfo_font_family="Times", + marker_colorbar_tickfont_size=10 + ) + want = go.Scatter({ + "x": [1, 2, 3, 4, 5], + "opacity": 0.9, + "line": {"width": 10}, + "hoverinfo": {"font": {"family": "Times"}}, + "marker": {"colorbar": {"tickfont": {"size": 10}}}, + }) + assert have == want diff --git a/plotly/tests/test_core/test_graph_objs/test_update.py b/plotly/tests/test_core/test_graph_objs/test_update.py index 5d769532e57..3f28423fed4 100644 --- a/plotly/tests/test_core/test_graph_objs/test_update.py +++ b/plotly/tests/test_core/test_graph_objs/test_update.py @@ -1,7 +1,8 @@ from __future__ import absolute_import -from unittest import skip +from unittest import skip, TestCase -from plotly.graph_objs import Data, Figure, Layout, Line, Scatter, XAxis +from plotly.graph_objs import Data, Figure, Layout, Line, Scatter, XAxis, attr +from plotly.exceptions import PlotlyDictKeyError def test_update_dict(): @@ -13,6 +14,46 @@ def test_update_dict(): assert fig == Figure(layout=Layout(title=title, xaxis=XAxis())) +class TestMagicUpdates(TestCase): + def test_update_magic_kwargs(self): + have = Scatter(y=[1, 2, 3, 4]) + have.update( + opacity=0.9, line_width=10, + hoverinfo_font_family="Times", + marker_colorbar_tickfont_size=10 + ) + want = Scatter({ + "y": [1, 2, 3, 4], + "opacity": 0.9, + "line": {"width": 10}, + "hoverinfo": {"font": {"family": "Times"}}, + "marker": {"colorbar": {"tickfont": {"size": 10}}}, + }) + assert have == want + + have2 = (Scatter(y=[1, 2, 3, 4]) + .update( + opacity=0.9, line_width=10, + hoverinfo_font_family="Times", + marker_colorbar_tickfont_size=10 + ) + ) + assert have2 == want + + def test_update_magic_and_attr(self): + have = Scatter() + have.update(marker=attr(color="red", line_width=4)) + want = Scatter({ + "marker": {"color": "red", "line": {"width": 4}} + }) + assert have == want + + def test_cant_update_invalid_attribute(self): + have = Scatter() + with self.assertRaises(PlotlyDictKeyError): + have.update(marker_line_fuzz=42) + + def test_update_list(): trace1 = Scatter(x=[1, 2, 3], y=[2, 1, 2]) trace2 = Scatter(x=[1, 2, 3], y=[3, 2, 1]) From 226a00981cf54fbade21f8fc7a2293fde3494d73 Mon Sep 17 00:00:00 2001 From: Spencer Lyon Date: Thu, 25 Jan 2018 13:22:39 -0500 Subject: [PATCH 2/3] DOC: add docstrings to underscore magic functions --- plotly/graph_objs/graph_objs_tools.py | 106 ++++++++++++++++++++++++++ plotly/graph_reference.py | 4 + 2 files changed, 110 insertions(+) diff --git a/plotly/graph_objs/graph_objs_tools.py b/plotly/graph_objs/graph_objs_tools.py index a29ca0c79ca..bd54350427a 100644 --- a/plotly/graph_objs/graph_objs_tools.py +++ b/plotly/graph_objs/graph_objs_tools.py @@ -277,6 +277,18 @@ def sort_keys(key): def _key_parts(key): + """ + Split a key containing undescores into all its parts. + + This function is aware of attributes that have underscores in their + name (e.g. ``scatter.error_x``) and does not split them incorrectly. + + Also, the function always returns a list, even if there is only one item + in that list (e.g. `_key_parts("marker")` would return `["marker"]`) + + :param (str|unicode) key: the attribute name + :return: (list[str|unicode]): a list with all the parts of the attribute + """ if "_" in key: match = _underscore_attr_regex.search(key) if match is not None: @@ -313,6 +325,45 @@ def _key_parts(key): def _underscore_magic(parts, val, obj=None, skip_dict_check=False): + """ + Set a potentially "deep" attribute of `obj` specified by a list of parent + keys (`parts`) to `val`. + + :param (list[(str|unicode)] or str|unicode) parts: The path to the + attribute to be set on obj. If the argument is a string, then it will + first be passed to `_key_parts(key)` to construct the path and then + this function will be called again + :param val: The value the attribute should have + :param (dict_like) obj: A dict_like object that should have the attribute + set. If nothing is given, then an empty dictionary is created. If + a subtype of `plotly.graph_obsj.PlotlyDict` is passed, then the + setting of the attribute (and creation of parent nodes) will be + validated + :param (bool) skip_dict_check: Optional, default is False. If True and val + is a dict, then this funciton will ensure that all parent nodes are + created in `obj`. + :returns (dict_like) obj: an updated version of the `obj` argument (or + a newly created dict if `obj` was not passed). + + + Example: + + ``` + import plotly.graph_objs as go + from plotly.graph_objs.graph_objs_tools import _underscore_magic + layout = go.Layout() + _underscore_magic(["xaxis", "title"], "this is my xaxis", layout) + _underscore_magic("yaxis_titlefont", {"size": 10, "color": "red"}, layout) + print(layout) + ``` + + Results in + + ``` + {'xaxis': {'title': 'this is my xaxis'}, + 'yaxis': {'titlefont': {'color': 'red', 'size': 10}}} + ``` + """ if obj is None: obj = {} @@ -376,6 +427,61 @@ def _underscore_magic_dict(parts, val, obj=None): def attr(obj=None, **kwargs): + """ + Create a nested attribute using "magic underscore" behavior + + :param (dict_like) obj: A dict like container on which to set the + attribute. This will be modified in place. If nothing is passed an + empty dict is constructed and then returned. If a plotly graph object + is passed, all attributes will be validated. + :kwargs: All attributes that should be set on obj + :returns (dict_like): A modified version of the object passed to this + function + + Example 1: + + ``` + from plotly.graph_objs import attr, Scatter + my_trace = attr(Scatter(), + marker=attr(size=4, symbol="diamond", line_color="red"), + hoverlabel_bgcolor="grey" + ) + ``` + + Returns the following: + + ``` + {'hoverlabel': {'bgcolor': 'grey'}, + 'marker': {'line': {'color': 'red'}, 'size': 4, 'symbol': 'diamond'}, + 'type': 'scatter'} + ``` + + Example 2: incorrect attribute leads to an error + ``` + from plotly.graph_objs import attr, Scatter + my_trace = attr(Scatter(), + marker_mode="markers" # incorrect, should just be mode + ) + ``` + + Returns an error: + + ``` + PlotlyDictKeyError: 'mode' is not allowed in 'marker' + + Path To Error: ['marker']['mode'] + + Valid attributes for 'marker' at path ['marker'] under parents ['scatter']: + + ['autocolorscale', 'cauto', 'cmax', 'cmin', 'color', 'colorbar', + 'colorscale', 'colorsrc', 'gradient', 'line', 'maxdisplayed', + 'opacity', 'opacitysrc', 'reversescale', 'showscale', 'size', + 'sizemin', 'sizemode', 'sizeref', 'sizesrc', 'symbol', 'symbolsrc'] + + Run `.help('attribute')` on any of the above. + '' is the object at ['marker'] + ``` + """ if obj is None: obj = dict() diff --git a/plotly/graph_reference.py b/plotly/graph_reference.py index bc81caa3733..fc14d3a401d 100644 --- a/plotly/graph_reference.py +++ b/plotly/graph_reference.py @@ -575,6 +575,10 @@ def _get_classes(): def _get_underscore_attrs(): + """ + Return a list of all figure attributes (on traces or layouts) that have + underscores in them + """ nms = set() From 1977432d1d4a91bcf3f11bebcf09528a6d817ef5 Mon Sep 17 00:00:00 2001 From: Spencer Lyon Date: Thu, 25 Jan 2018 13:32:26 -0500 Subject: [PATCH 3/3] DOC: update docstring for PlotlyDict.update to reflect application of underscore magic for keyword arguments --- plotly/graph_objs/graph_objs.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/plotly/graph_objs/graph_objs.py b/plotly/graph_objs/graph_objs.py index 5d164cf4fd8..ee002a2e81c 100644 --- a/plotly/graph_objs/graph_objs.py +++ b/plotly/graph_objs/graph_objs.py @@ -566,12 +566,23 @@ def help(self, attribute=None, return_help=False): def update(self, dict1=None, **dict2): """ - Update current dict with dict1 and then dict2. + Update current dict with dict1 and then dict2. Returns a modifed + version of self (updates are applied in place) This recursively updates the structure of the original dictionary-like object with the new entries in the second and third objects. This allows users to update with large, nested structures. + For all items in dict2, the "underscore magic" syntax for setting deep + attributes applies. To use this syntax, specify the path to the + attribute, separating each part of the path by an underscore. For + example, to set the "marker.line.color" attribute you can do + `obj.update(marker_line_color="red")` instead of + `obj.update(marker={"line": {"color": "red"}})`. Note that you can + use this in conjuction with the `plotly.graph_objs.attr` function to + set groups of deep attributes. See docstring for `attr` and the + examples below for more information. + Note, because the dict2 packs up all the keyword arguments, you can specify the changes as a list of keyword agruments. @@ -589,6 +600,19 @@ def update(self, dict1=None, **dict2): obj {'title': 'new title', 'xaxis': {'range': [0,1], 'domain': [0,.8]}} + # Update with underscore magic syntax for xaxis.domain + obj = Layout(title='my title', xaxis=XAxis(range=[0,1], domain=[0,1])) + obj.update(title="new title", xaxis_domain=[0, 0.8]) + obj + {'title': 'new title', 'xaxis': {'range': [0,1], 'domain': [0,.8]}} + + # Use underscore magic and attr function for xaxis + obj = Layout().update( + title="new title", + xaxis=attr(range=[0, 1], domain=[0, 0.8])) + obj + {'title': 'new title', 'xaxis': {'range': [0,1], 'domain': [0,.8]}} + This 'fully' supports duck-typing in that the call signature is identical, however this differs slightly from the normal update method provided by Python's dictionaries.