test_dst.py 7.8 KB

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