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