diff --git a/CHANGELOG.md b/CHANGELOG.md index 0583fec45c8..db1708f598f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Fixed - Fix concurrent first access to lazily initialized graph object properties, which could raise `ValueError("Invalid value")` [[#3441](https://github.com/plotly/plotly.py/issues/3441)], with thanks to @hb1915 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 [[#5765](https://github.com/plotly/plotly.py/issues/5765)], with thanks to @Irahan2 for the contribution! +- Fix `add_annotation`, `add_shape`, `add_layout_image` and `add_selection` rebuilding every existing layout object on each call, which made adding N objects take O(N^3) time (1000 annotations: 25 s → 0.05 s) [[#5316](https://github.com/plotly/plotly.py/issues/5316)] ## [7.1.0] - 2026-09-15 diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index a7326da8e5a..1c8ef3f975e 100644 --- a/plotly/basedatatypes.py +++ b/plotly/basedatatypes.py @@ -1627,7 +1627,7 @@ def _add_domain(ax_letter, new_axref): xref, yref = map(lambda t: _add_domain(*t), zip(["x", "y"], [xref, yref])) new_obj.update(xref=xref, yref=yref) - self.layout[prop_plural] += (new_obj,) + self.layout._append_array_prop(prop_plural, new_obj) # The 'new_obj.xref' and 'new_obj.yref' parameters need to be reset otherwise it # will appear as if user supplied yref params when looping through subplots and # will force annotation to be on the axis of the last drawn annotation @@ -5369,6 +5369,38 @@ def _set_array_prop(self, prop, val): self._compound_array_props[prop] = val return val + def _append_array_prop(self, prop, val): + """ + Append one element to a compound array property + + Unlike `self[prop] += (val,)`, this validates only the new element + and keeps the existing elements, so N appends take O(N) time + instead of rebuilding every existing element on each append. + + Parameters + ---------- + prop : str + Name of a compound array property + val + The element to append + """ + if self._in_batch_mode: + self[prop] += (val,) + return + + curr_val = self[prop] + validator = self._get_validator(prop) + (new_el,) = validator.validate_coerce([val], skip_invalid=self._skip_invalid) + + self._init_props() + new_dict_vals = self._props.get(prop, []) + [deepcopy(new_el._props)] + self._props[prop] = new_dict_vals + self._send_prop_set(prop, new_dict_vals) + + new_el._orphan_props.clear() + new_el._parent = self + self._compound_array_props[prop] = list(curr_val) + [new_el] + def _send_prop_set(self, prop_path_str, val): """ Notify parent that a property has been set to a new value diff --git a/tests/test_core/test_update_objects/test_update_annotations.py b/tests/test_core/test_update_objects/test_update_annotations.py index 773ca603b3a..bdc871e18e2 100644 --- a/tests/test_core/test_update_objects/test_update_annotations.py +++ b/tests/test_core/test_update_objects/test_update_annotations.py @@ -1,5 +1,6 @@ import types from unittest import TestCase +from unittest.mock import MagicMock import plotly.graph_objs as go from plotly.subplots import make_subplots @@ -428,3 +429,35 @@ def test_select_annotations_integer(select_annotations_integer): assert (len(anns) == 1) and anns[0]["text"] == "B" with pytest.raises(IndexError): fig.select_annotations(row=2, col=2, selector=3) + + +def test_add_annotation_keeps_existing_annotations(): + fig = go.Figure() + fig.add_annotation(text="first") + first = fig.layout.annotations[0] + + fig.add_annotation(text="second") + + assert fig.layout.annotations[0] is first + first.text = "edited" + assert fig.to_dict()["layout"]["annotations"] == [ + {"text": "edited"}, + {"text": "second"}, + ] + + +def test_add_shape_sends_full_array_relayout(): + fig = go.Figure() + fig.add_shape(type="line", x0=0, x1=1) + fig._send_relayout_msg = MagicMock() + + fig.add_shape(type="rect", x0=2, x1=3) + + fig._send_relayout_msg.assert_called_once_with( + { + "shapes": [ + {"type": "line", "x0": 0, "x1": 1}, + {"type": "rect", "x0": 2, "x1": 3}, + ] + } + )