Repository: pandas-dev/pandas
Pull request: handle DST appropriately in Timestamp.replace
```
ts = pd.Timestamp('2017-12-03 16:03:24', tz='US/Eastern')
# Timestamp('2017-12-03 16:03:24-0500', tz='US/Eastern')

ts2 = ts.replace(month=6)           # <-- across DST boundary
# master --> Timestamp('2017-06-03 16:03:24-0500', tz='US/Eastern')
# PR     --> Timestamp('2017-06-03 16:03:24-0400', tz='US/Eastern')

ts3 = ts2.tzinfo.normalize(ts2)
# master --> Timestamp('2017-06-03 18:03:24-0400', tz='US/Eastern')
# PR     --> Timestamp('2017-06-03 16:03:24-0400', tz='US/Eastern')

assert ts3 == ts2
assert ts3.hour == ts.hour
```

Both assertions fail under master, pass under this PR.

The whatsnew entry is not super-informative.  Any ideas for a one-sentence description of this fix?

- [x] closes #18319

xref #7825 (I think)
- [x] tests added / passed
- [x] passes `git diff upstream/master -u -- "*.py" | flake8 --diff`
- [x] whatsnew entry

  

Problem statement:
Timestamp.replace should handle DST transition gracefully
In fairness, datetime doesn't handle this any better, and differing from that behavior could be considered a drawback.

```
>>> ts = pd.Timestamp('1929-05-05', tz='US/Eastern')
>>> res = ts.replace(month=4)
>>> expected = pd.Timestamp('1929-04-05', tz='US/Eastern')
>>> res == expected
False
>>> expected.tzinfo.normalize(expected) == expected
True
>>> res.tzinfo.normalize(res) == res
False
>>> res.tzinfo.normalize(res)
Timestamp('1929-04-04 22:00:00-0500', tz='US/Eastern')
```

Is this a feature and not a bug for some reason I'm missing?

Hints from issue discussion:

Commit message: fixup merge messup

Patch to review:
diff --git a/doc/source/whatsnew/v0.23.0.txt b/doc/source/whatsnew/v0.23.0.txt
index 0301bf0a23dd5..6f518b153245d 100644
--- a/doc/source/whatsnew/v0.23.0.txt
+++ b/doc/source/whatsnew/v0.23.0.txt
@@ -378,3 +378,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 1792f852c9e1e..84b3c110e7a92 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 9530cd5ac3f43..196b062419194 100644
--- a/pandas/tests/tseries/test_timezones.py
+++ b/pandas/tests/tseries/test_timezones.py
@@ -1228,6 +1228,27 @@ def f():
         dt = Timestamp('2013-11-03 01:59:59.999999-0400', tz='US/Eastern')
         assert dt.tz_localize(None) == dt.replace(tzinfo=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 = pytz.timezone('US/Eastern')
+
+        ts_naive = Timestamp('2017-12-03 16:03:30')
+        ts_aware = tz.localize(ts_naive)
+
+        # Preliminary sanity-check
+        assert ts_aware == ts_aware.tzinfo.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 = ts2.tzinfo.normalize(ts2)
+        assert ts2 == ts2b
+
     def test_ambiguous_compat(self):
         # validate that pytz and dateutil are compat for dst
         # when the transition happens


Code review comments:
diff_hunk: @@ -1228,6 +1228,27 @@ def f():
         dt = Timestamp('2013-11-03 01:59:59.999999-0400', tz='US/Eastern')
         assert dt.tz_localize(None) == dt.replace(tzinfo=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 = pytz.timezone('US/Eastern')
+
+        ts_naive = Timestamp('2017-12-03 16:03:30')
+        ts_aware = tz.localize(ts_naive)
+
+        # Preliminary sanity-check
+        assert ts_aware == ts_aware.tzinfo.normalize(ts_aware)
+
path: pandas/tests/tseries/test_timezones.py
text: @user1:
need to parametrize this across dateutil & pytz, so you need to move this to ``TestTimeZoneSupportPytz`` and use ``self.tz(...)``