1#![cfg(feature = "jiff-02")]
2
3#![doc = concat!("pyo3 = { version = \"", env!("CARGO_PKG_VERSION"), "\", features = [\"jiff-02\"] }")]
14use 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 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 #[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 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 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 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 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 let offset = Offset::from_seconds(3600)
856 .unwrap()
857 .into_pyobject(py)
858 .unwrap();
859 let td = new_py_datetime_ob(py, "timedelta", (0, 3600, 0));
861 let py_timedelta = new_py_datetime_ob(py, "timezone", (td,));
862 assert!(offset.eq(py_timedelta).unwrap());
864
865 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 #[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 #[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 let py_iso_str = t.call_method0("isoformat").unwrap();
1044
1045 let rust_iso_str = t.extract::<Zoned>().unwrap().strftime("%Y-%m-%dT%H:%M:%S%:z").to_string();
1047
1048 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 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 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 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 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}