pyo3/conversions/
jiff.rs

1#![cfg(feature = "jiff-02")]
2
3//! Conversions to and from [jiff](https://docs.rs/jiff/)’s `Span`, `SignedDuration`, `TimeZone`,
4//! `Offset`, `Date`, `Time`, `DateTime`, `Zoned`, and `Timestamp`.
5//!
6//! # Setup
7//!
8//! To use this feature, add this to your **`Cargo.toml`**:
9//!
10//! ```toml
11//! [dependencies]
12//! jiff = "0.2"
13#![doc = concat!("pyo3 = { version = \"", env!("CARGO_PKG_VERSION"),  "\", features = [\"jiff-02\"] }")]
14//! ```
15//!
16//! Note that you must use compatible versions of jiff and PyO3.
17//! The required jiff version may vary based on the version of PyO3. Jiff also requires a MSRV
18//! of 1.70.
19//!
20//! # Example: Convert a `datetime.datetime` to jiff `Zoned`
21//!
22//! ```rust
23//! # #![cfg_attr(windows, allow(unused_imports))]
24//! # use jiff_02 as jiff;
25//! use jiff::{Zoned, SignedDuration, ToSpan};
26//! use pyo3::{Python, PyResult, IntoPyObject, types::PyAnyMethods};
27//!
28//! # #[cfg(windows)]
29//! # fn main() -> () {}
30//! # #[cfg(not(windows))]
31//! fn main() -> PyResult<()> {
32//!     pyo3::prepare_freethreaded_python();
33//!     Python::with_gil(|py| {
34//!         // Build some jiff values
35//!         let jiff_zoned = Zoned::now();
36//!         let jiff_span = 1.second();
37//!         // Convert them to Python
38//!         let py_datetime = jiff_zoned.into_pyobject(py)?;
39//!         let py_timedelta = SignedDuration::try_from(jiff_span)?.into_pyobject(py)?;
40//!         // Do an operation in Python
41//!         let py_sum = py_datetime.call_method1("__add__", (py_timedelta,))?;
42//!         // Convert back to Rust
43//!         let jiff_sum: Zoned = py_sum.extract()?;
44//!         println!("Zoned: {}", jiff_sum);
45//!         Ok(())
46//!     })
47//! }
48//! ```
49use crate::exceptions::{PyTypeError, PyValueError};
50use crate::pybacked::PyBackedStr;
51use crate::types::{PyAnyMethods, PyNone};
52use crate::types::{PyDate, PyDateTime, PyDelta, PyTime, PyTzInfo, PyTzInfoAccess};
53#[cfg(not(Py_LIMITED_API))]
54use crate::types::{PyDateAccess, PyDeltaAccess, PyTimeAccess};
55use crate::{intern, Bound, FromPyObject, IntoPyObject, PyAny, PyErr, PyResult, Python};
56use jiff::civil::{Date, DateTime, Time};
57use jiff::tz::{Offset, TimeZone};
58use jiff::{SignedDuration, Span, Timestamp, Zoned};
59#[cfg(feature = "jiff-02")]
60use jiff_02 as jiff;
61
62fn datetime_to_pydatetime<'py>(
63    py: Python<'py>,
64    datetime: &DateTime,
65    fold: bool,
66    timezone: Option<&TimeZone>,
67) -> PyResult<Bound<'py, PyDateTime>> {
68    PyDateTime::new_with_fold(
69        py,
70        datetime.year().into(),
71        datetime.month().try_into()?,
72        datetime.day().try_into()?,
73        datetime.hour().try_into()?,
74        datetime.minute().try_into()?,
75        datetime.second().try_into()?,
76        (datetime.subsec_nanosecond() / 1000).try_into()?,
77        timezone
78            .map(|tz| tz.into_pyobject(py))
79            .transpose()?
80            .as_ref(),
81        fold,
82    )
83}
84
85#[cfg(not(Py_LIMITED_API))]
86fn pytime_to_time(time: &impl PyTimeAccess) -> PyResult<Time> {
87    Ok(Time::new(
88        time.get_hour().try_into()?,
89        time.get_minute().try_into()?,
90        time.get_second().try_into()?,
91        (time.get_microsecond() * 1000).try_into()?,
92    )?)
93}
94
95#[cfg(Py_LIMITED_API)]
96fn pytime_to_time(time: &Bound<'_, PyAny>) -> PyResult<Time> {
97    let py = time.py();
98    Ok(Time::new(
99        time.getattr(intern!(py, "hour"))?.extract()?,
100        time.getattr(intern!(py, "minute"))?.extract()?,
101        time.getattr(intern!(py, "second"))?.extract()?,
102        time.getattr(intern!(py, "microsecond"))?.extract::<i32>()? * 1000,
103    )?)
104}
105
106impl<'py> IntoPyObject<'py> for Timestamp {
107    type Target = PyDateTime;
108    type Output = Bound<'py, Self::Target>;
109    type Error = PyErr;
110
111    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
112        (&self).into_pyobject(py)
113    }
114}
115
116impl<'py> IntoPyObject<'py> for &Timestamp {
117    type Target = PyDateTime;
118    type Output = Bound<'py, Self::Target>;
119    type Error = PyErr;
120
121    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
122        self.to_zoned(TimeZone::UTC).into_pyobject(py)
123    }
124}
125
126impl<'py> FromPyObject<'py> for Timestamp {
127    fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult<Self> {
128        let zoned = ob.extract::<Zoned>()?;
129        Ok(zoned.timestamp())
130    }
131}
132
133impl<'py> IntoPyObject<'py> for Date {
134    type Target = PyDate;
135    type Output = Bound<'py, Self::Target>;
136    type Error = PyErr;
137
138    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
139        (&self).into_pyobject(py)
140    }
141}
142
143impl<'py> IntoPyObject<'py> for &Date {
144    type Target = PyDate;
145    type Output = Bound<'py, Self::Target>;
146    type Error = PyErr;
147
148    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
149        PyDate::new(
150            py,
151            self.year().into(),
152            self.month().try_into()?,
153            self.day().try_into()?,
154        )
155    }
156}
157
158impl<'py> FromPyObject<'py> for Date {
159    fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult<Self> {
160        let date = ob.downcast::<PyDate>()?;
161
162        #[cfg(not(Py_LIMITED_API))]
163        {
164            Ok(Date::new(
165                date.get_year().try_into()?,
166                date.get_month().try_into()?,
167                date.get_day().try_into()?,
168            )?)
169        }
170
171        #[cfg(Py_LIMITED_API)]
172        {
173            let py = date.py();
174            Ok(Date::new(
175                date.getattr(intern!(py, "year"))?.extract()?,
176                date.getattr(intern!(py, "month"))?.extract()?,
177                date.getattr(intern!(py, "day"))?.extract()?,
178            )?)
179        }
180    }
181}
182
183impl<'py> IntoPyObject<'py> for Time {
184    type Target = PyTime;
185    type Output = Bound<'py, Self::Target>;
186    type Error = PyErr;
187
188    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
189        (&self).into_pyobject(py)
190    }
191}
192
193impl<'py> IntoPyObject<'py> for &Time {
194    type Target = PyTime;
195    type Output = Bound<'py, Self::Target>;
196    type Error = PyErr;
197
198    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
199        PyTime::new(
200            py,
201            self.hour().try_into()?,
202            self.minute().try_into()?,
203            self.second().try_into()?,
204            (self.subsec_nanosecond() / 1000).try_into()?,
205            None,
206        )
207    }
208}
209
210impl<'py> FromPyObject<'py> for Time {
211    fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult<Self> {
212        let ob = ob.downcast::<PyTime>()?;
213
214        pytime_to_time(ob)
215    }
216}
217
218impl<'py> IntoPyObject<'py> for DateTime {
219    type Target = PyDateTime;
220    type Output = Bound<'py, Self::Target>;
221    type Error = PyErr;
222
223    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
224        (&self).into_pyobject(py)
225    }
226}
227
228impl<'py> IntoPyObject<'py> for &DateTime {
229    type Target = PyDateTime;
230    type Output = Bound<'py, Self::Target>;
231    type Error = PyErr;
232
233    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
234        datetime_to_pydatetime(py, self, false, None)
235    }
236}
237
238impl<'py> FromPyObject<'py> for DateTime {
239    fn extract_bound(dt: &Bound<'py, PyAny>) -> PyResult<Self> {
240        let dt = dt.downcast::<PyDateTime>()?;
241        let has_tzinfo = dt.get_tzinfo().is_some();
242
243        if has_tzinfo {
244            return Err(PyTypeError::new_err("expected a datetime without tzinfo"));
245        }
246
247        Ok(DateTime::from_parts(dt.extract()?, pytime_to_time(dt)?))
248    }
249}
250
251impl<'py> IntoPyObject<'py> for Zoned {
252    type Target = PyDateTime;
253    type Output = Bound<'py, Self::Target>;
254    type Error = PyErr;
255
256    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
257        (&self).into_pyobject(py)
258    }
259}
260
261impl<'py> IntoPyObject<'py> for &Zoned {
262    type Target = PyDateTime;
263    type Output = Bound<'py, Self::Target>;
264    type Error = PyErr;
265
266    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
267        fn fold(zoned: &Zoned) -> Option<bool> {
268            let prev = zoned.time_zone().preceding(zoned.timestamp()).next()?;
269            let next = zoned.time_zone().following(prev.timestamp()).next()?;
270            let start_of_current_offset = if next.timestamp() == zoned.timestamp() {
271                next.timestamp()
272            } else {
273                prev.timestamp()
274            };
275            Some(zoned.timestamp() + (zoned.offset() - prev.offset()) <= start_of_current_offset)
276        }
277        datetime_to_pydatetime(
278            py,
279            &self.datetime(),
280            fold(self).unwrap_or(false),
281            Some(self.time_zone()),
282        )
283    }
284}
285
286impl<'py> FromPyObject<'py> for Zoned {
287    fn extract_bound(dt: &Bound<'py, PyAny>) -> PyResult<Self> {
288        let dt = dt.downcast::<PyDateTime>()?;
289
290        let tz = dt
291            .get_tzinfo()
292            .map(|tz| tz.extract::<TimeZone>())
293            .unwrap_or_else(|| {
294                Err(PyTypeError::new_err(
295                    "expected a datetime with non-None tzinfo",
296                ))
297            })?;
298        let datetime = DateTime::from_parts(dt.extract()?, pytime_to_time(dt)?);
299        let zoned = tz.into_ambiguous_zoned(datetime);
300
301        #[cfg(not(Py_LIMITED_API))]
302        let fold = dt.get_fold();
303
304        #[cfg(Py_LIMITED_API)]
305        let fold = dt.getattr(intern!(dt.py(), "fold"))?.extract::<usize>()? > 0;
306
307        if fold {
308            Ok(zoned.later()?)
309        } else {
310            Ok(zoned.earlier()?)
311        }
312    }
313}
314
315impl<'py> IntoPyObject<'py> for TimeZone {
316    type Target = PyTzInfo;
317    type Output = Bound<'py, Self::Target>;
318    type Error = PyErr;
319
320    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
321        (&self).into_pyobject(py)
322    }
323}
324
325impl<'py> IntoPyObject<'py> for &TimeZone {
326    type Target = PyTzInfo;
327    type Output = Bound<'py, Self::Target>;
328    type Error = PyErr;
329
330    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
331        if self == &TimeZone::UTC {
332            return Ok(PyTzInfo::utc(py)?.to_owned());
333        }
334
335        #[cfg(Py_3_9)]
336        if let Some(iana_name) = self.iana_name() {
337            return PyTzInfo::timezone(py, iana_name);
338        }
339
340        self.to_fixed_offset()?.into_pyobject(py)
341    }
342}
343
344impl<'py> FromPyObject<'py> for TimeZone {
345    fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult<Self> {
346        let ob = ob.downcast::<PyTzInfo>()?;
347
348        let attr = intern!(ob.py(), "key");
349        if ob.hasattr(attr)? {
350            Ok(TimeZone::get(&ob.getattr(attr)?.extract::<PyBackedStr>()?)?)
351        } else {
352            Ok(ob.extract::<Offset>()?.to_time_zone())
353        }
354    }
355}
356
357impl<'py> IntoPyObject<'py> for &Offset {
358    type Target = PyTzInfo;
359    type Output = Bound<'py, Self::Target>;
360    type Error = PyErr;
361
362    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
363        if self == &Offset::UTC {
364            return Ok(PyTzInfo::utc(py)?.to_owned());
365        }
366
367        PyTzInfo::fixed_offset(py, self.duration_since(Offset::UTC))
368    }
369}
370
371impl<'py> IntoPyObject<'py> for Offset {
372    type Target = PyTzInfo;
373    type Output = Bound<'py, Self::Target>;
374    type Error = PyErr;
375
376    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
377        (&self).into_pyobject(py)
378    }
379}
380
381impl<'py> FromPyObject<'py> for Offset {
382    fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult<Self> {
383        let py = ob.py();
384        let ob = ob.downcast::<PyTzInfo>()?;
385
386        let py_timedelta = ob.call_method1(intern!(py, "utcoffset"), (PyNone::get(py),))?;
387        if py_timedelta.is_none() {
388            return Err(PyTypeError::new_err(format!(
389                "{:?} is not a fixed offset timezone",
390                ob
391            )));
392        }
393
394        let total_seconds = py_timedelta.extract::<SignedDuration>()?.as_secs();
395        debug_assert!(
396            (total_seconds / 3600).abs() <= 24,
397            "Offset must be between -24 hours and 24 hours but was {}h",
398            total_seconds / 3600
399        );
400        // This cast is safe since the timedelta is limited to -24 hours and 24 hours.
401        Ok(Offset::from_seconds(total_seconds as i32)?)
402    }
403}
404
405impl<'py> IntoPyObject<'py> for &SignedDuration {
406    type Target = PyDelta;
407    type Output = Bound<'py, Self::Target>;
408    type Error = PyErr;
409
410    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
411        let total_seconds = self.as_secs();
412        let days: i32 = (total_seconds / (24 * 60 * 60)).try_into()?;
413        let seconds: i32 = (total_seconds % (24 * 60 * 60)).try_into()?;
414        let microseconds = self.subsec_micros();
415
416        PyDelta::new(py, days, seconds, microseconds, true)
417    }
418}
419
420impl<'py> IntoPyObject<'py> for SignedDuration {
421    type Target = PyDelta;
422    type Output = Bound<'py, Self::Target>;
423    type Error = PyErr;
424
425    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
426        (&self).into_pyobject(py)
427    }
428}
429
430impl<'py> FromPyObject<'py> for SignedDuration {
431    fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult<Self> {
432        let delta = ob.downcast::<PyDelta>()?;
433
434        #[cfg(not(Py_LIMITED_API))]
435        let (seconds, microseconds) = {
436            let days = delta.get_days() as i64;
437            let seconds = delta.get_seconds() as i64;
438            let microseconds = delta.get_microseconds();
439            (days * 24 * 60 * 60 + seconds, microseconds)
440        };
441
442        #[cfg(Py_LIMITED_API)]
443        let (seconds, microseconds) = {
444            let py = delta.py();
445            let days = delta.getattr(intern!(py, "days"))?.extract::<i64>()?;
446            let seconds = delta.getattr(intern!(py, "seconds"))?.extract::<i64>()?;
447            let microseconds = ob.getattr(intern!(py, "microseconds"))?.extract::<i32>()?;
448            (days * 24 * 60 * 60 + seconds, microseconds)
449        };
450
451        Ok(SignedDuration::new(seconds, microseconds * 1000))
452    }
453}
454
455impl<'py> FromPyObject<'py> for Span {
456    fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult<Self> {
457        let duration = ob.extract::<SignedDuration>()?;
458        Ok(duration.try_into()?)
459    }
460}
461
462impl From<jiff::Error> for PyErr {
463    fn from(e: jiff::Error) -> Self {
464        PyValueError::new_err(e.to_string())
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471    use crate::{types::PyTuple, BoundObject};
472    use jiff::tz::Offset;
473    use std::cmp::Ordering;
474
475    #[test]
476    // Only Python>=3.9 has the zoneinfo package
477    // We skip the test on windows too since we'd need to install
478    // tzdata there to make this work.
479    #[cfg(all(Py_3_9, not(target_os = "windows")))]
480    fn test_zoneinfo_is_not_fixed_offset() {
481        use crate::ffi;
482        use crate::types::any::PyAnyMethods;
483        use crate::types::dict::PyDictMethods;
484
485        Python::with_gil(|py| {
486            let locals = crate::types::PyDict::new(py);
487            py.run(
488                ffi::c_str!("import zoneinfo; zi = zoneinfo.ZoneInfo('Europe/London')"),
489                None,
490                Some(&locals),
491            )
492            .unwrap();
493            let result: PyResult<Offset> = locals.get_item("zi").unwrap().unwrap().extract();
494            assert!(result.is_err());
495            let res = result.err().unwrap();
496            // Also check the error message is what we expect
497            let msg = res.value(py).repr().unwrap().to_string();
498            assert_eq!(msg, "TypeError(\"zoneinfo.ZoneInfo(key='Europe/London') is not a fixed offset timezone\")");
499        });
500    }
501
502    #[test]
503    fn test_timezone_aware_to_naive_fails() {
504        // Test that if a user tries to convert a python's timezone aware datetime into a naive
505        // one, the conversion fails.
506        Python::with_gil(|py| {
507            let py_datetime =
508                new_py_datetime_ob(py, "datetime", (2022, 1, 1, 1, 0, 0, 0, python_utc(py)));
509            // Now test that converting a PyDateTime with tzinfo to a NaiveDateTime fails
510            let res: PyResult<DateTime> = py_datetime.extract();
511            assert_eq!(
512                res.unwrap_err().value(py).repr().unwrap().to_string(),
513                "TypeError('expected a datetime without tzinfo')"
514            );
515        });
516    }
517
518    #[test]
519    fn test_naive_to_timezone_aware_fails() {
520        // Test that if a user tries to convert a python's naive datetime into a timezone aware
521        // one, the conversion fails.
522        Python::with_gil(|py| {
523            let py_datetime = new_py_datetime_ob(py, "datetime", (2022, 1, 1, 1, 0, 0, 0));
524            let res: PyResult<Zoned> = py_datetime.extract();
525            assert_eq!(
526                res.unwrap_err().value(py).repr().unwrap().to_string(),
527                "TypeError('expected a datetime with non-None tzinfo')"
528            );
529        });
530    }
531
532    #[test]
533    fn test_invalid_types_fail() {
534        Python::with_gil(|py| {
535            let none = py.None().into_bound(py);
536            assert_eq!(
537                none.extract::<Span>().unwrap_err().to_string(),
538                "TypeError: 'NoneType' object cannot be converted to 'PyDelta'"
539            );
540            assert_eq!(
541                none.extract::<Offset>().unwrap_err().to_string(),
542                "TypeError: 'NoneType' object cannot be converted to 'PyTzInfo'"
543            );
544            assert_eq!(
545                none.extract::<TimeZone>().unwrap_err().to_string(),
546                "TypeError: 'NoneType' object cannot be converted to 'PyTzInfo'"
547            );
548            assert_eq!(
549                none.extract::<Time>().unwrap_err().to_string(),
550                "TypeError: 'NoneType' object cannot be converted to 'PyTime'"
551            );
552            assert_eq!(
553                none.extract::<Date>().unwrap_err().to_string(),
554                "TypeError: 'NoneType' object cannot be converted to 'PyDate'"
555            );
556            assert_eq!(
557                none.extract::<DateTime>().unwrap_err().to_string(),
558                "TypeError: 'NoneType' object cannot be converted to 'PyDateTime'"
559            );
560            assert_eq!(
561                none.extract::<Zoned>().unwrap_err().to_string(),
562                "TypeError: 'NoneType' object cannot be converted to 'PyDateTime'"
563            );
564        });
565    }
566
567    #[test]
568    fn test_pyo3_date_into_pyobject() {
569        let eq_ymd = |name: &'static str, year, month, day| {
570            Python::with_gil(|py| {
571                let date = Date::new(year, month, day)
572                    .unwrap()
573                    .into_pyobject(py)
574                    .unwrap();
575                let py_date = new_py_datetime_ob(py, "date", (year, month, day));
576                assert_eq!(
577                    date.compare(&py_date).unwrap(),
578                    Ordering::Equal,
579                    "{}: {} != {}",
580                    name,
581                    date,
582                    py_date
583                );
584            })
585        };
586
587        eq_ymd("past date", 2012, 2, 29);
588        eq_ymd("min date", 1, 1, 1);
589        eq_ymd("future date", 3000, 6, 5);
590        eq_ymd("max date", 9999, 12, 31);
591    }
592
593    #[test]
594    fn test_pyo3_date_frompyobject() {
595        let eq_ymd = |name: &'static str, year, month, day| {
596            Python::with_gil(|py| {
597                let py_date = new_py_datetime_ob(py, "date", (year, month, day));
598                let py_date: Date = py_date.extract().unwrap();
599                let date = Date::new(year, month, day).unwrap();
600                assert_eq!(py_date, date, "{}: {} != {}", name, date, py_date);
601            })
602        };
603
604        eq_ymd("past date", 2012, 2, 29);
605        eq_ymd("min date", 1, 1, 1);
606        eq_ymd("future date", 3000, 6, 5);
607        eq_ymd("max date", 9999, 12, 31);
608    }
609
610    #[test]
611    fn test_pyo3_datetime_into_pyobject_utc() {
612        Python::with_gil(|py| {
613            let check_utc =
614                |name: &'static str, year, month, day, hour, minute, second, ms, py_ms| {
615                    let datetime = DateTime::new(year, month, day, hour, minute, second, ms * 1000)
616                        .unwrap()
617                        .to_zoned(TimeZone::UTC)
618                        .unwrap();
619                    let datetime = datetime.into_pyobject(py).unwrap();
620                    let py_datetime = new_py_datetime_ob(
621                        py,
622                        "datetime",
623                        (
624                            year,
625                            month,
626                            day,
627                            hour,
628                            minute,
629                            second,
630                            py_ms,
631                            python_utc(py),
632                        ),
633                    );
634                    assert_eq!(
635                        datetime.compare(&py_datetime).unwrap(),
636                        Ordering::Equal,
637                        "{}: {} != {}",
638                        name,
639                        datetime,
640                        py_datetime
641                    );
642                };
643
644            check_utc("regular", 2014, 5, 6, 7, 8, 9, 999_999, 999_999);
645        })
646    }
647
648    #[test]
649    fn test_pyo3_datetime_into_pyobject_fixed_offset() {
650        Python::with_gil(|py| {
651            let check_fixed_offset =
652                |name: &'static str, year, month, day, hour, minute, second, ms, py_ms| {
653                    let offset = Offset::from_seconds(3600).unwrap();
654                    let datetime = DateTime::new(year, month, day, hour, minute, second, ms * 1000)
655                        .map_err(|e| {
656                            eprintln!("{}: {}", name, e);
657                            e
658                        })
659                        .unwrap()
660                        .to_zoned(offset.to_time_zone())
661                        .unwrap();
662                    let datetime = datetime.into_pyobject(py).unwrap();
663                    let py_tz = offset.into_pyobject(py).unwrap();
664                    let py_datetime = new_py_datetime_ob(
665                        py,
666                        "datetime",
667                        (year, month, day, hour, minute, second, py_ms, py_tz),
668                    );
669                    assert_eq!(
670                        datetime.compare(&py_datetime).unwrap(),
671                        Ordering::Equal,
672                        "{}: {} != {}",
673                        name,
674                        datetime,
675                        py_datetime
676                    );
677                };
678
679            check_fixed_offset("regular", 2014, 5, 6, 7, 8, 9, 999_999, 999_999);
680        })
681    }
682
683    #[test]
684    #[cfg(all(Py_3_9, not(windows)))]
685    fn test_pyo3_datetime_into_pyobject_tz() {
686        Python::with_gil(|py| {
687            let datetime = DateTime::new(2024, 12, 11, 23, 3, 13, 0)
688                .unwrap()
689                .to_zoned(TimeZone::get("Europe/London").unwrap())
690                .unwrap();
691            let datetime = datetime.into_pyobject(py).unwrap();
692            let py_datetime = new_py_datetime_ob(
693                py,
694                "datetime",
695                (
696                    2024,
697                    12,
698                    11,
699                    23,
700                    3,
701                    13,
702                    0,
703                    python_zoneinfo(py, "Europe/London"),
704                ),
705            );
706            assert_eq!(datetime.compare(&py_datetime).unwrap(), Ordering::Equal);
707        })
708    }
709
710    #[test]
711    fn test_pyo3_datetime_frompyobject_utc() {
712        Python::with_gil(|py| {
713            let year = 2014;
714            let month = 5;
715            let day = 6;
716            let hour = 7;
717            let minute = 8;
718            let second = 9;
719            let micro = 999_999;
720            let tz_utc = PyTzInfo::utc(py).unwrap();
721            let py_datetime = new_py_datetime_ob(
722                py,
723                "datetime",
724                (year, month, day, hour, minute, second, micro, tz_utc),
725            );
726            let py_datetime: Zoned = py_datetime.extract().unwrap();
727            let datetime = DateTime::new(year, month, day, hour, minute, second, micro * 1000)
728                .unwrap()
729                .to_zoned(TimeZone::UTC)
730                .unwrap();
731            assert_eq!(py_datetime, datetime,);
732        })
733    }
734
735    #[test]
736    #[cfg(all(Py_3_9, not(windows)))]
737    fn test_ambiguous_datetime_to_pyobject() {
738        use std::str::FromStr;
739        let dates = [
740            Zoned::from_str("2020-10-24 23:00:00[UTC]").unwrap(),
741            Zoned::from_str("2020-10-25 00:00:00[UTC]").unwrap(),
742            Zoned::from_str("2020-10-25 01:00:00[UTC]").unwrap(),
743            Zoned::from_str("2020-10-25 02:00:00[UTC]").unwrap(),
744        ];
745
746        let tz = TimeZone::get("Europe/London").unwrap();
747        let dates = dates.map(|dt| dt.with_time_zone(tz.clone()));
748
749        assert_eq!(
750            dates.clone().map(|ref dt| dt.to_string()),
751            [
752                "2020-10-25T00:00:00+01:00[Europe/London]",
753                "2020-10-25T01:00:00+01:00[Europe/London]",
754                "2020-10-25T01:00:00+00:00[Europe/London]",
755                "2020-10-25T02:00:00+00:00[Europe/London]",
756            ]
757        );
758
759        let dates = Python::with_gil(|py| {
760            let pydates = dates.map(|dt| dt.into_pyobject(py).unwrap());
761            assert_eq!(
762                pydates
763                    .clone()
764                    .map(|dt| dt.getattr("hour").unwrap().extract::<usize>().unwrap()),
765                [0, 1, 1, 2]
766            );
767
768            assert_eq!(
769                pydates
770                    .clone()
771                    .map(|dt| dt.getattr("fold").unwrap().extract::<usize>().unwrap() > 0),
772                [false, false, true, false]
773            );
774
775            pydates.map(|dt| dt.extract::<Zoned>().unwrap())
776        });
777
778        assert_eq!(
779            dates.map(|dt| dt.to_string()),
780            [
781                "2020-10-25T00:00:00+01:00[Europe/London]",
782                "2020-10-25T01:00:00+01:00[Europe/London]",
783                "2020-10-25T01:00:00+00:00[Europe/London]",
784                "2020-10-25T02:00:00+00:00[Europe/London]",
785            ]
786        );
787    }
788
789    #[test]
790    fn test_pyo3_datetime_frompyobject_fixed_offset() {
791        Python::with_gil(|py| {
792            let year = 2014;
793            let month = 5;
794            let day = 6;
795            let hour = 7;
796            let minute = 8;
797            let second = 9;
798            let micro = 999_999;
799            let offset = Offset::from_seconds(3600).unwrap();
800            let py_tz = offset.into_pyobject(py).unwrap();
801            let py_datetime = new_py_datetime_ob(
802                py,
803                "datetime",
804                (year, month, day, hour, minute, second, micro, py_tz),
805            );
806            let datetime_from_py: Zoned = py_datetime.extract().unwrap();
807            let datetime =
808                DateTime::new(year, month, day, hour, minute, second, micro * 1000).unwrap();
809            let datetime = datetime.to_zoned(offset.to_time_zone()).unwrap();
810
811            assert_eq!(datetime_from_py, datetime);
812        })
813    }
814
815    #[test]
816    fn test_pyo3_offset_fixed_into_pyobject() {
817        Python::with_gil(|py| {
818            // jiff offset
819            let offset = Offset::from_seconds(3600)
820                .unwrap()
821                .into_pyobject(py)
822                .unwrap();
823            // Python timezone from timedelta
824            let td = new_py_datetime_ob(py, "timedelta", (0, 3600, 0));
825            let py_timedelta = new_py_datetime_ob(py, "timezone", (td,));
826            // Should be equal
827            assert!(offset.eq(py_timedelta).unwrap());
828
829            // Same but with negative values
830            let offset = Offset::from_seconds(-3600)
831                .unwrap()
832                .into_pyobject(py)
833                .unwrap();
834            let td = new_py_datetime_ob(py, "timedelta", (0, -3600, 0));
835            let py_timedelta = new_py_datetime_ob(py, "timezone", (td,));
836            assert!(offset.eq(py_timedelta).unwrap());
837        })
838    }
839
840    #[test]
841    fn test_pyo3_offset_fixed_frompyobject() {
842        Python::with_gil(|py| {
843            let py_timedelta = new_py_datetime_ob(py, "timedelta", (0, 3600, 0));
844            let py_tzinfo = new_py_datetime_ob(py, "timezone", (py_timedelta,));
845            let offset: Offset = py_tzinfo.extract().unwrap();
846            assert_eq!(Offset::from_seconds(3600).unwrap(), offset);
847        })
848    }
849
850    #[test]
851    fn test_pyo3_offset_utc_into_pyobject() {
852        Python::with_gil(|py| {
853            let utc = Offset::UTC.into_pyobject(py).unwrap();
854            let py_utc = python_utc(py);
855            assert!(utc.is(&py_utc));
856        })
857    }
858
859    #[test]
860    fn test_pyo3_offset_utc_frompyobject() {
861        Python::with_gil(|py| {
862            let py_utc = python_utc(py);
863            let py_utc: Offset = py_utc.extract().unwrap();
864            assert_eq!(Offset::UTC, py_utc);
865
866            let py_timedelta = new_py_datetime_ob(py, "timedelta", (0, 0, 0));
867            let py_timezone_utc = new_py_datetime_ob(py, "timezone", (py_timedelta,));
868            let py_timezone_utc: Offset = py_timezone_utc.extract().unwrap();
869            assert_eq!(Offset::UTC, py_timezone_utc);
870
871            let py_timedelta = new_py_datetime_ob(py, "timedelta", (0, 3600, 0));
872            let py_timezone = new_py_datetime_ob(py, "timezone", (py_timedelta,));
873            assert_ne!(Offset::UTC, py_timezone.extract::<Offset>().unwrap());
874        })
875    }
876
877    #[test]
878    fn test_pyo3_time_into_pyobject() {
879        Python::with_gil(|py| {
880            let check_time = |name: &'static str, hour, minute, second, ms, py_ms| {
881                let time = Time::new(hour, minute, second, ms * 1000)
882                    .unwrap()
883                    .into_pyobject(py)
884                    .unwrap();
885                let py_time = new_py_datetime_ob(py, "time", (hour, minute, second, py_ms));
886                assert!(
887                    time.eq(&py_time).unwrap(),
888                    "{}: {} != {}",
889                    name,
890                    time,
891                    py_time
892                );
893            };
894
895            check_time("regular", 3, 5, 7, 999_999, 999_999);
896        })
897    }
898
899    #[test]
900    fn test_pyo3_time_frompyobject() {
901        let hour = 3;
902        let minute = 5;
903        let second = 7;
904        let micro = 999_999;
905        Python::with_gil(|py| {
906            let py_time = new_py_datetime_ob(py, "time", (hour, minute, second, micro));
907            let py_time: Time = py_time.extract().unwrap();
908            let time = Time::new(hour, minute, second, micro * 1000).unwrap();
909            assert_eq!(py_time, time);
910        })
911    }
912
913    fn new_py_datetime_ob<'py, A>(py: Python<'py>, name: &str, args: A) -> Bound<'py, PyAny>
914    where
915        A: IntoPyObject<'py, Target = PyTuple>,
916    {
917        py.import("datetime")
918            .unwrap()
919            .getattr(name)
920            .unwrap()
921            .call1(
922                args.into_pyobject(py)
923                    .map_err(Into::into)
924                    .unwrap()
925                    .into_bound(),
926            )
927            .unwrap()
928    }
929
930    fn python_utc(py: Python<'_>) -> Bound<'_, PyAny> {
931        py.import("datetime")
932            .unwrap()
933            .getattr("timezone")
934            .unwrap()
935            .getattr("utc")
936            .unwrap()
937    }
938
939    #[cfg(all(Py_3_9, not(windows)))]
940    fn python_zoneinfo<'py>(py: Python<'py>, timezone: &str) -> Bound<'py, PyAny> {
941        py.import("zoneinfo")
942            .unwrap()
943            .getattr("ZoneInfo")
944            .unwrap()
945            .call1((timezone,))
946            .unwrap()
947    }
948
949    #[cfg(not(any(target_arch = "wasm32", Py_GIL_DISABLED)))]
950    mod proptests {
951        use super::*;
952        use crate::types::IntoPyDict;
953        use jiff::tz::TimeZoneTransition;
954        use jiff::SpanRelativeTo;
955        use proptest::prelude::*;
956        use std::ffi::CString;
957
958        // This is to skip the test if we are creating an invalid date, like February 31.
959        fn try_date(year: i32, month: u32, day: u32) -> PyResult<Date> {
960            Ok(Date::new(
961                year.try_into()?,
962                month.try_into()?,
963                day.try_into()?,
964            )?)
965        }
966
967        fn try_time(hour: u32, min: u32, sec: u32, micro: u32) -> PyResult<Time> {
968            Ok(Time::new(
969                hour.try_into()?,
970                min.try_into()?,
971                sec.try_into()?,
972                (micro * 1000).try_into()?,
973            )?)
974        }
975
976        prop_compose! {
977            fn timezone_transitions(timezone: &TimeZone)
978                            (year in 1900i16..=2100i16, month in 1i8..=12i8)
979                            -> TimeZoneTransition<'_> {
980                let datetime = DateTime::new(year, month, 1, 0, 0, 0, 0).unwrap();
981                let timestamp= timezone.to_zoned(datetime).unwrap().timestamp();
982                timezone.following(timestamp).next().unwrap()
983            }
984        }
985
986        proptest! {
987
988            // Range is limited to 1970 to 2038 due to windows limitations
989            #[test]
990            fn test_pyo3_offset_fixed_frompyobject_created_in_python(timestamp in 0..(i32::MAX as i64), timedelta in -86399i32..=86399i32) {
991                Python::with_gil(|py| {
992
993                    let globals = [("datetime", py.import("datetime").unwrap())].into_py_dict(py).unwrap();
994                    let code = format!("datetime.datetime.fromtimestamp({}).replace(tzinfo=datetime.timezone(datetime.timedelta(seconds={})))", timestamp, timedelta);
995                    let t = py.eval(&CString::new(code).unwrap(), Some(&globals), None).unwrap();
996
997                    // Get ISO 8601 string from python
998                    let py_iso_str = t.call_method0("isoformat").unwrap();
999
1000                    // Get ISO 8601 string from rust
1001                    let rust_iso_str = t.extract::<Zoned>().unwrap().strftime("%Y-%m-%dT%H:%M:%S%:z").to_string();
1002
1003                    // They should be equal
1004                    assert_eq!(py_iso_str.to_string(), rust_iso_str);
1005                })
1006            }
1007
1008            #[test]
1009            fn test_duration_roundtrip(days in -999999999i64..=999999999i64) {
1010                // Test roundtrip conversion rust->python->rust for all allowed
1011                // python values of durations (from -999999999 to 999999999 days),
1012                Python::with_gil(|py| {
1013                    let dur = SignedDuration::new(days * 24 * 60 * 60, 0);
1014                    let py_delta = dur.into_pyobject(py).unwrap();
1015                    let roundtripped: SignedDuration = py_delta.extract().expect("Round trip");
1016                    assert_eq!(dur, roundtripped);
1017                })
1018            }
1019
1020            #[test]
1021            fn test_span_roundtrip(days in -999999999i64..=999999999i64) {
1022                // Test roundtrip conversion rust->python->rust for all allowed
1023                // python values of durations (from -999999999 to 999999999 days),
1024                Python::with_gil(|py| {
1025                    if let Ok(span) = Span::new().try_days(days) {
1026                        let relative_to = SpanRelativeTo::days_are_24_hours();
1027                        let jiff_duration = span.to_duration(relative_to).unwrap();
1028                        let py_delta = jiff_duration.into_pyobject(py).unwrap();
1029                        let roundtripped: Span = py_delta.extract().expect("Round trip");
1030                        assert_eq!(span.compare((roundtripped, relative_to)).unwrap(), Ordering::Equal);
1031                    }
1032                })
1033            }
1034
1035            #[test]
1036            fn test_fixed_offset_roundtrip(secs in -86399i32..=86399i32) {
1037                Python::with_gil(|py| {
1038                    let offset = Offset::from_seconds(secs).unwrap();
1039                    let py_offset = offset.into_pyobject(py).unwrap();
1040                    let roundtripped: Offset = py_offset.extract().expect("Round trip");
1041                    assert_eq!(offset, roundtripped);
1042                })
1043            }
1044
1045            #[test]
1046            fn test_naive_date_roundtrip(
1047                year in 1i32..=9999i32,
1048                month in 1u32..=12u32,
1049                day in 1u32..=31u32
1050            ) {
1051                // Test roundtrip conversion rust->python->rust for all allowed
1052                // python dates (from year 1 to year 9999)
1053                Python::with_gil(|py| {
1054                    if let Ok(date) = try_date(year, month, day) {
1055                        let py_date = date.into_pyobject(py).unwrap();
1056                        let roundtripped: Date = py_date.extract().expect("Round trip");
1057                        assert_eq!(date, roundtripped);
1058                    }
1059                })
1060            }
1061
1062            #[test]
1063            fn test_naive_time_roundtrip(
1064                hour in 0u32..=23u32,
1065                min in 0u32..=59u32,
1066                sec in 0u32..=59u32,
1067                micro in 0u32..=1_999_999u32
1068            ) {
1069                Python::with_gil(|py| {
1070                    if let Ok(time) = try_time(hour, min, sec, micro) {
1071                        let py_time = time.into_pyobject(py).unwrap();
1072                        let roundtripped: Time = py_time.extract().expect("Round trip");
1073                        assert_eq!(time, roundtripped);
1074                    }
1075                })
1076            }
1077
1078            #[test]
1079            fn test_naive_datetime_roundtrip(
1080                year in 1i32..=9999i32,
1081                month in 1u32..=12u32,
1082                day in 1u32..=31u32,
1083                hour in 0u32..=24u32,
1084                min in 0u32..=60u32,
1085                sec in 0u32..=60u32,
1086                micro in 0u32..=999_999u32
1087            ) {
1088                Python::with_gil(|py| {
1089                    let date_opt = try_date(year, month, day);
1090                    let time_opt = try_time(hour, min, sec, micro);
1091                    if let (Ok(date), Ok(time)) = (date_opt, time_opt) {
1092                        let dt = DateTime::from_parts(date, time);
1093                        let pydt = dt.into_pyobject(py).unwrap();
1094                        let roundtripped: DateTime = pydt.extract().expect("Round trip");
1095                        assert_eq!(dt, roundtripped);
1096                    }
1097                })
1098            }
1099
1100            #[test]
1101            fn test_utc_datetime_roundtrip(
1102                year in 1i32..=9999i32,
1103                month in 1u32..=12u32,
1104                day in 1u32..=31u32,
1105                hour in 0u32..=23u32,
1106                min in 0u32..=59u32,
1107                sec in 0u32..=59u32,
1108                micro in 0u32..=1_999_999u32
1109            ) {
1110                Python::with_gil(|py| {
1111                    let date_opt = try_date(year, month, day);
1112                    let time_opt = try_time(hour, min, sec, micro);
1113                    if let (Ok(date), Ok(time)) = (date_opt, time_opt) {
1114                        let dt: Zoned = DateTime::from_parts(date, time).to_zoned(TimeZone::UTC).unwrap();
1115                        let py_dt = (&dt).into_pyobject(py).unwrap();
1116                        let roundtripped: Zoned = py_dt.extract().expect("Round trip");
1117                        assert_eq!(dt, roundtripped);
1118                    }
1119                })
1120            }
1121
1122            #[test]
1123            fn test_fixed_offset_datetime_roundtrip(
1124                year in 1i32..=9999i32,
1125                month in 1u32..=12u32,
1126                day in 1u32..=31u32,
1127                hour in 0u32..=23u32,
1128                min in 0u32..=59u32,
1129                sec in 0u32..=59u32,
1130                micro in 0u32..=1_999_999u32,
1131                offset_secs in -86399i32..=86399i32
1132            ) {
1133                Python::with_gil(|py| {
1134                    let date_opt = try_date(year, month, day);
1135                    let time_opt = try_time(hour, min, sec, micro);
1136                    let offset = Offset::from_seconds(offset_secs).unwrap();
1137                    if let (Ok(date), Ok(time)) = (date_opt, time_opt) {
1138                        let dt: Zoned = DateTime::from_parts(date, time).to_zoned(offset.to_time_zone()).unwrap();
1139                        let py_dt = (&dt).into_pyobject(py).unwrap();
1140                        let roundtripped: Zoned = py_dt.extract().expect("Round trip");
1141                        assert_eq!(dt, roundtripped);
1142                    }
1143                })
1144            }
1145
1146            #[test]
1147            #[cfg(all(Py_3_9, not(windows)))]
1148            fn test_zoned_datetime_roundtrip_around_timezone_transition(
1149                (timezone, transition) in prop_oneof![
1150                                Just(&TimeZone::get("Europe/London").unwrap()),
1151                                Just(&TimeZone::get("America/New_York").unwrap()),
1152                                Just(&TimeZone::get("Australia/Sydney").unwrap()),
1153                            ].prop_flat_map(|tz| (Just(tz), timezone_transitions(tz))),
1154                hour in -2i32..=2i32,
1155                min in 0u32..=59u32,
1156            ) {
1157
1158                Python::with_gil(|py| {
1159                    let transition_moment = transition.timestamp();
1160                    let zoned = (transition_moment - Span::new().hours(hour).minutes(min))
1161                        .to_zoned(timezone.clone());
1162
1163                    let py_dt = (&zoned).into_pyobject(py).unwrap();
1164                    let roundtripped: Zoned = py_dt.extract().expect("Round trip");
1165                    assert_eq!(zoned, roundtripped);
1166                })
1167
1168            }
1169        }
1170    }
1171}
⚠️ Internal Docs ⚠️ Not Public API 👉 Official Docs Here