Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion guide/src/conversions/tables.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ Finally, the following Rust types are also able to convert to Python as return v

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

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

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

Expand Down
9 changes: 7 additions & 2 deletions guide/src/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,18 @@ Adds a dependency on [chrono](https://docs.rs/chrono). Enables a conversion from

### `chrono-local`

Enables conversion from and to [Local](https://docs.rs/chrono/latest/chrono/struct.Local.html) timezones.
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.

`chrono::DateTime<Local>` will convert from either of:
- `datetime` objects with `tzinfo` equivalent to the current system timezone.
- "naive" `datetime` objects (those without a `tzinfo`), as it is a convention that naive datetime objects should be treated as using the system timezone.

When converting to Python, `Local` tzinfo is converted to a `zoneinfo.ZoneInfo` matching the current system timezone.

### `chrono-tz`

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

### `either`

Expand Down
1 change: 1 addition & 0 deletions newsfragments/5507.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Allow converting naive datetime into chrono `DateTime<Local>`.
8 changes: 8 additions & 0 deletions src/conversion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,14 @@ pub trait FromPyObject<'a, 'py>: Sized {
) -> Option<Box<dyn FromPyObjectSequence<Target = Self> + 'b>> {
None
}

/// Helper used to make a specialized path in extracting `DateTime<Tz>` where `Tz` is
/// `chrono::Local`, which will accept "naive" datetime objects as being in the local timezone.
#[cfg(feature = "chrono-local")]
#[inline]
fn as_local_tz(_: private::Token) -> Option<Self> {
None
}
}

mod from_py_object_sequence {
Expand Down
81 changes: 61 additions & 20 deletions src/conversions/chrono.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,30 +345,18 @@ where
let tz = if let Some(tzinfo) = tzinfo {
tzinfo.extract().map_err(Into::into)?
} else {
// Special case: allow naive `datetime` objects for `DateTime<Local>`, interpreting them as local time.
#[cfg(feature = "chrono-local")]
if let Some(tz) = Tz::as_local_tz(crate::conversion::private::Token) {
return py_datetime_to_datetime_with_timezone(dt, tz);
}

return Err(PyTypeError::new_err(
"expected a datetime with non-None tzinfo",
));
};
let naive_dt = NaiveDateTime::new(py_date_to_naive_date(dt)?, py_time_to_naive_time(dt)?);
match naive_dt.and_local_timezone(tz) {
LocalResult::Single(value) => Ok(value),
LocalResult::Ambiguous(earliest, latest) => {
#[cfg(not(Py_LIMITED_API))]
let fold = dt.get_fold();

#[cfg(Py_LIMITED_API)]
let fold = dt.getattr(intern!(dt.py(), "fold"))?.extract::<usize>()? > 0;

if fold {
Ok(latest)
} else {
Ok(earliest)
}
}
LocalResult::None => Err(PyValueError::new_err(format!(
"The datetime {dt:?} contains an incompatible timezone"
))),
}

py_datetime_to_datetime_with_timezone(dt, tz)
}
}

Expand Down Expand Up @@ -613,6 +601,32 @@ fn py_time_to_naive_time(py_time: &Bound<'_, PyAny>) -> PyResult<NaiveTime> {
.ok_or_else(|| PyValueError::new_err("invalid or out-of-range time"))
}

fn py_datetime_to_datetime_with_timezone<Tz: TimeZone>(
dt: &Bound<'_, PyDateTime>,
tz: Tz,
) -> PyResult<DateTime<Tz>> {
let naive_dt = NaiveDateTime::new(py_date_to_naive_date(dt)?, py_time_to_naive_time(dt)?);
match naive_dt.and_local_timezone(tz) {
LocalResult::Single(value) => Ok(value),
LocalResult::Ambiguous(earliest, latest) => {
#[cfg(not(Py_LIMITED_API))]
let fold = dt.get_fold();

#[cfg(Py_LIMITED_API)]
let fold = dt.getattr(intern!(dt.py(), "fold"))?.extract::<usize>()? > 0;

if fold {
Ok(latest)
} else {
Ok(earliest)
}
}
LocalResult::None => Err(PyValueError::new_err(format!(
"The datetime {dt:?} contains an incompatible timezone"
))),
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -1003,6 +1017,33 @@ mod tests {
})
}

#[test]
#[cfg(feature = "chrono-local")]
fn test_pyo3_naive_datetime_frompyobject_local() {
Python::attach(|py| {
let year = 2014;
let month = 5;
let day = 6;
let hour = 7;
let minute = 8;
let second = 9;
let micro = 999_999;
let py_datetime = new_py_datetime_ob(
py,
"datetime",
(year, month, day, hour, minute, second, micro),
);
let py_datetime: DateTime<Local> = py_datetime.extract().unwrap();
let expected_datetime = NaiveDate::from_ymd_opt(year, month, day)
.unwrap()
.and_hms_micro_opt(hour, minute, second, micro)
.unwrap()
.and_local_timezone(Local)
.unwrap();
assert_eq!(py_datetime, expected_datetime);
})
}

#[test]
fn test_pyo3_datetime_frompyobject_fixed_offset() {
Python::attach(|py| {
Expand Down
Loading