pyo3/conversions/
chrono.rs

1#![cfg(feature = "chrono")]
2
3//! Conversions to and from [chrono](https://docs.rs/chrono/)’s `Duration`,
4//! `NaiveDate`, `NaiveTime`, `DateTime<Tz>`, `FixedOffset`, and `Utc`.
5//!
6//! # Setup
7//!
8//! To use this feature, add this to your **`Cargo.toml`**:
9//!
10//! ```toml
11//! [dependencies]
12//! chrono = "0.4"
13#![doc = concat!("pyo3 = { version = \"", env!("CARGO_PKG_VERSION"),  "\", features = [\"chrono\"] }")]
14//! ```
15//!
16//! Note that you must use compatible versions of chrono and PyO3.
17//! The required chrono version may vary based on the version of PyO3.
18//!
19//! # Example: Convert a `datetime.datetime` to chrono's `DateTime<Utc>`
20//!
21//! ```rust
22//! use chrono::{DateTime, Duration, TimeZone, Utc};
23//! use pyo3::{Python, PyResult, IntoPyObject, types::PyAnyMethods};
24//!
25//! fn main() -> PyResult<()> {
26//!     pyo3::prepare_freethreaded_python();
27//!     Python::with_gil(|py| {
28//!         // Build some chrono values
29//!         let chrono_datetime = Utc.with_ymd_and_hms(2022, 1, 1, 12, 0, 0).unwrap();
30//!         let chrono_duration = Duration::seconds(1);
31//!         // Convert them to Python
32//!         let py_datetime = chrono_datetime.into_pyobject(py)?;
33//!         let py_timedelta = chrono_duration.into_pyobject(py)?;
34//!         // Do an operation in Python
35//!         let py_sum = py_datetime.call_method1("__add__", (py_timedelta,))?;
36//!         // Convert back to Rust
37//!         let chrono_sum: DateTime<Utc> = py_sum.extract()?;
38//!         println!("DateTime<Utc>: {}", chrono_datetime);
39//!         Ok(())
40//!     })
41//! }
42//! ```
43
44use crate::conversion::IntoPyObject;
45use crate::exceptions::{PyTypeError, PyUserWarning, PyValueError};
46#[cfg(Py_LIMITED_API)]
47use crate::intern;
48use crate::types::any::PyAnyMethods;
49use crate::types::PyNone;
50use crate::types::{
51    datetime::timezone_from_offset, timezone_utc, PyDate, PyDateTime, PyDelta, PyTime, PyTzInfo,
52    PyTzInfoAccess,
53};
54#[cfg(not(Py_LIMITED_API))]
55use crate::types::{PyDateAccess, PyDeltaAccess, PyTimeAccess};
56use crate::{ffi, Bound, FromPyObject, IntoPyObjectExt, PyAny, PyErr, PyResult, Python};
57use chrono::offset::{FixedOffset, Utc};
58use chrono::{
59    DateTime, Datelike, Duration, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, Offset,
60    TimeZone, Timelike,
61};
62
63impl<'py> IntoPyObject<'py> for Duration {
64    type Target = PyDelta;
65    type Output = Bound<'py, Self::Target>;
66    type Error = PyErr;
67
68    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
69        // Total number of days
70        let days = self.num_days();
71        // Remainder of seconds
72        let secs_dur = self - Duration::days(days);
73        let secs = secs_dur.num_seconds();
74        // Fractional part of the microseconds
75        let micros = (secs_dur - Duration::seconds(secs_dur.num_seconds()))
76            .num_microseconds()
77            // This should never panic since we are just getting the fractional
78            // part of the total microseconds, which should never overflow.
79            .unwrap();
80        // We do not need to check the days i64 to i32 cast from rust because
81        // python will panic with OverflowError.
82        // We pass true as the `normalize` parameter since we'd need to do several checks here to
83        // avoid that, and it shouldn't have a big performance impact.
84        // The seconds and microseconds cast should never overflow since it's at most the number of seconds per day
85        PyDelta::new(
86            py,
87            days.try_into().unwrap_or(i32::MAX),
88            secs.try_into()?,
89            micros.try_into()?,
90            true,
91        )
92    }
93}
94
95impl<'py> IntoPyObject<'py> for &Duration {
96    type Target = PyDelta;
97    type Output = Bound<'py, Self::Target>;
98    type Error = PyErr;
99
100    #[inline]
101    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
102        (*self).into_pyobject(py)
103    }
104}
105
106impl FromPyObject<'_> for Duration {
107    fn extract_bound(ob: &Bound<'_, PyAny>) -> PyResult<Duration> {
108        let delta = ob.downcast::<PyDelta>()?;
109        // Python size are much lower than rust size so we do not need bound checks.
110        // 0 <= microseconds < 1000000
111        // 0 <= seconds < 3600*24
112        // -999999999 <= days <= 999999999
113        #[cfg(not(Py_LIMITED_API))]
114        let (days, seconds, microseconds) = {
115            (
116                delta.get_days().into(),
117                delta.get_seconds().into(),
118                delta.get_microseconds().into(),
119            )
120        };
121        #[cfg(Py_LIMITED_API)]
122        let (days, seconds, microseconds) = {
123            let py = delta.py();
124            (
125                delta.getattr(intern!(py, "days"))?.extract()?,
126                delta.getattr(intern!(py, "seconds"))?.extract()?,
127                delta.getattr(intern!(py, "microseconds"))?.extract()?,
128            )
129        };
130        Ok(
131            Duration::days(days)
132                + Duration::seconds(seconds)
133                + Duration::microseconds(microseconds),
134        )
135    }
136}
137
138impl<'py> IntoPyObject<'py> for NaiveDate {
139    type Target = PyDate;
140    type Output = Bound<'py, Self::Target>;
141    type Error = PyErr;
142
143    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
144        let DateArgs { year, month, day } = (&self).into();
145        PyDate::new(py, year, month, day)
146    }
147}
148
149impl<'py> IntoPyObject<'py> for &NaiveDate {
150    type Target = PyDate;
151    type Output = Bound<'py, Self::Target>;
152    type Error = PyErr;
153
154    #[inline]
155    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
156        (*self).into_pyobject(py)
157    }
158}
159
160impl FromPyObject<'_> for NaiveDate {
161    fn extract_bound(ob: &Bound<'_, PyAny>) -> PyResult<NaiveDate> {
162        let date = ob.downcast::<PyDate>()?;
163        py_date_to_naive_date(date)
164    }
165}
166
167impl<'py> IntoPyObject<'py> for NaiveTime {
168    type Target = PyTime;
169    type Output = Bound<'py, Self::Target>;
170    type Error = PyErr;
171
172    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
173        let TimeArgs {
174            hour,
175            min,
176            sec,
177            micro,
178            truncated_leap_second,
179        } = (&self).into();
180
181        let time = PyTime::new(py, hour, min, sec, micro, None)?;
182
183        if truncated_leap_second {
184            warn_truncated_leap_second(&time);
185        }
186
187        Ok(time)
188    }
189}
190
191impl<'py> IntoPyObject<'py> for &NaiveTime {
192    type Target = PyTime;
193    type Output = Bound<'py, Self::Target>;
194    type Error = PyErr;
195
196    #[inline]
197    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
198        (*self).into_pyobject(py)
199    }
200}
201
202impl FromPyObject<'_> for NaiveTime {
203    fn extract_bound(ob: &Bound<'_, PyAny>) -> PyResult<NaiveTime> {
204        let time = ob.downcast::<PyTime>()?;
205        py_time_to_naive_time(time)
206    }
207}
208
209impl<'py> IntoPyObject<'py> for NaiveDateTime {
210    type Target = PyDateTime;
211    type Output = Bound<'py, Self::Target>;
212    type Error = PyErr;
213
214    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
215        let DateArgs { year, month, day } = (&self.date()).into();
216        let TimeArgs {
217            hour,
218            min,
219            sec,
220            micro,
221            truncated_leap_second,
222        } = (&self.time()).into();
223
224        let datetime = PyDateTime::new(py, year, month, day, hour, min, sec, micro, None)?;
225
226        if truncated_leap_second {
227            warn_truncated_leap_second(&datetime);
228        }
229
230        Ok(datetime)
231    }
232}
233
234impl<'py> IntoPyObject<'py> for &NaiveDateTime {
235    type Target = PyDateTime;
236    type Output = Bound<'py, Self::Target>;
237    type Error = PyErr;
238
239    #[inline]
240    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
241        (*self).into_pyobject(py)
242    }
243}
244
245impl FromPyObject<'_> for NaiveDateTime {
246    fn extract_bound(dt: &Bound<'_, PyAny>) -> PyResult<NaiveDateTime> {
247        let dt = dt.downcast::<PyDateTime>()?;
248
249        // If the user tries to convert a timezone aware datetime into a naive one,
250        // we return a hard error. We could silently remove tzinfo, or assume local timezone
251        // and do a conversion, but better leave this decision to the user of the library.
252        let has_tzinfo = dt.get_tzinfo().is_some();
253        if has_tzinfo {
254            return Err(PyTypeError::new_err("expected a datetime without tzinfo"));
255        }
256
257        let dt = NaiveDateTime::new(py_date_to_naive_date(dt)?, py_time_to_naive_time(dt)?);
258        Ok(dt)
259    }
260}
261
262impl<'py, Tz: TimeZone> IntoPyObject<'py> for DateTime<Tz>
263where
264    Tz: IntoPyObject<'py>,
265{
266    type Target = PyDateTime;
267    type Output = Bound<'py, Self::Target>;
268    type Error = PyErr;
269
270    #[inline]
271    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
272        (&self).into_pyobject(py)
273    }
274}
275
276impl<'py, Tz: TimeZone> IntoPyObject<'py> for &DateTime<Tz>
277where
278    Tz: IntoPyObject<'py>,
279{
280    type Target = PyDateTime;
281    type Output = Bound<'py, Self::Target>;
282    type Error = PyErr;
283
284    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
285        let tz = self.timezone().into_bound_py_any(py)?.downcast_into()?;
286
287        let DateArgs { year, month, day } = (&self.naive_local().date()).into();
288        let TimeArgs {
289            hour,
290            min,
291            sec,
292            micro,
293            truncated_leap_second,
294        } = (&self.naive_local().time()).into();
295
296        let fold = matches!(
297            self.timezone().offset_from_local_datetime(&self.naive_local()),
298            LocalResult::Ambiguous(_, latest) if self.offset().fix() == latest.fix()
299        );
300
301        let datetime = PyDateTime::new_with_fold(
302            py,
303            year,
304            month,
305            day,
306            hour,
307            min,
308            sec,
309            micro,
310            Some(&tz),
311            fold,
312        )?;
313
314        if truncated_leap_second {
315            warn_truncated_leap_second(&datetime);
316        }
317
318        Ok(datetime)
319    }
320}
321
322impl<Tz: TimeZone + for<'py> FromPyObject<'py>> FromPyObject<'_> for DateTime<Tz> {
323    fn extract_bound(dt: &Bound<'_, PyAny>) -> PyResult<DateTime<Tz>> {
324        let dt = dt.downcast::<PyDateTime>()?;
325        let tzinfo = dt.get_tzinfo();
326
327        let tz = if let Some(tzinfo) = tzinfo {
328            tzinfo.extract()?
329        } else {
330            return Err(PyTypeError::new_err(
331                "expected a datetime with non-None tzinfo",
332            ));
333        };
334        let naive_dt = NaiveDateTime::new(py_date_to_naive_date(dt)?, py_time_to_naive_time(dt)?);
335        match naive_dt.and_local_timezone(tz) {
336            LocalResult::Single(value) => Ok(value),
337            LocalResult::Ambiguous(earliest, latest) => {
338                #[cfg(not(Py_LIMITED_API))]
339                let fold = dt.get_fold();
340
341                #[cfg(Py_LIMITED_API)]
342                let fold = dt.getattr(intern!(dt.py(), "fold"))?.extract::<usize>()? > 0;
343
344                if fold {
345                    Ok(latest)
346                } else {
347                    Ok(earliest)
348                }
349            }
350            LocalResult::None => Err(PyValueError::new_err(format!(
351                "The datetime {:?} contains an incompatible timezone",
352                dt
353            ))),
354        }
355    }
356}
357
358impl<'py> IntoPyObject<'py> for FixedOffset {
359    type Target = PyTzInfo;
360    type Output = Bound<'py, Self::Target>;
361    type Error = PyErr;
362
363    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
364        let seconds_offset = self.local_minus_utc();
365        let td = PyDelta::new(py, 0, seconds_offset, 0, true)?;
366        timezone_from_offset(&td)
367    }
368}
369
370impl<'py> IntoPyObject<'py> for &FixedOffset {
371    type Target = PyTzInfo;
372    type Output = Bound<'py, Self::Target>;
373    type Error = PyErr;
374
375    #[inline]
376    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
377        (*self).into_pyobject(py)
378    }
379}
380
381impl FromPyObject<'_> for FixedOffset {
382    /// Convert python tzinfo to rust [`FixedOffset`].
383    ///
384    /// Note that the conversion will result in precision lost in microseconds as chrono offset
385    /// does not supports microseconds.
386    fn extract_bound(ob: &Bound<'_, PyAny>) -> PyResult<FixedOffset> {
387        let ob = ob.downcast::<PyTzInfo>()?;
388
389        // Passing Python's None to the `utcoffset` function will only
390        // work for timezones defined as fixed offsets in Python.
391        // Any other timezone would require a datetime as the parameter, and return
392        // None if the datetime is not provided.
393        // Trying to convert None to a PyDelta in the next line will then fail.
394        let py_timedelta = ob.call_method1("utcoffset", (PyNone::get(ob.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        let total_seconds: Duration = py_timedelta.extract()?;
402        // This cast is safe since the timedelta is limited to -24 hours and 24 hours.
403        let total_seconds = total_seconds.num_seconds() as i32;
404        FixedOffset::east_opt(total_seconds)
405            .ok_or_else(|| PyValueError::new_err("fixed offset out of bounds"))
406    }
407}
408
409impl<'py> IntoPyObject<'py> for Utc {
410    type Target = PyTzInfo;
411    type Output = Bound<'py, Self::Target>;
412    type Error = PyErr;
413
414    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
415        Ok(timezone_utc(py))
416    }
417}
418
419impl<'py> IntoPyObject<'py> for &Utc {
420    type Target = PyTzInfo;
421    type Output = Bound<'py, Self::Target>;
422    type Error = PyErr;
423
424    #[inline]
425    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
426        (*self).into_pyobject(py)
427    }
428}
429
430impl FromPyObject<'_> for Utc {
431    fn extract_bound(ob: &Bound<'_, PyAny>) -> PyResult<Utc> {
432        let py_utc = timezone_utc(ob.py());
433        if ob.eq(py_utc)? {
434            Ok(Utc)
435        } else {
436            Err(PyValueError::new_err("expected datetime.timezone.utc"))
437        }
438    }
439}
440
441struct DateArgs {
442    year: i32,
443    month: u8,
444    day: u8,
445}
446
447impl From<&NaiveDate> for DateArgs {
448    fn from(value: &NaiveDate) -> Self {
449        Self {
450            year: value.year(),
451            month: value.month() as u8,
452            day: value.day() as u8,
453        }
454    }
455}
456
457struct TimeArgs {
458    hour: u8,
459    min: u8,
460    sec: u8,
461    micro: u32,
462    truncated_leap_second: bool,
463}
464
465impl From<&NaiveTime> for TimeArgs {
466    fn from(value: &NaiveTime) -> Self {
467        let ns = value.nanosecond();
468        let checked_sub = ns.checked_sub(1_000_000_000);
469        let truncated_leap_second = checked_sub.is_some();
470        let micro = checked_sub.unwrap_or(ns) / 1000;
471        Self {
472            hour: value.hour() as u8,
473            min: value.minute() as u8,
474            sec: value.second() as u8,
475            micro,
476            truncated_leap_second,
477        }
478    }
479}
480
481fn warn_truncated_leap_second(obj: &Bound<'_, PyAny>) {
482    let py = obj.py();
483    if let Err(e) = PyErr::warn(
484        py,
485        &py.get_type::<PyUserWarning>(),
486        ffi::c_str!("ignored leap-second, `datetime` does not support leap-seconds"),
487        0,
488    ) {
489        e.write_unraisable(py, Some(obj))
490    };
491}
492
493#[cfg(not(Py_LIMITED_API))]
494fn py_date_to_naive_date(py_date: &impl PyDateAccess) -> PyResult<NaiveDate> {
495    NaiveDate::from_ymd_opt(
496        py_date.get_year(),
497        py_date.get_month().into(),
498        py_date.get_day().into(),
499    )
500    .ok_or_else(|| PyValueError::new_err("invalid or out-of-range date"))
501}
502
503#[cfg(Py_LIMITED_API)]
504fn py_date_to_naive_date(py_date: &Bound<'_, PyAny>) -> PyResult<NaiveDate> {
505    NaiveDate::from_ymd_opt(
506        py_date.getattr(intern!(py_date.py(), "year"))?.extract()?,
507        py_date.getattr(intern!(py_date.py(), "month"))?.extract()?,
508        py_date.getattr(intern!(py_date.py(), "day"))?.extract()?,
509    )
510    .ok_or_else(|| PyValueError::new_err("invalid or out-of-range date"))
511}
512
513#[cfg(not(Py_LIMITED_API))]
514fn py_time_to_naive_time(py_time: &impl PyTimeAccess) -> PyResult<NaiveTime> {
515    NaiveTime::from_hms_micro_opt(
516        py_time.get_hour().into(),
517        py_time.get_minute().into(),
518        py_time.get_second().into(),
519        py_time.get_microsecond(),
520    )
521    .ok_or_else(|| PyValueError::new_err("invalid or out-of-range time"))
522}
523
524#[cfg(Py_LIMITED_API)]
525fn py_time_to_naive_time(py_time: &Bound<'_, PyAny>) -> PyResult<NaiveTime> {
526    NaiveTime::from_hms_micro_opt(
527        py_time.getattr(intern!(py_time.py(), "hour"))?.extract()?,
528        py_time
529            .getattr(intern!(py_time.py(), "minute"))?
530            .extract()?,
531        py_time
532            .getattr(intern!(py_time.py(), "second"))?
533            .extract()?,
534        py_time
535            .getattr(intern!(py_time.py(), "microsecond"))?
536            .extract()?,
537    )
538    .ok_or_else(|| PyValueError::new_err("invalid or out-of-range time"))
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544    use crate::{types::PyTuple, BoundObject};
545    use std::{cmp::Ordering, panic};
546
547    #[test]
548    // Only Python>=3.9 has the zoneinfo package
549    // We skip the test on windows too since we'd need to install
550    // tzdata there to make this work.
551    #[cfg(all(Py_3_9, not(target_os = "windows")))]
552    fn test_zoneinfo_is_not_fixed_offset() {
553        use crate::ffi;
554        use crate::types::any::PyAnyMethods;
555        use crate::types::dict::PyDictMethods;
556
557        Python::with_gil(|py| {
558            let locals = crate::types::PyDict::new(py);
559            py.run(
560                ffi::c_str!("import zoneinfo; zi = zoneinfo.ZoneInfo('Europe/London')"),
561                None,
562                Some(&locals),
563            )
564            .unwrap();
565            let result: PyResult<FixedOffset> = locals.get_item("zi").unwrap().unwrap().extract();
566            assert!(result.is_err());
567            let res = result.err().unwrap();
568            // Also check the error message is what we expect
569            let msg = res.value(py).repr().unwrap().to_string();
570            assert_eq!(msg, "TypeError(\"zoneinfo.ZoneInfo(key='Europe/London') is not a fixed offset timezone\")");
571        });
572    }
573
574    #[test]
575    fn test_timezone_aware_to_naive_fails() {
576        // Test that if a user tries to convert a python's timezone aware datetime into a naive
577        // one, the conversion fails.
578        Python::with_gil(|py| {
579            let py_datetime =
580                new_py_datetime_ob(py, "datetime", (2022, 1, 1, 1, 0, 0, 0, python_utc(py)));
581            // Now test that converting a PyDateTime with tzinfo to a NaiveDateTime fails
582            let res: PyResult<NaiveDateTime> = py_datetime.extract();
583            assert_eq!(
584                res.unwrap_err().value(py).repr().unwrap().to_string(),
585                "TypeError('expected a datetime without tzinfo')"
586            );
587        });
588    }
589
590    #[test]
591    fn test_naive_to_timezone_aware_fails() {
592        // Test that if a user tries to convert a python's timezone aware datetime into a naive
593        // one, the conversion fails.
594        Python::with_gil(|py| {
595            let py_datetime = new_py_datetime_ob(py, "datetime", (2022, 1, 1, 1, 0, 0, 0));
596            // Now test that converting a PyDateTime with tzinfo to a NaiveDateTime fails
597            let res: PyResult<DateTime<Utc>> = py_datetime.extract();
598            assert_eq!(
599                res.unwrap_err().value(py).repr().unwrap().to_string(),
600                "TypeError('expected a datetime with non-None tzinfo')"
601            );
602
603            // Now test that converting a PyDateTime with tzinfo to a NaiveDateTime fails
604            let res: PyResult<DateTime<FixedOffset>> = py_datetime.extract();
605            assert_eq!(
606                res.unwrap_err().value(py).repr().unwrap().to_string(),
607                "TypeError('expected a datetime with non-None tzinfo')"
608            );
609        });
610    }
611
612    #[test]
613    fn test_invalid_types_fail() {
614        // Test that if a user tries to convert a python's timezone aware datetime into a naive
615        // one, the conversion fails.
616        Python::with_gil(|py| {
617            let none = py.None().into_bound(py);
618            assert_eq!(
619                none.extract::<Duration>().unwrap_err().to_string(),
620                "TypeError: 'NoneType' object cannot be converted to 'PyDelta'"
621            );
622            assert_eq!(
623                none.extract::<FixedOffset>().unwrap_err().to_string(),
624                "TypeError: 'NoneType' object cannot be converted to 'PyTzInfo'"
625            );
626            assert_eq!(
627                none.extract::<Utc>().unwrap_err().to_string(),
628                "ValueError: expected datetime.timezone.utc"
629            );
630            assert_eq!(
631                none.extract::<NaiveTime>().unwrap_err().to_string(),
632                "TypeError: 'NoneType' object cannot be converted to 'PyTime'"
633            );
634            assert_eq!(
635                none.extract::<NaiveDate>().unwrap_err().to_string(),
636                "TypeError: 'NoneType' object cannot be converted to 'PyDate'"
637            );
638            assert_eq!(
639                none.extract::<NaiveDateTime>().unwrap_err().to_string(),
640                "TypeError: 'NoneType' object cannot be converted to 'PyDateTime'"
641            );
642            assert_eq!(
643                none.extract::<DateTime<Utc>>().unwrap_err().to_string(),
644                "TypeError: 'NoneType' object cannot be converted to 'PyDateTime'"
645            );
646            assert_eq!(
647                none.extract::<DateTime<FixedOffset>>()
648                    .unwrap_err()
649                    .to_string(),
650                "TypeError: 'NoneType' object cannot be converted to 'PyDateTime'"
651            );
652        });
653    }
654
655    #[test]
656    fn test_pyo3_timedelta_into_pyobject() {
657        // Utility function used to check different durations.
658        // The `name` parameter is used to identify the check in case of a failure.
659        let check = |name: &'static str, delta: Duration, py_days, py_seconds, py_ms| {
660            Python::with_gil(|py| {
661                let delta = delta.into_pyobject(py).unwrap();
662                let py_delta = new_py_datetime_ob(py, "timedelta", (py_days, py_seconds, py_ms));
663                assert!(
664                    delta.eq(&py_delta).unwrap(),
665                    "{}: {} != {}",
666                    name,
667                    delta,
668                    py_delta
669                );
670            });
671        };
672
673        let delta = Duration::days(-1) + Duration::seconds(1) + Duration::microseconds(-10);
674        check("delta normalization", delta, -1, 1, -10);
675
676        // Check the minimum value allowed by PyDelta, which is different
677        // from the minimum value allowed in Duration. This should pass.
678        let delta = Duration::seconds(-86399999913600); // min
679        check("delta min value", delta, -999999999, 0, 0);
680
681        // Same, for max value
682        let delta = Duration::seconds(86399999999999) + Duration::nanoseconds(999999000); // max
683        check("delta max value", delta, 999999999, 86399, 999999);
684
685        // Also check that trying to convert an out of bound value errors.
686        Python::with_gil(|py| {
687            // min_value and max_value were deprecated in chrono 0.4.39
688            #[allow(deprecated)]
689            {
690                assert!(Duration::min_value().into_pyobject(py).is_err());
691                assert!(Duration::max_value().into_pyobject(py).is_err());
692            }
693        });
694    }
695
696    #[test]
697    fn test_pyo3_timedelta_frompyobject() {
698        // Utility function used to check different durations.
699        // The `name` parameter is used to identify the check in case of a failure.
700        let check = |name: &'static str, delta: Duration, py_days, py_seconds, py_ms| {
701            Python::with_gil(|py| {
702                let py_delta = new_py_datetime_ob(py, "timedelta", (py_days, py_seconds, py_ms));
703                let py_delta: Duration = py_delta.extract().unwrap();
704                assert_eq!(py_delta, delta, "{}: {} != {}", name, py_delta, delta);
705            })
706        };
707
708        // Check the minimum value allowed by PyDelta, which is different
709        // from the minimum value allowed in Duration. This should pass.
710        check(
711            "min py_delta value",
712            Duration::seconds(-86399999913600),
713            -999999999,
714            0,
715            0,
716        );
717        // Same, for max value
718        check(
719            "max py_delta value",
720            Duration::seconds(86399999999999) + Duration::microseconds(999999),
721            999999999,
722            86399,
723            999999,
724        );
725
726        // This check is to assert that we can't construct every possible Duration from a PyDelta
727        // since they have different bounds.
728        Python::with_gil(|py| {
729            let low_days: i32 = -1000000000;
730            // This is possible
731            assert!(panic::catch_unwind(|| Duration::days(low_days as i64)).is_ok());
732            // This panics on PyDelta::new
733            assert!(panic::catch_unwind(|| {
734                let py_delta = new_py_datetime_ob(py, "timedelta", (low_days, 0, 0));
735                if let Ok(_duration) = py_delta.extract::<Duration>() {
736                    // So we should never get here
737                }
738            })
739            .is_err());
740
741            let high_days: i32 = 1000000000;
742            // This is possible
743            assert!(panic::catch_unwind(|| Duration::days(high_days as i64)).is_ok());
744            // This panics on PyDelta::new
745            assert!(panic::catch_unwind(|| {
746                let py_delta = new_py_datetime_ob(py, "timedelta", (high_days, 0, 0));
747                if let Ok(_duration) = py_delta.extract::<Duration>() {
748                    // So we should never get here
749                }
750            })
751            .is_err());
752        });
753    }
754
755    #[test]
756    fn test_pyo3_date_into_pyobject() {
757        let eq_ymd = |name: &'static str, year, month, day| {
758            Python::with_gil(|py| {
759                let date = NaiveDate::from_ymd_opt(year, month, day)
760                    .unwrap()
761                    .into_pyobject(py)
762                    .unwrap();
763                let py_date = new_py_datetime_ob(py, "date", (year, month, day));
764                assert_eq!(
765                    date.compare(&py_date).unwrap(),
766                    Ordering::Equal,
767                    "{}: {} != {}",
768                    name,
769                    date,
770                    py_date
771                );
772            })
773        };
774
775        eq_ymd("past date", 2012, 2, 29);
776        eq_ymd("min date", 1, 1, 1);
777        eq_ymd("future date", 3000, 6, 5);
778        eq_ymd("max date", 9999, 12, 31);
779    }
780
781    #[test]
782    fn test_pyo3_date_frompyobject() {
783        let eq_ymd = |name: &'static str, year, month, day| {
784            Python::with_gil(|py| {
785                let py_date = new_py_datetime_ob(py, "date", (year, month, day));
786                let py_date: NaiveDate = py_date.extract().unwrap();
787                let date = NaiveDate::from_ymd_opt(year, month, day).unwrap();
788                assert_eq!(py_date, date, "{}: {} != {}", name, date, py_date);
789            })
790        };
791
792        eq_ymd("past date", 2012, 2, 29);
793        eq_ymd("min date", 1, 1, 1);
794        eq_ymd("future date", 3000, 6, 5);
795        eq_ymd("max date", 9999, 12, 31);
796    }
797
798    #[test]
799    fn test_pyo3_datetime_into_pyobject_utc() {
800        Python::with_gil(|py| {
801            let check_utc =
802                |name: &'static str, year, month, day, hour, minute, second, ms, py_ms| {
803                    let datetime = NaiveDate::from_ymd_opt(year, month, day)
804                        .unwrap()
805                        .and_hms_micro_opt(hour, minute, second, ms)
806                        .unwrap()
807                        .and_utc();
808                    let datetime = datetime.into_pyobject(py).unwrap();
809                    let py_datetime = new_py_datetime_ob(
810                        py,
811                        "datetime",
812                        (
813                            year,
814                            month,
815                            day,
816                            hour,
817                            minute,
818                            second,
819                            py_ms,
820                            python_utc(py),
821                        ),
822                    );
823                    assert_eq!(
824                        datetime.compare(&py_datetime).unwrap(),
825                        Ordering::Equal,
826                        "{}: {} != {}",
827                        name,
828                        datetime,
829                        py_datetime
830                    );
831                };
832
833            check_utc("regular", 2014, 5, 6, 7, 8, 9, 999_999, 999_999);
834
835            #[cfg(not(Py_GIL_DISABLED))]
836            assert_warnings!(
837                py,
838                check_utc("leap second", 2014, 5, 6, 7, 8, 59, 1_999_999, 999_999),
839                [(
840                    PyUserWarning,
841                    "ignored leap-second, `datetime` does not support leap-seconds"
842                )]
843            );
844        })
845    }
846
847    #[test]
848    fn test_pyo3_datetime_into_pyobject_fixed_offset() {
849        Python::with_gil(|py| {
850            let check_fixed_offset =
851                |name: &'static str, year, month, day, hour, minute, second, ms, py_ms| {
852                    let offset = FixedOffset::east_opt(3600).unwrap();
853                    let datetime = NaiveDate::from_ymd_opt(year, month, day)
854                        .unwrap()
855                        .and_hms_micro_opt(hour, minute, second, ms)
856                        .unwrap()
857                        .and_local_timezone(offset)
858                        .unwrap();
859                    let datetime = datetime.into_pyobject(py).unwrap();
860                    let py_tz = offset.into_pyobject(py).unwrap();
861                    let py_datetime = new_py_datetime_ob(
862                        py,
863                        "datetime",
864                        (year, month, day, hour, minute, second, py_ms, py_tz),
865                    );
866                    assert_eq!(
867                        datetime.compare(&py_datetime).unwrap(),
868                        Ordering::Equal,
869                        "{}: {} != {}",
870                        name,
871                        datetime,
872                        py_datetime
873                    );
874                };
875
876            check_fixed_offset("regular", 2014, 5, 6, 7, 8, 9, 999_999, 999_999);
877
878            #[cfg(not(Py_GIL_DISABLED))]
879            assert_warnings!(
880                py,
881                check_fixed_offset("leap second", 2014, 5, 6, 7, 8, 59, 1_999_999, 999_999),
882                [(
883                    PyUserWarning,
884                    "ignored leap-second, `datetime` does not support leap-seconds"
885                )]
886            );
887        })
888    }
889
890    #[test]
891    #[cfg(all(Py_3_9, feature = "chrono-tz", not(windows)))]
892    fn test_pyo3_datetime_into_pyobject_tz() {
893        Python::with_gil(|py| {
894            let datetime = NaiveDate::from_ymd_opt(2024, 12, 11)
895                .unwrap()
896                .and_hms_opt(23, 3, 13)
897                .unwrap()
898                .and_local_timezone(chrono_tz::Tz::Europe__London)
899                .unwrap();
900            let datetime = datetime.into_pyobject(py).unwrap();
901            let py_datetime = new_py_datetime_ob(
902                py,
903                "datetime",
904                (
905                    2024,
906                    12,
907                    11,
908                    23,
909                    3,
910                    13,
911                    0,
912                    python_zoneinfo(py, "Europe/London"),
913                ),
914            );
915            assert_eq!(datetime.compare(&py_datetime).unwrap(), Ordering::Equal);
916        })
917    }
918
919    #[test]
920    fn test_pyo3_datetime_frompyobject_utc() {
921        Python::with_gil(|py| {
922            let year = 2014;
923            let month = 5;
924            let day = 6;
925            let hour = 7;
926            let minute = 8;
927            let second = 9;
928            let micro = 999_999;
929            let tz_utc = timezone_utc(py);
930            let py_datetime = new_py_datetime_ob(
931                py,
932                "datetime",
933                (year, month, day, hour, minute, second, micro, tz_utc),
934            );
935            let py_datetime: DateTime<Utc> = py_datetime.extract().unwrap();
936            let datetime = NaiveDate::from_ymd_opt(year, month, day)
937                .unwrap()
938                .and_hms_micro_opt(hour, minute, second, micro)
939                .unwrap()
940                .and_utc();
941            assert_eq!(py_datetime, datetime,);
942        })
943    }
944
945    #[test]
946    fn test_pyo3_datetime_frompyobject_fixed_offset() {
947        Python::with_gil(|py| {
948            let year = 2014;
949            let month = 5;
950            let day = 6;
951            let hour = 7;
952            let minute = 8;
953            let second = 9;
954            let micro = 999_999;
955            let offset = FixedOffset::east_opt(3600).unwrap();
956            let py_tz = offset.into_pyobject(py).unwrap();
957            let py_datetime = new_py_datetime_ob(
958                py,
959                "datetime",
960                (year, month, day, hour, minute, second, micro, py_tz),
961            );
962            let datetime_from_py: DateTime<FixedOffset> = py_datetime.extract().unwrap();
963            let datetime = NaiveDate::from_ymd_opt(year, month, day)
964                .unwrap()
965                .and_hms_micro_opt(hour, minute, second, micro)
966                .unwrap();
967            let datetime = datetime.and_local_timezone(offset).unwrap();
968
969            assert_eq!(datetime_from_py, datetime);
970            assert!(
971                py_datetime.extract::<DateTime<Utc>>().is_err(),
972                "Extracting Utc from nonzero FixedOffset timezone will fail"
973            );
974
975            let utc = python_utc(py);
976            let py_datetime_utc = new_py_datetime_ob(
977                py,
978                "datetime",
979                (year, month, day, hour, minute, second, micro, utc),
980            );
981            assert!(
982                py_datetime_utc.extract::<DateTime<FixedOffset>>().is_ok(),
983                "Extracting FixedOffset from Utc timezone will succeed"
984            );
985        })
986    }
987
988    #[test]
989    fn test_pyo3_offset_fixed_into_pyobject() {
990        Python::with_gil(|py| {
991            // Chrono offset
992            let offset = FixedOffset::east_opt(3600)
993                .unwrap()
994                .into_pyobject(py)
995                .unwrap();
996            // Python timezone from timedelta
997            let td = new_py_datetime_ob(py, "timedelta", (0, 3600, 0));
998            let py_timedelta = new_py_datetime_ob(py, "timezone", (td,));
999            // Should be equal
1000            assert!(offset.eq(py_timedelta).unwrap());
1001
1002            // Same but with negative values
1003            let offset = FixedOffset::east_opt(-3600)
1004                .unwrap()
1005                .into_pyobject(py)
1006                .unwrap();
1007            let td = new_py_datetime_ob(py, "timedelta", (0, -3600, 0));
1008            let py_timedelta = new_py_datetime_ob(py, "timezone", (td,));
1009            assert!(offset.eq(py_timedelta).unwrap());
1010        })
1011    }
1012
1013    #[test]
1014    fn test_pyo3_offset_fixed_frompyobject() {
1015        Python::with_gil(|py| {
1016            let py_timedelta = new_py_datetime_ob(py, "timedelta", (0, 3600, 0));
1017            let py_tzinfo = new_py_datetime_ob(py, "timezone", (py_timedelta,));
1018            let offset: FixedOffset = py_tzinfo.extract().unwrap();
1019            assert_eq!(FixedOffset::east_opt(3600).unwrap(), offset);
1020        })
1021    }
1022
1023    #[test]
1024    fn test_pyo3_offset_utc_into_pyobject() {
1025        Python::with_gil(|py| {
1026            let utc = Utc.into_pyobject(py).unwrap();
1027            let py_utc = python_utc(py);
1028            assert!(utc.is(&py_utc));
1029        })
1030    }
1031
1032    #[test]
1033    fn test_pyo3_offset_utc_frompyobject() {
1034        Python::with_gil(|py| {
1035            let py_utc = python_utc(py);
1036            let py_utc: Utc = py_utc.extract().unwrap();
1037            assert_eq!(Utc, py_utc);
1038
1039            let py_timedelta = new_py_datetime_ob(py, "timedelta", (0, 0, 0));
1040            let py_timezone_utc = new_py_datetime_ob(py, "timezone", (py_timedelta,));
1041            let py_timezone_utc: Utc = py_timezone_utc.extract().unwrap();
1042            assert_eq!(Utc, py_timezone_utc);
1043
1044            let py_timedelta = new_py_datetime_ob(py, "timedelta", (0, 3600, 0));
1045            let py_timezone = new_py_datetime_ob(py, "timezone", (py_timedelta,));
1046            assert!(py_timezone.extract::<Utc>().is_err());
1047        })
1048    }
1049
1050    #[test]
1051    fn test_pyo3_time_into_pyobject() {
1052        Python::with_gil(|py| {
1053            let check_time = |name: &'static str, hour, minute, second, ms, py_ms| {
1054                let time = NaiveTime::from_hms_micro_opt(hour, minute, second, ms)
1055                    .unwrap()
1056                    .into_pyobject(py)
1057                    .unwrap();
1058                let py_time = new_py_datetime_ob(py, "time", (hour, minute, second, py_ms));
1059                assert!(
1060                    time.eq(&py_time).unwrap(),
1061                    "{}: {} != {}",
1062                    name,
1063                    time,
1064                    py_time
1065                );
1066            };
1067
1068            check_time("regular", 3, 5, 7, 999_999, 999_999);
1069
1070            #[cfg(not(Py_GIL_DISABLED))]
1071            assert_warnings!(
1072                py,
1073                check_time("leap second", 3, 5, 59, 1_999_999, 999_999),
1074                [(
1075                    PyUserWarning,
1076                    "ignored leap-second, `datetime` does not support leap-seconds"
1077                )]
1078            );
1079        })
1080    }
1081
1082    #[test]
1083    fn test_pyo3_time_frompyobject() {
1084        let hour = 3;
1085        let minute = 5;
1086        let second = 7;
1087        let micro = 999_999;
1088        Python::with_gil(|py| {
1089            let py_time = new_py_datetime_ob(py, "time", (hour, minute, second, micro));
1090            let py_time: NaiveTime = py_time.extract().unwrap();
1091            let time = NaiveTime::from_hms_micro_opt(hour, minute, second, micro).unwrap();
1092            assert_eq!(py_time, time);
1093        })
1094    }
1095
1096    fn new_py_datetime_ob<'py, A>(py: Python<'py>, name: &str, args: A) -> Bound<'py, PyAny>
1097    where
1098        A: IntoPyObject<'py, Target = PyTuple>,
1099    {
1100        py.import("datetime")
1101            .unwrap()
1102            .getattr(name)
1103            .unwrap()
1104            .call1(
1105                args.into_pyobject(py)
1106                    .map_err(Into::into)
1107                    .unwrap()
1108                    .into_bound(),
1109            )
1110            .unwrap()
1111    }
1112
1113    fn python_utc(py: Python<'_>) -> Bound<'_, PyAny> {
1114        py.import("datetime")
1115            .unwrap()
1116            .getattr("timezone")
1117            .unwrap()
1118            .getattr("utc")
1119            .unwrap()
1120    }
1121
1122    #[cfg(all(Py_3_9, feature = "chrono-tz", not(windows)))]
1123    fn python_zoneinfo<'py>(py: Python<'py>, timezone: &str) -> Bound<'py, PyAny> {
1124        py.import("zoneinfo")
1125            .unwrap()
1126            .getattr("ZoneInfo")
1127            .unwrap()
1128            .call1((timezone,))
1129            .unwrap()
1130    }
1131
1132    #[cfg(not(any(target_arch = "wasm32", Py_GIL_DISABLED)))]
1133    mod proptests {
1134        use super::*;
1135        use crate::tests::common::CatchWarnings;
1136        use crate::types::IntoPyDict;
1137        use proptest::prelude::*;
1138        use std::ffi::CString;
1139
1140        proptest! {
1141
1142            // Range is limited to 1970 to 2038 due to windows limitations
1143            #[test]
1144            fn test_pyo3_offset_fixed_frompyobject_created_in_python(timestamp in 0..(i32::MAX as i64), timedelta in -86399i32..=86399i32) {
1145                Python::with_gil(|py| {
1146
1147                    let globals = [("datetime", py.import("datetime").unwrap())].into_py_dict(py).unwrap();
1148                    let code = format!("datetime.datetime.fromtimestamp({}).replace(tzinfo=datetime.timezone(datetime.timedelta(seconds={})))", timestamp, timedelta);
1149                    let t = py.eval(&CString::new(code).unwrap(), Some(&globals), None).unwrap();
1150
1151                    // Get ISO 8601 string from python
1152                    let py_iso_str = t.call_method0("isoformat").unwrap();
1153
1154                    // Get ISO 8601 string from rust
1155                    let t = t.extract::<DateTime<FixedOffset>>().unwrap();
1156                    // Python doesn't print the seconds of the offset if they are 0
1157                    let rust_iso_str = if timedelta % 60 == 0 {
1158                        t.format("%Y-%m-%dT%H:%M:%S%:z").to_string()
1159                    } else {
1160                        t.format("%Y-%m-%dT%H:%M:%S%::z").to_string()
1161                    };
1162
1163                    // They should be equal
1164                    assert_eq!(py_iso_str.to_string(), rust_iso_str);
1165                })
1166            }
1167
1168            #[test]
1169            fn test_duration_roundtrip(days in -999999999i64..=999999999i64) {
1170                // Test roundtrip conversion rust->python->rust for all allowed
1171                // python values of durations (from -999999999 to 999999999 days),
1172                Python::with_gil(|py| {
1173                    let dur = Duration::days(days);
1174                    let py_delta = dur.into_pyobject(py).unwrap();
1175                    let roundtripped: Duration = py_delta.extract().expect("Round trip");
1176                    assert_eq!(dur, roundtripped);
1177                })
1178            }
1179
1180            #[test]
1181            fn test_fixed_offset_roundtrip(secs in -86399i32..=86399i32) {
1182                Python::with_gil(|py| {
1183                    let offset = FixedOffset::east_opt(secs).unwrap();
1184                    let py_offset = offset.into_pyobject(py).unwrap();
1185                    let roundtripped: FixedOffset = py_offset.extract().expect("Round trip");
1186                    assert_eq!(offset, roundtripped);
1187                })
1188            }
1189
1190            #[test]
1191            fn test_naive_date_roundtrip(
1192                year in 1i32..=9999i32,
1193                month in 1u32..=12u32,
1194                day in 1u32..=31u32
1195            ) {
1196                // Test roundtrip conversion rust->python->rust for all allowed
1197                // python dates (from year 1 to year 9999)
1198                Python::with_gil(|py| {
1199                    // We use to `from_ymd_opt` constructor so that we only test valid `NaiveDate`s.
1200                    // This is to skip the test if we are creating an invalid date, like February 31.
1201                    if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) {
1202                        let py_date = date.into_pyobject(py).unwrap();
1203                        let roundtripped: NaiveDate = py_date.extract().expect("Round trip");
1204                        assert_eq!(date, roundtripped);
1205                    }
1206                })
1207            }
1208
1209            #[test]
1210            fn test_naive_time_roundtrip(
1211                hour in 0u32..=23u32,
1212                min in 0u32..=59u32,
1213                sec in 0u32..=59u32,
1214                micro in 0u32..=1_999_999u32
1215            ) {
1216                // Test roundtrip conversion rust->python->rust for naive times.
1217                // Python time has a resolution of microseconds, so we only test
1218                // NaiveTimes with microseconds resolution, even if NaiveTime has nanosecond
1219                // resolution.
1220                Python::with_gil(|py| {
1221                    if let Some(time) = NaiveTime::from_hms_micro_opt(hour, min, sec, micro) {
1222                        // Wrap in CatchWarnings to avoid to_object firing warning for truncated leap second
1223                        let py_time = CatchWarnings::enter(py, |_| time.into_pyobject(py)).unwrap();
1224                        let roundtripped: NaiveTime = py_time.extract().expect("Round trip");
1225                        // Leap seconds are not roundtripped
1226                        let expected_roundtrip_time = micro.checked_sub(1_000_000).map(|micro| NaiveTime::from_hms_micro_opt(hour, min, sec, micro).unwrap()).unwrap_or(time);
1227                        assert_eq!(expected_roundtrip_time, roundtripped);
1228                    }
1229                })
1230            }
1231
1232            #[test]
1233            fn test_naive_datetime_roundtrip(
1234                year in 1i32..=9999i32,
1235                month in 1u32..=12u32,
1236                day in 1u32..=31u32,
1237                hour in 0u32..=24u32,
1238                min in 0u32..=60u32,
1239                sec in 0u32..=60u32,
1240                micro in 0u32..=999_999u32
1241            ) {
1242                Python::with_gil(|py| {
1243                    let date_opt = NaiveDate::from_ymd_opt(year, month, day);
1244                    let time_opt = NaiveTime::from_hms_micro_opt(hour, min, sec, micro);
1245                    if let (Some(date), Some(time)) = (date_opt, time_opt) {
1246                        let dt = NaiveDateTime::new(date, time);
1247                        let pydt = dt.into_pyobject(py).unwrap();
1248                        let roundtripped: NaiveDateTime = pydt.extract().expect("Round trip");
1249                        assert_eq!(dt, roundtripped);
1250                    }
1251                })
1252            }
1253
1254            #[test]
1255            fn test_utc_datetime_roundtrip(
1256                year in 1i32..=9999i32,
1257                month in 1u32..=12u32,
1258                day in 1u32..=31u32,
1259                hour in 0u32..=23u32,
1260                min in 0u32..=59u32,
1261                sec in 0u32..=59u32,
1262                micro in 0u32..=1_999_999u32
1263            ) {
1264                Python::with_gil(|py| {
1265                    let date_opt = NaiveDate::from_ymd_opt(year, month, day);
1266                    let time_opt = NaiveTime::from_hms_micro_opt(hour, min, sec, micro);
1267                    if let (Some(date), Some(time)) = (date_opt, time_opt) {
1268                        let dt: DateTime<Utc> = NaiveDateTime::new(date, time).and_utc();
1269                        // Wrap in CatchWarnings to avoid into_py firing warning for truncated leap second
1270                        let py_dt = CatchWarnings::enter(py, |_| dt.into_pyobject(py)).unwrap();
1271                        let roundtripped: DateTime<Utc> = py_dt.extract().expect("Round trip");
1272                        // Leap seconds are not roundtripped
1273                        let expected_roundtrip_time = micro.checked_sub(1_000_000).map(|micro| NaiveTime::from_hms_micro_opt(hour, min, sec, micro).unwrap()).unwrap_or(time);
1274                        let expected_roundtrip_dt: DateTime<Utc> = NaiveDateTime::new(date, expected_roundtrip_time).and_utc();
1275                        assert_eq!(expected_roundtrip_dt, roundtripped);
1276                    }
1277                })
1278            }
1279
1280            #[test]
1281            fn test_fixed_offset_datetime_roundtrip(
1282                year in 1i32..=9999i32,
1283                month in 1u32..=12u32,
1284                day in 1u32..=31u32,
1285                hour in 0u32..=23u32,
1286                min in 0u32..=59u32,
1287                sec in 0u32..=59u32,
1288                micro in 0u32..=1_999_999u32,
1289                offset_secs in -86399i32..=86399i32
1290            ) {
1291                Python::with_gil(|py| {
1292                    let date_opt = NaiveDate::from_ymd_opt(year, month, day);
1293                    let time_opt = NaiveTime::from_hms_micro_opt(hour, min, sec, micro);
1294                    let offset = FixedOffset::east_opt(offset_secs).unwrap();
1295                    if let (Some(date), Some(time)) = (date_opt, time_opt) {
1296                        let dt: DateTime<FixedOffset> = NaiveDateTime::new(date, time).and_local_timezone(offset).unwrap();
1297                        // Wrap in CatchWarnings to avoid into_py firing warning for truncated leap second
1298                        let py_dt = CatchWarnings::enter(py, |_| dt.into_pyobject(py)).unwrap();
1299                        let roundtripped: DateTime<FixedOffset> = py_dt.extract().expect("Round trip");
1300                        // Leap seconds are not roundtripped
1301                        let expected_roundtrip_time = micro.checked_sub(1_000_000).map(|micro| NaiveTime::from_hms_micro_opt(hour, min, sec, micro).unwrap()).unwrap_or(time);
1302                        let expected_roundtrip_dt: DateTime<FixedOffset> = NaiveDateTime::new(date, expected_roundtrip_time).and_local_timezone(offset).unwrap();
1303                        assert_eq!(expected_roundtrip_dt, roundtripped);
1304                    }
1305                })
1306            }
1307        }
1308    }
1309}