From e2008d99bdb9ca78f0c639d32933ec8b19c42ab8 Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers Date: Wed, 4 Jun 2025 11:50:15 +0200 Subject: [PATCH 1/5] Allow naive datetime into chrono `DateTime` conversion --- newsfragments/5178.changed.md | 1 + src/conversions/chrono.rs | 109 +++++++++++++++++++++++----------- 2 files changed, 75 insertions(+), 35 deletions(-) create mode 100644 newsfragments/5178.changed.md diff --git a/newsfragments/5178.changed.md b/newsfragments/5178.changed.md new file mode 100644 index 00000000000..7b050ccda81 --- /dev/null +++ b/newsfragments/5178.changed.md @@ -0,0 +1 @@ +Allow converting naive datetime into chrono `DateTime`. diff --git a/src/conversions/chrono.rs b/src/conversions/chrono.rs index fab734422f2..14a82e2e5be 100644 --- a/src/conversions/chrono.rs +++ b/src/conversions/chrono.rs @@ -336,26 +336,28 @@ 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) - } + + py_datetime_to_datetime_with_timezone(dt, tz) + } +} + +#[cfg(feature = "chrono-local")] +impl<'py> FromPyObject<'py> for DateTime { + fn extract_bound(dt: &Bound<'_, PyAny>) -> PyResult> { + let dt = dt.downcast::()?; + + if let Some(tzinfo) = dt.get_tzinfo() { + let local_tz = Local.into_pyobject(dt.py())?; + if !(tzinfo.eq(local_tz)?) { + let name = local_tz.getattr("key")?.downcast_into::()?; + return Err(PyValueError::new_err(format!( + "expected a datetime without timezone or with local timezone {}", + name.to_cow()? + ))); } - LocalResult::None => Err(PyValueError::new_err(format!( - "The datetime {dt:?} contains an incompatible timezone" - ))), - } + }; + + py_datetime_to_datetime_with_timezone(dt, Local) } } @@ -474,22 +476,6 @@ impl<'py> IntoPyObject<'py> for &Local { } } -#[cfg(feature = "chrono-local")] -impl FromPyObject<'_> for Local { - fn extract_bound(ob: &Bound<'_, PyAny>) -> PyResult { - let local_tz = Local.into_pyobject(ob.py())?; - if ob.eq(local_tz)? { - Ok(Local) - } else { - let name = local_tz.getattr("key")?.downcast_into::()?; - Err(PyValueError::new_err(format!( - "expected local timezone {}", - name.to_cow()? - ))) - } - } -} - struct DateArgs { year: i32, month: u8, @@ -590,6 +576,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::*; @@ -982,6 +994,33 @@ mod tests { }) } + #[test] + #[cfg(feature = "chrono-local")] + fn test_pyo3_naive_datetime_frompyobject_local() { + Python::with_gil(|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::with_gil(|py| { From 41e1a48ab5533749a514c53fdd374de6e9042bc0 Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Fri, 10 Oct 2025 10:48:46 +0100 Subject: [PATCH 2/5] use `FromPyObject` helper to safely create `Local` specialization --- guide/src/conversions/tables.md | 2 +- guide/src/features.md | 9 +++++++-- src/conversion.rs | 8 ++++++++ src/conversions/chrono.rs | 8 +++++++- 4 files changed, 23 insertions(+), 4 deletions(-) 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/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 b51972928c0..b5175c6dfaa 100644 --- a/src/conversions/chrono.rs +++ b/src/conversions/chrono.rs @@ -345,6 +345,12 @@ 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", )); @@ -1014,7 +1020,7 @@ mod tests { #[test] #[cfg(feature = "chrono-local")] fn test_pyo3_naive_datetime_frompyobject_local() { - Python::with_gil(|py| { + Python::attach(|py| { let year = 2014; let month = 5; let day = 6; From d9bb709c3e7963cc03a874874239d7b76aaff6af Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Fri, 10 Oct 2025 10:53:05 +0100 Subject: [PATCH 3/5] rename newsfragment --- newsfragments/{5178.changed.md => 5507.changed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename newsfragments/{5178.changed.md => 5507.changed.md} (100%) diff --git a/newsfragments/5178.changed.md b/newsfragments/5507.changed.md similarity index 100% rename from newsfragments/5178.changed.md rename to newsfragments/5507.changed.md From a45aafb6292c38ef8251c019fa8bc685530371ed Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Sat, 11 Oct 2025 17:20:03 +0100 Subject: [PATCH 4/5] add `as_local_tz` implementation for local --- src/conversions/chrono.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/conversions/chrono.rs b/src/conversions/chrono.rs index b5175c6dfaa..61f50530d35 100644 --- a/src/conversions/chrono.rs +++ b/src/conversions/chrono.rs @@ -495,6 +495,11 @@ impl FromPyObject<'_, '_> for Local { ))) } } + + #[inline] + fn as_local_tz(_: crate::conversion::private::Token) -> Option { + Some(Local) + } } struct DateArgs { From 5076087a7a218155f38bc58ab294fa79fce92e1f Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Wed, 15 Oct 2025 18:51:03 +0100 Subject: [PATCH 5/5] coverage --- src/conversions/chrono_tz.rs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) 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() {