diff --git a/src/conversions/chrono.rs b/src/conversions/chrono.rs index 863ceefb..8312e367 100644 --- a/src/conversions/chrono.rs +++ b/src/conversions/chrono.rs @@ -32,10 +32,10 @@ //! // Create an UTC datetime in python //! let py_tz = Utc.to_object(py); //! let py_tz = py_tz.downcast(py).unwrap(); -//! let pydatetime = PyDateTime::new(py, 2022, 1, 1, 12, 0, 0, 0, Some(py_tz)).unwrap(); -//! println!("PyDateTime: {}", pydatetime); +//! let py_datetime = PyDateTime::new(py, 2022, 1, 1, 12, 0, 0, 0, Some(py_tz)).unwrap(); +//! println!("PyDateTime: {}", py_datetime); //! // Now convert it to chrono's DateTime -//! let chrono_datetime: DateTime = pydatetime.extract().unwrap(); +//! let chrono_datetime: DateTime = py_datetime.extract().unwrap(); //! println!("DateTime: {}", chrono_datetime); //! }); //! } @@ -77,7 +77,7 @@ impl ToPyObject for Duration { // We pass true as the `normalize` parameter since we'd need to do several checks here to // avoid that, and it shouldn't have a big performance impact. let delta = PyDelta::new(py, days.try_into().unwrap_or(i32::MAX), secs, micros, true) - .expect("Failed to construct delta"); + .expect("failed to construct delta"); delta.into() } } @@ -103,83 +103,59 @@ impl FromPyObject<'_> for Duration { impl ToPyObject for NaiveDate { fn to_object(&self, py: Python<'_>) -> PyObject { - let month = self.month() as u8; - let day = self.day() as u8; - let date = PyDate::new(py, self.year(), month, day).expect("Failed to construct date"); - date.into() + (*self).into_py(py) } } impl IntoPy for NaiveDate { fn into_py(self, py: Python<'_>) -> PyObject { - ToPyObject::to_object(&self, py) + let DateArgs { year, month, day } = self.into(); + PyDate::new(py, year, month, day) + .expect("failed to construct date") + .into() } } impl FromPyObject<'_> for NaiveDate { fn extract(ob: &PyAny) -> PyResult { let date: &PyDate = ob.downcast()?; - NaiveDate::from_ymd_opt( - date.get_year(), - date.get_month() as u32, - date.get_day() as u32, - ) - .ok_or_else(|| PyValueError::new_err("invalid or out-of-range date")) + py_date_to_naive_date(date) } } impl ToPyObject for NaiveTime { fn to_object(&self, py: Python<'_>) -> PyObject { - let h = self.hour() as u8; - let m = self.minute() as u8; - let s = self.second() as u8; - let ns = self.nanosecond(); - let (ms, fold) = match ns.checked_sub(1_000_000_000) { - Some(ns) => (ns / 1000, true), - None => (ns / 1000, false), - }; - let time = - PyTime::new_with_fold(py, h, m, s, ms, None, fold).expect("Failed to construct time"); - time.into() + (*self).into_py(py) } } impl IntoPy for NaiveTime { fn into_py(self, py: Python<'_>) -> PyObject { - ToPyObject::to_object(&self, py) + let TimeArgs { + hour, + min, + sec, + micro, + fold, + } = self.into(); + let time = PyTime::new_with_fold(py, hour, min, sec, micro, None, fold) + .expect("failed to construct time"); + time.into() } } impl FromPyObject<'_> for NaiveTime { fn extract(ob: &PyAny) -> PyResult { let time: &PyTime = ob.downcast()?; - let ms = time.get_fold() as u32 * 1_000_000 + time.get_microsecond(); - let h = time.get_hour() as u32; - let m = time.get_minute() as u32; - let s = time.get_second() as u32; - NaiveTime::from_hms_micro_opt(h, m, s, ms) - .ok_or_else(|| PyValueError::new_err("invalid or out-of-range time")) + py_time_to_naive_time(time, true) } } impl ToPyObject for NaiveDateTime { fn to_object(&self, py: Python<'_>) -> PyObject { - let date = self.date(); - let time = self.time(); - let yy = date.year(); - let mm = date.month() as u8; - let dd = date.day() as u8; - let h = time.hour() as u8; - let m = time.minute() as u8; - let s = time.second() as u8; - let ns = time.nanosecond(); - let (ms, fold) = match ns.checked_sub(1_000_000_000) { - Some(ns) => (ns / 1000, true), - None => (ns / 1000, false), - }; - let datetime = PyDateTime::new_with_fold(py, yy, mm, dd, h, m, s, ms, None, fold) - .expect("Failed to construct datetime"); - datetime.into() + naive_datetime_to_py_datetime(py, self, None) + .expect("failed to construct datetime") + .into() } } @@ -196,19 +172,12 @@ impl FromPyObject<'_> for NaiveDateTime { // we return a hard error. We could silently remove tzinfo, or assume local timezone // and do a conversion, but better leave this decision to the user of the library. if dt.get_tzinfo().is_some() { - return Err(PyTypeError::new_err( - "Trying to convert a timezone aware datetime into a NaiveDateTime.", - )); + return Err(PyTypeError::new_err("expected a datetime without tzinfo")); } - let h = dt.get_hour().into(); - let m = dt.get_minute().into(); - let s = dt.get_second().into(); - let ms = dt.get_microsecond(); + let dt = NaiveDateTime::new( - NaiveDate::from_ymd_opt(dt.get_year(), dt.get_month().into(), dt.get_day().into()) - .ok_or_else(|| PyValueError::new_err("invalid or out-of-range date"))?, - NaiveTime::from_hms_micro_opt(h, m, s, ms) - .ok_or_else(|| PyValueError::new_err("invalid or out-of-range time"))?, + py_date_to_naive_date(dt)?, + py_time_to_naive_time(dt, false)?, ); Ok(dt) } @@ -216,24 +185,13 @@ impl FromPyObject<'_> for NaiveDateTime { impl ToPyObject for DateTime { fn to_object(&self, py: Python<'_>) -> PyObject { - let date = self.naive_local().date(); - let time = self.naive_local().time(); - let yy = date.year(); - let mm = date.month() as u8; - let dd = date.day() as u8; - let h = time.hour() as u8; - let m = time.minute() as u8; - let s = time.second() as u8; - let ns = time.nanosecond(); - let (ms, fold) = match ns.checked_sub(1_000_000_000) { - Some(ns) => (ns / 1000, true), - None => (ns / 1000, false), - }; + // FIXME: convert to better timezone representation here than just convert to fixed offset + // See https://github.com/PyO3/pyo3/issues/3266 let tz = self.offset().fix().to_object(py); let tz = tz.downcast(py).unwrap(); - let datetime = PyDateTime::new_with_fold(py, yy, mm, dd, h, m, s, ms, Some(tz), fold) - .expect("Failed to construct datetime"); - datetime.into() + naive_datetime_to_py_datetime(py, &self.naive_local(), Some(tz)) + .expect("failed to construct datetime") + .into() } } @@ -246,20 +204,16 @@ impl IntoPy for DateTime { impl FromPyObject<'_> for DateTime { fn extract(ob: &PyAny) -> PyResult> { let dt: &PyDateTime = ob.downcast()?; - let ms = dt.get_microsecond(); - let h = dt.get_hour().into(); - let m = dt.get_minute().into(); - let s = dt.get_second().into(); let tz: FixedOffset = if let Some(tzinfo) = dt.get_tzinfo() { tzinfo.extract()? } else { - return Err(PyTypeError::new_err("Not datetime.tzinfo")); + return Err(PyTypeError::new_err( + "expected a datetime with non-None tzinfo", + )); }; let dt = NaiveDateTime::new( - NaiveDate::from_ymd_opt(dt.get_year(), dt.get_month().into(), dt.get_day().into()) - .ok_or_else(|| PyValueError::new_err("invalid or out-of-range date"))?, - NaiveTime::from_hms_micro_opt(h, m, s, ms) - .ok_or_else(|| PyValueError::new_err("invalid or out-of-range time"))?, + py_date_to_naive_date(dt)?, + py_time_to_naive_time(dt, false)?, ); // `FixedOffset` cannot have ambiguities so we don't have to worry about DST folds and such Ok(dt.and_local_timezone(tz).unwrap()) @@ -269,21 +223,14 @@ impl FromPyObject<'_> for DateTime { impl FromPyObject<'_> for DateTime { fn extract(ob: &PyAny) -> PyResult> { let dt: &PyDateTime = ob.downcast()?; - let ms = dt.get_fold() as u32 * 1_000_000 + dt.get_microsecond(); - let h = dt.get_hour().into(); - let m = dt.get_minute().into(); - let s = dt.get_second().into(); let _: Utc = if let Some(tzinfo) = dt.get_tzinfo() { tzinfo.extract()? } else { - return Err(PyTypeError::new_err("Not datetime.timezone.utc")); + return Err(PyTypeError::new_err( + "expected a datetime with non-None tzinfo", + )); }; - let dt = NaiveDateTime::new( - NaiveDate::from_ymd_opt(dt.get_year(), dt.get_month().into(), dt.get_day().into()) - .ok_or_else(|| PyValueError::new_err("invalid or out-of-range date"))?, - NaiveTime::from_hms_micro_opt(h, m, s, ms) - .ok_or_else(|| PyValueError::new_err("invalid or out-of-range time"))?, - ); + let dt = NaiveDateTime::new(py_date_to_naive_date(dt)?, py_time_to_naive_time(dt, true)?); Ok(dt.and_utc()) } } @@ -302,7 +249,7 @@ impl ToPyObject for FixedOffset { fn to_object(&self, py: Python<'_>) -> PyObject { let seconds_offset = self.local_minus_utc(); let td = - PyDelta::new(py, 0, seconds_offset, 0, true).expect("Failed to construct timedelta"); + PyDelta::new(py, 0, seconds_offset, 0, true).expect("failed to construct timedelta"); py_timezone_from_offset(&py, td).into() } } @@ -365,11 +312,97 @@ impl FromPyObject<'_> for Utc { if py_tzinfo.eq(py_utc)? { Ok(Utc) } else { - Err(PyTypeError::new_err("Not datetime.timezone.utc")) + Err(PyValueError::new_err("expected datetime.timezone.utc")) } } } +struct DateArgs { + year: i32, + month: u8, + day: u8, +} + +impl From for DateArgs { + fn from(value: NaiveDate) -> Self { + Self { + year: value.year(), + month: value.month() as u8, + day: value.day() as u8, + } + } +} + +struct TimeArgs { + hour: u8, + min: u8, + sec: u8, + micro: u32, + fold: bool, +} + +impl From for TimeArgs { + fn from(value: NaiveTime) -> Self { + let ns = value.nanosecond(); + let checked_sub = ns.checked_sub(1_000_000_000); + // FIXME: should not try to handle fold as leap-seconds, see + // https://github.com/PyO3/pyo3/issues/3424 + let fold = checked_sub.is_some(); + let micro = checked_sub.unwrap_or(ns) / 1000; + Self { + hour: value.hour() as u8, + min: value.minute() as u8, + sec: value.second() as u8, + micro, + fold, + } + } +} + +fn naive_datetime_to_py_datetime<'py>( + py: Python<'py>, + naive_datetime: &NaiveDateTime, + tzinfo: Option<&PyTzInfo>, +) -> PyResult<&'py PyDateTime> { + let DateArgs { year, month, day } = naive_datetime.date().into(); + let TimeArgs { + hour, + min, + sec, + micro, + fold, + } = naive_datetime.time().into(); + let datetime = + PyDateTime::new_with_fold(py, year, month, day, hour, min, sec, micro, tzinfo, fold)?; + Ok(datetime) +} + +fn py_date_to_naive_date(py_date: &impl PyDateAccess) -> PyResult { + NaiveDate::from_ymd_opt( + py_date.get_year(), + py_date.get_month().into(), + py_date.get_day().into(), + ) + .ok_or_else(|| PyValueError::new_err("invalid or out-of-range date")) +} + +fn py_time_to_naive_time(py_time: &impl PyTimeAccess, add_fold: bool) -> PyResult { + NaiveTime::from_hms_micro_opt( + py_time.get_hour().into(), + py_time.get_minute().into(), + py_time.get_second().into(), + py_time.get_microsecond() + // FIXME: should not try to handle fold as leap-seconds, see + // https://github.com/PyO3/pyo3/issues/3424 + + if add_fold && py_time.get_fold() { + 1_000_000 + } else { + 0 + }, + ) + .ok_or_else(|| PyValueError::new_err("invalid or out-of-range time")) +} + #[cfg(test)] mod tests { use std::{cmp::Ordering, panic}; @@ -412,9 +445,73 @@ mod tests { let res = res.err().unwrap(); // Also check the error message is what we expect let msg = res.value(py).repr().unwrap().to_string(); + assert_eq!(msg, "TypeError('expected a datetime without tzinfo')"); + }); + } + + #[test] + fn test_naive_to_timezone_aware_fails() { + // Test that if a user tries to convert a python's timezone aware datetime into a naive + // one, the conversion fails. + Python::with_gil(|py| { + let py_datetime = PyDateTime::new(py, 2022, 1, 1, 1, 0, 0, 0, None).unwrap(); + // Now test that converting a PyDateTime with tzinfo to a NaiveDateTime fails + let res: PyResult> = py_datetime.extract(); + assert!(res.is_err()); + let res = res.err().unwrap(); + // Also check the error message is what we expect + let msg = res.value(py).repr().unwrap().to_string(); + assert_eq!(msg, "TypeError('expected a datetime with non-None tzinfo')"); + + // Now test that converting a PyDateTime with tzinfo to a NaiveDateTime fails + let res: PyResult> = py_datetime.extract(); + assert!(res.is_err()); + let res = res.err().unwrap(); + // Also check the error message is what we expect + let msg = res.value(py).repr().unwrap().to_string(); + assert_eq!(msg, "TypeError('expected a datetime with non-None tzinfo')"); + }); + } + + #[test] + fn test_invalid_types_fail() { + // Test that if a user tries to convert a python's timezone aware datetime into a naive + // one, the conversion fails. + Python::with_gil(|py| { + let none = py.None().into_ref(py); assert_eq!( - msg, - "TypeError('Trying to convert a timezone aware datetime into a NaiveDateTime.')" + none.extract::().unwrap_err().to_string(), + "TypeError: 'NoneType' object cannot be converted to 'PyDelta'" + ); + assert_eq!( + none.extract::().unwrap_err().to_string(), + "TypeError: 'NoneType' object cannot be converted to 'PyTzInfo'" + ); + assert_eq!( + none.extract::().unwrap_err().to_string(), + "TypeError: 'NoneType' object cannot be converted to 'PyTzInfo'" + ); + assert_eq!( + none.extract::().unwrap_err().to_string(), + "TypeError: 'NoneType' object cannot be converted to 'PyTime'" + ); + assert_eq!( + none.extract::().unwrap_err().to_string(), + "TypeError: 'NoneType' object cannot be converted to 'PyDate'" + ); + assert_eq!( + none.extract::().unwrap_err().to_string(), + "TypeError: 'NoneType' object cannot be converted to 'PyDateTime'" + ); + assert_eq!( + none.extract::>().unwrap_err().to_string(), + "TypeError: 'NoneType' object cannot be converted to 'PyDateTime'" + ); + assert_eq!( + none.extract::>() + .unwrap_err() + .to_string(), + "TypeError: 'NoneType' object cannot be converted to 'PyDateTime'" ); }); } @@ -909,7 +1006,7 @@ mod tests { // We use to `from_ymd_opt` constructor so that we only test valid `NaiveDate`s. // This is to skip the test if we are creating an invalid date, like February 31. if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) { - let pydate = date.into_py(py); + let pydate = date.to_object(py); let roundtripped: NaiveDate = pydate.extract(py).expect("Round trip"); assert_eq!(date, roundtripped); } @@ -931,13 +1028,35 @@ mod tests { // We use to `from_hms_micro_opt` constructor so that we only test valid `NaiveTime`s. // This is to skip the test if we are creating an invalid time if let Some(time) = NaiveTime::from_hms_micro_opt(hour, min, sec, micro) { - let pytime = time.into_py(py); + let pytime = time.to_object(py); let roundtripped: NaiveTime = pytime.extract(py).expect("Round trip"); assert_eq!(time, roundtripped); } }) } + #[test] + fn test_naive_datetime_roundtrip( + year in 1i32..=9999i32, + month in 1u32..=12u32, + day in 1u32..=31u32, + hour in 0u32..=24u32, + min in 0u32..=60u32, + sec in 0u32..=60u32, + micro in 0u32..=999_999u32 + ) { + Python::with_gil(|py| { + let date_opt = NaiveDate::from_ymd_opt(year, month, day); + let time_opt = NaiveTime::from_hms_micro_opt(hour, min, sec, micro); + if let (Some(date), Some(time)) = (date_opt, time_opt) { + let dt = NaiveDateTime::new(date, time); + let pydt = dt.to_object(py); + let roundtripped: NaiveDateTime = pydt.extract(py).expect("Round trip"); + assert_eq!(dt, roundtripped); + } + }) + } + #[test] fn test_utc_datetime_roundtrip( year in 1i32..=9999i32,