From c56b97e7d5961c6176e31a38839765c0f2d469a1 Mon Sep 17 00:00:00 2001 From: Luke J Date: Fri, 6 Nov 2026 21:21:51 +1300 Subject: [PATCH 1/2] Keep axis-spanning shapes on an empty subplot selected by row and col --- CHANGELOG.md | 1 + plotly/basedatatypes.py | 11 ++- plotly/graph_objs/_figure.py | 8 +-- plotly/graph_objs/_figurewidget.py | 8 +-- .../test_autoshapes/test_axis_span_shapes.py | 69 +++++++++++++++++++ 5 files changed, 88 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8378fcf3477..6a6db97008f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Fix Plotly Express mutating lists passed to the `x` or `y` arguments in wide mode [[#5727](https://github.com/plotly/plotly.py/pull/5727)], with thanks to @cpruijsen for the contribution! - Fix `px.sunburst`, `px.treemap` and `px.icicle` listing sectors in a different order on every run when `path` is used with a Polars DataFrame; sectors now follow their order of first appearance for all dataframe backends [[#5766](https://github.com/plotly/plotly.py/pull/5766)], with thanks to @Irahan2 for the contribution! - Fix `trendline_scope="overall"` drawing the overall trendline on the `marginal_x`/`marginal_y` subplots as well as the main plot; it is now drawn only on the main subplots [[#5780](https://github.com/plotly/plotly.py/pull/5780)], with thanks to @jayzhou2309 for the contribution! +- Fix `add_hline`, `add_vline`, `add_hrect` and `add_vrect` dropping the shape on an empty subplot when `row` and `col` select a single subplot [[#XXXX](https://github.com/plotly/plotly.py/pull/XXXX)], with thanks to @LukeTheoJohnson for the contribution! - Fix `mpl_to_plotly` conversion of matplotlib contour lines and line collections: close contour rings ending with `Z` codes, support dash styles, convert date x-axes, separate disjoint subpaths with `None` separators, hide line collection traces from the legend by default, and group consecutive same-style lines into single traces [[#5770](https://github.com/plotly/plotly.py/pull/5770)], with thanks to @robertoffmoura for the contribution! - Fix `plotly.io.write_images` ignoring `layout.width`/`layout.height` (and the template's) and exporting every figure at the default 700x500 size; it now uses the same width/height priority as `write_image` and `to_image` [[#5784](https://github.com/plotly/plotly.py/pull/5784)], with thanks to @breken-ai for the contribution! - Fix color utilities mutating the list of colors passed to them [[#5836](https://github.com/plotly/plotly.py/pull/5836)], with thanks to @Enesuygurs for the contribution! diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index a7326da8e5a..aa299ccd9cd 100644 --- a/plotly/basedatatypes.py +++ b/plotly/basedatatypes.py @@ -339,7 +339,7 @@ def _axis_spanning_shapes_docstr(shape_type): docstr += """ exclude_empty_subplots: Boolean If True (default) do not place the shape on subplots that have no data - plotted on them. + plotted on them. Ignored when row and col select a single subplot. row: None, int or 'all' Subplot row for shape indexed starting at 1. If 'all', addresses all rows in the specified column(s). If both row and col are None, addresses the @@ -4003,6 +4003,15 @@ def _process_multiple_axis_spanning_shapes( augmented_annotation = shapeannotation.axis_spanning_shape_annotation( annotation, shape_type, shape_args, annotation_kwargs ) + if ( + row is not None + and col is not None + and ( + not _is_select_subplot_coordinates_arg(row, col) + or len(self._select_subplot_coordinates(row, col)) == 1 + ) + ): + exclude_empty_subplots = False self.add_shape( row=row, col=col, diff --git a/plotly/graph_objs/_figure.py b/plotly/graph_objs/_figure.py index 1c452a6e537..9fe500888a5 100644 --- a/plotly/graph_objs/_figure.py +++ b/plotly/graph_objs/_figure.py @@ -441,7 +441,7 @@ def add_vline( A number representing the x coordinate of the vertical line. exclude_empty_subplots: Boolean If True (default) do not place the shape on subplots that have no data - plotted on them. + plotted on them. Ignored when row and col select a single subplot. row: None, int or 'all' Subplot row for shape indexed starting at 1. If 'all', addresses all rows in the specified column(s). If both row and col are None, addresses the @@ -493,7 +493,7 @@ def add_hline( A number representing the y coordinate of the horizontal line. exclude_empty_subplots: Boolean If True (default) do not place the shape on subplots that have no data - plotted on them. + plotted on them. Ignored when row and col select a single subplot. row: None, int or 'all' Subplot row for shape indexed starting at 1. If 'all', addresses all rows in the specified column(s). If both row and col are None, addresses the @@ -548,7 +548,7 @@ def add_vrect( A number representing the x coordinate of the other side of the rectangle. exclude_empty_subplots: Boolean If True (default) do not place the shape on subplots that have no data - plotted on them. + plotted on them. Ignored when row and col select a single subplot. row: None, int or 'all' Subplot row for shape indexed starting at 1. If 'all', addresses all rows in the specified column(s). If both row and col are None, addresses the @@ -604,7 +604,7 @@ def add_hrect( A number representing the y coordinate of the other side of the rectangle. exclude_empty_subplots: Boolean If True (default) do not place the shape on subplots that have no data - plotted on them. + plotted on them. Ignored when row and col select a single subplot. row: None, int or 'all' Subplot row for shape indexed starting at 1. If 'all', addresses all rows in the specified column(s). If both row and col are None, addresses the diff --git a/plotly/graph_objs/_figurewidget.py b/plotly/graph_objs/_figurewidget.py index 4e2259a5f75..a2f564cc587 100644 --- a/plotly/graph_objs/_figurewidget.py +++ b/plotly/graph_objs/_figurewidget.py @@ -441,7 +441,7 @@ def add_vline( A number representing the x coordinate of the vertical line. exclude_empty_subplots: Boolean If True (default) do not place the shape on subplots that have no data - plotted on them. + plotted on them. Ignored when row and col select a single subplot. row: None, int or 'all' Subplot row for shape indexed starting at 1. If 'all', addresses all rows in the specified column(s). If both row and col are None, addresses the @@ -493,7 +493,7 @@ def add_hline( A number representing the y coordinate of the horizontal line. exclude_empty_subplots: Boolean If True (default) do not place the shape on subplots that have no data - plotted on them. + plotted on them. Ignored when row and col select a single subplot. row: None, int or 'all' Subplot row for shape indexed starting at 1. If 'all', addresses all rows in the specified column(s). If both row and col are None, addresses the @@ -548,7 +548,7 @@ def add_vrect( A number representing the x coordinate of the other side of the rectangle. exclude_empty_subplots: Boolean If True (default) do not place the shape on subplots that have no data - plotted on them. + plotted on them. Ignored when row and col select a single subplot. row: None, int or 'all' Subplot row for shape indexed starting at 1. If 'all', addresses all rows in the specified column(s). If both row and col are None, addresses the @@ -604,7 +604,7 @@ def add_hrect( A number representing the y coordinate of the other side of the rectangle. exclude_empty_subplots: Boolean If True (default) do not place the shape on subplots that have no data - plotted on them. + plotted on them. Ignored when row and col select a single subplot. row: None, int or 'all' Subplot row for shape indexed starting at 1. If 'all', addresses all rows in the specified column(s). If both row and col are None, addresses the diff --git a/tests/test_optional/test_autoshapes/test_axis_span_shapes.py b/tests/test_optional/test_autoshapes/test_axis_span_shapes.py index eb731a254ee..fa8301882e1 100644 --- a/tests/test_optional/test_autoshapes/test_axis_span_shapes.py +++ b/tests/test_optional/test_autoshapes/test_axis_span_shapes.py @@ -473,3 +473,72 @@ def _check_figure_shapes_custom_sized(test_input, expected, fig): ) def test_custom_sized_subplots(test_input, expected, custom_sized_subplots): _check_figure_shapes_custom_sized(test_input, expected, custom_sized_subplots) + + +@pytest.mark.parametrize( + "add_shape,kwargs", + [ + (go.Figure.add_hline, dict(y=1)), + (go.Figure.add_vline, dict(x=1)), + (go.Figure.add_hrect, dict(y0=1, y1=2)), + (go.Figure.add_vrect, dict(x0=1, x1=2)), + ], +) +@pytest.mark.parametrize( + "rows,cols,selection,expected_xref", + [ + (1, 1, dict(), "x"), + (2, 2, dict(row=2, col=1), "x3"), + (2, 1, dict(row=2, col="all"), "x2"), + (2, 2, dict(row=[1], col=[2]), "x2"), + ], +) +def test_single_empty_subplot_gets_shape( + add_shape, kwargs, rows, cols, selection, expected_xref +): + fig = make_subplots(rows=rows, cols=cols) + add_shape(fig, annotation_text="A", **kwargs, **selection) + assert len(fig.layout.shapes) == 1 + assert fig.layout.shapes[0].xref.split()[0] == expected_xref + assert len(fig.layout.annotations) == 1 + + +def test_single_subplot_with_none_cell_gets_shape(): + fig = make_subplots(rows=1, cols=2, specs=[[{}, None]]) + fig.add_hline(y=1) + assert len(fig.layout.shapes) == 1 + + +@pytest.mark.parametrize("selection", [dict(), dict(row=1, col="all")]) +def test_multiple_empty_subplots_are_still_excluded(selection): + fig = make_subplots(rows=1, cols=2) + fig.add_hline(y=1, **selection) + assert len(fig.layout.shapes) == 0 + + +def test_out_of_range_row_keeps_its_error(): + fig = make_subplots(rows=1, cols=1) + with pytest.raises(IndexError, match="row index 2 out-of-bounds"): + fig.add_hline(y=1, row=2, col=1) + + +def test_empty_secondary_y_gets_shape(): + fig = make_subplots(rows=1, cols=1, specs=[[{"secondary_y": True}]]) + fig.add_hline(y=1, row=1, col=1, secondary_y=True) + assert fig.layout.shapes[0].yref == "y2" + + +def test_explicit_empty_facet_gets_shape(subplot_empty_traces_fig_fixture): + fig = subplot_empty_traces_fig_fixture + empty = next( + (r, c) + for r, c in fig._get_subplot_coordinates() + if not fig._subplot_not_empty(*_xy_refs(fig, r, c)) + ) + fig.add_hline(y=1, row=empty[0], col=empty[1]) + assert len(fig.layout.shapes) == 1 + + +def _xy_refs(fig, row, col): + xaxis, yaxis = fig._grid_ref[row - 1][col - 1][0].layout_keys + return xaxis.replace("axis", ""), yaxis.replace("axis", "") From 076cd1cac574349f0961125f14dd01b57758238c Mon Sep 17 00:00:00 2001 From: Luke J Date: Fri, 6 Nov 2026 21:59:25 +1300 Subject: [PATCH 2/2] Add PR number to changelog entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a6db97008f..54923de7b5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Fix Plotly Express mutating lists passed to the `x` or `y` arguments in wide mode [[#5727](https://github.com/plotly/plotly.py/pull/5727)], with thanks to @cpruijsen for the contribution! - Fix `px.sunburst`, `px.treemap` and `px.icicle` listing sectors in a different order on every run when `path` is used with a Polars DataFrame; sectors now follow their order of first appearance for all dataframe backends [[#5766](https://github.com/plotly/plotly.py/pull/5766)], with thanks to @Irahan2 for the contribution! - Fix `trendline_scope="overall"` drawing the overall trendline on the `marginal_x`/`marginal_y` subplots as well as the main plot; it is now drawn only on the main subplots [[#5780](https://github.com/plotly/plotly.py/pull/5780)], with thanks to @jayzhou2309 for the contribution! -- Fix `add_hline`, `add_vline`, `add_hrect` and `add_vrect` dropping the shape on an empty subplot when `row` and `col` select a single subplot [[#XXXX](https://github.com/plotly/plotly.py/pull/XXXX)], with thanks to @LukeTheoJohnson for the contribution! +- Fix `add_hline`, `add_vline`, `add_hrect` and `add_vrect` dropping the shape on an empty subplot when `row` and `col` select a single subplot [[#5871](https://github.com/plotly/plotly.py/pull/5871)], with thanks to @LukeTheoJohnson for the contribution! - Fix `mpl_to_plotly` conversion of matplotlib contour lines and line collections: close contour rings ending with `Z` codes, support dash styles, convert date x-axes, separate disjoint subpaths with `None` separators, hide line collection traces from the legend by default, and group consecutive same-style lines into single traces [[#5770](https://github.com/plotly/plotly.py/pull/5770)], with thanks to @robertoffmoura for the contribution! - Fix `plotly.io.write_images` ignoring `layout.width`/`layout.height` (and the template's) and exporting every figure at the default 700x500 size; it now uses the same width/height priority as `write_image` and `to_image` [[#5784](https://github.com/plotly/plotly.py/pull/5784)], with thanks to @breken-ai for the contribution! - Fix color utilities mutating the list of colors passed to them [[#5836](https://github.com/plotly/plotly.py/pull/5836)], with thanks to @Enesuygurs for the contribution!