From 9bed9d301e2487a1b758a73c33c5861865995855 Mon Sep 17 00:00:00 2001 From: MoMo Date: Sat, 21 Feb 2026 00:00:18 +0200 Subject: [PATCH 1/5] Fix _mean() to handle datetime values for shape annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of assuming input values are equal, _mean() now converts datetime-like values (date strings and datetime objects) to milliseconds-since-epoch, computes the actual arithmetic mean, and converts back. This correctly handles annotation placement for ALL shapes (vlines, hlines, vrects, hrects) with datetime axes. The numeric fast path is unchanged — datetime handling only activates when sum() raises TypeError on non-numeric types. Added tests for datetime strings, datetime objects, and rects with different x0/x1 values on datetime axes. Fixes #3065 --- plotly/shapeannotation.py | 44 ++++++++++- .../test_autoshapes/test_annotated_shapes.py | 79 +++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/plotly/shapeannotation.py b/plotly/shapeannotation.py index a2323ed02d4..41173054a11 100644 --- a/plotly/shapeannotation.py +++ b/plotly/shapeannotation.py @@ -1,10 +1,52 @@ # some functions defined here to avoid numpy import +import datetime + + +def _is_date_string(val): + """Check if a value is a date/datetime string.""" + if not isinstance(val, str): + return False + try: + datetime.datetime.fromisoformat(val.replace("Z", "+00:00")) + return True + except (ValueError, AttributeError): + return False + + +def _datetime_str_to_ms(val): + """Convert a datetime string to milliseconds since epoch.""" + dt = datetime.datetime.fromisoformat(val.replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=datetime.timezone.utc) + return dt.timestamp() * 1000 + + +def _ms_to_datetime_str(ms): + """Convert milliseconds since epoch back to a datetime string.""" + dt = datetime.datetime.fromtimestamp(ms / 1000, tz=datetime.timezone.utc) + return dt.strftime("%Y-%m-%d %H:%M:%S") + def _mean(x): if len(x) == 0: raise ValueError("x must have positive length") - return float(sum(x)) / len(x) + try: + return float(sum(x)) / len(x) + except TypeError: + # Handle non-numeric types like datetime strings or datetime objects + if all(_is_date_string(v) for v in x): + ms_values = [_datetime_str_to_ms(v) for v in x] + mean_ms = sum(ms_values) / len(ms_values) + return _ms_to_datetime_str(mean_ms) + # Handle datetime.datetime, pd.Timestamp, or similar objects + if all(hasattr(v, "timestamp") for v in x): + ts_values = [v.timestamp() * 1000 for v in x] + mean_ms = sum(ts_values) / len(ts_values) + return datetime.datetime.fromtimestamp( + mean_ms / 1000, tz=datetime.timezone.utc + ).isoformat() + raise def _argmin(x): diff --git a/tests/test_optional/test_autoshapes/test_annotated_shapes.py b/tests/test_optional/test_autoshapes/test_annotated_shapes.py index a008e3bda12..14f7b31792a 100644 --- a/tests/test_optional/test_autoshapes/test_annotated_shapes.py +++ b/tests/test_optional/test_autoshapes/test_annotated_shapes.py @@ -425,5 +425,84 @@ def test_all_annotation_positions(): draw_all_annotation_positions(testing=True) + if __name__ == "__main__": draw_all_annotation_positions() + + +# Tests for datetime axis annotation support (issue #3065) +import datetime + + +def test_vline_datetime_string_annotation(): + """add_vline with annotation_text on datetime x-axis should not crash.""" + fig = go.Figure() + fig.add_trace( + go.Scatter(x=["2018-01-01", "2018-06-01", "2018-12-31"], y=[1, 2, 3]) + ) + fig.add_vline(x="2018-09-24", annotation_text="test") + assert len(fig.layout.annotations) == 1 + assert fig.layout.annotations[0].text == "test" + + +def test_hline_with_datetime_vline(): + """add_hline should still work alongside datetime vline usage.""" + fig = go.Figure() + fig.add_trace( + go.Scatter(x=["2018-01-01", "2018-06-01", "2018-12-31"], y=[1, 2, 3]) + ) + fig.add_hline(y=2, annotation_text="hline test") + assert len(fig.layout.annotations) == 1 + assert fig.layout.annotations[0].text == "hline test" + + +def test_vrect_datetime_string_annotation(): + """add_vrect with annotation_text on datetime x-axis should not crash.""" + fig = go.Figure() + fig.add_trace( + go.Scatter(x=["2018-01-01", "2018-06-01", "2018-12-31"], y=[1, 2, 3]) + ) + fig.add_vrect(x0="2018-03-01", x1="2018-09-01", annotation_text="rect test") + assert len(fig.layout.annotations) == 1 + assert fig.layout.annotations[0].text == "rect test" + + +def test_vline_datetime_object_annotation(): + """add_vline with datetime.datetime object should not crash.""" + fig = go.Figure() + fig.add_trace( + go.Scatter( + x=[ + datetime.datetime(2018, 1, 1), + datetime.datetime(2018, 6, 1), + datetime.datetime(2018, 12, 31), + ], + y=[1, 2, 3], + ) + ) + fig.add_vline(x=datetime.datetime(2018, 9, 24), annotation_text="dt test") + assert len(fig.layout.annotations) == 1 + assert fig.layout.annotations[0].text == "dt test" + + +def test_vrect_datetime_object_annotation(): + """add_vrect with datetime.datetime objects should compute correct mean.""" + fig = go.Figure() + fig.add_trace( + go.Scatter( + x=[ + datetime.datetime(2018, 1, 1), + datetime.datetime(2018, 6, 1), + datetime.datetime(2018, 12, 31), + ], + y=[1, 2, 3], + ) + ) + fig.add_vrect( + x0=datetime.datetime(2018, 3, 1), + x1=datetime.datetime(2018, 9, 1), + annotation_text="rect dt test", + ) + assert len(fig.layout.annotations) == 1 + assert fig.layout.annotations[0].text == "rect dt test" + From b83c5addd60659cae44aa1baa3f5aabbe12bdf1b Mon Sep 17 00:00:00 2001 From: Emily KL <4672118+emilykl@users.noreply.github.com> Date: Fri, 8 May 2026 12:01:22 -0400 Subject: [PATCH 2/5] Assert annotation x-position in tests Co-authored-by: Emily KL <4672118+emilykl@users.noreply.github.com> --- .../test_optional/test_autoshapes/test_annotated_shapes.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_optional/test_autoshapes/test_annotated_shapes.py b/tests/test_optional/test_autoshapes/test_annotated_shapes.py index 14f7b31792a..c7895fb42e5 100644 --- a/tests/test_optional/test_autoshapes/test_annotated_shapes.py +++ b/tests/test_optional/test_autoshapes/test_annotated_shapes.py @@ -443,10 +443,11 @@ def test_vline_datetime_string_annotation(): fig.add_vline(x="2018-09-24", annotation_text="test") assert len(fig.layout.annotations) == 1 assert fig.layout.annotations[0].text == "test" + assert fig.layout.annotations[0].x == "2018-09-24" def test_hline_with_datetime_vline(): - """add_hline should still work alongside datetime vline usage.""" + """numeric add_hline should still work with datetime x-axis.""" fig = go.Figure() fig.add_trace( go.Scatter(x=["2018-01-01", "2018-06-01", "2018-12-31"], y=[1, 2, 3]) @@ -454,6 +455,7 @@ def test_hline_with_datetime_vline(): fig.add_hline(y=2, annotation_text="hline test") assert len(fig.layout.annotations) == 1 assert fig.layout.annotations[0].text == "hline test" + assert fig.layout.annotations[0].y == 2 def test_vrect_datetime_string_annotation(): @@ -465,6 +467,7 @@ def test_vrect_datetime_string_annotation(): fig.add_vrect(x0="2018-03-01", x1="2018-09-01", annotation_text="rect test") assert len(fig.layout.annotations) == 1 assert fig.layout.annotations[0].text == "rect test" + assert fig.layout.annotations[0].x == "2018-09-01" def test_vline_datetime_object_annotation(): @@ -483,6 +486,7 @@ def test_vline_datetime_object_annotation(): fig.add_vline(x=datetime.datetime(2018, 9, 24), annotation_text="dt test") assert len(fig.layout.annotations) == 1 assert fig.layout.annotations[0].text == "dt test" + assert fig.layout.annotations[0].x == datetime.datetime(2018, 9, 24, 0, 0) def test_vrect_datetime_object_annotation(): @@ -505,4 +509,5 @@ def test_vrect_datetime_object_annotation(): ) assert len(fig.layout.annotations) == 1 assert fig.layout.annotations[0].text == "rect dt test" + assert fig.layout.annotations[0].x == datetime.datetime(2018, 9, 1) From 82e5759028938b2cdbf2fce965f9233436f91280 Mon Sep 17 00:00:00 2001 From: Emily KL <4672118+emilykl@users.noreply.github.com> Date: Fri, 8 May 2026 12:08:12 -0400 Subject: [PATCH 3/5] formatting --- tests/test_optional/test_autoshapes/test_annotated_shapes.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_optional/test_autoshapes/test_annotated_shapes.py b/tests/test_optional/test_autoshapes/test_annotated_shapes.py index c7895fb42e5..0df4a61594f 100644 --- a/tests/test_optional/test_autoshapes/test_annotated_shapes.py +++ b/tests/test_optional/test_autoshapes/test_annotated_shapes.py @@ -437,9 +437,7 @@ def test_all_annotation_positions(): def test_vline_datetime_string_annotation(): """add_vline with annotation_text on datetime x-axis should not crash.""" fig = go.Figure() - fig.add_trace( - go.Scatter(x=["2018-01-01", "2018-06-01", "2018-12-31"], y=[1, 2, 3]) - ) + fig.add_trace(go.Scatter(x=["2018-01-01", "2018-06-01", "2018-12-31"], y=[1, 2, 3])) fig.add_vline(x="2018-09-24", annotation_text="test") assert len(fig.layout.annotations) == 1 assert fig.layout.annotations[0].text == "test" From 05ac01868edd79752151c6e2e72853d9d65a01e2 Mon Sep 17 00:00:00 2001 From: Emily KL <4672118+emilykl@users.noreply.github.com> Date: Fri, 8 May 2026 12:25:06 -0400 Subject: [PATCH 4/5] formatting Co-authored-by: Emily KL <4672118+emilykl@users.noreply.github.com> --- .../test_autoshapes/test_annotated_shapes.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/tests/test_optional/test_autoshapes/test_annotated_shapes.py b/tests/test_optional/test_autoshapes/test_annotated_shapes.py index 0df4a61594f..6f8b68d981c 100644 --- a/tests/test_optional/test_autoshapes/test_annotated_shapes.py +++ b/tests/test_optional/test_autoshapes/test_annotated_shapes.py @@ -444,12 +444,10 @@ def test_vline_datetime_string_annotation(): assert fig.layout.annotations[0].x == "2018-09-24" -def test_hline_with_datetime_vline(): +def test_hline_with_datetime_xaxis(): """numeric add_hline should still work with datetime x-axis.""" fig = go.Figure() - fig.add_trace( - go.Scatter(x=["2018-01-01", "2018-06-01", "2018-12-31"], y=[1, 2, 3]) - ) + fig.add_trace(go.Scatter(x=["2018-01-01", "2018-06-01", "2018-12-31"], y=[1, 2, 3])) fig.add_hline(y=2, annotation_text="hline test") assert len(fig.layout.annotations) == 1 assert fig.layout.annotations[0].text == "hline test" @@ -459,9 +457,7 @@ def test_hline_with_datetime_vline(): def test_vrect_datetime_string_annotation(): """add_vrect with annotation_text on datetime x-axis should not crash.""" fig = go.Figure() - fig.add_trace( - go.Scatter(x=["2018-01-01", "2018-06-01", "2018-12-31"], y=[1, 2, 3]) - ) + fig.add_trace(go.Scatter(x=["2018-01-01", "2018-06-01", "2018-12-31"], y=[1, 2, 3])) fig.add_vrect(x0="2018-03-01", x1="2018-09-01", annotation_text="rect test") assert len(fig.layout.annotations) == 1 assert fig.layout.annotations[0].text == "rect test" @@ -508,4 +504,3 @@ def test_vrect_datetime_object_annotation(): assert len(fig.layout.annotations) == 1 assert fig.layout.annotations[0].text == "rect dt test" assert fig.layout.annotations[0].x == datetime.datetime(2018, 9, 1) - From 8c18945fb61b57e255683ee0c0d82f41bbcef32b Mon Sep 17 00:00:00 2001 From: Emily KL <4672118+emilykl@users.noreply.github.com> Date: Fri, 8 May 2026 12:54:32 -0400 Subject: [PATCH 5/5] formatting --- tests/test_optional/test_autoshapes/test_annotated_shapes.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_optional/test_autoshapes/test_annotated_shapes.py b/tests/test_optional/test_autoshapes/test_annotated_shapes.py index 6f8b68d981c..aa4d2ac794e 100644 --- a/tests/test_optional/test_autoshapes/test_annotated_shapes.py +++ b/tests/test_optional/test_autoshapes/test_annotated_shapes.py @@ -425,7 +425,6 @@ def test_all_annotation_positions(): draw_all_annotation_positions(testing=True) - if __name__ == "__main__": draw_all_annotation_positions()