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