Skip to content

Commit

Permalink
Add Python datetime interop
Browse files Browse the repository at this point in the history
Fixes #185
  • Loading branch information
ChristopherRabotin committed Oct 9, 2024
1 parent 0fb0423 commit 232d324
Show file tree
Hide file tree
Showing 3 changed files with 68 additions and 3 deletions.
46 changes: 45 additions & 1 deletion src/epoch/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use core::str::FromStr;
use crate::epoch::leap_seconds_file::LeapSecondsFile;
use pyo3::prelude::*;
use pyo3::pyclass::CompareOp;
use pyo3::types::PyType;
use pyo3::types::{PyDateAccess, PyDateTime, PyTimeAccess, PyType, PyTzInfoAccess};

#[pymethods]
impl Epoch {
Expand Down Expand Up @@ -502,4 +502,48 @@ impl Epoch {
CompareOp::Ge => *self >= other,
}
}

/// Returns a Python datetime object from this Epoch (truncating the nanoseconds away)
fn todatetime<'py>(&self, py: Python<'py>) -> Result<Bound<'py, PyDateTime>, PyErr> {
let (y, mm, dd, hh, min, s, nanos) =
Epoch::compute_gregorian(self.duration, TimeScale::UTC);

let datetime = PyDateTime::new_bound(py, y, mm, dd, hh, min, s, nanos / 1_000, None)?;

Ok(datetime)
}

/// Builds an Epoch in UTC from the provided datetime after timezone correction if any is present.
#[classmethod]
fn fromdatetime(
_cls: &Bound<'_, PyType>,
dt: &Bound<'_, PyAny>,
) -> Result<Self, HifitimeError> {
let dt = dt
.downcast::<PyDateTime>()
.map_err(|e| HifitimeError::PythonError {
reason: e.to_string(),
})?;

// If the user tries to convert a timezone aware datetime into a naive one,
// we return a hard error. We could silently remove tzinfo, or assume local timezone
// and do a conversion, but better leave this decision to the user of the library.
let has_tzinfo = dt.get_tzinfo_bound().is_some();
if has_tzinfo {
return Err(HifitimeError::PythonError {
reason: "expected a datetime without tzinfo, call my_datetime.replace(tzinfo=None)"
.to_string(),
});
}

Epoch::maybe_from_gregorian_utc(
dt.get_year(),
dt.get_month().into(),
dt.get_day().into(),
dt.get_hour().into(),
dt.get_minute().into(),
dt.get_second().into(),
dt.get_microsecond() * 1_000,
)
}
}
5 changes: 5 additions & 0 deletions src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ pub enum HifitimeError {
Duration {
source: DurationError,
},
#[cfg(feature = "python")]
#[snafu(display("python interop error: {reason}"))]
PythonError {
reason: String,
},
}

#[cfg_attr(kani, derive(kani::Arbitrary))]
Expand Down
20 changes: 18 additions & 2 deletions tests/python/test_epoch.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from hifitime import Duration, Epoch, HifitimeError, ParsingError, TimeScale, TimeSeries, Unit
from datetime import datetime
from datetime import datetime, timezone
import pickle


Expand Down Expand Up @@ -108,4 +108,20 @@ def test_regression_gh249():
e = Epoch.init_from_gregorian(year=2022, month=3, day=1, hour=1, minute=1, second=59, nanos=1, time_scale=TimeScale.GPST)
assert e.strftime("%Y %m %d %H %M %S %f %T") == "2022 03 01 01 01 59 000000001 GPST"
e = Epoch.init_from_gregorian(year=2022, month=3, day=1, hour=1, minute=1, second=59, nanos=1, time_scale=TimeScale.UTC)
assert e.strftime("%Y %m %d %H %M %S %f %T") == "2022 03 01 01 01 59 000000001 UTC"
assert e.strftime("%Y %m %d %H %M %S %f %T") == "2022 03 01 01 01 59 000000001 UTC"

def test_interop():
hifinow = Epoch.system_now()
lofinow = hifinow.todatetime()
hifirtn = Epoch.fromdatetime(lofinow)
assert hifirtn.timedelta(hifinow).abs() < Unit.Microsecond * 1
# Now test with timezone, expect an error
tz_datetime = datetime(2023, 10, 8, 15, 30, tzinfo=timezone.utc)
try:
Epoch.fromdatetime(tz_datetime)
except Exception as e:
print(e)
else:
assert False, "tz aware dt did not fail"
# Repeat after the strip
assert Epoch.fromdatetime(tz_datetime.replace(tzinfo=None)) == Epoch("2023-10-08 15:30:00")

0 comments on commit 232d324

Please sign in to comment.