diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4324cdf1..50825f03 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,11 @@ Changelog ========= +1.4.1 (unreleased) +------------------ + +- [FIXED] Fixed ``shift()`` producing incorrect results when shifting by hours/minutes/seconds across DST transitions. Absolute time units are now added via UTC to reflect real elapsed time. `Issue #1209 `_ + 1.4.0 (2025-10-18) ------------------ diff --git a/arrow/arrow.py b/arrow/arrow.py index f0a57f04..16f48d6d 100644 --- a/arrow/arrow.py +++ b/arrow/arrow.py @@ -1046,12 +1046,31 @@ def shift(self, check_imaginary: bool = True, **kwargs: Any) -> "Arrow": relative_kwargs.pop("quarters", 0) * self._MONTHS_PER_QUARTER ) + # Separate absolute time units from calendar units. Absolute units + # (hours, minutes, seconds, microseconds) represent fixed durations and + # must be added in UTC to correctly handle DST transitions. Calendar + # units (years, months, weeks, days, weekday) are wall-clock relative + # and use relativedelta directly. + absolute_keys = ("hours", "minutes", "seconds", "microseconds") + absolute_kwargs = { + k: relative_kwargs.pop(k) + for k in absolute_keys + if k in relative_kwargs + } + + # Step 1: Apply calendar units via relativedelta (wall-clock semantics). current = self._datetime + relativedelta(**relative_kwargs) # If check_imaginary is True, perform the check for imaginary times (DST transitions) if check_imaginary and not dateutil_tz.datetime_exists(current): current = dateutil_tz.resolve_imaginary(current) + # Step 2: Apply absolute units via UTC to handle DST correctly. + if any(v != 0 for v in absolute_kwargs.values()): + utc_current = current.astimezone(timezone.utc) + utc_current += timedelta(**absolute_kwargs) + current = utc_current.astimezone(current.tzinfo) + return self.fromdatetime(current) def to(self, tz: TZ_EXPR) -> "Arrow": diff --git a/tests/test_arrow.py b/tests/test_arrow.py index b595e4e2..85eb21bb 100644 --- a/tests/test_arrow.py +++ b/tests/test_arrow.py @@ -846,37 +846,69 @@ def test_shift_positive_imaginary(self): ) def test_shift_negative_imaginary(self): + # Absolute time arithmetic via UTC: shifting by hours should reflect + # real elapsed time, not wall-clock arithmetic. + + # 3:30 EDT (7:30 UTC) - 1 real hour = 6:30 UTC = 1:30 EST new_york = arrow.Arrow(2011, 3, 13, 3, 30, tzinfo="America/New_York") assert new_york.shift(hours=-1) == arrow.Arrow( - 2011, 3, 13, 3, 30, tzinfo="America/New_York" + 2011, 3, 13, 1, 30, tzinfo="America/New_York" ) + # 3:30 EDT (7:30 UTC) - 2 real hours = 5:30 UTC = 0:30 EST assert new_york.shift(hours=-2) == arrow.Arrow( - 2011, 3, 13, 1, 30, tzinfo="America/New_York" + 2011, 3, 13, 0, 30, tzinfo="America/New_York" ) + # 2:00 BST (1:00 UTC) - 1 real hour = 0:00 UTC = 0:00 GMT london = arrow.Arrow(2019, 3, 31, 2, tzinfo="Europe/London") assert london.shift(hours=-1) == arrow.Arrow( - 2019, 3, 31, 2, tzinfo="Europe/London" + 2019, 3, 31, 0, tzinfo="Europe/London" ) + # 2:00 BST (1:00 UTC) - 2 real hours = 23:00 UTC = 23:00 GMT Mar 30 assert london.shift(hours=-2) == arrow.Arrow( - 2019, 3, 31, 0, tzinfo="Europe/London" + 2019, 3, 30, 23, tzinfo="Europe/London" ) # edge case, crossing the international dateline + # 1:00 +14:00 (Dec 30 11:00 UTC) - 2 real hours = Dec 30 9:00 UTC = Dec 29 23:00 -10:00 apia = arrow.Arrow(2011, 12, 31, 1, tzinfo="Pacific/Apia") assert apia.shift(hours=-2) == arrow.Arrow( - 2011, 12, 31, 23, tzinfo="Pacific/Apia" + 2011, 12, 29, 23, tzinfo="Pacific/Apia" + ) + + def test_shift_dst_absolute_time(self): + # GH #1209: shift(hours=N) across DST should use absolute (UTC) time. + # 18:30 CST (00:30 UTC) + 10 real hours = 10:30 UTC = 05:30 CDT + start = arrow.Arrow(2025, 3, 8, 18, 30, tzinfo=ZoneInfo("US/Central")) + assert start.shift(hours=10) == arrow.Arrow( + 2025, 3, 9, 5, 30, tzinfo=ZoneInfo("US/Central") + ) + + # Fall-back: 0:30 CDT (5:30 UTC) + 2 real hours = 7:30 UTC = 1:30 CST + fall_start = arrow.Arrow(2025, 11, 2, 0, 30, tzinfo=ZoneInfo("US/Central")) + assert fall_start.shift(hours=2) == arrow.Arrow( + 2025, 11, 2, 1, 30, tzinfo=ZoneInfo("US/Central") + ) + + # Mixed calendar + absolute: shift(days=1, hours=3) should apply + # days as wall-clock, then hours as absolute. + mixed = arrow.Arrow(2025, 3, 8, 18, 30, tzinfo=ZoneInfo("US/Central")) + assert mixed.shift(days=1, hours=3) == arrow.Arrow( + 2025, 3, 9, 21, 30, tzinfo=ZoneInfo("US/Central") ) def test_shift_with_imaginary_check(self): + # Starting from an imaginary time (2:30 AM during spring-forward gap), + # ZoneInfo assigns EST offset (-05:00) so 2:30 EST = 7:30 UTC. + # +1 real hour = 8:30 UTC = 4:30 EDT. dt = arrow.Arrow(2024, 3, 10, 2, 30, tzinfo=ZoneInfo("US/Eastern")) shifted = dt.shift(hours=1) - assert shifted.datetime.hour == 3 + assert shifted.datetime.hour == 4 def test_shift_without_imaginary_check(self): dt = arrow.Arrow(2024, 3, 10, 2, 30, tzinfo=ZoneInfo("US/Eastern")) shifted = dt.shift(hours=1, check_imaginary=False) - assert shifted.datetime.hour == 3 + assert shifted.datetime.hour == 4 @pytest.mark.skipif( dateutil.__version__ < "2.7.1", reason="old tz database (2018d needed)"