Skip to content

Commit 2829027

Browse files
bschoenmaeckersdavidhewitt
authored andcommitted
Add conversions for chrono's Local timezone (#5174)
1 parent 5db4871 commit 2829027

File tree

4 files changed

+106
-3
lines changed

4 files changed

+106
-3
lines changed

Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ serde = { version = "1.0", optional = true }
5050
smallvec = { version = "1.0", optional = true }
5151
uuid = { version = "1.11.0", optional = true }
5252
lock_api = { version = "0.4", optional = true }
53-
parking_lot = { version = "0.12", optional = true}
53+
parking_lot = { version = "0.12", optional = true }
54+
iana-time-zone = { version = "0.1", optional = true, features = ["fallback"]}
5455

5556
[target.'cfg(not(target_has_atomic = "64"))'.dependencies]
5657
portable-atomic = "1.0"
@@ -121,6 +122,8 @@ py-clone = []
121122
parking_lot = ["dep:parking_lot", "lock_api"]
122123
arc_lock = ["lock_api", "lock_api/arc_lock", "parking_lot?/arc_lock"]
123124

125+
chrono-local = ["chrono/clock", "dep:iana-time-zone"]
126+
124127
# Optimizes PyObject to Vec conversion and so on.
125128
nightly = []
126129

@@ -133,6 +136,7 @@ full = [
133136
"arc_lock",
134137
"bigdecimal",
135138
"chrono",
139+
"chrono-local",
136140
"chrono-tz",
137141
"either",
138142
"experimental-async",

guide/src/features.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ Adds a dependency on [chrono](https://docs.rs/chrono). Enables a conversion from
138138
- [NaiveTime](https://docs.rs/chrono/latest/chrono/naive/struct.NaiveTime.html) -> [`PyTime`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyTime.html)
139139
- [DateTime](https://docs.rs/chrono/latest/chrono/struct.DateTime.html) -> [`PyDateTime`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyDateTime.html)
140140

141+
### `chrono-local`
142+
143+
Enables conversion from and to [Local](https://docs.rs/chrono/latest/chrono/struct.Local.html) timezones.
144+
141145
### `chrono-tz`
142146

143147
Adds a dependency on [chrono-tz](https://docs.rs/chrono-tz).

newsfragments/5174.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add conversions for chrono's `Local` timezone & `DateTime<Local>` instances.

src/conversions/chrono.rs

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,23 @@
4343
4444
use crate::conversion::IntoPyObject;
4545
use crate::exceptions::{PyTypeError, PyUserWarning, PyValueError};
46-
#[cfg(Py_LIMITED_API)]
4746
use crate::intern;
4847
use crate::types::any::PyAnyMethods;
4948
use crate::types::PyNone;
5049
use crate::types::{PyDate, PyDateTime, PyDelta, PyTime, PyTzInfo, PyTzInfoAccess};
5150
#[cfg(not(Py_LIMITED_API))]
5251
use crate::types::{PyDateAccess, PyDeltaAccess, PyTimeAccess};
52+
#[cfg(feature = "chrono-local")]
53+
use crate::{
54+
exceptions::PyRuntimeError,
55+
sync::GILOnceCell,
56+
types::{PyString, PyStringMethods},
57+
Py,
58+
};
5359
use crate::{ffi, Borrowed, Bound, FromPyObject, IntoPyObjectExt, PyAny, PyErr, PyResult, Python};
5460
use chrono::offset::{FixedOffset, Utc};
61+
#[cfg(feature = "chrono-local")]
62+
use chrono::Local;
5563
use chrono::{
5664
DateTime, Datelike, Duration, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, Offset,
5765
TimeZone, Timelike,
@@ -387,7 +395,8 @@ impl FromPyObject<'_> for FixedOffset {
387395
// Any other timezone would require a datetime as the parameter, and return
388396
// None if the datetime is not provided.
389397
// Trying to convert None to a PyDelta in the next line will then fail.
390-
let py_timedelta = ob.call_method1("utcoffset", (PyNone::get(ob.py()),))?;
398+
let py_timedelta =
399+
ob.call_method1(intern!(ob.py(), "utcoffset"), (PyNone::get(ob.py()),))?;
391400
if py_timedelta.is_none() {
392401
return Err(PyTypeError::new_err(format!(
393402
"{ob:?} is not a fixed offset timezone"
@@ -433,6 +442,54 @@ impl FromPyObject<'_> for Utc {
433442
}
434443
}
435444

445+
#[cfg(feature = "chrono-local")]
446+
impl<'py> IntoPyObject<'py> for Local {
447+
type Target = PyTzInfo;
448+
type Output = Borrowed<'static, 'py, Self::Target>;
449+
type Error = PyErr;
450+
451+
fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
452+
static LOCAL_TZ: GILOnceCell<Py<PyTzInfo>> = GILOnceCell::new();
453+
let tz = LOCAL_TZ
454+
.get_or_try_init(py, || {
455+
let iana_name = iana_time_zone::get_timezone().map_err(|e| {
456+
PyRuntimeError::new_err(format!("Could not get local timezone: {e}"))
457+
})?;
458+
PyTzInfo::timezone(py, iana_name).map(Bound::unbind)
459+
})?
460+
.bind_borrowed(py);
461+
Ok(tz)
462+
}
463+
}
464+
465+
#[cfg(feature = "chrono-local")]
466+
impl<'py> IntoPyObject<'py> for &Local {
467+
type Target = PyTzInfo;
468+
type Output = Borrowed<'static, 'py, Self::Target>;
469+
type Error = PyErr;
470+
471+
#[inline]
472+
fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
473+
(*self).into_pyobject(py)
474+
}
475+
}
476+
477+
#[cfg(feature = "chrono-local")]
478+
impl FromPyObject<'_> for Local {
479+
fn extract_bound(ob: &Bound<'_, PyAny>) -> PyResult<Local> {
480+
let local_tz = Local.into_pyobject(ob.py())?;
481+
if ob.eq(local_tz)? {
482+
Ok(Local)
483+
} else {
484+
let name = local_tz.getattr("key")?.downcast_into::<PyString>()?;
485+
Err(PyValueError::new_err(format!(
486+
"expected local timezone {}",
487+
name.to_cow()?
488+
)))
489+
}
490+
}
491+
}
492+
436493
struct DateArgs {
437494
year: i32,
438495
month: u8,
@@ -1281,6 +1338,43 @@ mod tests {
12811338
}
12821339
})
12831340
}
1341+
1342+
#[test]
1343+
#[cfg(all(feature = "chrono-local", not(target_os = "windows")))]
1344+
fn test_local_datetime_roundtrip(
1345+
year in 1i32..=9999i32,
1346+
month in 1u32..=12u32,
1347+
day in 1u32..=31u32,
1348+
hour in 0u32..=23u32,
1349+
min in 0u32..=59u32,
1350+
sec in 0u32..=59u32,
1351+
micro in 0u32..=1_999_999u32,
1352+
) {
1353+
Python::with_gil(|py| {
1354+
let date_opt = NaiveDate::from_ymd_opt(year, month, day);
1355+
let time_opt = NaiveTime::from_hms_micro_opt(hour, min, sec, micro);
1356+
if let (Some(date), Some(time)) = (date_opt, time_opt) {
1357+
let dts = match NaiveDateTime::new(date, time).and_local_timezone(Local) {
1358+
LocalResult::None => return,
1359+
LocalResult::Single(dt) => [Some((dt, false)), None],
1360+
LocalResult::Ambiguous(dt1, dt2) => [Some((dt1, false)), Some((dt2, true))],
1361+
};
1362+
for (dt, fold) in dts.iter().filter_map(|input| *input) {
1363+
// Wrap in CatchWarnings to avoid into_py firing warning for truncated leap second
1364+
let py_dt = CatchWarnings::enter(py, |_| dt.into_pyobject(py)).unwrap();
1365+
let roundtripped: DateTime<Local> = py_dt.extract().expect("Round trip");
1366+
// Leap seconds are not roundtripped
1367+
let expected_roundtrip_time = micro.checked_sub(1_000_000).map(|micro| NaiveTime::from_hms_micro_opt(hour, min, sec, micro).unwrap()).unwrap_or(time);
1368+
let expected_roundtrip_dt: DateTime<Local> = if fold {
1369+
NaiveDateTime::new(date, expected_roundtrip_time).and_local_timezone(Local).latest()
1370+
} else {
1371+
NaiveDateTime::new(date, expected_roundtrip_time).and_local_timezone(Local).earliest()
1372+
}.unwrap();
1373+
assert_eq!(expected_roundtrip_dt, roundtripped);
1374+
}
1375+
}
1376+
})
1377+
}
12841378
}
12851379
}
12861380
}

0 commit comments

Comments
 (0)