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