From 8b614745cf3526c2de3156c218e17f451ff3761c Mon Sep 17 00:00:00 2001 From: Tpt Date: Mon, 18 Dec 2023 17:29:48 +0100 Subject: [PATCH] Adds std::duration::Duration from/to Python conversions --- guide/src/conversions/tables.md | 2 +- newsfragments/3670.added.md | 1 + src/conversions/chrono.rs | 5 +- src/conversions/std/duration.rs | 224 ++++++++++++++++++++++++++++++++ src/conversions/std/mod.rs | 1 + src/err/mod.rs | 1 - src/tests/common.rs | 4 +- 7 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 newsfragments/3670.added.md create mode 100755 src/conversions/std/duration.rs diff --git a/guide/src/conversions/tables.md b/guide/src/conversions/tables.md index 92dc8139..f9d716b3 100644 --- a/guide/src/conversions/tables.md +++ b/guide/src/conversions/tables.md @@ -33,7 +33,7 @@ The table below contains the Python type and the corresponding function argument | `datetime.date` | - | `&PyDate` | | `datetime.time` | - | `&PyTime` | | `datetime.tzinfo` | - | `&PyTzInfo` | -| `datetime.timedelta` | - | `&PyDelta` | +| `datetime.timedelta` | `Duration` | `&PyDelta` | | `decimal.Decimal` | `rust_decimal::Decimal`[^5] | - | | `ipaddress.IPv4Address` | `std::net::IpAddr`, `std::net::IpV4Addr` | - | | `ipaddress.IPv6Address` | `std::net::IpAddr`, `std::net::IpV6Addr` | - | diff --git a/newsfragments/3670.added.md b/newsfragments/3670.added.md new file mode 100644 index 00000000..a524261e --- /dev/null +++ b/newsfragments/3670.added.md @@ -0,0 +1 @@ +`FromPyObject`, `IntoPy` and `ToPyObject` are implemented on `std::duration::Duration` \ No newline at end of file diff --git a/src/conversions/chrono.rs b/src/conversions/chrono.rs index 8daa0e05..e01793c1 100644 --- a/src/conversions/chrono.rs +++ b/src/conversions/chrono.rs @@ -564,7 +564,7 @@ fn timezone_utc(py: Python<'_>) -> &PyAny { #[cfg(test)] mod tests { use super::*; - use crate::{tests::common::CatchWarnings, types::PyTuple, Py, PyTypeInfo}; + use crate::{types::PyTuple, Py}; use std::{cmp::Ordering, panic}; #[test] @@ -1090,9 +1090,10 @@ mod tests { .unwrap() } - #[cfg(all(test, not(target_arch = "wasm32")))] + #[cfg(not(target_arch = "wasm32"))] mod proptests { use super::*; + use crate::tests::common::CatchWarnings; use crate::types::IntoPyDict; use proptest::prelude::*; diff --git a/src/conversions/std/duration.rs b/src/conversions/std/duration.rs new file mode 100755 index 00000000..ae659c93 --- /dev/null +++ b/src/conversions/std/duration.rs @@ -0,0 +1,224 @@ +use crate::exceptions::{PyUserWarning, PyValueError}; +#[cfg(Py_LIMITED_API)] +use crate::sync::GILOnceCell; +#[cfg(Py_LIMITED_API)] +use crate::types::PyType; +#[cfg(not(Py_LIMITED_API))] +use crate::types::{PyDelta, PyDeltaAccess}; +#[cfg(Py_LIMITED_API)] +use crate::{intern, Py}; +use crate::{FromPyObject, IntoPy, PyAny, PyErr, PyObject, PyResult, Python, ToPyObject}; +use std::time::Duration; + +const SECONDS_PER_DAY: u64 = 24 * 60 * 60; + +impl FromPyObject<'_> for Duration { + fn extract(obj: &PyAny) -> PyResult { + #[cfg(not(Py_LIMITED_API))] + let (days, seconds, microseconds) = { + let delta: &PyDelta = obj.downcast()?; + ( + delta.get_days(), + delta.get_seconds(), + delta.get_microseconds(), + ) + }; + #[cfg(Py_LIMITED_API)] + let (days, seconds, microseconds): (i32, i32, i32) = { + ( + obj.getattr(intern!(obj.py(), "days"))?.extract()?, + obj.getattr(intern!(obj.py(), "seconds"))?.extract()?, + obj.getattr(intern!(obj.py(), "microseconds"))?.extract()?, + ) + }; + + // We cast + let days = u64::try_from(days).map_err(|_| { + PyValueError::new_err( + "It is not possible to convert a negative timedelta to a Rust Duration", + ) + })?; + let seconds = u64::try_from(seconds).unwrap(); // 0 <= seconds < 3600*24 + let microseconds = u32::try_from(microseconds).unwrap(); // 0 <= microseconds < 1000000 + + // We convert + let total_seconds = days * SECONDS_PER_DAY + seconds; // We casted from i32, this can't overflow + let nanoseconds = microseconds.checked_mul(1_000).unwrap(); // 0 <= microseconds < 1000000 + + Ok(Duration::new(total_seconds, nanoseconds)) + } +} + +impl ToPyObject for Duration { + fn to_object(&self, py: Python<'_>) -> PyObject { + let days = self.as_secs() / SECONDS_PER_DAY; + let seconds = self.as_secs() % SECONDS_PER_DAY; + let microseconds = self.subsec_micros(); + + #[cfg(not(Py_LIMITED_API))] + let delta = PyDelta::new( + py, + days.try_into() + .expect("Too large Rust duration for timedelta"), + seconds.try_into().unwrap(), + microseconds.try_into().unwrap(), + false, + ) + .expect("failed to construct timedelta (overflow?)"); + #[cfg(Py_LIMITED_API)] + let delta = { + static TIMEDELTA: GILOnceCell> = GILOnceCell::new(); + TIMEDELTA + .get_or_try_init_type_ref(py, "datetime", "timedelta") + .unwrap() + .call1((days, seconds, microseconds)) + .unwrap() + }; + + if self.subsec_nanos() % 1_000 != 0 { + warn_truncated_nanoseconds(delta); + } + + delta.into() + } +} + +impl IntoPy for Duration { + fn into_py(self, py: Python<'_>) -> PyObject { + self.to_object(py) + } +} + +fn warn_truncated_nanoseconds(obj: &PyAny) { + let py = obj.py(); + if let Err(e) = PyErr::warn( + py, + py.get_type::(), + "ignored nanoseconds, `datetime.timedelta` does not support nanoseconds", + 0, + ) { + e.write_unraisable(py, Some(obj)) + }; +} + +#[cfg(test)] +mod tests { + use super::*; + use std::panic; + + #[test] + fn test_frompyobject() { + Python::with_gil(|py| { + assert_eq!( + new_timedelta(py, 0, 0, 0).extract::().unwrap(), + Duration::new(0, 0) + ); + assert_eq!( + new_timedelta(py, 1, 0, 0).extract::().unwrap(), + Duration::new(86400, 0) + ); + assert_eq!( + new_timedelta(py, 0, 1, 0).extract::().unwrap(), + Duration::new(1, 0) + ); + assert_eq!( + new_timedelta(py, 0, 0, 1).extract::().unwrap(), + Duration::new(0, 1_000) + ); + assert_eq!( + new_timedelta(py, 1, 1, 1).extract::().unwrap(), + Duration::new(86401, 1_000) + ); + assert_eq!( + timedelta_class(py) + .getattr("max") + .unwrap() + .extract::() + .unwrap(), + Duration::new(86399999999999, 999999000) + ); + }); + } + + #[test] + fn test_frompyobject_negative() { + Python::with_gil(|py| { + assert_eq!( + new_timedelta(py, 0, -1, 0) + .extract::() + .unwrap_err() + .to_string(), + "ValueError: It is not possible to convert a negative timedelta to a Rust Duration" + ); + }) + } + + #[test] + fn test_topyobject() { + Python::with_gil(|py| { + let assert_eq = |l: PyObject, r: &PyAny| { + assert!(l.as_ref(py).eq(r).unwrap()); + }; + + assert_eq( + Duration::new(0, 0).to_object(py), + new_timedelta(py, 0, 0, 0), + ); + assert_eq( + Duration::new(86400, 0).to_object(py), + new_timedelta(py, 1, 0, 0), + ); + assert_eq( + Duration::new(1, 0).to_object(py), + new_timedelta(py, 0, 1, 0), + ); + assert_eq( + Duration::new(0, 1_000).to_object(py), + new_timedelta(py, 0, 0, 1), + ); + assert_eq( + Duration::new(0, 1).to_object(py), + new_timedelta(py, 0, 0, 0), + ); + assert_eq( + Duration::new(86401, 1_000).to_object(py), + new_timedelta(py, 1, 1, 1), + ); + assert_eq( + Duration::new(86399999999999, 999999000).to_object(py), + timedelta_class(py).getattr("max").unwrap(), + ); + }); + } + + #[test] + fn test_topyobject_overflow() { + Python::with_gil(|py| { + assert!(panic::catch_unwind(|| Duration::MAX.to_object(py)).is_err()); + }) + } + + #[test] + fn test_topyobject_precision_loss() { + Python::with_gil(|py| { + assert_warnings!( + py, + Duration::new(0, 1).to_object(py), + [( + PyUserWarning, + "ignored nanoseconds, `datetime.timedelta` does not support nanoseconds" + )] + ); + }) + } + + fn new_timedelta(py: Python<'_>, days: i32, seconds: i32, microseconds: i32) -> &PyAny { + timedelta_class(py) + .call1((days, seconds, microseconds)) + .unwrap() + } + + fn timedelta_class(py: Python<'_>) -> &PyAny { + py.import("datetime").unwrap().getattr("timedelta").unwrap() + } +} diff --git a/src/conversions/std/mod.rs b/src/conversions/std/mod.rs index f5e917d0..ebe1c955 100644 --- a/src/conversions/std/mod.rs +++ b/src/conversions/std/mod.rs @@ -1,4 +1,5 @@ mod array; +mod duration; mod ipaddr; mod map; mod num; diff --git a/src/err/mod.rs b/src/err/mod.rs index 249225ac..9009e29e 100644 --- a/src/err/mod.rs +++ b/src/err/mod.rs @@ -908,7 +908,6 @@ impl_signed_integer!(isize); mod tests { use super::PyErrState; use crate::exceptions::{self, PyTypeError, PyValueError}; - use crate::tests::common::CatchWarnings; use crate::{PyErr, PyTypeInfo, Python}; #[test] diff --git a/src/tests/common.rs b/src/tests/common.rs index bc5a33d5..89b4a83f 100644 --- a/src/tests/common.rs +++ b/src/tests/common.rs @@ -129,9 +129,9 @@ mod inner { #[macro_export] macro_rules! assert_warnings { ($py:expr, $body:expr, [$(($category:ty, $message:literal)),+] $(,)? ) => {{ - CatchWarnings::enter($py, |w| { + $crate::tests::common::CatchWarnings::enter($py, |w| { $body; - let expected_warnings = [$((<$category>::type_object($py), $message)),+]; + let expected_warnings = [$((<$category as $crate::type_object::PyTypeInfo>::type_object($py), $message)),+]; assert_eq!(w.len(), expected_warnings.len()); for (warning, (category, message)) in w.iter().zip(expected_warnings) {