From 2d2a527874a65ba47a38425eab058758d3fe8066 Mon Sep 17 00:00:00 2001 From: David Hewitt <1939362+davidhewitt@users.noreply.github.com> Date: Sat, 16 Sep 2023 22:33:30 +0200 Subject: [PATCH] truncate leap seconds on `chrono` to `datetime` conversions --- newsfragments/3458.changed.md | 1 + src/conversions/chrono.rs | 502 +++++++++++++++++++--------------- src/lib.rs | 4 + src/test_utils.rs | 2 + tests/common.rs | 221 +++++++++------ 5 files changed, 423 insertions(+), 307 deletions(-) create mode 100644 newsfragments/3458.changed.md create mode 100644 src/test_utils.rs diff --git a/newsfragments/3458.changed.md b/newsfragments/3458.changed.md new file mode 100644 index 00000000000..7d283052a8f --- /dev/null +++ b/newsfragments/3458.changed.md @@ -0,0 +1 @@ +Truncate leap-seconds and warn when converting `chrono` types to Python `datetime` types (`datetime` cannot represent leap-seconds). diff --git a/src/conversions/chrono.rs b/src/conversions/chrono.rs index 1554898e323..0381d01947b 100644 --- a/src/conversions/chrono.rs +++ b/src/conversions/chrono.rs @@ -32,20 +32,20 @@ //! // 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); //! }); //! } //! ``` -use crate::exceptions::{PyTypeError, PyValueError}; +use crate::exceptions::{PyTypeError, PyUserWarning, PyValueError}; use crate::types::{ timezone_utc, PyDate, PyDateAccess, PyDateTime, PyDelta, PyDeltaAccess, PyTime, PyTimeAccess, PyTzInfo, PyTzInfoAccess, PyUnicode, }; -use crate::{FromPyObject, IntoPy, PyAny, PyObject, PyResult, Python, ToPyObject}; +use crate::{FromPyObject, IntoPy, PyAny, PyErr, PyObject, PyResult, Python, ToPyObject}; use chrono::offset::{FixedOffset, Utc}; use chrono::{ DateTime, Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime, Offset, TimeZone, Timelike, @@ -130,56 +130,44 @@ impl FromPyObject<'_> for NaiveDate { 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, + truncated_leap_second, + } = self.into(); + let time = PyTime::new(py, hour, min, sec, micro, None).expect("Failed to construct time"); + if truncated_leap_second { + warn_truncated_leap_second(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 micro = 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) + NaiveTime::from_hms_micro_opt(h, m, s, micro) .ok_or_else(|| PyValueError::new_err("invalid or out-of-range time")) } } 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() } } @@ -216,24 +204,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() } } @@ -269,7 +246,7 @@ 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 ms = dt.get_microsecond(); let h = dt.get_hour().into(); let m = dt.get_minute().into(); let s = dt.get_second().into(); @@ -370,10 +347,71 @@ impl FromPyObject<'_> for Utc { } } +struct TimeArgs { + hour: u8, + min: u8, + sec: u8, + micro: u32, + truncated_leap_second: bool, +} + +impl From for TimeArgs { + fn from(value: NaiveTime) -> Self { + let ns = value.nanosecond(); + let checked_sub = ns.checked_sub(1_000_000_000); + let truncated_leap_second = 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, + truncated_leap_second, + } + } +} + +fn naive_datetime_to_py_datetime<'py>( + py: Python<'py>, + naive_datetime: &NaiveDateTime, + tzinfo: Option<&PyTzInfo>, +) -> PyResult<&'py PyDateTime> { + let date = naive_datetime.date(); + let TimeArgs { + hour, + min, + sec, + micro, + truncated_leap_second, + } = naive_datetime.time().into(); + let yy = date.year(); + let mm = date.month() as u8; + let dd = date.day() as u8; + let datetime = PyDateTime::new(py, yy, mm, dd, hour, min, sec, micro, tzinfo)?; + if truncated_leap_second { + warn_truncated_leap_second(datetime); + } + Ok(datetime) +} + +fn warn_truncated_leap_second(obj: &PyAny) { + let py = obj.py(); + if let Err(e) = PyErr::warn( + py, + py.get_type::(), + "ignored leap-second, `datetime` does not support leap-seconds", + 0, + ) { + e.write_unraisable(py, Some(obj)) + }; +} + #[cfg(test)] mod tests { use std::{cmp::Ordering, panic}; + use crate::{test_utils::CatchWarnings, PyTypeInfo}; + use super::*; #[test] @@ -472,7 +510,7 @@ mod tests { // Check the minimum value allowed by PyDelta, which is different // from the minimum value allowed in Duration. This should pass. check( - "min pydelta value", + "min py_delta value", Duration::seconds(-86399999913600), -999999999, 0, @@ -480,7 +518,7 @@ mod tests { ); // Same, for max value check( - "max pydelta value", + "max py_delta value", Duration::seconds(86399999999999) + Duration::microseconds(999999), 999999999, 86399, @@ -495,8 +533,8 @@ mod tests { assert!(panic::catch_unwind(|| Duration::days(low_days as i64)).is_ok()); // This panics on PyDelta::new assert!(panic::catch_unwind(|| { - let pydelta = PyDelta::new(py, low_days, 0, 0, true).unwrap(); - if let Ok(_duration) = pydelta.extract::() { + let py_delta = PyDelta::new(py, low_days, 0, 0, true).unwrap(); + if let Ok(_duration) = py_delta.extract::() { // So we should never get here } }) @@ -507,8 +545,8 @@ mod tests { assert!(panic::catch_unwind(|| Duration::days(high_days as i64)).is_ok()); // This panics on PyDelta::new assert!(panic::catch_unwind(|| { - let pydelta = PyDelta::new(py, high_days, 0, 0, true).unwrap(); - if let Ok(_duration) = pydelta.extract::() { + let py_delta = PyDelta::new(py, high_days, 0, 0, true).unwrap(); + if let Ok(_duration) = py_delta.extract::() { // So we should never get here } }) @@ -560,10 +598,10 @@ mod tests { } #[test] - fn test_pyo3_datetime_topyobject() { - let check_utc = - |name: &'static str, year, month, day, hour, minute, second, ms, py_ms, fold| { - Python::with_gil(|py| { + fn test_pyo3_datetime_topyobject_utc() { + Python::with_gil(|py| { + let check_utc = + |name: &'static str, year, month, day, hour, minute, second, ms, py_ms| { let datetime = NaiveDate::from_ymd_opt(year, month, day) .unwrap() .and_hms_micro_opt(hour, minute, second, ms) @@ -571,9 +609,8 @@ mod tests { .and_utc(); let datetime = datetime.to_object(py); let datetime: &PyDateTime = datetime.extract(py).unwrap(); - let py_tz = Utc.to_object(py); - let py_tz = py_tz.downcast(py).unwrap(); - let py_datetime = PyDateTime::new_with_fold( + let py_tz = timezone_utc(py); + let py_datetime = PyDateTime::new( py, year, month as u8, @@ -583,7 +620,6 @@ mod tests { second as u8, py_ms, Some(py_tz), - fold, ) .unwrap(); assert_eq!( @@ -594,15 +630,26 @@ mod tests { datetime, py_datetime ); - }) - }; + }; - 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("regular", 2014, 5, 6, 7, 8, 9, 999_999, 999_999); - let check_fixed_offset = - |name: &'static str, year, month, day, hour, minute, second, ms, py_ms, fold| { - Python::with_gil(|py| { + assert_warnings!( + py, + check_utc("leap second", 2014, 5, 6, 7, 8, 59, 1_999_999, 999_999), + [( + PyUserWarning, + "ignored leap-second, `datetime` does not support leap-seconds" + )] + ); + }) + } + + #[test] + fn test_pyo3_datetime_topyobject_fixed_offset() { + Python::with_gil(|py| { + let check_fixed_offset = + |name: &'static str, year, month, day, hour, minute, second, ms, py_ms| { let offset = FixedOffset::east_opt(3600).unwrap(); let datetime = NaiveDate::from_ymd_opt(year, month, day) .unwrap() @@ -614,7 +661,7 @@ mod tests { let datetime: &PyDateTime = datetime.extract(py).unwrap(); let py_tz = offset.to_object(py); let py_tz = py_tz.downcast(py).unwrap(); - let py_datetime = PyDateTime::new_with_fold( + let py_datetime = PyDateTime::new( py, year, month as u8, @@ -624,7 +671,6 @@ mod tests { second as u8, py_ms, Some(py_tz), - fold, ) .unwrap(); assert_eq!( @@ -635,95 +681,100 @@ mod tests { datetime, py_datetime ); - }) - }; + }; + + check_fixed_offset("regular", 2014, 5, 6, 7, 8, 9, 999_999, 999_999); - check_fixed_offset("fold", 2014, 5, 6, 7, 8, 9, 1_999_999, 999_999, true); - check_fixed_offset("non fold", 2014, 5, 6, 7, 8, 9, 999_999, 999_999, false); + assert_warnings!( + py, + check_fixed_offset("leap second", 2014, 5, 6, 7, 8, 59, 1_999_999, 999_999), + [( + PyUserWarning, + "ignored leap-second, `datetime` does not support leap-seconds" + )] + ); + }) } #[test] - fn test_pyo3_datetime_frompyobject() { - let check_utc = - |name: &'static str, year, month, day, hour, minute, second, ms, py_ms, fold| { - Python::with_gil(|py| { - let py_tz = Utc.to_object(py); - let py_tz = py_tz.downcast(py).unwrap(); - let py_datetime = PyDateTime::new_with_fold( - py, - year, - month as u8, - day as u8, - hour as u8, - minute as u8, - second as u8, - py_ms, - Some(py_tz), - fold, - ) - .unwrap(); - let py_datetime: DateTime = py_datetime.extract().unwrap(); - let datetime = NaiveDate::from_ymd_opt(year, month, day) - .unwrap() - .and_hms_micro_opt(hour, minute, second, ms) - .unwrap(); - let datetime = datetime.and_utc(); - assert_eq!( - py_datetime, datetime, - "{}: {} != {}", - name, datetime, py_datetime - ); - }) - }; - - 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); - - let check_fixed_offset = |year, month, day, hour, minute, second, ms| { - Python::with_gil(|py| { - let offset = FixedOffset::east_opt(3600).unwrap(); - let py_tz = offset.to_object(py); - let py_tz = py_tz.downcast(py).unwrap(); - let py_datetime = PyDateTime::new_with_fold( - py, - year, - month as u8, - day as u8, - hour as u8, - minute as u8, - second as u8, - ms, - Some(py_tz), - false, // No such thing as fold for fixed offset timezones - ) - .unwrap(); - let py_datetime: DateTime = py_datetime.extract().unwrap(); - let datetime = NaiveDate::from_ymd_opt(year, month, day) - .unwrap() - .and_hms_micro_opt(hour, minute, second, ms) - .unwrap(); - let datetime = datetime.and_local_timezone(offset).unwrap(); - - assert_eq!(py_datetime, datetime, "{} != {}", datetime, py_datetime); - }) - }; - - check_fixed_offset(2014, 5, 6, 7, 8, 9, 999_999); + fn test_pyo3_datetime_frompyobject_utc() { + 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_tz = timezone_utc(py); + let py_datetime = PyDateTime::new( + py, + year, + month, + day, + hour, + minute, + second, + micro, + Some(py_tz), + ) + .unwrap(); + let py_datetime: DateTime = py_datetime.extract().unwrap(); + let datetime = NaiveDate::from_ymd_opt(year, month.into(), day.into()) + .unwrap() + .and_hms_micro_opt(hour.into(), minute.into(), second.into(), micro) + .unwrap() + .and_utc(); + assert_eq!(py_datetime, datetime,); + }) + } + #[test] + fn test_pyo3_datetime_frompyobject_fixed_offset() { Python::with_gil(|py| { - let py_tz = Utc.to_object(py); - let py_tz = py_tz.downcast(py).unwrap(); - let py_datetime = - PyDateTime::new_with_fold(py, 2014, 5, 6, 7, 8, 9, 999_999, Some(py_tz), false) - .unwrap(); - assert!(py_datetime.extract::>().is_ok()); + let year = 2014; + let month = 5; + let day = 6; + let hour = 7; + let minute = 8; + let second = 9; + let micro = 999_999; let offset = FixedOffset::east_opt(3600).unwrap(); let py_tz = offset.to_object(py); let py_tz = py_tz.downcast(py).unwrap(); - let py_datetime = - PyDateTime::new_with_fold(py, 2014, 5, 6, 7, 8, 9, 999_999, Some(py_tz), false) + let py_datetime = PyDateTime::new( + py, + year, + month, + day, + hour, + minute, + second, + micro, + Some(py_tz), + ) + .unwrap(); + let datetime_from_py: DateTime = py_datetime.extract().unwrap(); + let datetime = NaiveDate::from_ymd_opt(year, month.into(), day.into()) + .unwrap() + .and_hms_micro_opt(hour.into(), minute.into(), second.into(), micro) + .unwrap(); + let datetime = datetime.and_local_timezone(offset).unwrap(); + + assert_eq!(datetime_from_py, datetime); + assert!( + py_datetime.extract::>().is_err(), + "Extracting Utc from nonzero FixedOffset timezone will fail" + ); + + let utc = timezone_utc(py); + let py_datetime_utc = + PyDateTime::new(py, year, month, day, hour, minute, second, micro, Some(utc)) .unwrap(); - assert!(py_datetime.extract::>().is_err()); + assert!( + py_datetime_utc.extract::>().is_ok(), + "Extracting FixedOffset from Utc timezone will succeed" + ); }) } @@ -785,59 +836,49 @@ mod tests { #[test] fn test_pyo3_time_topyobject() { - let check_time = |name: &'static str, hour, minute, second, ms, py_ms, fold| { - Python::with_gil(|py| { + Python::with_gil(|py| { + let check_time = |name: &'static str, hour, minute, second, ms, py_ms| { let time = NaiveTime::from_hms_micro_opt(hour, minute, second, ms) .unwrap() .to_object(py); let time: &PyTime = time.extract(py).unwrap(); - let py_time = PyTime::new_with_fold( - py, - hour as u8, - minute as u8, - second as u8, - py_ms, - None, - fold, - ) - .unwrap(); - assert_eq!( - time.compare(py_time).unwrap(), - Ordering::Equal, + let py_time = + PyTime::new(py, hour as u8, minute as u8, second as u8, py_ms, None).unwrap(); + assert!( + time.eq(py_time).unwrap(), "{}: {} != {}", name, time, py_time ); - }) - }; + }; + + check_time("regular", 3, 5, 7, 999_999, 999_999); - check_time("fold", 3, 5, 7, 1_999_999, 999_999, true); - check_time("non fold", 3, 5, 7, 999_999, 999_999, false); + assert_warnings!( + py, + check_time("leap second", 3, 5, 59, 1_999_999, 999_999), + [( + PyUserWarning, + "ignored leap-second, `datetime` does not support leap-seconds" + )] + ); + }) } #[test] fn test_pyo3_time_frompyobject() { - let check_time = |name: &'static str, hour, minute, second, ms, py_ms, fold| { - Python::with_gil(|py| { - let py_time = PyTime::new_with_fold( - py, - hour as u8, - minute as u8, - second as u8, - py_ms, - None, - fold, - ) - .unwrap(); - let py_time: NaiveTime = py_time.extract().unwrap(); - let time = NaiveTime::from_hms_micro_opt(hour, minute, second, ms).unwrap(); - assert_eq!(py_time, time, "{}: {} != {}", name, py_time, time); - }) - }; - - check_time("fold", 3, 5, 7, 1_999_999, 999_999, true); - check_time("non fold", 3, 5, 7, 999_999, 999_999, false); + let hour = 3; + let minute = 5; + let second = 7; + let micro = 999_999; + Python::with_gil(|py| { + let py_time = + PyTime::new(py, hour as u8, minute as u8, second as u8, micro, None).unwrap(); + let py_time: NaiveTime = py_time.extract().unwrap(); + let time = NaiveTime::from_hms_micro_opt(hour, minute, second, micro).unwrap(); + assert_eq!(py_time, time); + }) } #[cfg(all(test, not(target_arch = "wasm32")))] @@ -881,24 +922,24 @@ mod tests { // python values of durations (from -999999999 to 999999999 days), Python::with_gil(|py| { let dur = Duration::days(days); - let pydelta = dur.into_py(py); - let roundtripped: Duration = pydelta.extract(py).expect("Round trip"); + let py_delta = dur.into_py(py); + let roundtripped: Duration = py_delta.extract(py).expect("Round trip"); assert_eq!(dur, roundtripped); }) } #[test] - fn test_fixedoffset_roundtrip(secs in -86399i32..=86399i32) { + fn test_fixed_offset_roundtrip(secs in -86399i32..=86399i32) { Python::with_gil(|py| { let offset = FixedOffset::east_opt(secs).unwrap(); - let pyoffset = offset.into_py(py); - let roundtripped: FixedOffset = pyoffset.extract(py).expect("Round trip"); + let py_offset = offset.into_py(py); + let roundtripped: FixedOffset = py_offset.extract(py).expect("Round trip"); assert_eq!(offset, roundtripped); }) } #[test] - fn test_naivedate_roundtrip( + fn test_naive_date_roundtrip( year in 1i32..=9999i32, month in 1u32..=12u32, day in 1u32..=31u32 @@ -909,31 +950,32 @@ 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 roundtripped: NaiveDate = pydate.extract(py).expect("Round trip"); + let py_date = date.into_py(py); + let roundtripped: NaiveDate = py_date.extract(py).expect("Round trip"); assert_eq!(date, roundtripped); } }) } #[test] - fn test_naivetime_roundtrip( - hour in 0u32..=24u32, - min in 0u32..=60u32, - sec in 0u32..=60u32, - micro in 0u32..=2_000_000u32 + fn test_naive_time_roundtrip( + hour in 0u32..=23u32, + min in 0u32..=59u32, + sec in 0u32..=59u32, + micro in 0u32..=1_999_999u32 ) { // Test roundtrip conversion rust->python->rust for naive times. // Python time has a resolution of microseconds, so we only test // NaiveTimes with microseconds resolution, even if NaiveTime has nanosecond // resolution. Python::with_gil(|py| { - // 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 roundtripped: NaiveTime = pytime.extract(py).expect("Round trip"); - assert_eq!(time, roundtripped); + // Wrap in CatchWarnings to avoid into_py firing warning for truncated leap second + let py_time = CatchWarnings::enter(py, |_| Ok(time.into_py(py))).unwrap(); + let roundtripped: NaiveTime = py_time.extract(py).expect("Round trip"); + // Leap seconds are not roundtripped + let expected_roundtrip_time = micro.checked_sub(1_000_000).map(|micro| NaiveTime::from_hms_micro_opt(hour, min, sec, micro).unwrap()).unwrap_or(time); + assert_eq!(expected_roundtrip_time, roundtripped); } }) } @@ -943,32 +985,36 @@ mod tests { 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..=2_000_000u32 + hour in 0u32..=23u32, + min in 0u32..=59u32, + sec in 0u32..=59u32, + micro in 0u32..=1_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: DateTime = NaiveDateTime::new(date, time).and_utc(); - let pydt = dt.into_py(py); - let roundtripped: DateTime = pydt.extract(py).expect("Round trip"); - assert_eq!(dt, roundtripped); + // Wrap in CatchWarnings to avoid into_py firing warning for truncated leap second + let py_dt = CatchWarnings::enter(py, |_| Ok(dt.into_py(py))).unwrap(); + let roundtripped: DateTime = py_dt.extract(py).expect("Round trip"); + // Leap seconds are not roundtripped + let expected_roundtrip_time = micro.checked_sub(1_000_000).map(|micro| NaiveTime::from_hms_micro_opt(hour, min, sec, micro).unwrap()).unwrap_or(time); + let expected_roundtrip_dt: DateTime = NaiveDateTime::new(date, expected_roundtrip_time).and_utc(); + assert_eq!(expected_roundtrip_dt, roundtripped); } }) } #[test] - fn test_fixedoffset_datetime_roundtrip( + fn test_fixed_offset_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..=1_000_000u32, + hour in 0u32..=23u32, + min in 0u32..=59u32, + sec in 0u32..=59u32, + micro in 0u32..=1_999_999u32, offset_secs in -86399i32..=86399i32 ) { Python::with_gil(|py| { @@ -977,9 +1023,13 @@ mod tests { let offset = FixedOffset::east_opt(offset_secs).unwrap(); if let (Some(date), Some(time)) = (date_opt, time_opt) { let dt: DateTime = NaiveDateTime::new(date, time).and_local_timezone(offset).unwrap(); - let pydt = dt.into_py(py); - let roundtripped: DateTime = pydt.extract(py).expect("Round trip"); - assert_eq!(dt, roundtripped); + // Wrap in CatchWarnings to avoid into_py firing warning for truncated leap second + let py_dt = CatchWarnings::enter(py, |_| Ok(dt.into_py(py))).unwrap(); + let roundtripped: DateTime = py_dt.extract(py).expect("Round trip"); + // Leap seconds are not roundtripped + let expected_roundtrip_time = micro.checked_sub(1_000_000).map(|micro| NaiveTime::from_hms_micro_opt(hour, min, sec, micro).unwrap()).unwrap_or(time); + let expected_roundtrip_dt: DateTime = NaiveDateTime::new(date, expected_roundtrip_time).and_local_timezone(offset).unwrap(); + assert_eq!(expected_roundtrip_dt, roundtripped); } }) } diff --git a/src/lib.rs b/src/lib.rs index e8d813363b8..e728d47e4c5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -386,6 +386,10 @@ pub use { #[doc(hidden)] pub use inventory; // Re-exported for `#[pyclass]` and `#[pymethods]` with `multiple-pymethods`. +#[cfg(test)] +#[macro_use] +pub(crate) mod test_utils; + #[macro_use] mod internal_tricks; diff --git a/src/test_utils.rs b/src/test_utils.rs new file mode 100644 index 00000000000..a007483fad4 --- /dev/null +++ b/src/test_utils.rs @@ -0,0 +1,2 @@ +use crate as pyo3; +include!("../tests/common.rs"); diff --git a/tests/common.rs b/tests/common.rs index 1003d54658e..e74b09a7170 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -1,93 +1,152 @@ -//! Some common macros for tests - -#[cfg(all(feature = "macros", Py_3_8))] -use pyo3::prelude::*; - -#[macro_export] -macro_rules! py_assert { - ($py:expr, $($val:ident)+, $assertion:literal) => { - pyo3::py_run!($py, $($val)+, concat!("assert ", $assertion)) - }; - ($py:expr, *$dict:expr, $assertion:literal) => { - pyo3::py_run!($py, *$dict, concat!("assert ", $assertion)) - }; -} +// the inner mod enables the #![allow(dead_code)] to +// be applied - `test_utils.rs` uses `include!` to pull in this file -#[macro_export] -macro_rules! py_expect_exception { - // Case1: idents & no err_msg - ($py:expr, $($val:ident)+, $code:expr, $err:ident) => {{ - use pyo3::types::IntoPyDict; - let d = [$((stringify!($val), $val.to_object($py)),)+].into_py_dict($py); - py_expect_exception!($py, *d, $code, $err) - }}; - // Case2: dict & no err_msg - ($py:expr, *$dict:expr, $code:expr, $err:ident) => {{ - let res = $py.run($code, None, Some($dict)); - let err = res.expect_err(&format!("Did not raise {}", stringify!($err))); - if !err.matches($py, $py.get_type::()) { - panic!("Expected {} but got {:?}", stringify!($err), err) - } - err - }}; - // Case3: idents & err_msg - ($py:expr, $($val:ident)+, $code:expr, $err:ident, $err_msg:literal) => {{ - let err = py_expect_exception!($py, $($val)+, $code, $err); - // Suppose that the error message looks like 'TypeError: ~' - assert_eq!(format!("Py{}", err), concat!(stringify!($err), ": ", $err_msg)); - err - }}; - // Case4: dict & err_msg - ($py:expr, *$dict:expr, $code:expr, $err:ident, $err_msg:literal) => {{ - let err = py_expect_exception!($py, *$dict, $code, $err); - assert_eq!(format!("Py{}", err), concat!(stringify!($err), ": ", $err_msg)); - err - }}; -} +/// Common macros and helpers for tests +#[allow(dead_code)] // many tests do not use the complete set of functionality offered here +#[macro_use] +mod inner { -// sys.unraisablehook not available until Python 3.8 -#[cfg(all(feature = "macros", Py_3_8))] -#[pyclass] -pub struct UnraisableCapture { - pub capture: Option<(PyErr, PyObject)>, - old_hook: Option, -} + #[allow(unused_imports)] // pulls in `use crate as pyo3` in `test_utils.rs` + use super::*; -#[cfg(all(feature = "macros", Py_3_8))] -#[pymethods] -impl UnraisableCapture { - pub fn hook(&mut self, unraisable: &PyAny) { - let err = PyErr::from_value(unraisable.getattr("exc_value").unwrap()); - let instance = unraisable.getattr("object").unwrap(); - self.capture = Some((err, instance.into())); + use pyo3::prelude::*; + + use pyo3::types::{IntoPyDict, PyList}; + + #[macro_export] + macro_rules! py_assert { + ($py:expr, $($val:ident)+, $assertion:literal) => { + pyo3::py_run!($py, $($val)+, concat!("assert ", $assertion)) + }; + ($py:expr, *$dict:expr, $assertion:literal) => { + pyo3::py_run!($py, *$dict, concat!("assert ", $assertion)) + }; + } + + #[macro_export] + macro_rules! py_expect_exception { + // Case1: idents & no err_msg + ($py:expr, $($val:ident)+, $code:expr, $err:ident) => {{ + use pyo3::types::IntoPyDict; + let d = [$((stringify!($val), $val.to_object($py)),)+].into_py_dict($py); + py_expect_exception!($py, *d, $code, $err) + }}; + // Case2: dict & no err_msg + ($py:expr, *$dict:expr, $code:expr, $err:ident) => {{ + let res = $py.run($code, None, Some($dict)); + let err = res.expect_err(&format!("Did not raise {}", stringify!($err))); + if !err.matches($py, $py.get_type::()) { + panic!("Expected {} but got {:?}", stringify!($err), err) + } + err + }}; + // Case3: idents & err_msg + ($py:expr, $($val:ident)+, $code:expr, $err:ident, $err_msg:literal) => {{ + let err = py_expect_exception!($py, $($val)+, $code, $err); + // Suppose that the error message looks like 'TypeError: ~' + assert_eq!(format!("Py{}", err), concat!(stringify!($err), ": ", $err_msg)); + err + }}; + // Case4: dict & err_msg + ($py:expr, *$dict:expr, $code:expr, $err:ident, $err_msg:literal) => {{ + let err = py_expect_exception!($py, *$dict, $code, $err); + assert_eq!(format!("Py{}", err), concat!(stringify!($err), ": ", $err_msg)); + err + }}; + } + + // sys.unraisablehook not available until Python 3.8 + #[cfg(all(feature = "macros", Py_3_8))] + #[pyclass(crate = "pyo3")] + pub struct UnraisableCapture { + pub capture: Option<(PyErr, PyObject)>, + old_hook: Option, + } + + #[cfg(all(feature = "macros", Py_3_8))] + #[pymethods(crate = "pyo3")] + impl UnraisableCapture { + pub fn hook(&mut self, unraisable: &PyAny) { + let err = PyErr::from_value(unraisable.getattr("exc_value").unwrap()); + let instance = unraisable.getattr("object").unwrap(); + self.capture = Some((err, instance.into())); + } } -} -#[cfg(all(feature = "macros", Py_3_8))] -impl UnraisableCapture { - pub fn install(py: Python<'_>) -> Py { - let sys = py.import("sys").unwrap(); - let old_hook = sys.getattr("unraisablehook").unwrap().into(); - - let capture = Py::new( - py, - UnraisableCapture { - capture: None, - old_hook: Some(old_hook), - }, - ) - .unwrap(); - - sys.setattr("unraisablehook", capture.getattr(py, "hook").unwrap()) + #[cfg(all(feature = "macros", Py_3_8))] + impl UnraisableCapture { + pub fn install(py: Python<'_>) -> Py { + let sys = py.import("sys").unwrap(); + let old_hook = sys.getattr("unraisablehook").unwrap().into(); + + let capture = Py::new( + py, + UnraisableCapture { + capture: None, + old_hook: Some(old_hook), + }, + ) .unwrap(); - capture + sys.setattr("unraisablehook", capture.getattr(py, "hook").unwrap()) + .unwrap(); + + capture + } + + pub fn uninstall(&mut self, py: Python<'_>) { + let old_hook = self.old_hook.take().unwrap(); + + let sys = py.import("sys").unwrap(); + sys.setattr("unraisablehook", old_hook).unwrap(); + } } - pub fn uninstall(&mut self, py: Python<'_>) { - let old_hook = self.old_hook.take().unwrap(); + pub struct CatchWarnings<'py> { + catch_warnings: &'py PyAny, + } - let sys = py.import("sys").unwrap(); - sys.setattr("unraisablehook", old_hook).unwrap(); + impl<'py> CatchWarnings<'py> { + pub fn enter(py: Python<'py>, f: impl FnOnce(&PyList) -> PyResult) -> PyResult { + let warnings = py.import("warnings")?; + let kwargs = [("record", true)].into_py_dict(py); + let catch_warnings = warnings.getattr("catch_warnings")?.call((), Some(kwargs))?; + let list = catch_warnings.call_method0("__enter__")?.extract()?; + let _guard = Self { catch_warnings }; + f(list) + } + } + + impl Drop for CatchWarnings<'_> { + fn drop(&mut self) { + let py = self.catch_warnings.py(); + self.catch_warnings + .call_method1("__exit__", (py.None(), py.None(), py.None())) + .unwrap(); + } + } + + #[macro_export] + macro_rules! assert_warnings { + ($py:expr, $body:expr, [$(($category:ty, $message:literal)),+] $(,)? ) => {{ + CatchWarnings::enter($py, |w| { + $body; + let expected_warnings = [$((<$category>::type_object($py), $message)),+]; + assert_eq!(w.len(), expected_warnings.len()); + for (warning, (category, message)) in w.iter().zip(expected_warnings) { + + assert!(warning.getattr("category").unwrap().is(category)); + assert_eq!( + warning.getattr("message").unwrap().str().unwrap().to_string_lossy(), + message + ); + } + + Ok(()) + }) + .unwrap(); + }}; } } + +pub use inner::*;