Skip to content

Commit 1ea1d97

Browse files
committed
fix adding shapes and annotations inside batch_update()
Inside batch_update() property edits are deferred, but appending to a compound array property still swaps in the new child objects, so the freshly added shapes have no properties to read back yet. add_vline() and friends then fail with "unsupported operand type(s) for +=: 'NoneType' and 'str'" while appending " domain" to the axis reference, and a second add_shape()/add_annotation() in the same batch copies the first one as an empty object. Layout objects are structural additions like traces, and add_traces() already applies those immediately regardless of batch mode. Do the same here: add the objects and fix up their axis references with batch mode switched off, so only regular property edits stay batched. Closes #4742
1 parent 05579b0 commit 1ea1d97

4 files changed

Lines changed: 121 additions & 41 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).
44

55
## Unreleased
66

7+
### Fixed
8+
- Fix `add_vline`, `add_hline`, `add_vrect` and `add_hrect` raising a `TypeError` inside `batch_update()`, and `add_shape`/`add_annotation` losing previously added objects there [[#4742](https://github.com/plotly/plotly.py/issues/4742)]
79

810
## [7.1.0] - 2026-09-15
911

plotly/basedatatypes.py

Lines changed: 61 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1627,7 +1627,8 @@ def _add_domain(ax_letter, new_axref):
16271627
xref, yref = map(lambda t: _add_domain(*t), zip(["x", "y"], [xref, yref]))
16281628
new_obj.update(xref=xref, yref=yref)
16291629

1630-
self.layout[prop_plural] += (new_obj,)
1630+
with self._batch_mode_disabled():
1631+
self.layout[prop_plural] += (new_obj,)
16311632
# The 'new_obj.xref' and 'new_obj.yref' parameters need to be reset otherwise it
16321633
# will appear as if user supplied yref params when looping through subplots and
16331634
# will force annotation to be on the axis of the last drawn annotation
@@ -3077,6 +3078,24 @@ def batch_update(self):
30773078
self._batch_layout_edits.clear()
30783079
self._batch_trace_edits.clear()
30793080

3081+
@contextmanager
3082+
def _batch_mode_disabled(self):
3083+
"""
3084+
Temporarily leave batch mode.
3085+
3086+
Adding layout objects (shapes, annotations, images, selections)
3087+
rebuilds the whole compound array property and, for the axis
3088+
spanning shapes, reads the new objects back. Neither works while
3089+
`batch_update()` is deferring property edits, so like `add_traces()`
3090+
these structural changes are applied immediately.
3091+
"""
3092+
in_batch_mode = self._in_batch_mode
3093+
self._in_batch_mode = False
3094+
try:
3095+
yield
3096+
finally:
3097+
self._in_batch_mode = in_batch_mode
3098+
30803099
def _build_update_params_from_batch(self):
30813100
"""
30823101
Convert `_batch_trace_edits` and `_batch_layout_edits` into the
@@ -4003,52 +4022,53 @@ def _process_multiple_axis_spanning_shapes(
40034022
augmented_annotation = shapeannotation.axis_spanning_shape_annotation(
40044023
annotation, shape_type, shape_args, annotation_kwargs
40054024
)
4006-
self.add_shape(
4007-
row=row,
4008-
col=col,
4009-
exclude_empty_subplots=exclude_empty_subplots,
4010-
**_combine_dicts([shape_args, shape_kwargs]),
4011-
)
4012-
if augmented_annotation is not None:
4013-
self.add_annotation(
4014-
augmented_annotation,
4025+
with self._batch_mode_disabled():
4026+
self.add_shape(
40154027
row=row,
40164028
col=col,
40174029
exclude_empty_subplots=exclude_empty_subplots,
4018-
yref=shape_kwargs.get("yref", "y"),
4030+
**_combine_dicts([shape_args, shape_kwargs]),
40194031
)
4020-
# update xref and yref for the new shapes and annotations
4021-
for layout_obj, n_layout_objs_before in zip(
4022-
["shapes", "annotations"], [n_shapes_before, n_annotations_before]
4023-
):
4024-
n_layout_objs_after = len(self.layout[layout_obj])
4025-
if (n_layout_objs_after > n_layout_objs_before) and (
4026-
row is None and col is None
4032+
if augmented_annotation is not None:
4033+
self.add_annotation(
4034+
augmented_annotation,
4035+
row=row,
4036+
col=col,
4037+
exclude_empty_subplots=exclude_empty_subplots,
4038+
yref=shape_kwargs.get("yref", "y"),
4039+
)
4040+
# update xref and yref for the new shapes and annotations
4041+
for layout_obj, n_layout_objs_before in zip(
4042+
["shapes", "annotations"], [n_shapes_before, n_annotations_before]
40274043
):
4028-
# this was called intending to add to a single plot (and
4029-
# self.add_{layout_obj} succeeded)
4030-
# however, in the case of a single plot, xref and yref MAY not be
4031-
# specified, IF they are not specified we specify them here so the following routines can work
4032-
# (they need to append " domain" to xref or yref). If they are specified, we leave them alone.
4033-
if self.layout[layout_obj][-1].xref is None:
4034-
self.layout[layout_obj][-1].update(xref="x")
4035-
if self.layout[layout_obj][-1].yref is None:
4036-
self.layout[layout_obj][-1].update(yref="y")
4037-
new_layout_objs = tuple(
4038-
filter(
4039-
lambda x: x is not None,
4040-
[
4041-
self._make_axis_spanning_layout_object(
4042-
direction,
4043-
self.layout[layout_obj][n],
4044-
)
4045-
for n in range(n_layout_objs_before, n_layout_objs_after)
4046-
],
4044+
n_layout_objs_after = len(self.layout[layout_obj])
4045+
if (n_layout_objs_after > n_layout_objs_before) and (
4046+
row is None and col is None
4047+
):
4048+
# this was called intending to add to a single plot (and
4049+
# self.add_{layout_obj} succeeded)
4050+
# however, in the case of a single plot, xref and yref MAY not be
4051+
# specified, IF they are not specified we specify them here so the following routines can work
4052+
# (they need to append " domain" to xref or yref). If they are specified, we leave them alone.
4053+
if self.layout[layout_obj][-1].xref is None:
4054+
self.layout[layout_obj][-1].update(xref="x")
4055+
if self.layout[layout_obj][-1].yref is None:
4056+
self.layout[layout_obj][-1].update(yref="y")
4057+
new_layout_objs = tuple(
4058+
filter(
4059+
lambda x: x is not None,
4060+
[
4061+
self._make_axis_spanning_layout_object(
4062+
direction,
4063+
self.layout[layout_obj][n],
4064+
)
4065+
for n in range(n_layout_objs_before, n_layout_objs_after)
4066+
],
4067+
)
4068+
)
4069+
self.layout[layout_obj] = (
4070+
self.layout[layout_obj][:n_layout_objs_before] + new_layout_objs
40474071
)
4048-
)
4049-
self.layout[layout_obj] = (
4050-
self.layout[layout_obj][:n_layout_objs_before] + new_layout_objs
4051-
)
40524072

40534073
def add_vline(
40544074
self,

tests/test_core/test_figure_messages/test_plotly_update.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,32 @@ def test_batch_update(self):
5757
trace_indexes=[0, 1],
5858
)
5959

60+
def test_add_layout_objects_in_batch_update(self):
61+
self.figure._send_relayout_msg = MagicMock()
62+
63+
with self.figure.batch_update():
64+
self.figure.add_shape(type="line", x0=0, x1=1, y0=0, y1=1)
65+
self.figure.add_shape(type="rect", x0=1, x1=2, y0=1, y1=2)
66+
self.figure.add_annotation(text="a", x=0, y=0)
67+
self.figure.layout.xaxis.range = [10, 20]
68+
69+
# Like traces, layout objects are added right away
70+
self.assertEqual(
71+
[s.type for s in self.figure.layout.shapes], ["line", "rect"]
72+
)
73+
self.assertEqual(self.figure.layout.annotations[0].text, "a")
74+
self.assertEqual(self.figure._send_relayout_msg.call_count, 3)
75+
76+
# while property assignments still wait for the context to exit
77+
self.assertEqual(self.figure.layout.xaxis.range, (-1, 4))
78+
79+
self.assertEqual(self.figure.layout.xaxis.range, (10, 20))
80+
self.figure._send_update_msg.assert_called_once()
81+
self.assertEqual(
82+
self.figure._send_update_msg.call_args.kwargs["relayout_data"],
83+
{"xaxis.range": [10, 20]},
84+
)
85+
6086
def test_plotly_update(self):
6187
self.figure.plotly_update(
6288
restyle_data={

tests/test_optional/test_autoshapes/test_axis_span_shapes.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,3 +473,35 @@ def _check_figure_shapes_custom_sized(test_input, expected, fig):
473473
)
474474
def test_custom_sized_subplots(test_input, expected, custom_sized_subplots):
475475
_check_figure_shapes_custom_sized(test_input, expected, custom_sized_subplots)
476+
477+
478+
@pytest.mark.parametrize(
479+
"f,kwargs",
480+
[
481+
(go.Figure.add_vline, dict(x=20)),
482+
(go.Figure.add_hline, dict(y=6, annotation_text="six")),
483+
(go.Figure.add_vrect, dict(x0=20, x1=30, annotation_text="twenties")),
484+
(go.Figure.add_hrect, dict(y0=6, y1=8)),
485+
],
486+
)
487+
@pytest.mark.parametrize(
488+
"fixture_name,row_col",
489+
[
490+
("non_subplot_fig_fixture", dict()),
491+
("subplot_fig_fixture", dict(row=2, col=2)),
492+
("subplot_fig_fixture", dict(row="all", col="all")),
493+
],
494+
)
495+
def test_add_axis_spanning_shape_in_batch_update(
496+
request, f, kwargs, fixture_name, row_col
497+
):
498+
fig = request.getfixturevalue(fixture_name)
499+
fig_batch = go.Figure(fig)
500+
501+
f(fig, **kwargs, **row_col)
502+
with fig_batch.batch_update():
503+
f(fig_batch, **kwargs, **row_col)
504+
505+
assert len(fig.layout.shapes) > 0
506+
assert fig_batch.layout.shapes == fig.layout.shapes
507+
assert fig_batch.layout.annotations == fig.layout.annotations

0 commit comments

Comments
 (0)