Skip to content
Draft
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
21 changes: 21 additions & 0 deletions arrow/arrow.py
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,8 @@ def shift(self, check_imaginary: bool = True, **kwargs: Any) -> "Arrow":

relative_kwargs = {}
additional_attrs = ["weeks", "quarters", "weekday"]
# Absolute time units that should use timedelta for correct DST handling
absolute_time_attrs = ["hours", "minutes", "seconds", "microseconds"]

for key, value in kwargs.items():
if key in self._ATTRS_PLURAL or key in additional_attrs:
Expand All @@ -1046,8 +1048,27 @@ def shift(self, check_imaginary: bool = True, **kwargs: Any) -> "Arrow":
relative_kwargs.pop("quarters", 0) * self._MONTHS_PER_QUARTER
)

# Separate absolute time units from relative ones.
# Absolute time units (hours, minutes, seconds, microseconds) should be
# added in UTC to ensure correct DST handling - they represent actual
# elapsed time, not wall-clock time.
absolute_time_kwargs = {}
for attr in absolute_time_attrs:
if attr in relative_kwargs:
absolute_time_kwargs[attr] = relative_kwargs.pop(attr)

# First apply calendar-based shifts using relativedelta
current = self._datetime + relativedelta(**relative_kwargs)

# Then apply absolute time shifts by converting to UTC first, adding the
# timedelta, then converting back. This ensures we add actual elapsed
# time rather than wall-clock time, which matters during DST transitions.
if absolute_time_kwargs:
original_tz = current.tzinfo
utc_current = current.astimezone(timezone.utc)
utc_current = utc_current + timedelta(**absolute_time_kwargs)
current = utc_current.astimezone(original_tz)

# 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)
Expand Down
44 changes: 36 additions & 8 deletions tests/test_arrow.py
Original file line number Diff line number Diff line change
Expand Up @@ -846,37 +846,65 @@ def test_shift_positive_imaginary(self):
)

def test_shift_negative_imaginary(self):
# With actual elapsed time semantics, shift(hours=-1) from 03:30-04:00
# goes back 1 actual hour to 01:30-05:00 (during fall-back, we "gain" an hour)
new_york = arrow.Arrow(2011, 3, 13, 3, 30, tzinfo="America/New_York")
# Going back 1 actual hour from 03:30 EDT crosses into EST
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"
)
# Going back 2 actual hours from 03:30 EDT
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"
)


# London: 02:00 BST (+01:00) minus 1 actual hour = 00:00 GMT (+00:00)
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"
)
# London: 02:00 BST minus 2 actual hours = 23:00 GMT (previous day)
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
# edge case, crossing the international dateline (Apia skipped Dec 30, 2011)
# 2011-12-31 01:00+14:00 in Apia minus 2 actual hours crosses the dateline change
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_with_imaginary_check(self):
# 2024-03-10 02:30 is imaginary (DST spring forward)
# Arrow constructor resolves it to 03:30, then shift adds 1 actual hour = 04:30
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):
# Same as above - the imaginary time is resolved before shift
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

def test_shift_dst_spring_forward_issue_1209(self):
# Regression test for issue #1209
# shift(hours=10) should add 10 actual hours even across DST spring forward
# DST in US/Central: March 9, 2025 at 2am clocks spring forward to 3am
start = arrow.Arrow(2025, 3, 8, 18, 30, tzinfo="US/Central")
shifted = start.shift(hours=10)

# 18:30 + 10 hours = 04:30 next day (wall clock), but due to DST spring forward
# 10 actual hours later should be 05:30
assert shifted.datetime.hour == 5
assert shifted.datetime.minute == 30
assert shifted.datetime.day == 9

# Verify actual elapsed time is 10 hours (36000 seconds)
elapsed_seconds = shifted.timestamp() - start.timestamp()
assert elapsed_seconds == 36000 # 10 hours * 3600 seconds

@pytest.mark.skipif(
dateutil.__version__ < "2.7.1", reason="old tz database (2018d needed)"
Expand Down
Loading