diff --git a/doc/source/whatsnew/v0.23.0.txt b/doc/source/whatsnew/v0.23.0.txt index 92564285bb36a..a670bf2348bfc 100644 --- a/doc/source/whatsnew/v0.23.0.txt +++ b/doc/source/whatsnew/v0.23.0.txt @@ -381,3 +381,4 @@ Other ^^^^^ - Improved error message when attempting to use a Python keyword as an identifier in a ``numexpr`` backed query (:issue:`18221`) +- :func:`Timestamp.replace` will now handle Daylight Savings transitions gracefully (:issue:`18319`) diff --git a/pandas/_libs/tslibs/timestamps.pyx b/pandas/_libs/tslibs/timestamps.pyx index c7744bf9db58e..ffc1c89dd8adf 100644 --- a/pandas/_libs/tslibs/timestamps.pyx +++ b/pandas/_libs/tslibs/timestamps.pyx @@ -33,7 +33,7 @@ from np_datetime cimport (reverse_ops, cmp_scalar, check_dts_bounds, is_leapyear) from timedeltas import Timedelta from timedeltas cimport delta_to_nanoseconds -from timezones cimport get_timezone, is_utc, maybe_get_tz +from timezones cimport get_timezone, is_utc, maybe_get_tz, treat_tz_as_pytz # ---------------------------------------------------------------------- # Constants @@ -922,8 +922,18 @@ class Timestamp(_Timestamp): _tzinfo = tzinfo # reconstruct & check bounds - ts_input = datetime(dts.year, dts.month, dts.day, dts.hour, dts.min, - dts.sec, dts.us, tzinfo=_tzinfo) + if _tzinfo is not None and treat_tz_as_pytz(_tzinfo): + # replacing across a DST boundary may induce a new tzinfo object + # see GH#18319 + ts_input = _tzinfo.localize(datetime(dts.year, dts.month, dts.day, + dts.hour, dts.min, dts.sec, + dts.us)) + _tzinfo = ts_input.tzinfo + else: + ts_input = datetime(dts.year, dts.month, dts.day, + dts.hour, dts.min, dts.sec, dts.us, + tzinfo=_tzinfo) + ts = convert_datetime_to_tsobject(ts_input, _tzinfo) value = ts.value + (dts.ps // 1000) if value != NPY_NAT: diff --git a/pandas/tests/tseries/test_timezones.py b/pandas/tests/tseries/test_timezones.py index b3813d03532fb..7ae63d7d080cc 100644 --- a/pandas/tests/tseries/test_timezones.py +++ b/pandas/tests/tseries/test_timezones.py @@ -61,6 +61,10 @@ def tzstr(self, tz): def localize(self, tz, x): return tz.localize(x) + def normalize(self, ts): + tzinfo = ts.tzinfo + return tzinfo.normalize(ts) + def cmptz(self, tz1, tz2): # Compare two timezones. Overridden in subclass to parameterize # tests. @@ -935,6 +939,27 @@ def test_datetimeindex_tz_nat(self): assert isna(idx[1]) assert idx[0].tzinfo is not None + def test_replace_across_dst(self): + # GH#18319 check that 1) timezone is correctly normalized and + # 2) that hour is not incorrectly changed by this normalization + tz = self.tz('US/Eastern') + + ts_naive = Timestamp('2017-12-03 16:03:30') + ts_aware = self.localize(tz, ts_naive) + + # Preliminary sanity-check + assert ts_aware == self.normalize(ts_aware) + + # Replace across DST boundary + ts2 = ts_aware.replace(month=6) + + # Check that `replace` preserves hour literal + assert (ts2.hour, ts2.minute) == (ts_aware.hour, ts_aware.minute) + + # Check that post-replace object is appropriately normalized + ts2b = self.normalize(ts2) + assert ts2 == ts2b + class TestTimeZoneSupportDateutil(TestTimeZoneSupportPytz): @@ -959,6 +984,10 @@ def cmptz(self, tz1, tz2): def localize(self, tz, x): return x.replace(tzinfo=tz) + def normalize(self, ts): + # no-op for dateutil + return ts + @td.skip_if_windows def test_utc_with_system_utc(self): from pandas._libs.tslibs.timezones import maybe_get_tz