From 17658da1184ba91a98dc19e9f1efd290d27a2499 Mon Sep 17 00:00:00 2001 From: Nicholas Esterer Date: Thu, 22 Oct 2020 12:32:44 -0400 Subject: [PATCH 1/5] Added selector argument and changed _subplot_has_no_traces It is now called _subplot_not_empty and selector can be used to choose what is meant by non-empty. The prior tests pass, but new tests for the subset selection criteria have to be introduced. --- .../python/plotly/plotly/basedatatypes.py | 67 ++++++++++++++----- 1 file changed, 52 insertions(+), 15 deletions(-) diff --git a/packages/python/plotly/plotly/basedatatypes.py b/packages/python/plotly/plotly/basedatatypes.py index 8cb0b07a05d..ce76a8cdd73 100644 --- a/packages/python/plotly/plotly/basedatatypes.py +++ b/packages/python/plotly/plotly/basedatatypes.py @@ -1302,7 +1302,7 @@ def _add_annotation_like( # if exclude_empty_subplots is True, check to see if subplot is # empty and return if it is if exclude_empty_subplots and ( - not self._subplot_contains_trace(xref, yref) + not self._subplot_not_empty(xref, yref, selector=exclude_empty_subplots) ): return self # in case the user specified they wanted an axis to refer to the @@ -1993,8 +1993,8 @@ def add_traces( if exclude_empty_subplots: data = list( filter( - lambda trace: self._subplot_contains_trace( - trace["xaxis"], trace["yaxis"] + lambda trace: self._subplot_not_empty( + trace["xaxis"], trace["yaxis"], exclude_empty_subplots ), data, ) @@ -3873,19 +3873,56 @@ def _has_subplots(self): single plot and so this returns False. """ return self._grid_ref is not None - def _subplot_contains_trace(self, xref, yref): - return any( - t == (xref, yref) - for t in [ - # if a trace exists but has no xaxis or yaxis keys, then it - # is plotted with xaxis 'x' and yaxis 'y' - ( - "x" if d["xaxis"] is None else d["xaxis"], - "y" if d["yaxis"] is None else d["yaxis"], + def _subplot_not_empty(self, xref, yref, selector="all"): + """ + xref: string representing the axis. Objects in the plot will be checked + for this xref (for layout objects) or xaxis (for traces) to + determine if they lie in a certain subplot. + yref: string representing the axis. Objects in the plot will be checked + for this yref (for layout objects) or yaxis (for traces) to + determine if they lie in a certain subplot. + selector: can be "all" or an iterable containing some combination of + "traces", "shapes", "annotations", "images". Only the presence + of objects specified in selector will be checked. So if + ["traces","shapes"] is passed then a plot we be considered + non-empty if it contains traces or shapes. If + bool(selector) returns False, no checking is performed and + this function returns True. If selector is True, it is + converted to "all". + """ + if not selector: + # If nothing to select was specified then a subplot is always deemed non-empty + return True + if selector == True: + selector = "all" + if selector == "all": + selector = ["traces", "shapes", "annotations", "images"] + ret = False + for s in selector: + if s == "traces": + obj = self.data + xaxiskw = "xaxis" + yaxiskw = "yaxis" + elif s in ["shapes", "annotations", "images"]: + obj = self.layout[s] + xaxiskw = "xref" + yaxiskw = "yref" + else: + obj = None + if obj: + ret |= any( + t == (xref, yref) + for t in [ + # if a object exists but has no xaxis or yaxis keys, then it + # is plotted with xaxis/xref 'x' and yaxis/yref 'y' + ( + "x" if d[xaxiskw] is None else d[xaxiskw], + "y" if d[yaxiskw] is None else d[yaxiskw], + ) + for d in obj + ] ) - for d in self.data - ] - ) + return ret class BasePlotlyType(object): From bc1e78ac1caf18e0dfaa6479388cb8496e406419 Mon Sep 17 00:00:00 2001 From: Nicholas Esterer Date: Thu, 22 Oct 2020 14:26:08 -0400 Subject: [PATCH 2/5] Test finding empty subplots for all selector combinations This tests go.Figure._subplot_not_empty --- .../test_find_nonempty_subplots.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 packages/python/plotly/plotly/tests/test_core/test_subplots/test_find_nonempty_subplots.py diff --git a/packages/python/plotly/plotly/tests/test_core/test_subplots/test_find_nonempty_subplots.py b/packages/python/plotly/plotly/tests/test_core/test_subplots/test_find_nonempty_subplots.py new file mode 100644 index 00000000000..4ed428b5a0e --- /dev/null +++ b/packages/python/plotly/plotly/tests/test_core/test_subplots/test_find_nonempty_subplots.py @@ -0,0 +1,60 @@ +import pytest +import plotly.graph_objects as go +from plotly.subplots import make_subplots +from itertools import combinations, product +from functools import reduce + + +def all_combos(it): + return list( + reduce( + lambda a, b: a + b, + [list(combinations(it, r)) for r in range(1, len(it))], + [], + ) + ) + + +def translate_layout_keys(t): + xr, yr = t + xr = xr.replace("axis", "") + yr = yr.replace("axis", "") + return (xr, yr) + + +def get_non_empty_subplots(fig, selector): + gr = fig._validate_get_grid_ref() + nrows = len(gr) + ncols = len(gr[0]) + sp_addresses = product(range(nrows), range(ncols)) + # assign a number similar to plotly's xref/yref (e.g, xref=x2) to each + # subplot address (xref=x -> 1, but xref=x3 -> 3) + # sp_ax_numbers=range(1,len(sp_addresses)+1) + # Get those subplot numbers which contain something + ret = list( + filter( + lambda sp: fig._subplot_not_empty( + *translate_layout_keys(sp.layout_keys), selector=selector + ), + [gr[r][c][0] for r, c in sp_addresses], + ) + ) + return ret + + +def test_choose_correct_non_empty_subplots(): + # This checks to see that the correct subplots are selected for all + # combinations of selectors + fig = make_subplots(2, 2) + fig.add_trace(go.Scatter(x=[1, 2], y=[3, 4]), row=1, col=1) + fig.add_shape(dict(type="rect", x0=1, x1=2, y0=3, y1=4), row=1, col=2) + fig.add_annotation(dict(text="A", x=1, y=2), row=2, col=1) + fig.add_layout_image( + dict(source="test", x=1, y=2, sizex=0.5, sizey=0.5), row=2, col=2 + ) + all_subplots = get_non_empty_subplots(fig, "all") + selectors = all_combos(["traces", "shapes", "annotations", "images"]) + subplot_combos = all_combos(all_subplots) + for s, spc in zip(selectors, subplot_combos): + sps = tuple(get_non_empty_subplots(fig, s)) + assert sps == spc From e4fe7cf016c72b078993f2dfc169e235100696d9 Mon Sep 17 00:00:00 2001 From: Nicholas Esterer Date: Thu, 22 Oct 2020 14:29:04 -0400 Subject: [PATCH 3/5] Assert that we indeed test all the selector combinations in test_find_nonempty_subplots.py --- .../tests/test_core/test_subplots/test_find_nonempty_subplots.py | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/python/plotly/plotly/tests/test_core/test_subplots/test_find_nonempty_subplots.py b/packages/python/plotly/plotly/tests/test_core/test_subplots/test_find_nonempty_subplots.py index 4ed428b5a0e..27a66b4feea 100644 --- a/packages/python/plotly/plotly/tests/test_core/test_subplots/test_find_nonempty_subplots.py +++ b/packages/python/plotly/plotly/tests/test_core/test_subplots/test_find_nonempty_subplots.py @@ -55,6 +55,7 @@ def test_choose_correct_non_empty_subplots(): all_subplots = get_non_empty_subplots(fig, "all") selectors = all_combos(["traces", "shapes", "annotations", "images"]) subplot_combos = all_combos(all_subplots) + assert len(selectors) == len(subplot_combos) for s, spc in zip(selectors, subplot_combos): sps = tuple(get_non_empty_subplots(fig, s)) assert sps == spc From eb21739f37d000a987669e367d822cb9e4ee321a Mon Sep 17 00:00:00 2001 From: Nicholas Esterer Date: Thu, 22 Oct 2020 17:50:21 -0400 Subject: [PATCH 4/5] Anything truthy passed to exclude_empty_subplots works That means that truthy values exclude subplots containing absolutely nothing (but not those containing shapes, but no traces, say) and anything falsey adds to all subplots. --- .../python/plotly/plotly/basedatatypes.py | 6 ++- .../test_figure_messages/test_add_traces.py | 38 ++++++++++++++- .../test_update_annotations.py | 47 +++++++++++++++---- 3 files changed, 78 insertions(+), 13 deletions(-) diff --git a/packages/python/plotly/plotly/basedatatypes.py b/packages/python/plotly/plotly/basedatatypes.py index ce76a8cdd73..f2033cf359a 100644 --- a/packages/python/plotly/plotly/basedatatypes.py +++ b/packages/python/plotly/plotly/basedatatypes.py @@ -1302,7 +1302,9 @@ def _add_annotation_like( # if exclude_empty_subplots is True, check to see if subplot is # empty and return if it is if exclude_empty_subplots and ( - not self._subplot_not_empty(xref, yref, selector=exclude_empty_subplots) + not self._subplot_not_empty( + xref, yref, selector=bool(exclude_empty_subplots) + ) ): return self # in case the user specified they wanted an axis to refer to the @@ -1994,7 +1996,7 @@ def add_traces( data = list( filter( lambda trace: self._subplot_not_empty( - trace["xaxis"], trace["yaxis"], exclude_empty_subplots + trace["xaxis"], trace["yaxis"], bool(exclude_empty_subplots) ), data, ) diff --git a/packages/python/plotly/plotly/tests/test_core/test_figure_messages/test_add_traces.py b/packages/python/plotly/plotly/tests/test_core/test_figure_messages/test_add_traces.py index 5563a419732..63f379bbcd8 100644 --- a/packages/python/plotly/plotly/tests/test_core/test_figure_messages/test_add_traces.py +++ b/packages/python/plotly/plotly/tests/test_core/test_figure_messages/test_add_traces.py @@ -120,7 +120,43 @@ def test_add_trace_no_exclude_empty_subplots(): fig.add_trace(go.Scatter(x=[1, 2, 3], y=[5, 1, 2]), row=1, col=1) fig.add_trace(go.Scatter(x=[1, 2, 3], y=[2, 1, -7]), row=2, col=2) # Add traces with exclude_empty_subplots set to true and make sure this - # doesn't add to traces that don't already have data + # even adds to traces that don't already have data + fig.add_trace(go.Scatter(x=[1, 2, 3], y=[0, 1, -1]), row="all", col="all") + assert len(fig.data) == 6 + assert fig.data[2]["xaxis"] == "x" and fig.data[2]["yaxis"] == "y" + assert fig.data[3]["xaxis"] == "x2" and fig.data[3]["yaxis"] == "y2" + assert fig.data[4]["xaxis"] == "x3" and fig.data[4]["yaxis"] == "y3" + assert fig.data[5]["xaxis"] == "x4" and fig.data[5]["yaxis"] == "y4" + + +def test_add_trace_exclude_totally_empty_subplots(): + # Add traces + fig = make_subplots(2, 2) + fig.add_trace(go.Scatter(x=[1, 2, 3], y=[5, 1, 2]), row=1, col=1) + fig.add_trace(go.Scatter(x=[1, 2, 3], y=[2, 1, -7]), row=2, col=2) + fig.add_shape(dict(type="rect", x0=0, x1=1, y0=0, y1=1), row=1, col=2) + # Add traces with exclude_empty_subplots set to true and make sure this + # doesn't add to traces that don't already have data or layout objects + fig.add_trace( + go.Scatter(x=[1, 2, 3], y=[0, 1, -1]), + row="all", + col="all", + exclude_empty_subplots=["anything", "truthy"], + ) + assert len(fig.data) == 5 + assert fig.data[2]["xaxis"] == "x" and fig.data[2]["yaxis"] == "y" + assert fig.data[3]["xaxis"] == "x2" and fig.data[3]["yaxis"] == "y2" + assert fig.data[4]["xaxis"] == "x4" and fig.data[4]["yaxis"] == "y4" + + +def test_add_trace_no_exclude_totally_empty_subplots(): + # Add traces + fig = make_subplots(2, 2) + fig.add_trace(go.Scatter(x=[1, 2, 3], y=[5, 1, 2]), row=1, col=1) + fig.add_trace(go.Scatter(x=[1, 2, 3], y=[2, 1, -7]), row=2, col=2) + fig.add_shape(dict(type="rect", x0=0, x1=1, y0=0, y1=1), row=1, col=2) + # Add traces with exclude_empty_subplots set to true and make sure this + # even adds to traces that don't already have data or layout objects fig.add_trace(go.Scatter(x=[1, 2, 3], y=[0, 1, -1]), row="all", col="all") assert len(fig.data) == 6 assert fig.data[2]["xaxis"] == "x" and fig.data[2]["yaxis"] == "y" diff --git a/packages/python/plotly/plotly/tests/test_core/test_update_objects/test_update_annotations.py b/packages/python/plotly/plotly/tests/test_core/test_update_objects/test_update_annotations.py index c08129bb31b..e7ac974d68a 100644 --- a/packages/python/plotly/plotly/tests/test_core/test_update_objects/test_update_annotations.py +++ b/packages/python/plotly/plotly/tests/test_core/test_update_objects/test_update_annotations.py @@ -270,51 +270,78 @@ def test_image_attributes(self): def test_exclude_empty_subplots(): - for k, fun, d in [ + for k, fun, d, fun2, d2 in [ ( "shapes", go.Figure.add_shape, dict(type="rect", x0=1.5, x1=2.5, y0=3.5, y1=4.5), + # add a different type to make the check easier (otherwise we might + # mix up the objects added before and after fun was run) + go.Figure.add_annotation, + dict(x=1, y=2, text="A"), + ), + ( + "annotations", + go.Figure.add_annotation, + dict(x=1, y=2, text="A"), + go.Figure.add_layout_image, + dict(x=3, y=4, sizex=2, sizey=3, source="test"), ), - ("annotations", go.Figure.add_annotation, dict(x=1, y=2, text="A")), ( "images", go.Figure.add_layout_image, dict(x=3, y=4, sizex=2, sizey=3, source="test"), + go.Figure.add_shape, + dict(type="rect", x0=1.5, x1=2.5, y0=3.5, y1=4.5), ), ]: # make a figure where not all the subplots are populated fig = make_subplots(2, 2) fig.add_trace(go.Scatter(x=[1, 2, 3], y=[5, 1, 2]), row=1, col=1) fig.add_trace(go.Scatter(x=[1, 2, 3], y=[2, 1, -7]), row=2, col=2) + fun2(fig, d2, row=1, col=2) # add a thing to all subplots but make sure it only goes on the - # plots without data - fun(fig, d, row="all", col="all", exclude_empty_subplots=True) - assert len(fig.layout[k]) == 2 + # plots without data or layout objects + fun(fig, d, row="all", col="all", exclude_empty_subplots="anything_truthy") + assert len(fig.layout[k]) == 3 assert fig.layout[k][0]["xref"] == "x" and fig.layout[k][0]["yref"] == "y" - assert fig.layout[k][1]["xref"] == "x4" and fig.layout[k][1]["yref"] == "y4" + assert fig.layout[k][1]["xref"] == "x2" and fig.layout[k][1]["yref"] == "y2" + assert fig.layout[k][2]["xref"] == "x4" and fig.layout[k][2]["yref"] == "y4" def test_no_exclude_empty_subplots(): - for k, fun, d in [ + for k, fun, d, fun2, d2 in [ ( "shapes", go.Figure.add_shape, dict(type="rect", x0=1.5, x1=2.5, y0=3.5, y1=4.5), + # add a different type to make the check easier (otherwise we might + # mix up the objects added before and after fun was run) + go.Figure.add_annotation, + dict(x=1, y=2, text="A"), + ), + ( + "annotations", + go.Figure.add_annotation, + dict(x=1, y=2, text="A"), + go.Figure.add_layout_image, + dict(x=3, y=4, sizex=2, sizey=3, source="test"), ), - ("annotations", go.Figure.add_annotation, dict(x=1, y=2, text="A")), ( "images", go.Figure.add_layout_image, dict(x=3, y=4, sizex=2, sizey=3, source="test"), + go.Figure.add_shape, + dict(type="rect", x0=1.5, x1=2.5, y0=3.5, y1=4.5), ), ]: # make a figure where not all the subplots are populated fig = make_subplots(2, 2) fig.add_trace(go.Scatter(x=[1, 2, 3], y=[5, 1, 2]), row=1, col=1) fig.add_trace(go.Scatter(x=[1, 2, 3], y=[2, 1, -7]), row=2, col=2) - # add a thing to all subplots and make sure it even goes on the - # plots without data + fun2(fig, d2, row=1, col=2) + # add a thing to all subplots but make sure it only goes on the + # plots without data or layout objects fun(fig, d, row="all", col="all", exclude_empty_subplots=False) assert len(fig.layout[k]) == 4 assert fig.layout[k][0]["xref"] == "x" and fig.layout[k][0]["yref"] == "y" From 39c1cbd7f7ecd4f9e94b23dce09ded87286497e0 Mon Sep 17 00:00:00 2001 From: Nicholas Esterer Date: Thu, 22 Oct 2020 18:29:15 -0400 Subject: [PATCH 5/5] changelog included entries for `exclude_empty_sublots`, for the add_{hline,vline,hrect,vrect} commands, and `row="all"`, `col="all"`. --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d135720aa1e..4753c8df0ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added +- For `add_trace`, `add_shape`, `add_annotation` and `add_layout_image`, the `row` and/or `col` argument now also accept the string `"all"`. `row="all"` adds the object to all the subplot rows and `col="all"` adds the object to all the subplot columns. + +- Shapes that reference the plot axes in one dimension and the data in another dimension can be added with the new `add_hline`, `add_vline`, `add_hrect`, `add_vrect` functions, which also support the `row="all"` and `col="all"` arguments. + +- The `add_trace`, `add_shape`, `add_annotation`, `add_layout_image`, `add_hline`, `add_vline`, `add_hrect`, `add_vrect` functions accept an argument `exclude_empty_subplots` which if `True`, only adds the object to subplots already containing traces or layout objects. This is useful in conjunction with the `row="all"` and `col="all"` arguments. + - For all `go.Figure` functions accepting a selector argument (e.g., `select_traces`), this argument can now also be a function which is passed each relevant graph object (in the case of `select_traces`, it is passed every trace in the figure). For graph objects where this function returns true, the graph object is included in the selection. ### Updated