test_dst.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. """
  2. Tests for DateOffset additions over Daylight Savings Time
  3. """
  4. from datetime import timedelta
  5. import pytest
  6. import pytz
  7. from pandas._libs.tslibs import Timestamp
  8. from pandas._libs.tslibs.offsets import (
  9. BMonthBegin,
  10. BMonthEnd,
  11. BQuarterBegin,
  12. BQuarterEnd,
  13. BYearBegin,
  14. BYearEnd,
  15. CBMonthBegin,
  16. CBMonthEnd,
  17. CustomBusinessDay,
  18. DateOffset,
  19. Day,
  20. MonthBegin,
  21. MonthEnd,
  22. QuarterBegin,
  23. QuarterEnd,
  24. SemiMonthBegin,
  25. SemiMonthEnd,
  26. Week,
  27. YearBegin,
  28. YearEnd,
  29. )
  30. from pandas.errors import PerformanceWarning
  31. from pandas import DatetimeIndex
  32. import pandas._testing as tm
  33. from pandas.util.version import Version
  34. # error: Module has no attribute "__version__"
  35. pytz_version = Version(pytz.__version__) # type: ignore[attr-defined]
  36. def get_utc_offset_hours(ts):
  37. # take a Timestamp and compute total hours of utc offset
  38. o = ts.utcoffset()
  39. return (o.days * 24 * 3600 + o.seconds) / 3600.0
  40. class TestDST:
  41. # one microsecond before the DST transition
  42. ts_pre_fallback = "2013-11-03 01:59:59.999999"
  43. ts_pre_springfwd = "2013-03-10 01:59:59.999999"
  44. # test both basic names and dateutil timezones
  45. timezone_utc_offsets = {
  46. "US/Eastern": {"utc_offset_daylight": -4, "utc_offset_standard": -5},
  47. "dateutil/US/Pacific": {"utc_offset_daylight": -7, "utc_offset_standard": -8},
  48. }
  49. valid_date_offsets_singular = [
  50. "weekday",
  51. "day",
  52. "hour",
  53. "minute",
  54. "second",
  55. "microsecond",
  56. ]
  57. valid_date_offsets_plural = [
  58. "weeks",
  59. "days",
  60. "hours",
  61. "minutes",
  62. "seconds",
  63. "milliseconds",
  64. "microseconds",
  65. ]
  66. def _test_all_offsets(self, n, **kwds):
  67. valid_offsets = (
  68. self.valid_date_offsets_plural
  69. if n > 1
  70. else self.valid_date_offsets_singular
  71. )
  72. for name in valid_offsets:
  73. self._test_offset(offset_name=name, offset_n=n, **kwds)
  74. def _test_offset(self, offset_name, offset_n, tstart, expected_utc_offset):
  75. offset = DateOffset(**{offset_name: offset_n})
  76. if (
  77. offset_name in ["hour", "minute", "second", "microsecond"]
  78. and offset_n == 1
  79. and tstart == Timestamp("2013-11-03 01:59:59.999999-0500", tz="US/Eastern")
  80. ):
  81. # This addition results in an ambiguous wall time
  82. err_msg = {
  83. "hour": "2013-11-03 01:59:59.999999",
  84. "minute": "2013-11-03 01:01:59.999999",
  85. "second": "2013-11-03 01:59:01.999999",
  86. "microsecond": "2013-11-03 01:59:59.000001",
  87. }[offset_name]
  88. with pytest.raises(pytz.AmbiguousTimeError, match=err_msg):
  89. tstart + offset
  90. # While we're here, let's check that we get the same behavior in a
  91. # vectorized path
  92. dti = DatetimeIndex([tstart])
  93. warn_msg = "Non-vectorized DateOffset"
  94. with pytest.raises(pytz.AmbiguousTimeError, match=err_msg):
  95. with tm.assert_produces_warning(PerformanceWarning, match=warn_msg):
  96. dti + offset
  97. return
  98. t = tstart + offset
  99. if expected_utc_offset is not None:
  100. assert get_utc_offset_hours(t) == expected_utc_offset
  101. if offset_name == "weeks":
  102. # dates should match
  103. assert t.date() == timedelta(days=7 * offset.kwds["weeks"]) + tstart.date()
  104. # expect the same day of week, hour of day, minute, second, ...
  105. assert (
  106. t.dayofweek == tstart.dayofweek
  107. and t.hour == tstart.hour
  108. and t.minute == tstart.minute
  109. and t.second == tstart.second
  110. )
  111. elif offset_name == "days":
  112. # dates should match
  113. assert timedelta(offset.kwds["days"]) + tstart.date() == t.date()
  114. # expect the same hour of day, minute, second, ...
  115. assert (
  116. t.hour == tstart.hour
  117. and t.minute == tstart.minute
  118. and t.second == tstart.second
  119. )
  120. elif offset_name in self.valid_date_offsets_singular:
  121. # expect the singular offset value to match between tstart and t
  122. datepart_offset = getattr(
  123. t, offset_name if offset_name != "weekday" else "dayofweek"
  124. )
  125. assert datepart_offset == offset.kwds[offset_name]
  126. else:
  127. # the offset should be the same as if it was done in UTC
  128. assert t == (tstart.tz_convert("UTC") + offset).tz_convert("US/Pacific")
  129. def _make_timestamp(self, string, hrs_offset, tz):
  130. if hrs_offset >= 0:
  131. offset_string = f"{hrs_offset:02d}00"
  132. else:
  133. offset_string = f"-{(hrs_offset * -1):02}00"
  134. return Timestamp(string + offset_string).tz_convert(tz)
  135. def test_springforward_plural(self):
  136. # test moving from standard to daylight savings
  137. for tz, utc_offsets in self.timezone_utc_offsets.items():
  138. hrs_pre = utc_offsets["utc_offset_standard"]
  139. hrs_post = utc_offsets["utc_offset_daylight"]
  140. self._test_all_offsets(
  141. n=3,
  142. tstart=self._make_timestamp(self.ts_pre_springfwd, hrs_pre, tz),
  143. expected_utc_offset=hrs_post,
  144. )
  145. def test_fallback_singular(self):
  146. # in the case of singular offsets, we don't necessarily know which utc
  147. # offset the new Timestamp will wind up in (the tz for 1 month may be
  148. # different from 1 second) so we don't specify an expected_utc_offset
  149. for tz, utc_offsets in self.timezone_utc_offsets.items():
  150. hrs_pre = utc_offsets["utc_offset_standard"]
  151. self._test_all_offsets(
  152. n=1,
  153. tstart=self._make_timestamp(self.ts_pre_fallback, hrs_pre, tz),
  154. expected_utc_offset=None,
  155. )
  156. def test_springforward_singular(self):
  157. for tz, utc_offsets in self.timezone_utc_offsets.items():
  158. hrs_pre = utc_offsets["utc_offset_standard"]
  159. self._test_all_offsets(
  160. n=1,
  161. tstart=self._make_timestamp(self.ts_pre_springfwd, hrs_pre, tz),
  162. expected_utc_offset=None,
  163. )
  164. offset_classes = {
  165. MonthBegin: ["11/2/2012", "12/1/2012"],
  166. MonthEnd: ["11/2/2012", "11/30/2012"],
  167. BMonthBegin: ["11/2/2012", "12/3/2012"],
  168. BMonthEnd: ["11/2/2012", "11/30/2012"],
  169. CBMonthBegin: ["11/2/2012", "12/3/2012"],
  170. CBMonthEnd: ["11/2/2012", "11/30/2012"],
  171. SemiMonthBegin: ["11/2/2012", "11/15/2012"],
  172. SemiMonthEnd: ["11/2/2012", "11/15/2012"],
  173. Week: ["11/2/2012", "11/9/2012"],
  174. YearBegin: ["11/2/2012", "1/1/2013"],
  175. YearEnd: ["11/2/2012", "12/31/2012"],
  176. BYearBegin: ["11/2/2012", "1/1/2013"],
  177. BYearEnd: ["11/2/2012", "12/31/2012"],
  178. QuarterBegin: ["11/2/2012", "12/1/2012"],
  179. QuarterEnd: ["11/2/2012", "12/31/2012"],
  180. BQuarterBegin: ["11/2/2012", "12/3/2012"],
  181. BQuarterEnd: ["11/2/2012", "12/31/2012"],
  182. Day: ["11/4/2012", "11/4/2012 23:00"],
  183. }.items()
  184. @pytest.mark.parametrize("tup", offset_classes)
  185. def test_all_offset_classes(self, tup):
  186. offset, test_values = tup
  187. first = Timestamp(test_values[0], tz="US/Eastern") + offset()
  188. second = Timestamp(test_values[1], tz="US/Eastern")
  189. assert first == second
  190. @pytest.mark.parametrize(
  191. "original_dt, target_dt, offset, tz",
  192. [
  193. pytest.param(
  194. Timestamp("1900-01-01"),
  195. Timestamp("1905-07-01"),
  196. MonthBegin(66),
  197. "Africa/Lagos",
  198. marks=pytest.mark.xfail(
  199. pytz_version < Version("2020.5") or pytz_version == Version("2022.2"),
  200. reason="GH#41906: pytz utc transition dates changed",
  201. ),
  202. ),
  203. (
  204. Timestamp("2021-10-01 01:15"),
  205. Timestamp("2021-10-31 01:15"),
  206. MonthEnd(1),
  207. "Europe/London",
  208. ),
  209. (
  210. Timestamp("2010-12-05 02:59"),
  211. Timestamp("2010-10-31 02:59"),
  212. SemiMonthEnd(-3),
  213. "Europe/Paris",
  214. ),
  215. (
  216. Timestamp("2021-10-31 01:20"),
  217. Timestamp("2021-11-07 01:20"),
  218. CustomBusinessDay(2, weekmask="Sun Mon"),
  219. "US/Eastern",
  220. ),
  221. (
  222. Timestamp("2020-04-03 01:30"),
  223. Timestamp("2020-11-01 01:30"),
  224. YearBegin(1, month=11),
  225. "America/Chicago",
  226. ),
  227. ],
  228. )
  229. def test_nontick_offset_with_ambiguous_time_error(original_dt, target_dt, offset, tz):
  230. # .apply for non-Tick offsets throws AmbiguousTimeError when the target dt
  231. # is dst-ambiguous
  232. localized_dt = original_dt.tz_localize(tz)
  233. msg = f"Cannot infer dst time from {target_dt}, try using the 'ambiguous' argument"
  234. with pytest.raises(pytz.AmbiguousTimeError, match=msg):
  235. localized_dt + offset