Skip to content

Commit

Permalink
Adds from/to Python conversions to std::duration::Duration
Browse files Browse the repository at this point in the history
  • Loading branch information
Tpt committed Dec 19, 2023
1 parent 1dca879 commit e507c8a
Show file tree
Hide file tree
Showing 6 changed files with 229 additions and 5 deletions.
2 changes: 1 addition & 1 deletion guide/src/conversions/tables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | - |
Expand Down
2 changes: 1 addition & 1 deletion src/conversions/chrono.rs
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ fn py_time_to_naive_time(py_time: &impl PyTimeAccess) -> PyResult<NaiveTime> {
#[cfg(test)]
mod tests {
use super::*;
use crate::{tests::common::CatchWarnings, types::PyTuple, Py, PyTypeInfo};
use crate::{tests::common::CatchWarnings, types::PyTuple, Py};
use std::{cmp::Ordering, panic};

#[test]
Expand Down
224 changes: 224 additions & 0 deletions src/conversions/std/duration.rs
Original file line number Diff line number Diff line change
@@ -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 = 26 * 60 * 60;

impl FromPyObject<'_> for Duration {
fn extract(obj: &PyAny) -> PyResult<Self> {
#[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<Py<PyType>> = 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<PyObject> 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::<PyUserWarning>(),
"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::<Duration>().unwrap(),
Duration::new(0, 0)
);
assert_eq!(
new_timedelta(py, 1, 0, 0).extract::<Duration>().unwrap(),
Duration::new(SECONDS_PER_DAY, 0)
);
assert_eq!(
new_timedelta(py, 0, 1, 0).extract::<Duration>().unwrap(),
Duration::new(1, 0)
);
assert_eq!(
new_timedelta(py, 0, 0, 1).extract::<Duration>().unwrap(),
Duration::new(0, 1_000)
);
assert_eq!(
new_timedelta(py, 1, 1, 1).extract::<Duration>().unwrap(),
Duration::new(SECONDS_PER_DAY + 1, 1_000)
);
assert_eq!(
timedelta_class(py)
.getattr("max")
.unwrap()
.extract::<Duration>()
.unwrap(),
Duration::new(93599999992799, 999999000)
);
});
}

#[test]
fn test_frompyobject_negative() {
Python::with_gil(|py| {
assert_eq!(
new_timedelta(py, 0, -1, 0)
.extract::<Duration>()
.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(SECONDS_PER_DAY, 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(SECONDS_PER_DAY + 1, 1_000).to_object(py),
new_timedelta(py, 1, 1, 1),
);
assert_eq(
Duration::new(93599999992799, 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()
}
}
1 change: 1 addition & 0 deletions src/conversions/std/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod array;
mod duration;
mod ipaddr;
mod map;
mod num;
Expand Down
1 change: 0 additions & 1 deletion src/err/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -821,7 +821,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]
Expand Down
4 changes: 2 additions & 2 deletions src/tests/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Expand Down

0 comments on commit e507c8a

Please sign in to comment.