From f5a53afbc20855e7e937e9a8bbac43ce038ceb86 Mon Sep 17 00:00:00 2001 From: Adams Michael Date: Wed, 8 Oct 2025 12:07:56 +0200 Subject: [PATCH 1/3] Allow for vlines on datetime axes with annotations by extending _mean() functionality to cover datetime sequences (fixes issue #3065) --- plotly/shapeannotation.py | 27 ++++++++++- .../test_date_axis_annotated_shapes.py | 48 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 tests/test_optional/test_autoshapes/test_date_axis_annotated_shapes.py diff --git a/plotly/shapeannotation.py b/plotly/shapeannotation.py index a2323ed02d4..7c6d7049820 100644 --- a/plotly/shapeannotation.py +++ b/plotly/shapeannotation.py @@ -1,10 +1,32 @@ # some functions defined here to avoid numpy import +from datetime import datetime +from numbers import Number + def _mean(x): if len(x) == 0: raise ValueError("x must have positive length") - return float(sum(x)) / len(x) + + # Numeric sequence: default behaviour + if all(isinstance(v, Number) for v in x): + return float(sum(x)) / len(x) + + # Datetime sequence: delegate to _mean_datetime + if all(isinstance(v, datetime) for v in x): + return _mean_datetime(x) + + # Fallback for non-numeric and non-datetime types: return first element + return x[0] + + +def _mean_datetime(x: list[datetime]) -> datetime: + """Return midpoint of a sequence of datetime objects (assumes homogenous).""" + + timestamps = [v.timestamp() for v in x] # convert to POSIX timestamps + avg = sum(timestamps) / len(timestamps) + tzinfo = x[0].tzinfo # extract timezone info from first element + return datetime.fromtimestamp(avg, tz=tzinfo) def _argmin(x): @@ -212,6 +234,7 @@ def axis_spanning_shape_annotation(annotation, shape_type, shape_args, kwargs): annotation_position = None if "annotation_position" in kwargs.keys(): annotation_position = kwargs["annotation_position"] + shape_dict = {} if shape_type.endswith("line"): shape_dict = annotation_params_for_line( shape_type, shape_args, annotation_position @@ -220,6 +243,8 @@ def axis_spanning_shape_annotation(annotation, shape_type, shape_args, kwargs): shape_dict = annotation_params_for_rect( shape_type, shape_args, annotation_position ) + else: # pragma: no cover (defensive) + raise ValueError("Unsupported shape_type '%s'" % shape_type) for k in shape_dict.keys(): # only set property derived from annotation_position if it hasn't already been set # see above: this would be better as a go.layout.Annotation then the key diff --git a/tests/test_optional/test_autoshapes/test_date_axis_annotated_shapes.py b/tests/test_optional/test_autoshapes/test_date_axis_annotated_shapes.py new file mode 100644 index 00000000000..5f4f7df6edb --- /dev/null +++ b/tests/test_optional/test_autoshapes/test_date_axis_annotated_shapes.py @@ -0,0 +1,48 @@ +"""Tests for annotated axis-spanning shapes with datetime coordinates. + +These tests cover the regression described in GitHub issue #3065: +https://github.com/plotly/plotly.py/issues/3065 + +""" + +from datetime import datetime + +import plotly.express as px +import plotly.graph_objects as go + + +def test_add_vline_with_date_annotation_express(): + """MWE from Github issue https://github.com/plotly/plotly.py/issues/3065""" + df = px.data.stocks(indexed=True) + fig = px.line(df) + fig.add_vline(x="2018-09-24", annotation_text="test") + + +def test_add_vline_with_date_annotation(): + fig = go.Figure() + # Provide a couple of traces so axis type is inferred as date from data + dates = [datetime(2025, 9, 23), datetime(2025, 9, 24), datetime(2025, 9, 25)] + fig.add_scatter(x=dates, y=[1, 2, 3]) + fig.add_vline(x=dates[1], annotation_text="Test") + + # Ensure one annotation was added and x coordinate preserved + annotations = getattr(fig.layout, "annotations", []) + assert len(annotations) == 1 + ann = annotations[0] + assert ann.x == dates[1] + assert ann.text == "Test" + + +def test_add_hline_with_date_annotation(): + fig = go.Figure() + # Provide a couple of traces so axis type is inferred as date from data + dates = [datetime(2025, 9, 23), datetime(2025, 9, 24), datetime(2025, 9, 25)] + fig.add_scatter(y=dates, x=[1, 2, 3]) + fig.add_hline(y=dates[1], annotation_text="Test") + + # Ensure one annotation was added and x coordinate preserved + annotations = getattr(fig.layout, "annotations", []) + assert len(annotations) == 1 + ann = annotations[0] + assert ann.y == dates[1] + assert ann.text == "Test" From 8a46c4c22bf37882be112efcf799c4e4ec5f9881 Mon Sep 17 00:00:00 2001 From: Adams Michael Date: Wed, 8 Oct 2025 13:01:47 +0200 Subject: [PATCH 2/3] remove type hints for python 3.8 compatibility --- plotly/shapeannotation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plotly/shapeannotation.py b/plotly/shapeannotation.py index 7c6d7049820..a73b05a2704 100644 --- a/plotly/shapeannotation.py +++ b/plotly/shapeannotation.py @@ -20,7 +20,7 @@ def _mean(x): return x[0] -def _mean_datetime(x: list[datetime]) -> datetime: +def _mean_datetime(x): """Return midpoint of a sequence of datetime objects (assumes homogenous).""" timestamps = [v.timestamp() for v in x] # convert to POSIX timestamps From ff88033006d8daba631df6033fcf68d2f11e19bc Mon Sep 17 00:00:00 2001 From: Adams Michael Date: Wed, 8 Oct 2025 13:17:39 +0200 Subject: [PATCH 3/3] revert an accidental change not related to this feature to keep the PR clean --- plotly/shapeannotation.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/plotly/shapeannotation.py b/plotly/shapeannotation.py index a73b05a2704..a3498973fa9 100644 --- a/plotly/shapeannotation.py +++ b/plotly/shapeannotation.py @@ -234,7 +234,6 @@ def axis_spanning_shape_annotation(annotation, shape_type, shape_args, kwargs): annotation_position = None if "annotation_position" in kwargs.keys(): annotation_position = kwargs["annotation_position"] - shape_dict = {} if shape_type.endswith("line"): shape_dict = annotation_params_for_line( shape_type, shape_args, annotation_position @@ -243,8 +242,6 @@ def axis_spanning_shape_annotation(annotation, shape_type, shape_args, kwargs): shape_dict = annotation_params_for_rect( shape_type, shape_args, annotation_position ) - else: # pragma: no cover (defensive) - raise ValueError("Unsupported shape_type '%s'" % shape_type) for k in shape_dict.keys(): # only set property derived from annotation_position if it hasn't already been set # see above: this would be better as a go.layout.Annotation then the key