Skip to main content

pyo3/conversions/
jiff.rs

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