Skip to content

Commit e8bf47c

Browse files
committed
FIX: robust DatetimeIndex.union across DST transitions + flip test to pass (GH#62915)
1 parent f487dc9 commit e8bf47c

File tree

2 files changed

+21
-6
lines changed

2 files changed

+21
-6
lines changed

pandas/core/indexes/datetimelike.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -541,7 +541,14 @@ def _as_range_index(self) -> RangeIndex:
541541
return RangeIndex(rng)
542542

543543
def _can_range_setop(self, other) -> bool:
544-
return isinstance(self.freq, Tick) and isinstance(other.freq, Tick)
544+
# Only allow range-based setops when both objects are tick-based AND
545+
# not timezone-aware. For tz-aware DatetimeIndex, constant i8 stepping
546+
# does not hold across DST transitions in local time, so avoid range path.
547+
if not (isinstance(self.freq, Tick) and isinstance(other.freq, Tick)):
548+
return False
549+
self_tz = getattr(self.dtype, "tz", None)
550+
other_tz = getattr(other.dtype, "tz", None)
551+
return self_tz is None and other_tz is None
545552

546553
def _wrap_range_setop(self, other, res_i8) -> Self:
547554
new_freq = None
@@ -726,6 +733,15 @@ def _union(self, other, sort):
726733
# that result.freq == self.freq
727734
return result
728735
else:
736+
# For tz-aware DatetimeIndex, perform union in UTC to avoid
737+
# local-time irregularities across DST transitions, then convert back.
738+
tz = getattr(self.dtype, "tz", None)
739+
if tz is not None:
740+
left_utc = self.tz_convert("UTC")
741+
right_utc = other.tz_convert("UTC")
742+
res_utc = super(type(left_utc), left_utc)._union(right_utc, sort)
743+
res = res_utc.tz_convert(tz)
744+
return res._with_freq("infer")
729745
return super()._union(other, sort)._with_freq("infer")
730746

731747
# --------------------------------------------------------------------

pandas/tests/indexes/datetimes/test_setops.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,14 @@ def test_union3(self, sort, box):
6161
tm.assert_index_equal(result, expected)
6262

6363

64-
@pytest.mark.xfail(reason="see GH#62915: union across DST boundary", strict=False)
65-
def test_union_across_dst_boundary_xfail():
64+
def test_union_across_dst_boundary():
6665
# US/Eastern DST spring-forward on 2021-03-14 at 02:00
6766
# (02:00-02:59 local time does not exist)
6867
tz = "US/Eastern"
6968
# Left side spans up to the missing hour window
70-
left = date_range("2021-03-14 00:00", periods=3, freq="H", tz=tz)
69+
left = date_range("2021-03-14 00:00", periods=3, freq="h", tz=tz)
7170
# right side continues from the first valid post-DST hour
72-
right = date_range("2021-03-14 03:00", periods=3, freq="H", tz=tz)
71+
right = date_range("2021-03-14 03:00", periods=3, freq="h", tz=tz)
7372

7473
# Expect a union that preserves tz and includes valid hours without duplicates
7574
expected = DatetimeIndex(
@@ -80,7 +79,7 @@ def test_union_across_dst_boundary_xfail():
8079
Timestamp("2021-03-14 04:00", tz=tz),
8180
Timestamp("2021-03-14 05:00", tz=tz),
8281
]
83-
)
82+
).as_unit(left.unit)
8483

8584
result = left.union(right)
8685
tm.assert_index_equal(result, expected)

0 commit comments

Comments
 (0)