From 72c85cd5cc569096387ab24c641510dee1cd761a Mon Sep 17 00:00:00 2001 From: Floura Angel Date: Sun, 28 Sep 2025 22:08:08 -0400 Subject: [PATCH 1/6] BUG: honor ambiguous/nonexistent for tz-aware endpoints in date_range --- pandas/core/arrays/datetimes.py | 13 ++++++------ .../indexes/datetimes/test_date_range.py | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/pandas/core/arrays/datetimes.py b/pandas/core/arrays/datetimes.py index 38be038efcaa5..9ad9f8f1bbef3 100644 --- a/pandas/core/arrays/datetimes.py +++ b/pandas/core/arrays/datetimes.py @@ -456,13 +456,14 @@ def _generate_range( end = _maybe_localize_point(end, freq, tz, ambiguous, nonexistent) if freq is not None: - # We break Day arithmetic (fixed 24 hour) here and opt for - # Day to mean calendar day (23/24/25 hour). Therefore, strip - # tz info from start and day to avoid DST arithmetic - if isinstance(freq, Day): - if start is not None: + # Offset handling: + # Ticks (fixed-duration like hours/minutes): keep tz; do absolute-time math. + # Other calendar offsets: drop tz; do naive wall time; localize once later + # so `ambiguous`/`nonexistent` are applied correctly. + if not isinstance(freq, Tick): + if start is not None and start.tz is not None: start = start.tz_localize(None) - if end is not None: + if end is not None and end.tz is not None: end = end.tz_localize(None) if isinstance(freq, (Tick, Day)): diff --git a/pandas/tests/indexes/datetimes/test_date_range.py b/pandas/tests/indexes/datetimes/test_date_range.py index 83e1a7a276875..6976c228cc71a 100644 --- a/pandas/tests/indexes/datetimes/test_date_range.py +++ b/pandas/tests/indexes/datetimes/test_date_range.py @@ -1740,3 +1740,23 @@ def test_date_range_negative_freq_year_end_inbounds(self, unit): freq="-1YE", ) tm.assert_index_equal(rng, exp) + + def test_date_range_tzaware_endpoints_accept_ambiguous(self): + # With tz-aware endpoints and a calendar offset (MS), + # date_range should accept `ambiguous=True` and produce + # the same result as passing tz explicitly with naive endpoints. + start = Timestamp("1916-08-01", tz="Europe/Oslo") + end = Timestamp("1916-12-01", tz="Europe/Oslo") + res = date_range(start, end, freq="MS", ambiguous=True) + exp = date_range( + "1916-08-01", "1916-12-01", freq="MS", tz="Europe/Oslo", ambiguous=True + ) + tm.assert_index_equal(res, exp) + + def test_date_range_tzaware_endpoints_raise_ambiguous_raises_by_default(self): + # By default (`ambiguous="raise"`), an ambiguous DST transition + # should raise instead of guessing. + start = Timestamp("1916-08-01", tz="Europe/Oslo") + end = Timestamp("1916-12-01", tz="Europe/Oslo") + with pytest.raises(ValueError, match="Cannot infer dst time"): + date_range(start, end, freq="MS") From b591b34ccb44805fd486cfd3c0d59c3251655321 Mon Sep 17 00:00:00 2001 From: Floura Angel Date: Sun, 28 Sep 2025 22:19:50 -0400 Subject: [PATCH 2/6] update what's new doc --- doc/source/whatsnew/v3.0.0.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index e7d70ebb7b27f..b4151c08c6dd6 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -926,7 +926,8 @@ Timezones ^^^^^^^^^ - Bug in :meth:`DatetimeIndex.union`, :meth:`DatetimeIndex.intersection`, and :meth:`DatetimeIndex.symmetric_difference` changing timezone to UTC when merging two DatetimeIndex objects with the same timezone but different units (:issue:`60080`) - Bug in :meth:`Series.dt.tz_localize` with a timezone-aware :class:`ArrowDtype` incorrectly converting to UTC when ``tz=None`` (:issue:`61780`) -- +- Fixed bug in :func:`date_range` where tz-aware endpoints with calendar offsets (e.g. ``"MS"``) failed on DST fall-back. These now respect ``ambiguous``/ ``nonexistent``. (:issue:`52908`) + Numeric ^^^^^^^ From 6ed30c4903818de940709047b5f90ca752812028 Mon Sep 17 00:00:00 2001 From: Floura Angel Date: Sun, 28 Sep 2025 23:04:33 -0400 Subject: [PATCH 3/6] fix unit test --- pandas/tests/indexes/datetimes/test_date_range.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/pandas/tests/indexes/datetimes/test_date_range.py b/pandas/tests/indexes/datetimes/test_date_range.py index 6976c228cc71a..6e96dceee5aca 100644 --- a/pandas/tests/indexes/datetimes/test_date_range.py +++ b/pandas/tests/indexes/datetimes/test_date_range.py @@ -1752,11 +1752,3 @@ def test_date_range_tzaware_endpoints_accept_ambiguous(self): "1916-08-01", "1916-12-01", freq="MS", tz="Europe/Oslo", ambiguous=True ) tm.assert_index_equal(res, exp) - - def test_date_range_tzaware_endpoints_raise_ambiguous_raises_by_default(self): - # By default (`ambiguous="raise"`), an ambiguous DST transition - # should raise instead of guessing. - start = Timestamp("1916-08-01", tz="Europe/Oslo") - end = Timestamp("1916-12-01", tz="Europe/Oslo") - with pytest.raises(ValueError, match="Cannot infer dst time"): - date_range(start, end, freq="MS") From c4040efe9df17f24abb61c97b74db8d0dbf4ff10 Mon Sep 17 00:00:00 2001 From: FLOURA ANGEL Date: Mon, 29 Sep 2025 14:37:28 -0400 Subject: [PATCH 4/6] Update pandas/tests/indexes/datetimes/test_date_range.py Co-authored-by: Matthew Roeschke <10647082+mroeschke@users.noreply.github.com> --- pandas/tests/indexes/datetimes/test_date_range.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pandas/tests/indexes/datetimes/test_date_range.py b/pandas/tests/indexes/datetimes/test_date_range.py index 6e96dceee5aca..081220339f841 100644 --- a/pandas/tests/indexes/datetimes/test_date_range.py +++ b/pandas/tests/indexes/datetimes/test_date_range.py @@ -1742,9 +1742,7 @@ def test_date_range_negative_freq_year_end_inbounds(self, unit): tm.assert_index_equal(rng, exp) def test_date_range_tzaware_endpoints_accept_ambiguous(self): - # With tz-aware endpoints and a calendar offset (MS), - # date_range should accept `ambiguous=True` and produce - # the same result as passing tz explicitly with naive endpoints. + # https://github.com/pandas-dev/pandas/issues/52908 start = Timestamp("1916-08-01", tz="Europe/Oslo") end = Timestamp("1916-12-01", tz="Europe/Oslo") res = date_range(start, end, freq="MS", ambiguous=True) From 80ab012b56dbcef472067a7f868e304c2e96c129 Mon Sep 17 00:00:00 2001 From: Floura Angel Date: Mon, 29 Sep 2025 15:11:01 -0400 Subject: [PATCH 5/6] add nonexistent test --- .../tests/indexes/datetimes/test_date_range.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pandas/tests/indexes/datetimes/test_date_range.py b/pandas/tests/indexes/datetimes/test_date_range.py index 6e96dceee5aca..57db05cd3143f 100644 --- a/pandas/tests/indexes/datetimes/test_date_range.py +++ b/pandas/tests/indexes/datetimes/test_date_range.py @@ -1752,3 +1752,19 @@ def test_date_range_tzaware_endpoints_accept_ambiguous(self): "1916-08-01", "1916-12-01", freq="MS", tz="Europe/Oslo", ambiguous=True ) tm.assert_index_equal(res, exp) + + def test_date_range_tzaware_endpoints_accept_nonexistent(self): + # Europe/London spring-forward: 2015-03-29 01:30 does not exist. + start = Timestamp("2015-03-28 01:30", tz="Europe/London") + end = Timestamp("2015-03-30 01:30", tz="Europe/London") + + result = date_range(start, end, freq="D", nonexistent="shift_forward") + expected = [ + Timestamp("2015-03-28 01:30:00+00:00"), + Timestamp( + "2015-03-29 02:00:00+01:00" + ), # shifted forward over next valid wall time + Timestamp("2015-03-30 01:30:00+01:00"), + ] + + assert list(result) == expected From f16727cb66199047f74b393ce5a6d83db8913893 Mon Sep 17 00:00:00 2001 From: Floura Angel Date: Tue, 30 Sep 2025 22:05:39 -0400 Subject: [PATCH 6/6] update test --- .../indexes/datetimes/test_date_range.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/pandas/tests/indexes/datetimes/test_date_range.py b/pandas/tests/indexes/datetimes/test_date_range.py index b8f27b037b7bf..85e2f6a8070e0 100644 --- a/pandas/tests/indexes/datetimes/test_date_range.py +++ b/pandas/tests/indexes/datetimes/test_date_range.py @@ -1753,16 +1753,15 @@ def test_date_range_tzaware_endpoints_accept_ambiguous(self): def test_date_range_tzaware_endpoints_accept_nonexistent(self): # Europe/London spring-forward: 2015-03-29 01:30 does not exist. - start = Timestamp("2015-03-28 01:30", tz="Europe/London") - end = Timestamp("2015-03-30 01:30", tz="Europe/London") + tz = "Europe/London" + start = Timestamp("2015-03-28 01:30", tz=tz) + end = Timestamp("2015-03-30 01:30", tz=tz) result = date_range(start, end, freq="D", nonexistent="shift_forward") - expected = [ - Timestamp("2015-03-28 01:30:00+00:00"), - Timestamp( - "2015-03-29 02:00:00+01:00" - ), # shifted forward over next valid wall time - Timestamp("2015-03-30 01:30:00+01:00"), - ] - assert list(result) == expected + # Build expected by generating naive daily times, then tz_localize so + # the nonexistent handling is applied during localization. + expected = date_range( + "2015-03-28 01:30", "2015-03-30 01:30", freq="D" + ).tz_localize(tz, nonexistent="shift_forward") + tm.assert_index_equal(result, expected)