diff --git a/rust/src/python/helpers.rs b/rust/src/python/helpers.rs index f1ba0806..46dd1a6d 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 a9660495..ce640dae 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