Skip to content

Commit

Permalink
Make from_py_datetime() methods more permissive
Browse files Browse the repository at this point in the history
  • Loading branch information
ariebovenberg committed Nov 14, 2024
1 parent 2cf6722 commit 29bb5b0
Show file tree
Hide file tree
Showing 13 changed files with 337 additions and 104 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
🚀 Changelog
============

0.6.13 (2024-11-14)
-------------------

- Make ``from_py_datetime()`` on ``Instant``/``OffsetDateTime`` less pedantic.
It now accepts any aware datetime

0.6.12 (2024-11-08)
-------------------

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,11 +181,13 @@ or [API reference](https://whenever.readthedocs.io/en/latest/api.html).
- ✅ Deltas
- ✅ Date and time of day (separate from datetime)
- ✅ Implement Rust extension for performance
- 🚧 Parsing leap seconds
- 🔒 **1.0**: API stability and backwards compatibility
- 🚧 Improved parsing and formatting
- 🚧 More helpful error messages
- 🚧 Intervals
- 🔒 **1.0**: API stability and backwards compatibility
- 🚧 Ranges and recurring times
- 🚧 Parsing leap seconds
- 🚧 More helpful error messages


## Limitations

Expand Down
2 changes: 1 addition & 1 deletion docs/overview.rst
Original file line number Diff line number Diff line change
Expand Up @@ -762,7 +762,7 @@ To and from the standard library
Each **whenever** datetime class can be converted to a standard
library :class:`~datetime.datetime`
with the :meth:`~whenever._BasicConversions.py_datetime` method.
Conversely, you can create a type from a standard library datetime with the
Conversely, you can create instances from a standard library datetime with the
:meth:`~whenever._BasicConversions.from_py_datetime` classmethod.

>>> from datetime import datetime, UTC
Expand Down
56 changes: 31 additions & 25 deletions pysrc/whenever/_pywhenever.py
Original file line number Diff line number Diff line change
Expand Up @@ -2978,24 +2978,24 @@ def from_timestamp_nanos(cls, i: int, /) -> Instant:

@classmethod
def from_py_datetime(cls, d: _datetime, /) -> Instant:
"""Create an Instant from a standard library ``datetime`` object
with ``tzinfo=datetime.timezone.utc``.
"""Create an Instant from a standard library ``datetime`` object.
The datetime must be aware.
The inverse of the ``py_datetime()`` method.
Important
---------
If the datetime tzinfo is *not* UTC, a ``ValueError`` is raised.
If you want to convert a datetime with a different timezone to UTC,
use ``ZonedDateTime`` or ``OffsetDateTime``.
"""
if d.tzinfo is not _UTC:
if d.tzinfo is None:
raise ValueError(
"Can only create Instant from UTC datetime, "
f"got datetime with tzinfo={d.tzinfo!r}"
"Cannot create Instant from a naive datetime. "
"Use LocalDateTime.from_py_datetime() for this."
)
if d.utcoffset() is None:
raise ValueError(
"Cannot create from datetime with utcoffset() None"
)
as_utc = d.astimezone(_UTC)
return cls._from_py_unchecked(
_strip_subclasses(d.replace(microsecond=0)), d.microsecond * 1_000
_strip_subclasses(as_utc.replace(microsecond=0)),
as_utc.microsecond * 1_000,
)

def format_common_iso(self) -> str:
Expand Down Expand Up @@ -3475,22 +3475,28 @@ def from_timestamp_nanos(
@classmethod
def from_py_datetime(cls, d: _datetime, /) -> OffsetDateTime:
"""Create an instance from a standard library ``datetime`` object.
The datetime must be aware.
The inverse of the ``py_datetime()`` method.
Important
---------
If the datetime tzinfo is *not* a fixed offset,
a ``ValueError`` is raised. If you want to convert a datetime
with a ``ZoneInfo`` tzinfo, use ``ZonedDateTime.from_py_datetime()`` instead.
"""
if not isinstance(d.tzinfo, _timezone):
if d.tzinfo is None:
raise ValueError(
"Datetime's tzinfo is not a datetime.timezone, "
f"got tzinfo={d.tzinfo!r}"
"Cannot create from a naive datetime. "
"Use LocalDateTime.from_py_datetime() for this."
)
if (offset := d.utcoffset()) is None:
raise ValueError(
"Cannot create from datetime with utcoffset() None"
)
elif offset.microseconds:
raise ValueError("Sub-second offsets are not supported")
return cls._from_py_unchecked(
_check_utc_bounds(_strip_subclasses(d.replace(microsecond=0))),
_check_utc_bounds(
_strip_subclasses(
d.replace(microsecond=0, tzinfo=_timezone(offset))
)
),
d.microsecond * 1_000,
)

Expand Down Expand Up @@ -4442,12 +4448,12 @@ def from_timestamp_nanos(cls, i: int, /) -> SystemDateTime:
_fromtimestamp(secs, _UTC).astimezone(), nanos
)

# TODO py_datetime docstring

@classmethod
def from_py_datetime(cls, d: _datetime, /) -> SystemDateTime:
"""Create an instance from a standard library ``datetime`` object
with fixed-offset tzinfo.
"""Create an instance from a standard library ``datetime`` object.
The datetime must be aware.
The inverse of the ``py_datetime()`` method.
"""
odt = OffsetDateTime.from_py_datetime(d)
return cls._from_py_unchecked(odt._py_dt, odt._nanos)
Expand Down
26 changes: 21 additions & 5 deletions src/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -684,11 +684,23 @@ pub(crate) unsafe fn newref<'a>(obj: *mut PyObject) -> &'a mut PyObject {
obj.as_mut().unwrap()
}

// FUTURE: replace with Py_IsNone when dropping Py 3.9 support
pub(crate) unsafe fn is_none(x: *mut PyObject) -> bool {
x == Py_None()
}

// NOTE: assumes it's an "aware" datetime object
pub(crate) unsafe fn offset_from_py_dt(dt: *mut PyObject) -> PyResult<i32> {
// OPTIMIZE: is calling ZoneInfo.utcoffset() faster?
let delta = methcall0(dt, "utcoffset")?;
defer_decref!(delta);
Ok(PyDateTime_DELTA_GET_DAYS(delta) * 86_400 + PyDateTime_DELTA_GET_SECONDS(delta))
if is_none(delta) {
// This case is rare, but possible even with aware datetimes
Err(value_err!("utcoffset() returned None"))
} else if PyDateTime_DELTA_GET_MICROSECONDS(delta) != 0 {
Err(value_err!("Sub-second offsets are not supported",))
} else {
Ok(PyDateTime_DELTA_GET_DAYS(delta) * 86_400 + PyDateTime_DELTA_GET_SECONDS(delta))
}
}

pub(crate) fn offset_fmt(secs: i32) -> String {
Expand Down Expand Up @@ -721,6 +733,10 @@ pub(crate) enum OffsetResult {
Fold(i32, i32),
}

pub(crate) unsafe fn is_naive(py_dt: *mut PyObject) -> bool {
is_none(get_dt_tzinfo(py_dt))
}

unsafe fn system_offset(
date: Date,
time: Time,
Expand Down Expand Up @@ -1051,7 +1067,7 @@ where

#[inline]
#[allow(dead_code)]
unsafe fn getattr_tzinfo(dt: *mut PyObject) -> *mut PyObject {
unsafe fn getattr_tzinfo_unchecked(dt: *mut PyObject) -> *mut PyObject {
let tzinfo = PyObject_GetAttrString(dt, c"tzinfo".as_ptr());
// To keep things consistent with the Py3.10 version,
// we need to decref it, turning it into a borrowed reference.
Expand All @@ -1068,7 +1084,7 @@ pub(crate) unsafe fn get_dt_tzinfo(dt: *mut PyObject) -> *mut PyObject {
}
#[cfg(not(Py_3_10))]
{
getattr_tzinfo(dt)
getattr_tzinfo_unchecked(dt)
}
}

Expand All @@ -1080,7 +1096,7 @@ pub(crate) unsafe fn get_time_tzinfo(dt: *mut PyObject) -> *mut PyObject {
}
#[cfg(not(Py_3_10))]
{
getattr_tzinfo(dt)
getattr_tzinfo_unchecked(dt)
}
}

Expand Down
28 changes: 22 additions & 6 deletions src/instant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,9 +230,12 @@ impl Instant {
.as_result()
}

pub(crate) unsafe fn from_py(dt: *mut PyObject, state: &State) -> Option<Self> {
unsafe fn from_py(dt: *mut PyObject, state: &State) -> PyResult<Option<Self>> {
let tzinfo = get_dt_tzinfo(dt);
(tzinfo == state.py_api.TimeZone_UTC).then_some(Instant::from_datetime(
if is_none(tzinfo) {
Err(value_err!("datetime cannot be naive"))?;
};
let inst = Instant::from_datetime(
Date {
year: PyDateTime_GET_YEAR(dt) as u16,
month: PyDateTime_GET_MONTH(dt) as u8,
Expand All @@ -244,7 +247,20 @@ impl Instant {
second: PyDateTime_DATE_GET_SECOND(dt) as u8,
nanos: PyDateTime_DATE_GET_MICROSECOND(dt) as u32 * 1_000,
},
))
);
Ok(if tzinfo == state.py_api.TimeZone_UTC {
// Fast path for the common case
Some(inst)
} else {
let delta = methcall1(tzinfo, "utcoffset", dt)?;
if is_none(delta) {
Err(value_err!("datetime utcoffset() is None"))?;
}
let nanos = i128::from(PyDateTime_DELTA_GET_DAYS(delta)) * NS_PER_DAY
+ i128::from(PyDateTime_DELTA_GET_SECONDS(delta)) * 1_000_000_000
+ i128::from(PyDateTime_DELTA_GET_MICROSECONDS(delta)) * 1_000;
inst.shift(-nanos)
})
}

#[cfg(target_pointer_width = "64")]
Expand Down Expand Up @@ -533,8 +549,8 @@ unsafe fn from_py_datetime(cls: *mut PyObject, dt: *mut PyObject) -> PyReturn {
if PyDateTime_Check(dt) == 0 {
Err(type_err!("Expected a datetime object"))?;
}
Instant::from_py(dt, State::for_type(cls.cast()))
.ok_or_else(|| value_err!("datetime must have tzinfo set to UTC, got {}", dt.repr()))?
Instant::from_py(dt, State::for_type(cls.cast()))?
.ok_or_else(|| value_err!("datetime out of range: {}", dt.repr()))?
.to_obj(cls.cast())
}

Expand Down Expand Up @@ -776,7 +792,7 @@ unsafe fn parse_rfc2822(cls: *mut PyObject, s_obj: *mut PyObject) -> PyReturn {
defer_decref!(dt);
let tzinfo = get_dt_tzinfo(dt);
if tzinfo == state.py_api.TimeZone_UTC
|| (tzinfo == Py_None() && s_obj.to_str()?.unwrap().contains("-0000"))
|| (is_none(tzinfo) && s_obj.to_str()?.unwrap().contains("-0000"))
{
Instant::from_datetime(
Date {
Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ unsafe fn time_machine_installed() -> PyResult<bool> {
defer_decref!(find_spec);
let spec = call1(find_spec, steal!("time_machine".to_py()?))?;
defer_decref!(spec);
Ok((spec as *mut PyObject) != Py_None())
Ok(!is_none(spec))
}

unsafe fn traverse(target: *mut PyObject, visit: visitproc, arg: *mut c_void) {
Expand Down
4 changes: 2 additions & 2 deletions src/local_datetime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -569,7 +569,7 @@ unsafe fn from_py_datetime(type_: *mut PyObject, dt: *mut PyObject) -> PyReturn
Err(type_err!("argument must be datetime.datetime"))?
}
let tzinfo = get_dt_tzinfo(dt);
if tzinfo != Py_None() {
if !is_none(tzinfo) {
Err(value_err!(
"datetime must be naive, but got tzinfo={}",
tzinfo.repr()
Expand Down Expand Up @@ -670,7 +670,7 @@ unsafe fn strptime(cls: *mut PyObject, args: &[*mut PyObject]) -> PyReturn {
.as_result()?;
defer_decref!(parsed);
let tzinfo = get_dt_tzinfo(parsed);
if tzinfo != Py_None() {
if !is_none(tzinfo) {
Err(value_err!(
"datetime must be naive, but got tzinfo={}",
tzinfo.repr()
Expand Down
42 changes: 23 additions & 19 deletions src/offset_datetime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,25 +129,23 @@ impl OffsetDateTime {
// Returns None if the tzinfo is incorrect, or the UTC time is out of bounds
pub(crate) unsafe fn from_py(dt: *mut PyObject, state: &State) -> PyResult<Option<Self>> {
debug_assert!(PyObject_IsInstance(dt, state.py_api.DateTimeType.cast()).is_positive());
let tzinfo = get_dt_tzinfo(dt);
Ok(match PyObject_IsInstance(tzinfo, state.timezone_type) {
1 => OffsetDateTime::new(
Date {
year: PyDateTime_GET_YEAR(dt) as u16,
month: PyDateTime_GET_MONTH(dt) as u8,
day: PyDateTime_GET_DAY(dt) as u8,
},
Time {
hour: PyDateTime_DATE_GET_HOUR(dt) as u8,
minute: PyDateTime_DATE_GET_MINUTE(dt) as u8,
second: PyDateTime_DATE_GET_SECOND(dt) as u8,
nanos: PyDateTime_DATE_GET_MICROSECOND(dt) as u32 * 1_000,
},
offset_from_py_dt(dt)?,
),
0 => None,
_ => Err(py_err!())?,
})
if is_none(get_dt_tzinfo(dt)) {
Err(value_err!("Datetime cannot be naive"))?
}
Ok(OffsetDateTime::new(
Date {
year: PyDateTime_GET_YEAR(dt) as u16,
month: PyDateTime_GET_MONTH(dt) as u8,
day: PyDateTime_GET_DAY(dt) as u8,
},
Time {
hour: PyDateTime_DATE_GET_HOUR(dt) as u8,
minute: PyDateTime_DATE_GET_MINUTE(dt) as u8,
second: PyDateTime_DATE_GET_SECOND(dt) as u8,
nanos: PyDateTime_DATE_GET_MICROSECOND(dt) as u32 * 1_000,
},
offset_from_py_dt(dt)?,
))
}

pub(crate) unsafe fn from_py_and_nanos_unchecked(
Expand Down Expand Up @@ -1088,6 +1086,12 @@ unsafe fn parse_rfc2822(cls: *mut PyObject, s_obj: *mut PyObject) -> PyReturn {
let state = State::for_type(cls.cast());
let py_dt = call1(state.parse_rfc2822, s_obj)?;
defer_decref!(py_dt);
if is_naive(py_dt) {
Err(value_err!(
"parsed datetime must have a timezone, got {}",
s_obj.repr()
))?
};
OffsetDateTime::from_py(py_dt, state)?
.ok_or_else(|| {
value_err!(
Expand Down
6 changes: 3 additions & 3 deletions src/time.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,10 +324,10 @@ unsafe fn py_time(slf: *mut PyObject, _: *mut PyObject) -> PyReturn {

unsafe fn from_py_time(type_: *mut PyObject, time: *mut PyObject) -> PyReturn {
if PyTime_Check(time) == 0 {
Err(type_err!("argument must be a whenever.Time"))?
Err(type_err!("argument must be a datetime.time"))?
}
if get_time_tzinfo(time) != Py_None() {
Err(value_err!("time with timezone is not supported"))?
if !is_none(get_time_tzinfo(time)) {
Err(value_err!("time with tzinfo is not supported"))?
}
// FUTURE: check `fold=0`?
Time {
Expand Down
Loading

0 comments on commit 29bb5b0

Please sign in to comment.