Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -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 <https://github.com/arrow-py/arrow/issues/1209>`_

1.4.0 (2025-10-18)
------------------

Expand Down
19 changes: 19 additions & 0 deletions arrow/arrow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
46 changes: 39 additions & 7 deletions tests/test_arrow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down