From 63f7df905dc771cf43b59a2a7d6bf967d2747d4e Mon Sep 17 00:00:00 2001 From: Federico Dolce Date: Thu, 22 Sep 2022 09:00:09 +0200 Subject: [PATCH] Add chrono 0.4 integration (#2612) Co-authored-by: Ivan Tham Co-authored-by: David Hewitt <1939362+davidhewitt@users.noreply.github.com> --- Cargo.toml | 5 +- guide/src/features.md | 10 + newsfragments/2612.packaging.md | 1 + src/conversions/chrono.rs | 951 ++++++++++++++++++++++++++++++++ src/conversions/mod.rs | 1 + src/lib.rs | 1 + 6 files changed, 968 insertions(+), 1 deletion(-) create mode 100644 newsfragments/2612.packaging.md create mode 100644 src/conversions/chrono.rs diff --git a/Cargo.toml b/Cargo.toml index ef6b10c0..38f0ec03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ inventory = { version = "0.3.0", optional = true } # crate integrations that can be added using the eponymous features anyhow = { version = "1.0", optional = true } +chrono = { version = "0.4", optional = true } eyre = { version = ">= 0.4, < 0.7", optional = true } hashbrown = { version = ">= 0.9, < 0.13", optional = true } indexmap = { version = ">= 1.6, < 1.8", optional = true } @@ -41,6 +42,7 @@ serde = { version = "1.0", optional = true } [dev-dependencies] assert_approx_eq = "1.1.0" +chrono = { version = "0.4" } criterion = "0.3.5" trybuild = "1.0.49" rustversion = "1.0" @@ -95,6 +97,7 @@ nightly = [] full = [ "macros", # "multiple-pymethods", # TODO re-add this when MSRV is greater than 1.62 + "chrono", "num-bigint", "num-complex", "hashbrown", @@ -163,5 +166,5 @@ members = [ [package.metadata.docs.rs] no-default-features = true -features = ["macros", "num-bigint", "num-complex", "hashbrown", "serde", "multiple-pymethods", "indexmap", "eyre"] +features = ["macros", "num-bigint", "num-complex", "hashbrown", "serde", "multiple-pymethods", "indexmap", "eyre", "chrono"] rustdoc-args = ["--cfg", "docsrs"] diff --git a/guide/src/features.md b/guide/src/features.md index cda6b36c..57066429 100644 --- a/guide/src/features.md +++ b/guide/src/features.md @@ -93,6 +93,16 @@ These features enable conversions between Python types and types from other Rust Adds a dependency on [anyhow](https://docs.rs/anyhow). Enables a conversion from [anyhow](https://docs.rs/anyhow)’s [`Error`](https://docs.rs/anyhow/latest/anyhow/struct.Error.html) type to [`PyErr`](https://docs.rs/pyo3/latest/pyo3/struct.PyErr.html), for easy error handling. +### `chrono` + +Adds a dependency on [chrono](https://docs.rs/chrono). Enables a conversion from [chrono](https://docs.rs/chrono)'s types to python: +- [Duration](https://docs.rs/chrono/latest/chrono/struct.Duration.html) -> [`PyDelta`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyDelta.html) +- [FixedOffset](https://docs.rs/chrono/latest/chrono/offset/struct.FixedOffset.html) -> [`PyDelta`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyDelta.html) +- [Utc](https://docs.rs/chrono/latest/chrono/offset/struct.Utc.html) -> [`PyTzInfo`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyTzInfo.html) +- [NaiveDate](https://docs.rs/chrono/latest/chrono/naive/struct.NaiveDate.html) -> [`PyDate`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyDate.html) +- [NaiveTime](https://docs.rs/chrono/latest/chrono/naive/struct.NaiveTime.html) -> [`PyTime`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyTime.html) +- [DateTime](https://docs.rs/chrono/latest/chrono/struct.DateTime.html) -> [`PyDateTime`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyDateTime.html) + ### `eyre` Adds a dependency on [eyre](https://docs.rs/eyre). Enables a conversion from [eyre](https://docs.rs/eyre)’s [`Report`](https://docs.rs/eyre/latest/eyre/struct.Report.html) type to [`PyErr`](https://docs.rs/pyo3/latest/pyo3/struct.PyErr.html), for easy error handling. diff --git a/newsfragments/2612.packaging.md b/newsfragments/2612.packaging.md new file mode 100644 index 00000000..c562698e --- /dev/null +++ b/newsfragments/2612.packaging.md @@ -0,0 +1 @@ +Added optional `chrono` feature to convert `chrono` types into types in the `datetime` module. diff --git a/src/conversions/chrono.rs b/src/conversions/chrono.rs new file mode 100644 index 00000000..d76ed722 --- /dev/null +++ b/src/conversions/chrono.rs @@ -0,0 +1,951 @@ +#![cfg(all(feature = "chrono", not(Py_LIMITED_API)))] + +//! Conversions to and from [chrono](https://docs.rs/chrono/)’s `Duration`, +//! `NaiveDate`, `NaiveTime`, `DateTime`, `FixedOffset`, and `Utc`. +//! +//! Unavailable with the `abi3` feature. +//! +//! # Setup +//! +//! To use this feature, add this to your **`Cargo.toml`**: +//! +//! ```toml +//! [dependencies] +//! # change * to the latest versions +//! pyo3 = { version = "*", features = ["chrono"] } +//! chrono = "0.4" +// workaround for `extended_key_value_attributes`: https://github.com/rust-lang/rust/issues/82768#issuecomment-803935643 +#![cfg_attr(docsrs, cfg_attr(docsrs, doc = concat!("pyo3 = { version = \"", env!("CARGO_PKG_VERSION"), "\", features = [\"chrono\"] }")))] +#![cfg_attr( + not(docsrs), + doc = "pyo3 = { version = \"*\", features = [\"chrono\"] }" +)] +//! ``` +//! +//! Note that you must use compatible versions of chrono and PyO3. +//! The required chrono version may vary based on the version of PyO3. +//! +//! # Example: Convert a PyDateTime to chrono's DateTime +//! +//! ```rust +//! use chrono::{Utc, DateTime}; +//! use pyo3::{Python, ToPyObject, types::PyDateTime}; +//! +//! fn main() { +//! pyo3::prepare_freethreaded_python(); +//! Python::with_gil(|py| { +//! // Create an UTC datetime in python +//! let py_tz = Utc.to_object(py); +//! let py_tz = py_tz.cast_as(py).unwrap(); +//! let pydatetime = PyDateTime::new(py, 2022, 1, 1, 12, 0, 0, 0, Some(py_tz)).unwrap(); +//! println!("PyDateTime: {}", pydatetime); +//! // Now convert it to chrono's DateTime +//! let chrono_datetime: DateTime = pydatetime.extract().unwrap(); +//! println!("DateTime: {}", chrono_datetime); +//! }); +//! } +//! ``` +use crate::exceptions::PyTypeError; +use crate::types::{ + timezone_utc, PyDate, PyDateAccess, PyDateTime, PyDelta, PyDeltaAccess, PyTime, PyTimeAccess, + PyTzInfo, PyTzInfoAccess, PyUnicode, +}; +use crate::{ + AsPyPointer, FromPyObject, IntoPy, PyAny, PyErr, PyObject, PyResult, PyTryFrom, Python, + ToPyObject, +}; +use chrono::offset::{FixedOffset, Utc}; +use chrono::{ + DateTime, Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime, Offset, TimeZone, Timelike, +}; +use pyo3_ffi::{PyDateTime_IMPORT, PyTimeZone_FromOffset}; +use std::convert::TryInto; + +impl ToPyObject for Duration { + fn to_object(&self, py: Python<'_>) -> PyObject { + // Total number of days + let days = self.num_days(); + // Remainder of seconds + let secs_dur = *self - Duration::days(days); + // .try_into() converts i64 to i32, but this should never overflow + // since it's at most the number of seconds per day + let secs = secs_dur.num_seconds().try_into().unwrap(); + // Fractional part of the microseconds + let micros = (secs_dur - Duration::seconds(secs_dur.num_seconds())) + .num_microseconds() + // This should never panic since we are just getting the fractional + // part of the total microseconds, which should never overflow. + .unwrap() + // Same for the conversion from i64 to i32 + .try_into() + .unwrap(); + + // We do not need to check i64 to i32 cast from rust because + // python will panic with OverflowError. + // 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"); + delta.into() + } +} + +impl IntoPy for Duration { + fn into_py(self, py: Python<'_>) -> PyObject { + ToPyObject::to_object(&self, py) + } +} + +impl FromPyObject<'_> for Duration { + fn extract(ob: &PyAny) -> PyResult { + let delta = ::try_from(ob)?; + // Python size are much lower than rust size so we do not need bound checks. + // 0 <= microseconds < 1000000 + // 0 <= seconds < 3600*24 + // -999999999 <= days <= 999999999 + Ok(Duration::days(delta.get_days().into()) + + Duration::seconds(delta.get_seconds().into()) + + Duration::microseconds(delta.get_microseconds().into())) + } +} + +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() + } +} + +impl IntoPy for NaiveDate { + fn into_py(self, py: Python<'_>) -> PyObject { + ToPyObject::to_object(&self, py) + } +} + +impl FromPyObject<'_> for NaiveDate { + fn extract(ob: &PyAny) -> PyResult { + let date = ::try_from(ob)?; + Ok(NaiveDate::from_ymd( + date.get_year(), + date.get_month() as u32, + date.get_day() as u32, + )) + } +} + +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() + } +} + +impl IntoPy for NaiveTime { + fn into_py(self, py: Python<'_>) -> PyObject { + ToPyObject::to_object(&self, py) + } +} + +impl FromPyObject<'_> for NaiveTime { + fn extract(ob: &PyAny) -> PyResult { + let time = ::try_from(ob)?; + 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; + Ok(NaiveTime::from_hms_micro(h, m, s, ms)) + } +} + +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() + } +} + +impl IntoPy for NaiveDateTime { + fn into_py(self, py: Python<'_>) -> PyObject { + ToPyObject::to_object(&self, py) + } +} + +impl FromPyObject<'_> for NaiveDateTime { + fn extract(ob: &PyAny) -> PyResult { + let dt = ::try_from(ob)?; + // If the user tries to convert a timezone aware datetime into a naive one, + // 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(PyErr::new::( + "Trying to convert a timezone aware datetime into a NaiveDateTime.", + )); + } + 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(dt.get_year(), dt.get_month().into(), dt.get_day().into()), + NaiveTime::from_hms_micro(h, m, s, ms), + ); + Ok(dt) + } +} + +impl ToPyObject for DateTime { + fn to_object(&self, py: Python<'_>) -> PyObject { + let date = self.naive_utc().date(); + let time = self.naive_utc().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 tz = self.offset().fix().to_object(py); + let tz = tz.cast_as(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() + } +} + +impl IntoPy for DateTime { + fn into_py(self, py: Python<'_>) -> PyObject { + ToPyObject::to_object(&self, py) + } +} + +impl FromPyObject<'_> for DateTime { + fn extract(ob: &PyAny) -> PyResult> { + let dt = ::try_from(ob)?; + 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 tz = if let Some(tzinfo) = dt.get_tzinfo() { + tzinfo.extract()? + } else { + return Err(PyTypeError::new_err("Not datetime.tzinfo")); + }; + let dt = NaiveDateTime::new( + NaiveDate::from_ymd(dt.get_year(), dt.get_month().into(), dt.get_day().into()), + NaiveTime::from_hms_micro(h, m, s, ms), + ); + Ok(DateTime::from_utc(dt, tz)) + } +} + +impl FromPyObject<'_> for DateTime { + fn extract(ob: &PyAny) -> PyResult> { + let dt = ::try_from(ob)?; + 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 tz = if let Some(tzinfo) = dt.get_tzinfo() { + tzinfo.extract()? + } else { + return Err(PyTypeError::new_err("Not datetime.timezone.utc")); + }; + let dt = NaiveDateTime::new( + NaiveDate::from_ymd(dt.get_year(), dt.get_month().into(), dt.get_day().into()), + NaiveTime::from_hms_micro(h, m, s, ms), + ); + Ok(DateTime::from_utc(dt, tz)) + } +} + +// Utiliy function used to convert PyDelta to timezone +fn pytimezone_fromoffset<'a>(py: &Python<'a>, td: &PyDelta) -> &'a PyAny { + // Safety: py.from_owned_ptr needs the cast to be valid. + // Since we are forcing a &PyDelta as input, the cast should always be valid. + unsafe { + PyDateTime_IMPORT(); + py.from_owned_ptr(PyTimeZone_FromOffset(td.as_ptr())) + } +} + +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 contruct timedelta"); + pytimezone_fromoffset(&py, td).into() + } +} + +impl IntoPy for FixedOffset { + fn into_py(self, py: Python<'_>) -> PyObject { + ToPyObject::to_object(&self, py) + } +} + +impl FromPyObject<'_> for FixedOffset { + /// Convert python tzinfo to rust [`FixedOffset`]. + /// + /// Note that the conversion will result in precision lost in microseconds as chrono offset + /// does not supports microseconds. + fn extract(ob: &PyAny) -> PyResult { + let py_tzinfo = ::try_from(ob)?; + // Passing `ob.py().None()` (so Python's None) to the `utcoffset` function will only + // work for timezones defined as fixed offsets in Python. + // Any other timezone would require a datetime as the parameter, and return + // None if the datetime is not provided. + // Trying to convert None to a PyDelta in the next line will then fail. + let py_timedelta = py_tzinfo.call_method1("utcoffset", (ob.py().None(),))?; + let py_timedelta = ::try_from(py_timedelta).map_err(|_| { + PyErr::new::(format!( + "{:?} is not a fixed offset timezone", + py_tzinfo + .repr() + .unwrap_or_else(|_| PyUnicode::new(ob.py(), "repr failed")) + )) + })?; + let days = py_timedelta.get_days() as i64; + let seconds = py_timedelta.get_seconds() as i64; + // Here we won't get microseconds as noted before + // let microseconds = py_timedelta.get_microseconds() as i64; + let total_seconds = Duration::days(days) + Duration::seconds(seconds); + // This cast is safe since the timedelta is limited to -24 hours and 24 hours. + let total_seconds = total_seconds.num_seconds() as i32; + Ok(FixedOffset::east(total_seconds)) + } +} + +impl ToPyObject for Utc { + fn to_object(&self, py: Python<'_>) -> PyObject { + timezone_utc(py).to_object(py) + } +} + +impl IntoPy for Utc { + fn into_py(self, py: Python<'_>) -> PyObject { + ToPyObject::to_object(&self, py) + } +} + +impl FromPyObject<'_> for Utc { + fn extract(ob: &PyAny) -> PyResult { + let py_tzinfo = ::try_from(ob)?; + let py_utc = timezone_utc(ob.py()); + if py_tzinfo.eq(py_utc)? { + Ok(Utc) + } else { + Err(PyTypeError::new_err("Not datetime.timezone.utc")) + } + } +} + +#[cfg(test)] +mod tests { + use std::{cmp::Ordering, panic}; + + use super::*; + + #[test] + // Only Python>=3.9 has the zoneinfo package + // We skip the test on windows too since we'd need to install + // tzdata there to make this work. + #[cfg(all(Py_3_9, not(target_os = "windows")))] + fn test_zoneinfo_is_not_fixedoffset() { + Python::with_gil(|py| { + let locals = crate::types::PyDict::new(py); + py.run( + "import zoneinfo; zi = zoneinfo.ZoneInfo('Europe/London')", + None, + Some(locals), + ) + .unwrap(); + let result: PyResult = locals.get_item("zi").unwrap().extract(); + assert!(result.is_err()); + let res = result.err().unwrap(); + // Also check the error message is what we expect + let msg = res.value(py).repr().unwrap().to_string(); + assert_eq!(msg, "TypeError('\"zoneinfo.ZoneInfo(key=\\'Europe/London\\')\" is not a fixed offset timezone')"); + }); + } + + #[test] + fn test_timezone_aware_to_naive_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 utc = timezone_utc(py); + let py_datetime = PyDateTime::new(py, 2022, 1, 1, 1, 0, 0, 0, Some(utc)).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('Trying to convert a timezone aware datetime into a NaiveDateTime.')" + ); + }); + } + + #[test] + fn test_pyo3_timedelta_topyobject() { + // Utility function used to check different durations. + // The `name` parameter is used to identify the check in case of a failure. + let check = |name: &'static str, delta: Duration, py_days, py_seconds, py_ms| { + Python::with_gil(|py| { + let delta = delta.to_object(py); + let delta: &PyDelta = delta.extract(py).unwrap(); + let py_delta = PyDelta::new(py, py_days, py_seconds, py_ms, true).unwrap(); + assert!( + delta.eq(py_delta).unwrap(), + "{}: {} != {}", + name, + delta, + py_delta + ); + }); + }; + + let delta = Duration::days(-1) + Duration::seconds(1) + Duration::microseconds(-10); + check("delta normalization", delta, -1, 1, -10); + + // Check the minimum value allowed by PyDelta, which is different + // from the minimum value allowed in Duration. This should pass. + let delta = Duration::seconds(-86399999913600); // min + check("delta min value", delta, -999999999, 0, 0); + + // Same, for max value + let delta = Duration::seconds(86399999999999) + Duration::nanoseconds(999999000); // max + check("delta max value", delta, 999999999, 86399, 999999); + + // Also check that trying to convert an out of bound value panics. + Python::with_gil(|py| { + assert!(panic::catch_unwind(|| Duration::min_value().to_object(py)).is_err()); + assert!(panic::catch_unwind(|| Duration::max_value().to_object(py)).is_err()); + }); + } + + #[test] + fn test_pyo3_timedelta_frompyobject() { + // Utility function used to check different durations. + // The `name` parameter is used to identify the check in case of a failure. + let check = |name: &'static str, delta: Duration, py_days, py_seconds, py_ms| { + Python::with_gil(|py| { + let py_delta = PyDelta::new(py, py_days, py_seconds, py_ms, true).unwrap(); + let py_delta: Duration = py_delta.extract().unwrap(); + assert_eq!(py_delta, delta, "{}: {} != {}", name, py_delta, delta); + }) + }; + + // Check the minimum value allowed by PyDelta, which is different + // from the minimum value allowed in Duration. This should pass. + check( + "min pydelta value", + Duration::seconds(-86399999913600), + -999999999, + 0, + 0, + ); + // Same, for max value + check( + "max pydelta value", + Duration::seconds(86399999999999) + Duration::microseconds(999999), + 999999999, + 86399, + 999999, + ); + + // This check is to assert that we can't construct every possible Duration from a PyDelta + // since they have different bounds. + Python::with_gil(|py| { + let low_days: i32 = -1000000000; + // This is possible + 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::() { + // So we should never get here + } + }) + .is_err()); + + let high_days: i32 = 1000000000; + // This is possible + 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::() { + // So we should never get here + } + }) + .is_err()); + }); + } + + #[test] + fn test_pyo3_date_topyobject() { + let eq_ymd = |name: &'static str, year, month, day| { + Python::with_gil(|py| { + let date = NaiveDate::from_ymd(year, month, day).to_object(py); + let date: &PyDate = date.extract(py).unwrap(); + let py_date = PyDate::new(py, year, month as u8, day as u8).unwrap(); + assert_eq!( + date.compare(py_date).unwrap(), + Ordering::Equal, + "{}: {} != {}", + name, + date, + py_date + ); + }) + }; + + eq_ymd("past date", 2012, 2, 29); + eq_ymd("min date", 1, 1, 1); + eq_ymd("future date", 3000, 6, 5); + eq_ymd("max date", 9999, 12, 31); + } + + #[test] + fn test_pyo3_date_frompyobject() { + let eq_ymd = |name: &'static str, year, month, day| { + Python::with_gil(|py| { + let py_date = PyDate::new(py, year, month as u8, day as u8).unwrap(); + let py_date: NaiveDate = py_date.extract().unwrap(); + let date = NaiveDate::from_ymd(year, month, day); + assert_eq!(py_date, date, "{}: {} != {}", name, date, py_date); + }) + }; + + eq_ymd("past date", 2012, 2, 29); + eq_ymd("min date", 1, 1, 1); + eq_ymd("future date", 3000, 6, 5); + eq_ymd("max date", 9999, 12, 31); + } + + #[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| { + let datetime = NaiveDate::from_ymd(year, month, day) + .and_hms_micro(hour, minute, second, ms); + let datetime = DateTime::::from_utc(datetime, Utc).to_object(py); + let datetime: &PyDateTime = datetime.extract(py).unwrap(); + let py_tz = Utc.to_object(py); + let py_tz = py_tz.cast_as(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(); + assert_eq!( + datetime.compare(py_datetime).unwrap(), + Ordering::Equal, + "{}: {} != {}", + 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 = + |name: &'static str, year, month, day, hour, minute, ssecond, ms, py_ms, fold| { + Python::with_gil(|py| { + let offset = FixedOffset::east(3600); + let datetime = NaiveDate::from_ymd(year, month, day) + .and_hms_micro(hour, minute, ssecond, ms); + let datetime = + DateTime::::from_utc(datetime, offset).to_object(py); + let datetime: &PyDateTime = datetime.extract(py).unwrap(); + let py_tz = offset.to_object(py); + let py_tz = py_tz.cast_as(py).unwrap(); + let py_datetime = PyDateTime::new_with_fold( + py, + year, + month as u8, + day as u8, + hour as u8, + minute as u8, + ssecond as u8, + py_ms, + Some(py_tz), + fold, + ) + .unwrap(); + assert_eq!( + datetime.compare(py_datetime).unwrap(), + Ordering::Equal, + "{}: {} != {}", + name, + datetime, + py_datetime + ); + }) + }; + + 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); + } + + #[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.cast_as(py).unwrap(); + let py_datetime = PyDateTime::new_with_fold( + py, + year as i32, + 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(year, month, day) + .and_hms_micro(hour, minute, second, ms); + let datetime = DateTime::::from_utc(datetime, 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 = + |name: &'static str, year, month, day, hour, minute, second, ms, py_ms, fold| { + Python::with_gil(|py| { + let offset = FixedOffset::east(3600); + let py_tz = offset.to_object(py); + let py_tz = py_tz.cast_as(py).unwrap(); + let py_datetime = PyDateTime::new_with_fold( + py, + year as i32, + 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(year, month, day) + .and_hms_micro(hour, minute, second, ms); + let datetime = DateTime::::from_utc(datetime, offset); + assert_eq!( + py_datetime, datetime, + "{}: {} != {}", + name, datetime, py_datetime + ); + }) + }; + + 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); + + Python::with_gil(|py| { + let py_tz = Utc.to_object(py); + let py_tz = py_tz.cast_as(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 offset = FixedOffset::east(3600); + let py_tz = offset.to_object(py); + let py_tz = py_tz.cast_as(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_err()); + }) + } + + #[test] + fn test_pyo3_offset_fixed_topyobject() { + Python::with_gil(|py| { + // Chrono offset + let offset = FixedOffset::east(3600).to_object(py); + // Python timezone from timedelta + let td = PyDelta::new(py, 0, 3600, 0, true).unwrap(); + let py_timedelta = pytimezone_fromoffset(&py, td); + // Should be equal + assert!(offset.as_ref(py).eq(py_timedelta).unwrap()); + + // Same but with negative values + let offset = FixedOffset::east(-3600).to_object(py); + let td = PyDelta::new(py, 0, -3600, 0, true).unwrap(); + let py_timedelta = pytimezone_fromoffset(&py, td); + assert!(offset.as_ref(py).eq(py_timedelta).unwrap()); + }) + } + + #[test] + fn test_pyo3_offset_fixed_frompyobject() { + Python::with_gil(|py| { + let py_timedelta = PyDelta::new(py, 0, 3600, 0, true).unwrap(); + let py_tzinfo = pytimezone_fromoffset(&py, py_timedelta); + let offset: FixedOffset = py_tzinfo.extract().unwrap(); + assert_eq!(FixedOffset::east(3600), offset); + }) + } + + #[test] + fn test_pyo3_offset_utc_topyobject() { + Python::with_gil(|py| { + let utc = Utc.to_object(py); + let py_utc = timezone_utc(py); + assert!(utc.as_ref(py).is(py_utc)); + }) + } + + #[test] + fn test_pyo3_offset_utc_frompyobject() { + Python::with_gil(|py| { + let py_utc = timezone_utc(py); + let py_utc: Utc = py_utc.extract().unwrap(); + assert_eq!(Utc, py_utc); + + let py_timedelta = PyDelta::new(py, 0, 0, 0, true).unwrap(); + let py_timezone_utc = pytimezone_fromoffset(&py, py_timedelta); + let py_timezone_utc: Utc = py_timezone_utc.extract().unwrap(); + assert_eq!(Utc, py_timezone_utc); + + let py_timedelta = PyDelta::new(py, 0, 3600, 0, true).unwrap(); + let py_timezone = pytimezone_fromoffset(&py, py_timedelta); + assert!(py_timezone.extract::().is_err()); + }) + } + + #[test] + fn test_pyo3_time_topyobject() { + let check_time = |name: &'static str, hour, minute, second, ms, py_ms, fold| { + Python::with_gil(|py| { + let time = NaiveTime::from_hms_micro(hour, minute, second, ms).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, + "{}: {} != {}", + name, + time, + py_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); + } + + #[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(hour, minute, second, ms); + 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); + } + + #[cfg(test)] + mod proptests { + use super::*; + + #[cfg(not(target_arch = "wasm32"))] + use proptest::prelude::*; + + #[cfg(not(target_arch = "wasm32"))] + proptest! { + #[test] + fn test_duration_roundtrip(days in -999999999i64..=999999999i64) { + // Test roundtrip convertion rust->python->rust for all allowed + // 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"); + assert_eq!(dur, roundtripped); + }) + } + + #[test] + fn test_fixedoffset_roundtrip(secs in -86_400i32..=86_400i32) { + Python::with_gil(|py| { + let offset = FixedOffset::east(secs); + let pyoffset = offset.into_py(py); + let roundtripped: FixedOffset = pyoffset.extract(py).expect("Round trip"); + assert_eq!(offset, roundtripped); + }) + } + + #[test] + fn test_naivedate_roundtrip( + year in 1i32..=9999i32, + month in 1u32..=12u32, + day in 1u32..=31u32 + ) { + // Test roundtrip convertion rust->python->rust for all allowed + // python dates (from year 1 to year 9999) + Python::with_gil(|py| { + // 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"); + 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 + ) { + // Test roundtrip convertion 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); + } + }) + } + + #[test] + fn test_utc_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..=2_000_000u32 + ) { + 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 = DateTime::from_utc(NaiveDateTime::new(date, time), Utc); + let pydt = dt.into_py(py); + let roundtripped: DateTime = pydt.extract(py).expect("Round trip"); + assert_eq!(dt, roundtripped); + } + }) + } + + #[test] + fn test_fixedoffset_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..=2_000_000u32, + offset_secs in -86_400i32..=86_400i32 + ) { + 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); + let offset = FixedOffset::east(offset_secs); + if let (Some(date), Some(time)) = (date_opt, time_opt) { + let dt: DateTime = DateTime::from_utc(NaiveDateTime::new(date, time), offset); + let pydt = dt.into_py(py); + let roundtripped: DateTime = pydt.extract(py).expect("Round trip"); + assert_eq!(dt, roundtripped); + } + }) + } + } + } +} diff --git a/src/conversions/mod.rs b/src/conversions/mod.rs index b82f123e..23f284bb 100644 --- a/src/conversions/mod.rs +++ b/src/conversions/mod.rs @@ -2,6 +2,7 @@ pub mod anyhow; mod array; +pub mod chrono; pub mod eyre; pub mod hashbrown; pub mod indexmap; diff --git a/src/lib.rs b/src/lib.rs index 618b8d74..0d3a3ca1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -90,6 +90,7 @@ //! //! The following features enable interactions with other crates in the Rust ecosystem: //! - [`anyhow`]: Enables a conversion from [anyhow]’s [`Error`][anyhow_error] type to [`PyErr`]. +//! - [`chrono`]: Enables a conversion from [chrono]'s structures to the equivalent Python ones. //! - [`eyre`]: Enables a conversion from [eyre]’s [`Report`] type to [`PyErr`]. //! - [`hashbrown`]: Enables conversions between Python objects and [hashbrown]'s [`HashMap`] and //! [`HashSet`] types.