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