From f7c4a442862aa379d33a67b52f1c5da4037869c1 Mon Sep 17 00:00:00 2001 From: ybai08 <271969568+ybai08@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:48:32 -0500 Subject: [PATCH] Carry the UTC shift into the date in the Rust precise_diff When the two datetimes are in different time zones (or on the same day), the Rust precise_diff moves each one to UTC by adjusting the hour, minute and second fields by hand. That adjustment only rolls over when a field goes strictly past 60 or 24, so a result of exactly 24:00 stays as hour 24, and a day pushed past either end of the month becomes day 0 or day 32 instead of moving into the neighbouring month. The pure-Python implementation subtracts the offset with datetime arithmetic and does not have this problem, so the two disagree, e.g. 2024-06-01 00:00 UTC to 2024-06-01 20:00 America/New_York is "1 day" in Python but "24 hours" with the extension. Do the shift on the total seconds of the day and carry any day change into the day, month and year. --- rust/src/python/helpers.rs | 113 ++++++++++++++++++------------------- tests/test_helpers.py | 27 +++++++++ 2 files changed, 82 insertions(+), 58 deletions(-) diff --git a/rust/src/python/helpers.rs b/rust/src/python/helpers.rs index f1ba0806e..46dd1a6de 100644 --- a/rust/src/python/helpers.rs +++ b/rust/src/python/helpers.rs @@ -28,6 +28,59 @@ struct DateTimeInfo<'py> { pub is_datetime: bool, } +impl DateTimeInfo<'_> { + /// Subtract the UTC offset from the wall-clock fields, + /// carrying over into the day, month and year as needed. + fn shift_to_utc(&mut self) { + let seconds = + self.hour * SECS_PER_HOUR as i32 + self.minute * SECS_PER_MIN as i32 + self.second + - self.offset; + let day_shift = seconds.div_euclid(SECS_PER_DAY as i32); + let seconds = seconds.rem_euclid(SECS_PER_DAY as i32); + + self.hour = seconds / SECS_PER_HOUR as i32; + self.minute = seconds % SECS_PER_HOUR as i32 / SECS_PER_MIN as i32; + self.second = seconds % SECS_PER_MIN as i32; + + // UTC offsets are strictly less than a day, + // so the date moves by at most one day. + match day_shift.cmp(&0) { + Ordering::Less => { + self.day -= 1; + + if self.day == 0 { + if self.month == 1 { + self.month = 12; + self.year -= 1; + } else { + self.month -= 1; + } + + self.day = DAYS_PER_MONTHS[usize::from(helpers::is_leap(self.year))] + [self.month as usize]; + } + } + Ordering::Greater => { + self.day += 1; + + if self.day + > DAYS_PER_MONTHS[usize::from(helpers::is_leap(self.year))][self.month as usize] + { + self.day = 1; + + if self.month == 12 { + self.month = 1; + self.year += 1; + } else { + self.month += 1; + } + } + } + Ordering::Equal => {} + } + } +} + impl PartialEq for DateTimeInfo<'_> { fn eq(&self, other: &Self) -> bool { ( @@ -197,35 +250,7 @@ pub fn precise_diff<'py>( dtinfo1.microsecond = dt1dt.get_microsecond() as i32; if !in_same_tz && dtinfo1.offset != 0 || total_days == 0 { - dtinfo1.hour -= dtinfo1.offset / SECS_PER_HOUR as i32; - dtinfo1.offset %= SECS_PER_HOUR as i32; - dtinfo1.minute -= dtinfo1.offset / SECS_PER_MIN as i32; - dtinfo1.offset %= SECS_PER_MIN as i32; - dtinfo1.second -= dtinfo1.offset; - - if dtinfo1.second < 0 { - dtinfo1.second += 60; - dtinfo1.minute -= 1; - } else if dtinfo1.second > 60 { - dtinfo1.second -= 60; - dtinfo1.minute += 1; - } - - if dtinfo1.minute < 0 { - dtinfo1.minute += 60; - dtinfo1.hour -= 1; - } else if dtinfo1.minute > 60 { - dtinfo1.minute -= 60; - dtinfo1.hour += 1; - } - - if dtinfo1.hour < 0 { - dtinfo1.hour += 24; - dtinfo1.day -= 1; - } else if dtinfo1.hour > 24 { - dtinfo1.hour -= 24; - dtinfo1.day += 1; - } + dtinfo1.shift_to_utc(); } dtinfo1.total_seconds = dtinfo1.hour * SECS_PER_HOUR as i32 @@ -242,35 +267,7 @@ pub fn precise_diff<'py>( dtinfo2.microsecond = dt2dt.get_microsecond() as i32; if !in_same_tz && dtinfo2.offset != 0 || total_days == 0 { - dtinfo2.hour -= dtinfo2.offset / SECS_PER_HOUR as i32; - dtinfo2.offset %= SECS_PER_HOUR as i32; - dtinfo2.minute -= dtinfo2.offset / SECS_PER_MIN as i32; - dtinfo2.offset %= SECS_PER_MIN as i32; - dtinfo2.second -= dtinfo2.offset; - - if dtinfo2.second < 0 { - dtinfo2.second += 60; - dtinfo2.minute -= 1; - } else if dtinfo2.second > 60 { - dtinfo2.second -= 60; - dtinfo2.minute += 1; - } - - if dtinfo2.minute < 0 { - dtinfo2.minute += 60; - dtinfo2.hour -= 1; - } else if dtinfo2.minute > 60 { - dtinfo2.minute -= 60; - dtinfo2.hour += 1; - } - - if dtinfo2.hour < 0 { - dtinfo2.hour += 24; - dtinfo2.day -= 1; - } else if dtinfo2.hour > 24 { - dtinfo2.hour -= 24; - dtinfo2.day += 1; - } + dtinfo2.shift_to_utc(); } dtinfo2.total_seconds = dtinfo2.hour * SECS_PER_HOUR as i32 diff --git a/tests/test_helpers.py b/tests/test_helpers.py index a96604954..ce640dae3 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -126,6 +126,33 @@ def test_precise_diff_timezone() -> None: assert diff.total_days == 0 +def test_precise_diff_timezone_crossing_a_date_boundary() -> None: + utc = zoneinfo.ZoneInfo("UTC") + new_york = zoneinfo.ZoneInfo("America/New_York") + tokyo = zoneinfo.ZoneInfo("Asia/Tokyo") + + # 2024-06-01 20:00-04:00 is 2024-06-02 00:00 UTC + dt1 = datetime(2024, 6, 1, tzinfo=utc) + dt2 = datetime(2024, 6, 1, 20, tzinfo=new_york) + + assert_diff(precise_diff(dt1, dt2), days=1) + assert_diff(precise_diff(dt2, dt1), days=-1) + + # 2023-12-31 20:00-05:00 is 2024-01-01 01:00 UTC + dt1 = datetime(2023, 6, 1, tzinfo=utc) + dt2 = datetime(2023, 12, 31, 20, tzinfo=new_york) + + assert_diff(precise_diff(dt1, dt2), months=7, hours=1) + assert_diff(precise_diff(dt2, dt1), months=-7, hours=-1) + + # 2024-03-01 05:00+09:00 is 2024-02-29 20:00 UTC + dt1 = datetime(2024, 1, 30, tzinfo=utc) + dt2 = datetime(2024, 3, 1, 5, tzinfo=tokyo) + + assert_diff(precise_diff(dt1, dt2), days=30, hours=20) + assert_diff(precise_diff(dt2, dt1), days=-30, hours=-20) + + def test_week_day() -> None: assert week_day(2017, 6, 2) == 5 assert week_day(2017, 1, 1) == 7