Skip to content

Commit 9a213ae

Browse files
Allow naive datetime into chrono DateTime<Local> conversion (#5507)
* Allow naive datetime into chrono `DateTime<Local>` conversion * use `FromPyObject` helper to safely create `Local` specialization * rename newsfragment * add `as_local_tz` implementation for local * coverage --------- Co-authored-by: Bas Schoenmaeckers <b.schoenmaeckers@zanders.eu>
1 parent 888bb00 commit 9a213ae

File tree

6 files changed

+117
-23
lines changed

6 files changed

+117
-23
lines changed

guide/src/conversions/tables.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ Finally, the following Rust types are also able to convert to Python as return v
109109

110110
[^4]: Requires the `indexmap` optional feature.
111111

112-
[^5]: Requires the `chrono` optional feature.
112+
[^5]: Requires the `chrono` (and maybe `chrono-local`) optional feature(s).
113113

114114
[^6]: Requires the `chrono-tz` optional feature.
115115

guide/src/features.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,13 +137,18 @@ Adds a dependency on [chrono](https://docs.rs/chrono). Enables a conversion from
137137

138138
### `chrono-local`
139139

140-
Enables conversion from and to [Local](https://docs.rs/chrono/latest/chrono/struct.Local.html) timezones.
140+
Enables conversion from and to [Local](https://docs.rs/chrono/latest/chrono/struct.Local.html) timezones. The current system timezone as determined by [`iana_time_zone::get_timezone()`](https://docs.rs/iana-time-zone/latest/iana_time_zone/fn.get_timezone.html) will be used for conversions.
141+
142+
`chrono::DateTime<Local>` will convert from either of:
143+
- `datetime` objects with `tzinfo` equivalent to the current system timezone.
144+
- "naive" `datetime` objects (those without a `tzinfo`), as it is a convention that naive datetime objects should be treated as using the system timezone.
145+
146+
When converting to Python, `Local` tzinfo is converted to a `zoneinfo.ZoneInfo` matching the current system timezone.
141147

142148
### `chrono-tz`
143149

144150
Adds a dependency on [chrono-tz](https://docs.rs/chrono-tz).
145151
Enables conversion from and to [`Tz`](https://docs.rs/chrono-tz/latest/chrono_tz/enum.Tz.html).
146-
It requires at least Python 3.9.
147152

148153
### `either`
149154

newsfragments/5507.changed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Allow converting naive datetime into chrono `DateTime<Local>`.

src/conversion.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,14 @@ pub trait FromPyObject<'a, 'py>: Sized {
449449
) -> Option<Box<dyn FromPyObjectSequence<Target = Self> + 'b>> {
450450
None
451451
}
452+
453+
/// Helper used to make a specialized path in extracting `DateTime<Tz>` where `Tz` is
454+
/// `chrono::Local`, which will accept "naive" datetime objects as being in the local timezone.
455+
#[cfg(feature = "chrono-local")]
456+
#[inline]
457+
fn as_local_tz(_: private::Token) -> Option<Self> {
458+
None
459+
}
452460
}
453461

454462
mod from_py_object_sequence {

src/conversions/chrono.rs

Lines changed: 66 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -345,30 +345,18 @@ where
345345
let tz = if let Some(tzinfo) = tzinfo {
346346
tzinfo.extract().map_err(Into::into)?
347347
} else {
348+
// Special case: allow naive `datetime` objects for `DateTime<Local>`, interpreting them as local time.
349+
#[cfg(feature = "chrono-local")]
350+
if let Some(tz) = Tz::as_local_tz(crate::conversion::private::Token) {
351+
return py_datetime_to_datetime_with_timezone(dt, tz);
352+
}
353+
348354
return Err(PyTypeError::new_err(
349355
"expected a datetime with non-None tzinfo",
350356
));
351357
};
352-
let naive_dt = NaiveDateTime::new(py_date_to_naive_date(dt)?, py_time_to_naive_time(dt)?);
353-
match naive_dt.and_local_timezone(tz) {
354-
LocalResult::Single(value) => Ok(value),
355-
LocalResult::Ambiguous(earliest, latest) => {
356-
#[cfg(not(Py_LIMITED_API))]
357-
let fold = dt.get_fold();
358-
359-
#[cfg(Py_LIMITED_API)]
360-
let fold = dt.getattr(intern!(dt.py(), "fold"))?.extract::<usize>()? > 0;
361-
362-
if fold {
363-
Ok(latest)
364-
} else {
365-
Ok(earliest)
366-
}
367-
}
368-
LocalResult::None => Err(PyValueError::new_err(format!(
369-
"The datetime {dt:?} contains an incompatible timezone"
370-
))),
371-
}
358+
359+
py_datetime_to_datetime_with_timezone(dt, tz)
372360
}
373361
}
374362

@@ -507,6 +495,11 @@ impl FromPyObject<'_, '_> for Local {
507495
)))
508496
}
509497
}
498+
499+
#[inline]
500+
fn as_local_tz(_: crate::conversion::private::Token) -> Option<Self> {
501+
Some(Local)
502+
}
510503
}
511504

512505
struct DateArgs {
@@ -613,6 +606,32 @@ fn py_time_to_naive_time(py_time: &Bound<'_, PyAny>) -> PyResult<NaiveTime> {
613606
.ok_or_else(|| PyValueError::new_err("invalid or out-of-range time"))
614607
}
615608

609+
fn py_datetime_to_datetime_with_timezone<Tz: TimeZone>(
610+
dt: &Bound<'_, PyDateTime>,
611+
tz: Tz,
612+
) -> PyResult<DateTime<Tz>> {
613+
let naive_dt = NaiveDateTime::new(py_date_to_naive_date(dt)?, py_time_to_naive_time(dt)?);
614+
match naive_dt.and_local_timezone(tz) {
615+
LocalResult::Single(value) => Ok(value),
616+
LocalResult::Ambiguous(earliest, latest) => {
617+
#[cfg(not(Py_LIMITED_API))]
618+
let fold = dt.get_fold();
619+
620+
#[cfg(Py_LIMITED_API)]
621+
let fold = dt.getattr(intern!(dt.py(), "fold"))?.extract::<usize>()? > 0;
622+
623+
if fold {
624+
Ok(latest)
625+
} else {
626+
Ok(earliest)
627+
}
628+
}
629+
LocalResult::None => Err(PyValueError::new_err(format!(
630+
"The datetime {dt:?} contains an incompatible timezone"
631+
))),
632+
}
633+
}
634+
616635
#[cfg(test)]
617636
mod tests {
618637
use super::*;
@@ -1003,6 +1022,33 @@ mod tests {
10031022
})
10041023
}
10051024

1025+
#[test]
1026+
#[cfg(feature = "chrono-local")]
1027+
fn test_pyo3_naive_datetime_frompyobject_local() {
1028+
Python::attach(|py| {
1029+
let year = 2014;
1030+
let month = 5;
1031+
let day = 6;
1032+
let hour = 7;
1033+
let minute = 8;
1034+
let second = 9;
1035+
let micro = 999_999;
1036+
let py_datetime = new_py_datetime_ob(
1037+
py,
1038+
"datetime",
1039+
(year, month, day, hour, minute, second, micro),
1040+
);
1041+
let py_datetime: DateTime<Local> = py_datetime.extract().unwrap();
1042+
let expected_datetime = NaiveDate::from_ymd_opt(year, month, day)
1043+
.unwrap()
1044+
.and_hms_micro_opt(hour, minute, second, micro)
1045+
.unwrap()
1046+
.and_local_timezone(Local)
1047+
.unwrap();
1048+
assert_eq!(py_datetime, expected_datetime);
1049+
})
1050+
}
1051+
10061052
#[test]
10071053
fn test_pyo3_datetime_frompyobject_fixed_offset() {
10081054
Python::attach(|py| {

src/conversions/chrono_tz.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,12 @@ impl FromPyObject<'_, '_> for Tz {
7979
mod tests {
8080
use super::*;
8181
use crate::prelude::PyAnyMethods;
82+
use crate::types::IntoPyDict;
8283
use crate::types::PyTzInfo;
8384
use crate::Bound;
8485
use crate::Python;
86+
use chrono::offset::LocalResult;
87+
use chrono::NaiveDate;
8588
use chrono::{DateTime, Utc};
8689
use chrono_tz::Tz;
8790

@@ -148,6 +151,37 @@ mod tests {
148151
);
149152
}
150153

154+
#[test]
155+
fn test_nonexistent_datetime_from_pyobject() {
156+
// Pacific_Apia skipped the 30th of December 2011 entirely
157+
158+
let naive_dt = NaiveDate::from_ymd_opt(2011, 12, 30)
159+
.unwrap()
160+
.and_hms_opt(2, 0, 0)
161+
.unwrap();
162+
let tz = Tz::Pacific__Apia;
163+
164+
// sanity check
165+
assert_eq!(naive_dt.and_local_timezone(tz), LocalResult::None);
166+
167+
Python::attach(|py| {
168+
// create as a Python object manually
169+
let py_tz = tz.into_pyobject(py).unwrap();
170+
let py_dt_naive = naive_dt.into_pyobject(py).unwrap();
171+
let py_dt = py_dt_naive
172+
.call_method(
173+
"replace",
174+
(),
175+
Some(&[("tzinfo", py_tz)].into_py_dict(py).unwrap()),
176+
)
177+
.unwrap();
178+
179+
// now try to extract
180+
let err = py_dt.extract::<DateTime<Tz>>().unwrap_err();
181+
assert_eq!(err.to_string(), "ValueError: The datetime datetime.datetime(2011, 12, 30, 2, 0, tzinfo=zoneinfo.ZoneInfo(key='Pacific/Apia')) contains an incompatible timezone");
182+
});
183+
}
184+
151185
#[test]
152186
#[cfg(not(Py_GIL_DISABLED))] // https://github.com/python/cpython/issues/116738#issuecomment-2404360445
153187
fn test_into_pyobject() {

0 commit comments

Comments
 (0)