From b17de4f54e6b33f68c2766e182dbe579c1fcf505 Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Mon, 16 Dec 2024 20:14:13 +0100 Subject: [PATCH] use `datetime.fold` to distinguish ambiguous datetimes when converting (#4791) * use `datetime.fold` to distinguish ambiguous datetimes when converting * Set correct fold when converting to ambiguous `chrono::DateTime` --- newsfragments/4791.fixed.md | 1 + src/conversions/chrono.rs | 44 +++++++++++++++++++++++------- src/conversions/chrono_tz.rs | 52 ++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 newsfragments/4791.fixed.md diff --git a/newsfragments/4791.fixed.md b/newsfragments/4791.fixed.md new file mode 100644 index 00000000000..2aab452eb51 --- /dev/null +++ b/newsfragments/4791.fixed.md @@ -0,0 +1 @@ +use `datetime.fold` to distinguish ambiguous datetimes when converting to and from `chrono::DateTime` diff --git a/src/conversions/chrono.rs b/src/conversions/chrono.rs index 02bc65e59c5..04febb43b78 100644 --- a/src/conversions/chrono.rs +++ b/src/conversions/chrono.rs @@ -48,6 +48,8 @@ use crate::sync::GILOnceCell; use crate::types::any::PyAnyMethods; #[cfg(not(Py_LIMITED_API))] use crate::types::datetime::timezone_from_offset; +#[cfg(Py_LIMITED_API)] +use crate::types::IntoPyDict; use crate::types::PyNone; #[cfg(not(Py_LIMITED_API))] use crate::types::{ @@ -61,7 +63,8 @@ use crate::{intern, DowncastError}; use crate::{IntoPy, ToPyObject}; use chrono::offset::{FixedOffset, Utc}; use chrono::{ - DateTime, Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime, Offset, TimeZone, Timelike, + DateTime, Datelike, Duration, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, Offset, + TimeZone, Timelike, }; #[allow(deprecated)] @@ -465,14 +468,21 @@ where truncated_leap_second, } = (&self.naive_local().time()).into(); + let fold = matches!( + self.timezone().offset_from_local_datetime(&self.naive_local()), + LocalResult::Ambiguous(_, latest) if self.offset().fix() == latest.fix() + ); + #[cfg(not(Py_LIMITED_API))] - let datetime = PyDateTime::new(py, year, month, day, hour, min, sec, micro, Some(tz))?; + let datetime = + PyDateTime::new_with_fold(py, year, month, day, hour, min, sec, micro, Some(tz), fold)?; #[cfg(Py_LIMITED_API)] let datetime = DatetimeTypes::try_get(py).and_then(|dt| { - dt.datetime - .bind(py) - .call1((year, month, day, hour, min, sec, micro, tz)) + dt.datetime.bind(py).call( + (year, month, day, hour, min, sec, micro, tz), + Some(&[("fold", fold as u8)].into_py_dict(py)?), + ) })?; if truncated_leap_second { @@ -503,12 +513,26 @@ impl FromPyObject<'py>> FromPyObject<'_> for DateTime Ok(value), + LocalResult::Ambiguous(earliest, latest) => { + #[cfg(not(Py_LIMITED_API))] + let fold = dt.get_fold(); + + #[cfg(Py_LIMITED_API)] + let fold = dt.getattr(intern!(dt.py(), "fold"))?.extract::()? > 0; + + if fold { + Ok(latest) + } else { + Ok(earliest) + } + } + LocalResult::None => Err(PyValueError::new_err(format!( + "The datetime {:?} contains an incompatible timezone", dt - )) - }) + ))), + } } } diff --git a/src/conversions/chrono_tz.rs b/src/conversions/chrono_tz.rs index bb1a74c1519..60a3bab4918 100644 --- a/src/conversions/chrono_tz.rs +++ b/src/conversions/chrono_tz.rs @@ -98,6 +98,10 @@ impl FromPyObject<'_> for Tz { #[cfg(all(test, not(windows)))] // Troubles loading timezones on Windows mod tests { use super::*; + use crate::prelude::PyAnyMethods; + use crate::Python; + use chrono::{DateTime, Utc}; + use chrono_tz::Tz; #[test] fn test_frompyobject() { @@ -114,6 +118,54 @@ mod tests { }); } + #[test] + fn test_ambiguous_datetime_to_pyobject() { + let dates = [ + DateTime::::from_str("2020-10-24 23:00:00 UTC").unwrap(), + DateTime::::from_str("2020-10-25 00:00:00 UTC").unwrap(), + DateTime::::from_str("2020-10-25 01:00:00 UTC").unwrap(), + ]; + + let dates = dates.map(|dt| dt.with_timezone(&Tz::Europe__London)); + + assert_eq!( + dates.map(|dt| dt.to_string()), + [ + "2020-10-25 00:00:00 BST", + "2020-10-25 01:00:00 BST", + "2020-10-25 01:00:00 GMT" + ] + ); + + let dates = Python::with_gil(|py| { + let pydates = dates.map(|dt| dt.into_pyobject(py).unwrap()); + assert_eq!( + pydates + .clone() + .map(|dt| dt.getattr("hour").unwrap().extract::().unwrap()), + [0, 1, 1] + ); + + assert_eq!( + pydates + .clone() + .map(|dt| dt.getattr("fold").unwrap().extract::().unwrap() > 0), + [false, false, true] + ); + + pydates.map(|dt| dt.extract::>().unwrap()) + }); + + assert_eq!( + dates.map(|dt| dt.to_string()), + [ + "2020-10-25 00:00:00 BST", + "2020-10-25 01:00:00 BST", + "2020-10-25 01:00:00 GMT" + ] + ); + } + #[test] #[cfg(not(Py_GIL_DISABLED))] // https://github.com/python/cpython/issues/116738#issuecomment-2404360445 fn test_into_pyobject() {