diff --git a/guide/src/conversions/tables.md b/guide/src/conversions/tables.md index 498e7028a53..e120d63cf6d 100644 --- a/guide/src/conversions/tables.md +++ b/guide/src/conversions/tables.md @@ -109,7 +109,7 @@ Finally, the following Rust types are also able to convert to Python as return v [^4]: Requires the `indexmap` optional feature. -[^5]: Requires the `chrono` optional feature. +[^5]: Requires the `chrono` (and maybe `chrono-local`) optional feature(s). [^6]: Requires the `chrono-tz` optional feature. diff --git a/guide/src/features.md b/guide/src/features.md index da3f79d8752..03b9734d92f 100644 --- a/guide/src/features.md +++ b/guide/src/features.md @@ -137,13 +137,18 @@ Adds a dependency on [chrono](https://docs.rs/chrono). Enables a conversion from ### `chrono-local` -Enables conversion from and to [Local](https://docs.rs/chrono/latest/chrono/struct.Local.html) timezones. +Enables conversion from and to [Local](https://docs.rs/chrono/latest/chrono/struct.Local.html) timezones. The current system timezone as determined by [`iana_time_zone::get_timezone()`](https://docs.rs/iana-time-zone/latest/iana_time_zone/fn.get_timezone.html) will be used for conversions. + +`chrono::DateTime` will convert from either of: +- `datetime` objects with `tzinfo` equivalent to the current system timezone. +- "naive" `datetime` objects (those without a `tzinfo`), as it is a convention that naive datetime objects should be treated as using the system timezone. + +When converting to Python, `Local` tzinfo is converted to a `zoneinfo.ZoneInfo` matching the current system timezone. ### `chrono-tz` Adds a dependency on [chrono-tz](https://docs.rs/chrono-tz). Enables conversion from and to [`Tz`](https://docs.rs/chrono-tz/latest/chrono_tz/enum.Tz.html). -It requires at least Python 3.9. ### `either` diff --git a/newsfragments/5507.changed.md b/newsfragments/5507.changed.md new file mode 100644 index 00000000000..7b050ccda81 --- /dev/null +++ b/newsfragments/5507.changed.md @@ -0,0 +1 @@ +Allow converting naive datetime into chrono `DateTime`. diff --git a/src/conversion.rs b/src/conversion.rs index 77aa078a201..c7004f8d9c7 100644 --- a/src/conversion.rs +++ b/src/conversion.rs @@ -449,6 +449,14 @@ pub trait FromPyObject<'a, 'py>: Sized { ) -> Option + 'b>> { None } + + /// Helper used to make a specialized path in extracting `DateTime` where `Tz` is + /// `chrono::Local`, which will accept "naive" datetime objects as being in the local timezone. + #[cfg(feature = "chrono-local")] + #[inline] + fn as_local_tz(_: private::Token) -> Option { + None + } } mod from_py_object_sequence { diff --git a/src/conversions/chrono.rs b/src/conversions/chrono.rs index e9ca64d0ba7..61f50530d35 100644 --- a/src/conversions/chrono.rs +++ b/src/conversions/chrono.rs @@ -345,30 +345,18 @@ where let tz = if let Some(tzinfo) = tzinfo { tzinfo.extract().map_err(Into::into)? } else { + // Special case: allow naive `datetime` objects for `DateTime`, interpreting them as local time. + #[cfg(feature = "chrono-local")] + if let Some(tz) = Tz::as_local_tz(crate::conversion::private::Token) { + return py_datetime_to_datetime_with_timezone(dt, tz); + } + return Err(PyTypeError::new_err( "expected a datetime with non-None tzinfo", )); }; - let naive_dt = NaiveDateTime::new(py_date_to_naive_date(dt)?, py_time_to_naive_time(dt)?); - match naive_dt.and_local_timezone(tz) { - LocalResult::Single(value) => 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 {dt:?} contains an incompatible timezone" - ))), - } + + py_datetime_to_datetime_with_timezone(dt, tz) } } @@ -507,6 +495,11 @@ impl FromPyObject<'_, '_> for Local { ))) } } + + #[inline] + fn as_local_tz(_: crate::conversion::private::Token) -> Option { + Some(Local) + } } struct DateArgs { @@ -613,6 +606,32 @@ fn py_time_to_naive_time(py_time: &Bound<'_, PyAny>) -> PyResult { .ok_or_else(|| PyValueError::new_err("invalid or out-of-range time")) } +fn py_datetime_to_datetime_with_timezone( + dt: &Bound<'_, PyDateTime>, + tz: Tz, +) -> PyResult> { + let naive_dt = NaiveDateTime::new(py_date_to_naive_date(dt)?, py_time_to_naive_time(dt)?); + match naive_dt.and_local_timezone(tz) { + LocalResult::Single(value) => 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 {dt:?} contains an incompatible timezone" + ))), + } +} + #[cfg(test)] mod tests { use super::*; @@ -1003,6 +1022,33 @@ mod tests { }) } + #[test] + #[cfg(feature = "chrono-local")] + fn test_pyo3_naive_datetime_frompyobject_local() { + Python::attach(|py| { + let year = 2014; + let month = 5; + let day = 6; + let hour = 7; + let minute = 8; + let second = 9; + let micro = 999_999; + let py_datetime = new_py_datetime_ob( + py, + "datetime", + (year, month, day, hour, minute, second, micro), + ); + let py_datetime: DateTime = py_datetime.extract().unwrap(); + let expected_datetime = NaiveDate::from_ymd_opt(year, month, day) + .unwrap() + .and_hms_micro_opt(hour, minute, second, micro) + .unwrap() + .and_local_timezone(Local) + .unwrap(); + assert_eq!(py_datetime, expected_datetime); + }) + } + #[test] fn test_pyo3_datetime_frompyobject_fixed_offset() { Python::attach(|py| { diff --git a/src/conversions/chrono_tz.rs b/src/conversions/chrono_tz.rs index 6926559c084..433a56b4ebf 100644 --- a/src/conversions/chrono_tz.rs +++ b/src/conversions/chrono_tz.rs @@ -79,9 +79,12 @@ impl FromPyObject<'_, '_> for Tz { mod tests { use super::*; use crate::prelude::PyAnyMethods; + use crate::types::IntoPyDict; use crate::types::PyTzInfo; use crate::Bound; use crate::Python; + use chrono::offset::LocalResult; + use chrono::NaiveDate; use chrono::{DateTime, Utc}; use chrono_tz::Tz; @@ -148,6 +151,37 @@ mod tests { ); } + #[test] + fn test_nonexistent_datetime_from_pyobject() { + // Pacific_Apia skipped the 30th of December 2011 entirely + + let naive_dt = NaiveDate::from_ymd_opt(2011, 12, 30) + .unwrap() + .and_hms_opt(2, 0, 0) + .unwrap(); + let tz = Tz::Pacific__Apia; + + // sanity check + assert_eq!(naive_dt.and_local_timezone(tz), LocalResult::None); + + Python::attach(|py| { + // create as a Python object manually + let py_tz = tz.into_pyobject(py).unwrap(); + let py_dt_naive = naive_dt.into_pyobject(py).unwrap(); + let py_dt = py_dt_naive + .call_method( + "replace", + (), + Some(&[("tzinfo", py_tz)].into_py_dict(py).unwrap()), + ) + .unwrap(); + + // now try to extract + let err = py_dt.extract::>().unwrap_err(); + assert_eq!(err.to_string(), "ValueError: The datetime datetime.datetime(2011, 12, 30, 2, 0, tzinfo=zoneinfo.ZoneInfo(key='Pacific/Apia')) contains an incompatible timezone"); + }); + } + #[test] #[cfg(not(Py_GIL_DISABLED))] // https://github.com/python/cpython/issues/116738#issuecomment-2404360445 fn test_into_pyobject() {