From 93c65617eccd3bca49fe9f7a62d78e7b24843f6a Mon Sep 17 00:00:00 2001 From: Ivan Tham Date: Wed, 31 Aug 2022 23:05:52 +0800 Subject: [PATCH] Add chrono support Moved from https://github.com/chronotope/chrono/pull/542 --- Cargo.toml | 7 +- src/conversions/chrono.rs | 266 ++++++++++++++++++++++++++++++++++++++ src/conversions/mod.rs | 1 + src/types/datetime.rs | 35 ++++- 4 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 src/conversions/chrono.rs diff --git a/Cargo.toml b/Cargo.toml index ef6b10c02fe..7141b5d925a 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", path = "../chrono", 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", path = "../chrono" } criterion = "0.3.5" trybuild = "1.0.49" rustversion = "1.0" @@ -56,7 +58,7 @@ widestring = "0.5.1" pyo3-build-config = { path = "pyo3-build-config", version = "0.17.1", features = ["resolve-config"] } [features] -default = ["macros"] +default = ["macros", "chrono"] # Enables macros: #[pyclass], #[pymodule], #[pyfunction] etc. macros = ["pyo3-macros", "indoc", "unindent"] @@ -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/src/conversions/chrono.rs b/src/conversions/chrono.rs new file mode 100644 index 00000000000..c18d5a9ecda --- /dev/null +++ b/src/conversions/chrono.rs @@ -0,0 +1,266 @@ +#![cfg(feature = "chrono")] + +//! Conversions to and from [chrono](https://docs.rs/chrono/)’s `Duration`, +//! `NaiveDate`, `NaiveTime`, `DateTime`, `FixedOffset`, and `Utc`. +//! +//! # Setup +//! +//! To use this feature, add this to your **`Cargo.toml`**: +//! +//! ```toml +//! [dependencies] +//! # change * to the latest versions +//! hashbrown = "*" +// 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. +use crate::exceptions::PyTypeError; +use crate::types::{ + timezone_utc, PyDate, PyDateAccess, PyDateTime, PyDelta, PyDeltaAccess, PyTime, PyTimeAccess, + PyTzInfo, PyTzInfoAccess, +}; +use crate::{FromPyObject, IntoPy, PyAny, PyObject, PyResult, PyTryFrom, Python, ToPyObject}; +use chrono::offset::{FixedOffset, Utc}; +use chrono::{ + div_mod_floor_64, DateTime, Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime, Offset, + TimeZone, Timelike, NANOS_PER_MICRO, SECS_PER_DAY, +}; +use std::convert::TryInto; + +impl ToPyObject for Duration { + fn to_object(&self, py: Python<'_>) -> PyObject { + let micros = self.nanos / NANOS_PER_MICRO; + let (days, secs) = div_mod_floor_64(self.secs, SECS_PER_DAY); + // Python will check overflow so even if we reduce the size + // it will still overflow. + let days = days.try_into().unwrap_or(i32::MAX); + let secs = secs.try_into().unwrap_or(i32::MAX); + + // We do not need to check i64 to i32 cast from rust because + // python will panic with OverflowError. + // Not sure if we need normalize here. + let delta = PyDelta::new(py, days, secs, micros, false).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 + let secs = delta.get_days() as i64 * SECS_PER_DAY + delta.get_seconds() as i64; + let nanos = delta.get_microseconds() * NANOS_PER_MICRO; + + Ok(Duration { secs, nanos }) + } +} + +impl ToPyObject for NaiveDate { + fn to_object(&self, py: Python<'_>) -> PyObject { + let mdf = self.mdf(); + let date = PyDate::new(py, self.year(), mdf.month() as u8, mdf.day() as u8) + .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, m, s) = self.hms(); + 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 as u8, m as u8, s as u8, 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, m, s) = (time.get_hour(), time.get_minute(), time.get_second()); + Ok(NaiveTime::from_hms_micro(h as u32, m as u32, s as u32, ms)) + } +} + +impl ToPyObject for DateTime { + fn to_object(&self, py: Python<'_>) -> PyObject { + let (date, time) = (self.naive_utc().date(), self.naive_utc().time()); + let mdf = date.mdf(); + let (yy, mm, dd) = (date.year(), mdf.month(), mdf.day()); + let (h, m, s) = time.hms(); + 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 as u8, + dd as u8, + h as u8, + m as u8, + s as u8, + 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, m, s) = (dt.get_hour(), dt.get_minute(), dt.get_second()); + let tz = if let Some(tzinfo) = dt.get_tzinfo() { + tzinfo.extract()? + } else { + return Err(PyTypeError::new_err("Not datetime.timezone.tzinfo")); + }; + let dt = NaiveDateTime::new( + NaiveDate::from_ymd(dt.get_year(), dt.get_month() as u32, dt.get_day() as u32), + NaiveTime::from_hms_micro(h as u32, m as u32, s as u32, 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, m, s) = (dt.get_hour(), dt.get_minute(), dt.get_second()); + 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() as u32, dt.get_day() as u32), + NaiveTime::from_hms_micro(h as u32, m as u32, s as u32, ms), + ); + Ok(DateTime::from_utc(dt, tz)) + } +} + +impl ToPyObject for FixedOffset { + fn to_object(&self, py: Python<'_>) -> PyObject { + let dt_module = py.import("datetime").expect("Failed to import datetime"); + let dt_timezone = dt_module + .getattr("timezone") + .expect("Failed to getattr timezone"); + let seconds_offset = self.local_minus_utc(); + let td = + PyDelta::new(py, 0, seconds_offset, 0, true).expect("Failed to contruct timedelta"); + let offset = dt_timezone + .call1((td,)) + .expect("Failed to call timezone with timedelta"); + offset.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)?; + let py_timedelta = py_tzinfo.call_method1("utcoffset", (ob.py().None(),))?; + let py_timedelta = ::try_from(py_timedelta)?; + Ok(FixedOffset::east(py_timedelta.get_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 test_chrono { + use crate::types::*; + use crate::{IntoPy, PyObject, PyTryFrom, Python, ToPyObject}; + + // TODO +} diff --git a/src/conversions/mod.rs b/src/conversions/mod.rs index b82f123e5cc..36c4146f36b 100644 --- a/src/conversions/mod.rs +++ b/src/conversions/mod.rs @@ -2,6 +2,7 @@ pub mod anyhow; mod array; +mod chrono; pub mod eyre; pub mod hashbrown; pub mod indexmap; diff --git a/src/types/datetime.rs b/src/types/datetime.rs index 4d78b44d9bb..0661758f25b 100644 --- a/src/types/datetime.rs +++ b/src/types/datetime.rs @@ -473,7 +473,7 @@ impl PyTzInfoAccess for PyTime { /// For concrete time zone implementations, see [`timezone_utc`] and /// the [`zoneinfo` module](https://docs.python.org/3/library/zoneinfo.html). #[repr(transparent)] -pub struct PyTzInfo(PyAny); +pub struct PyTzInfo(pub(crate) PyAny); pyobject_native_type!( PyTzInfo, crate::ffi::PyObject, @@ -487,6 +487,39 @@ pub fn timezone_utc(py: Python<'_>) -> &PyTzInfo { unsafe { &*(ensure_datetime_api(py).TimeZone_UTC as *const PyTzInfo) } } +/// Equivalent to `datetime.timezone` +// Not making this public yet as the implementation is slightly different from +// original cpython. +// #[crate::pyclass] +// pub(crate) struct PyTimeZone { +// offset: PyDelta, +// // name +// } +// +// #[crate::pymethods] +// impl PyTimeZone { +// #[new] +// fn new(offset: PyDelta) -> Self { +// PyTimeZone { offset } +// } +// +// fn utcoffset(&self, dt: &PyDateTime) -> PyDelta { +// self.offset +// } +// +// fn tzname(&self, dt: &PyDateTime) -> PyString { +// todo!("_name_from_offset") +// } +// +// fn dst(&self, py: Python<'_>, dt: &PyDateTime) { +// py.None() +// } +// +// fn fromutc(&self, dt: &PyDateTime) -> PyDelta { +// dt.get_tzinfo() + self.offset +// } +// } + /// Bindings for `datetime.timedelta` #[repr(transparent)] pub struct PyDelta(PyAny);