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