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