Skip to main content

tempoch_py/
interop.rs

1//! Reusable PyO3 interoperability for Python datetimes and tempoch UTC values.
2//!
3//! Python inputs must be timezone-aware. Fixed offsets are normalized to UTC,
4//! and all scientific time handling is delegated to [`tempoch`].
5//!
6//! ```no_run
7//! # use pyo3::prelude::*;
8//! # fn roundtrip<'py>(value: &Bound<'py, PyAny>) -> PyResult<Bound<'py, PyAny>> {
9//! let instant = tempoch_py::interop::datetime_to_time(value)?;
10//! tempoch_py::interop::time_to_datetime(value.py(), instant)
11//! # }
12//! ```
13
14use chrono::{DateTime, FixedOffset, Utc};
15use pyo3::exceptions::{PyTypeError, PyValueError};
16use pyo3::prelude::*;
17use pyo3::types::PyDateTime;
18use tempoch::{Period, Time, TimeContext, UTC};
19
20use crate::errors::map_conversion_error;
21
22fn aware_datetime(value: &Bound<'_, PyAny>) -> PyResult<DateTime<FixedOffset>> {
23    if !value.is_instance_of::<PyDateTime>() {
24        return Err(PyTypeError::new_err(
25            "value must be a timezone-aware datetime.datetime",
26        ));
27    }
28
29    if value.call_method0("utcoffset")?.is_none() {
30        return Err(PyValueError::new_err(
31            "naive datetimes are not accepted; value must be timezone-aware",
32        ));
33    }
34
35    value.extract::<DateTime<FixedOffset>>().map_err(|error| {
36        PyValueError::new_err(format!(
37            "value must be a valid timezone-aware datetime.datetime: {error}"
38        ))
39    })
40}
41
42/// Convert a timezone-aware Python `datetime.datetime` to canonical tempoch UTC.
43///
44/// Non-UTC offsets are normalized to UTC. Naive datetimes and non-datetime
45/// objects are rejected with Python exceptions.
46pub fn datetime_to_time(value: &Bound<'_, PyAny>) -> PyResult<Time<UTC>> {
47    datetime_to_time_with(value, &TimeContext::new())
48}
49
50/// Convert with an explicit tempoch [`TimeContext`].
51pub fn datetime_to_time_with(
52    value: &Bound<'_, PyAny>,
53    context: &TimeContext,
54) -> PyResult<Time<UTC>> {
55    let datetime = aware_datetime(value)?.with_timezone(&Utc);
56    Time::<UTC>::try_from_chrono_with(datetime, context).map_err(map_conversion_error)
57}
58
59/// Convert canonical tempoch UTC to a timezone-aware UTC Python datetime.
60pub fn time_to_datetime<'py>(py: Python<'py>, value: Time<UTC>) -> PyResult<Bound<'py, PyAny>> {
61    time_to_datetime_with(py, value, &TimeContext::new())
62}
63
64/// Convert to a Python datetime with an explicit tempoch [`TimeContext`].
65pub fn time_to_datetime_with<'py>(
66    py: Python<'py>,
67    value: Time<UTC>,
68    context: &TimeContext,
69) -> PyResult<Bound<'py, PyAny>> {
70    let datetime = value
71        .try_to_chrono_with(context)
72        .map_err(map_conversion_error)?;
73    Ok(datetime.into_pyobject(py)?.into_any())
74}
75
76/// Convert a canonical tempoch UTC period to Python start/end datetimes.
77pub fn period_to_datetimes<'py>(
78    py: Python<'py>,
79    period: Period<UTC>,
80) -> PyResult<(Bound<'py, PyAny>, Bound<'py, PyAny>)> {
81    period_to_datetimes_with(py, period, &TimeContext::new())
82}
83
84/// Convert a UTC period with an explicit tempoch [`TimeContext`].
85pub fn period_to_datetimes_with<'py>(
86    py: Python<'py>,
87    period: Period<UTC>,
88    context: &TimeContext,
89) -> PyResult<(Bound<'py, PyAny>, Bound<'py, PyAny>)> {
90    Ok((
91        time_to_datetime_with(py, period.start, context)?,
92        time_to_datetime_with(py, period.end, context)?,
93    ))
94}