Merge pull request #3269 from grantslatton/timezone-conversion-bugfix

Fix fixed offset timezone conversion bug.
This commit is contained in:
Adam Reichold 2023-07-04 18:22:13 +00:00 committed by GitHub
commit 54ab9090be
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 64 additions and 39 deletions

View file

@ -0,0 +1 @@
Fix timezone conversion bug for FixedOffset datetimes that were being incorrectly converted to and from UTC.

View file

@ -216,8 +216,8 @@ impl FromPyObject<'_> for NaiveDateTime {
impl<Tz: TimeZone> ToPyObject for DateTime<Tz> { impl<Tz: TimeZone> ToPyObject for DateTime<Tz> {
fn to_object(&self, py: Python<'_>) -> PyObject { fn to_object(&self, py: Python<'_>) -> PyObject {
let date = self.naive_utc().date(); let date = self.naive_local().date();
let time = self.naive_utc().time(); let time = self.naive_local().time();
let yy = date.year(); let yy = date.year();
let mm = date.month() as u8; let mm = date.month() as u8;
let dd = date.day() as u8; let dd = date.day() as u8;
@ -246,7 +246,7 @@ impl<Tz: TimeZone> IntoPy<PyObject> for DateTime<Tz> {
impl FromPyObject<'_> for DateTime<FixedOffset> { impl FromPyObject<'_> for DateTime<FixedOffset> {
fn extract(ob: &PyAny) -> PyResult<DateTime<FixedOffset>> { fn extract(ob: &PyAny) -> PyResult<DateTime<FixedOffset>> {
let dt: &PyDateTime = ob.downcast()?; let dt: &PyDateTime = ob.downcast()?;
let ms = dt.get_fold() as u32 * 1_000_000 + dt.get_microsecond(); let ms = dt.get_microsecond();
let h = dt.get_hour().into(); let h = dt.get_hour().into();
let m = dt.get_minute().into(); let m = dt.get_minute().into();
let s = dt.get_second().into(); let s = dt.get_second().into();
@ -261,7 +261,8 @@ impl FromPyObject<'_> for DateTime<FixedOffset> {
NaiveTime::from_hms_micro_opt(h, m, s, ms) NaiveTime::from_hms_micro_opt(h, m, s, ms)
.ok_or_else(|| PyValueError::new_err("invalid or out-of-range time"))?, .ok_or_else(|| PyValueError::new_err("invalid or out-of-range time"))?,
); );
Ok(DateTime::from_utc(dt, tz)) // `FixedOffset` cannot have ambiguities so we don't have to worry about DST folds and such
Ok(DateTime::from_local(dt, tz))
} }
} }
@ -607,7 +608,7 @@ mod tests {
.and_hms_micro_opt(hour, minute, ssecond, ms) .and_hms_micro_opt(hour, minute, ssecond, ms)
.unwrap(); .unwrap();
let datetime = let datetime =
DateTime::<FixedOffset>::from_utc(datetime, offset).to_object(py); DateTime::<FixedOffset>::from_local(datetime, offset).to_object(py);
let datetime: &PyDateTime = datetime.extract(py).unwrap(); let datetime: &PyDateTime = datetime.extract(py).unwrap();
let py_tz = offset.to_object(py); let py_tz = offset.to_object(py);
let py_tz = py_tz.downcast(py).unwrap(); let py_tz = py_tz.downcast(py).unwrap();
@ -676,41 +677,36 @@ mod tests {
check_utc("fold", 2014, 5, 6, 7, 8, 9, 1_999_999, 999_999, true); check_utc("fold", 2014, 5, 6, 7, 8, 9, 1_999_999, 999_999, true);
check_utc("non fold", 2014, 5, 6, 7, 8, 9, 999_999, 999_999, false); check_utc("non fold", 2014, 5, 6, 7, 8, 9, 999_999, 999_999, false);
let check_fixed_offset = let check_fixed_offset = |year, month, day, hour, minute, second, ms| {
|name: &'static str, year, month, day, hour, minute, second, ms, py_ms, fold| { Python::with_gil(|py| {
Python::with_gil(|py| { let offset = FixedOffset::east_opt(3600).unwrap();
let offset = FixedOffset::east_opt(3600).unwrap(); let py_tz = offset.to_object(py);
let py_tz = offset.to_object(py); let py_tz = py_tz.downcast(py).unwrap();
let py_tz = py_tz.downcast(py).unwrap(); let py_datetime = PyDateTime::new_with_fold(
let py_datetime = PyDateTime::new_with_fold( py,
py, year,
year, month as u8,
month as u8, day as u8,
day as u8, hour as u8,
hour as u8, minute as u8,
minute as u8, second as u8,
second as u8, ms,
py_ms, Some(py_tz),
Some(py_tz), false, // No such thing as fold for fixed offset timezones
fold, )
) .unwrap();
let py_datetime: DateTime<FixedOffset> = py_datetime.extract().unwrap();
let datetime = NaiveDate::from_ymd_opt(year, month, day)
.unwrap()
.and_hms_micro_opt(hour, minute, second, ms)
.unwrap(); .unwrap();
let py_datetime: DateTime<FixedOffset> = py_datetime.extract().unwrap(); let datetime = DateTime::<FixedOffset>::from_local(datetime, offset);
let datetime = NaiveDate::from_ymd_opt(year, month, day)
.unwrap()
.and_hms_micro_opt(hour, minute, second, ms)
.unwrap();
let datetime = DateTime::<FixedOffset>::from_utc(datetime, offset);
assert_eq!(
py_datetime, datetime,
"{}: {} != {}",
name, datetime, py_datetime
);
})
};
check_fixed_offset("fold", 2014, 5, 6, 7, 8, 9, 1_999_999, 999_999, true); assert_eq!(py_datetime, datetime, "{} != {}", datetime, py_datetime);
check_fixed_offset("non fold", 2014, 5, 6, 7, 8, 9, 999_999, 999_999, false); })
};
check_fixed_offset(2014, 5, 6, 7, 8, 9, 999_999);
Python::with_gil(|py| { Python::with_gil(|py| {
let py_tz = Utc.to_object(py); let py_tz = Utc.to_object(py);
@ -845,10 +841,38 @@ mod tests {
#[cfg(all(test, not(target_arch = "wasm32")))] #[cfg(all(test, not(target_arch = "wasm32")))]
mod proptests { mod proptests {
use super::*; use super::*;
use crate::types::IntoPyDict;
use proptest::prelude::*; use proptest::prelude::*;
proptest! { proptest! {
// Range is limited to 1970 to 2038 due to windows limitations
#[test]
fn test_pyo3_offset_fixed_frompyobject_created_in_python(timestamp in 0..(i32::MAX as i64), timedelta in -86399i32..=86399i32) {
Python::with_gil(|py| {
let globals = [("datetime", py.import("datetime").unwrap())].into_py_dict(py);
let code = format!("datetime.datetime.fromtimestamp({}).replace(tzinfo=datetime.timezone(datetime.timedelta(seconds={})))", timestamp, timedelta);
let t = py.eval(&code, Some(globals), None).unwrap();
// Get ISO 8601 string from python
let py_iso_str = t.call_method0("isoformat").unwrap();
// Get ISO 8601 string from rust
let t = t.extract::<DateTime<FixedOffset>>().unwrap();
// Python doesn't print the seconds of the offset if they are 0
let rust_iso_str = if timedelta % 60 == 0 {
t.format("%Y-%m-%dT%H:%M:%S%:z").to_string()
} else {
t.format("%Y-%m-%dT%H:%M:%S%::z").to_string()
};
// They should be equal
assert_eq!(py_iso_str.to_string(), rust_iso_str);
})
}
#[test] #[test]
fn test_duration_roundtrip(days in -999999999i64..=999999999i64) { fn test_duration_roundtrip(days in -999999999i64..=999999999i64) {
// Test roundtrip convertion rust->python->rust for all allowed // Test roundtrip convertion rust->python->rust for all allowed
@ -942,7 +966,7 @@ mod tests {
hour in 0u32..=24u32, hour in 0u32..=24u32,
min in 0u32..=60u32, min in 0u32..=60u32,
sec in 0u32..=60u32, sec in 0u32..=60u32,
micro in 0u32..=2_000_000u32, micro in 0u32..=1_000_000u32,
offset_secs in -86399i32..=86399i32 offset_secs in -86399i32..=86399i32
) { ) {
Python::with_gil(|py| { Python::with_gil(|py| {