Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
86 changes: 66 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 @@ -507,6 +495,11 @@ impl FromPyObject<'_, '_> for Local {
)))
}
}

#[inline]
fn as_local_tz(_: crate::conversion::private::Token) -> Option<Self> {
Some(Local)
}
}

struct DateArgs {
Expand Down Expand Up @@ -613,6 +606,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 +1022,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
34 changes: 34 additions & 0 deletions src/conversions/chrono_tz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,12 @@ impl FromPyObject<'_, '_> for Tz {
mod tests {
use super::*;
use crate::prelude::PyAnyMethods;
use crate::types::IntoPyDict;
use crate::types::PyTzInfo;
use crate::Bound;
use crate::Python;
use chrono::offset::LocalResult;
use chrono::NaiveDate;
use chrono::{DateTime, Utc};
use chrono_tz::Tz;

Expand Down Expand Up @@ -148,6 +151,37 @@ mod tests {
);
}

#[test]
fn test_nonexistent_datetime_from_pyobject() {
// Pacific_Apia skipped the 30th of December 2011 entirely

let naive_dt = NaiveDate::from_ymd_opt(2011, 12, 30)
.unwrap()
.and_hms_opt(2, 0, 0)
.unwrap();
let tz = Tz::Pacific__Apia;

// sanity check
assert_eq!(naive_dt.and_local_timezone(tz), LocalResult::None);

Python::attach(|py| {
// create as a Python object manually
let py_tz = tz.into_pyobject(py).unwrap();
let py_dt_naive = naive_dt.into_pyobject(py).unwrap();
let py_dt = py_dt_naive
.call_method(
"replace",
(),
Some(&[("tzinfo", py_tz)].into_py_dict(py).unwrap()),
)
.unwrap();

// now try to extract
let err = py_dt.extract::<DateTime<Tz>>().unwrap_err();
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");
});
}

#[test]
#[cfg(not(Py_GIL_DISABLED))] // https://github.com/python/cpython/issues/116738#issuecomment-2404360445
fn test_into_pyobject() {
Expand Down
Loading