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