diff --git a/CHANGELOG.md b/CHANGELOG.md index c92c3ea6d64..15e7ee8fa71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,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 `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 `add_vline`, `add_hline`, `add_vrect` and `add_hrect` modifying the dict or `go.layout.Annotation` passed as `annotation`, which placed every later shape's annotation at the first shape's position when the same `annotation` was reused [[#3547](https://github.com/plotly/plotly.py/issues/3547)], with thanks to @adarshsm for the contribution! ## [7.1.0] - 2026-09-15 diff --git a/plotly/shapeannotation.py b/plotly/shapeannotation.py index 41173054a11..2b06e689206 100644 --- a/plotly/shapeannotation.py +++ b/plotly/shapeannotation.py @@ -1,5 +1,6 @@ # some functions defined here to avoid numpy import +import copy import datetime @@ -244,6 +245,10 @@ def axis_spanning_shape_annotation(annotation, shape_type, shape_args, kwargs): # go.layout.Annotation ? if annotation is None: annotation = dict() + else: + # Fill in a copy, so that an annotation passed in can be reused for + # other shapes without keeping this shape's text and position. + annotation = copy.copy(annotation) for k in annotation_keys: if k == "annotation_position": # don't set so that Annotation constructor doesn't complain diff --git a/tests/test_optional/test_autoshapes/test_annotated_shapes.py b/tests/test_optional/test_autoshapes/test_annotated_shapes.py index aa4d2ac794e..e190ca5f936 100644 --- a/tests/test_optional/test_autoshapes/test_annotated_shapes.py +++ b/tests/test_optional/test_autoshapes/test_annotated_shapes.py @@ -24,6 +24,7 @@ import plotly.graph_objects as go from plotly.subplots import make_subplots from itertools import product +import copy import os import pytest import json @@ -282,6 +283,30 @@ def test_specify_annotation_as_dict(multi_plot_fixture): assert ret +# Test that the annotation passed in isn't modified, so it can be reused for +# several shapes +@pytest.mark.parametrize( + "annotation", + [dict(font_color="red"), go.layout.Annotation(font_color="red")], + ids=["dict", "Annotation"], +) +def test_reuse_annotation(single_plot_fixture, annotation): + before = copy.deepcopy(annotation) + single_plot_fixture.add_vline(x=2, annotation_text="A", annotation=annotation) + single_plot_fixture.add_vline(x=3, annotation_text="B", annotation=annotation) + assert annotation == before + ret = len(single_plot_fixture.layout.annotations) == 2 + for sh, d in zip( + single_plot_fixture.layout.annotations, + [ + {"text": "A", "x": 2, "font.color": "red"}, + {"text": "B", "x": 3, "font.color": "red"}, + ], + ): + ret &= _cmp_partial_dict(sh, d) + assert ret + + # Test the default and coerced positions work def test_default_annotation_positions(multi_plot_fixture): # default position is (inside) top right