Skip to content

Commit

Permalink
Adjustments to from_timestamp methods
Browse files Browse the repository at this point in the history
  • Loading branch information
ariebovenberg committed Jul 27, 2024
1 parent a2807b9 commit c952cd2
Show file tree
Hide file tree
Showing 12 changed files with 332 additions and 44 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
🚀 Changelog
============

0.6.5 (2024-07-27)
------------------

- ``from_timestamp`` now also accepts floats, to ease porting code from ``datetime`` (#159)
- Fixed incorrect fractional seconds when parsing negative values in ``from_timestamp`` methods.
- Fix some places where ``ValueError`` was raised instead of ``TypeError``

0.6.4 (2024-07-26)
------------------

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ authors = [
{name = "Arie Bovenberg", email = "[email protected]"},
]
readme = "README.md"
version = "0.6.4"
version = "0.6.5"
description = "Modern datetime library for Python, written in Rust"
requires-python = ">=3.9"
classifiers = [
Expand Down
15 changes: 11 additions & 4 deletions pysrc/whenever/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ class Instant(_KnowsInstant):
@classmethod
def now(cls) -> Instant: ...
@classmethod
def from_timestamp(cls, i: int, /) -> Instant: ...
def from_timestamp(cls, i: int | float, /) -> Instant: ...
@classmethod
def from_timestamp_millis(cls, i: int, /) -> Instant: ...
@classmethod
Expand Down Expand Up @@ -389,7 +389,12 @@ class OffsetDateTime(_KnowsInstantAndLocal):
) -> OffsetDateTime: ...
@classmethod
def from_timestamp(
cls, i: int, /, *, offset: int | TimeDelta, ignore_dst: Literal[True]
cls,
i: int | float,
/,
*,
offset: int | TimeDelta,
ignore_dst: Literal[True],
) -> OffsetDateTime: ...
@classmethod
def from_timestamp_millis(
Expand Down Expand Up @@ -496,7 +501,9 @@ class ZonedDateTime(_KnowsInstantAndLocal):
@classmethod
def from_py_datetime(cls, d: _datetime, /) -> ZonedDateTime: ...
@classmethod
def from_timestamp(cls, i: int, /, *, tz: str) -> ZonedDateTime: ...
def from_timestamp(
cls, i: int | float, /, *, tz: str
) -> ZonedDateTime: ...
@classmethod
def from_timestamp_millis(cls, i: int, /, *, tz: str) -> ZonedDateTime: ...
@classmethod
Expand Down Expand Up @@ -612,7 +619,7 @@ class SystemDateTime(_KnowsInstantAndLocal):
@classmethod
def now(cls) -> SystemDateTime: ...
@classmethod
def from_timestamp(cls, i: int, /) -> SystemDateTime: ...
def from_timestamp(cls, i: int | float, /) -> SystemDateTime: ...
@classmethod
def from_timestamp_millis(cls, i: int, /) -> SystemDateTime: ...
@classmethod
Expand Down
58 changes: 50 additions & 8 deletions pysrc/whenever/_pywhenever.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
# - It saves some overhead
from __future__ import annotations

__version__ = "0.6.4"
__version__ = "0.6.5"

import enum
import re
Expand Down Expand Up @@ -2186,6 +2186,13 @@ def now(cls: type[_T], **kwargs) -> _T:
def timestamp(self) -> int:
"""The UNIX timestamp for this datetime. Inverse of :meth:`from_timestamp`.
Note
----
In contrast to the standard library, this method always returns an integer,
not a float. This is because floating point timestamps are not precise
enough to represent all instants to nanosecond precision.
This decision is consistent with other modern date-time libraries.
Example
-------
>>> Instant.from_utc(1970, 1, 1).timestamp()
Expand All @@ -2207,13 +2214,21 @@ def timestamp_nanos(self) -> int:
if not TYPE_CHECKING:

@classmethod
def from_timestamp(cls: type[_T], i: int, /, **kwargs) -> _T:
def from_timestamp(cls: type[_T], i: int | float, /, **kwargs) -> _T:
"""Create an instance from a UNIX timestamp.
The inverse of :meth:`~_KnowsInstant.timestamp`.
:class:`~ZonedDateTime` and :class:`~OffsetDateTime` require
a ``tz=`` and ``offset=`` kwarg, respectively.
Note
----
``from_timestamp()`` also accepts floats, in order to ease
migration from the standard library.
Note however that ``timestamp()`` only returns integers.
The reason is that floating point timestamps are not precise
enough to represent all instants to nanosecond precision.
Example
-------
>>> Instant.from_timestamp(0)
Expand Down Expand Up @@ -2561,18 +2576,25 @@ def now(cls) -> Instant:
return cls._from_py_unchecked(_fromtimestamp(secs, _UTC), nanos)

@classmethod
def from_timestamp(cls, i: int, /) -> Instant:
return cls._from_py_unchecked(_fromtimestamp(i, _UTC), 0)
def from_timestamp(cls, i: int | float, /) -> Instant:
secs, fract = divmod(i, 1)
return cls._from_py_unchecked(
_fromtimestamp(secs, _UTC), int(fract * 1_000_000_000)
)

@classmethod
def from_timestamp_millis(cls, i: int, /) -> Instant:
if not isinstance(i, int):
raise TypeError("method requires an integer")
secs, millis = divmod(i, 1_000)
return cls._from_py_unchecked(
_fromtimestamp(secs, _UTC), millis * 1_000_000
)

@classmethod
def from_timestamp_nanos(cls, i: int, /) -> Instant:
if not isinstance(i, int):
raise TypeError("method requires an integer")
secs, nanos = divmod(i, 1_000_000_000)
return cls._from_py_unchecked(_fromtimestamp(secs, _UTC), nanos)

Expand Down Expand Up @@ -2978,8 +3000,10 @@ def from_timestamp(
) -> OffsetDateTime:
if ignore_dst is not True:
raise _EXC_TIMESTAMP_DST
secs, fract = divmod(i, 1)
return cls._from_py_unchecked(
_fromtimestamp(i, _load_offset(offset)), 0
_fromtimestamp(secs, _load_offset(offset)),
int(fract * 1_000_000_000),
)

@classmethod
Expand All @@ -2988,6 +3012,8 @@ def from_timestamp_millis(
) -> OffsetDateTime:
if ignore_dst is not True:
raise _EXC_TIMESTAMP_DST
if not isinstance(i, int):
raise TypeError("method requires an integer")
secs, millis = divmod(i, 1_000)
return cls._from_py_unchecked(
_fromtimestamp(secs, _load_offset(offset)), millis * 1_000_000
Expand All @@ -2999,6 +3025,8 @@ def from_timestamp_nanos(
) -> OffsetDateTime:
if ignore_dst is not True:
raise _EXC_TIMESTAMP_DST
if not isinstance(i, int):
raise TypeError("method requires an integer")
secs, nanos = divmod(i, 1_000_000_000)
return cls._from_py_unchecked(
_fromtimestamp(secs, _load_offset(offset)), nanos
Expand Down Expand Up @@ -3404,17 +3432,24 @@ def parse_common_iso(cls, s: str, /) -> ZonedDateTime:

@classmethod
def from_timestamp(cls, i: int, /, *, tz: str) -> ZonedDateTime:
return cls._from_py_unchecked(_fromtimestamp(i, ZoneInfo(tz)), 0)
secs, fract = divmod(i, 1)
return cls._from_py_unchecked(
_fromtimestamp(secs, ZoneInfo(tz)), int(fract * 1_000_000_000)
)

@classmethod
def from_timestamp_millis(cls, i: int, /, *, tz: str) -> ZonedDateTime:
if not isinstance(i, int):
raise TypeError("method requires an integer")
secs, millis = divmod(i, 1_000)
return cls._from_py_unchecked(
_fromtimestamp(secs, ZoneInfo(tz)), millis * 1_000_000
)

@classmethod
def from_timestamp_nanos(cls, i: int, /, *, tz: str) -> ZonedDateTime:
if not isinstance(i, int):
raise TypeError("method requires an integer")
secs, nanos = divmod(i, 1_000_000_000)
return cls._from_py_unchecked(
_fromtimestamp(secs, ZoneInfo(tz)), nanos
Expand Down Expand Up @@ -3738,18 +3773,25 @@ def parse_common_iso(cls, s: str, /) -> SystemDateTime:
return cls._from_py_unchecked(odt._py_dt, odt._nanos)

@classmethod
def from_timestamp(cls, i: int, /) -> SystemDateTime:
return cls._from_py_unchecked(_fromtimestamp(i, _UTC).astimezone(), 0)
def from_timestamp(cls, i: int | float, /) -> SystemDateTime:
secs, fract = divmod(i, 1)
return cls._from_py_unchecked(
_fromtimestamp(secs, _UTC).astimezone(), int(fract * 1_000_000_000)
)

@classmethod
def from_timestamp_millis(cls, i: int, /) -> SystemDateTime:
if not isinstance(i, int):
raise TypeError("method requires an integer")
secs, millis = divmod(i, 1_000)
return cls._from_py_unchecked(
_fromtimestamp(secs, _UTC).astimezone(), millis * 1_000_000
)

@classmethod
def from_timestamp_nanos(cls, i: int, /) -> SystemDateTime:
if not isinstance(i, int):
raise TypeError("method requires an integer")
secs, nanos = divmod(i, 1_000_000_000)
return cls._from_py_unchecked(
_fromtimestamp(secs, _UTC).astimezone(), nanos
Expand Down
34 changes: 24 additions & 10 deletions src/instant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ pub(crate) const SINGLETONS: &[(&CStr, Instant); 2] = &[
pub(crate) const UNIX_EPOCH_INSTANT: i64 = 62_135_683_200; // 1970-01-01 in seconds after 0000-12-31
pub(crate) const MIN_INSTANT: i64 = 24 * 60 * 60;
pub(crate) const MAX_INSTANT: i64 = 315_537_983_999;
const MIN_EPOCH: i64 = MIN_INSTANT - UNIX_EPOCH_INSTANT;
const MAX_EPOCH: i64 = MAX_INSTANT - UNIX_EPOCH_INSTANT;

impl Instant {
pub(crate) fn to_datetime(self) -> DateTime {
Expand Down Expand Up @@ -109,22 +111,31 @@ impl Instant {
.map(|secs| Instant { secs, nanos: 0 })
}

pub(crate) fn from_timestamp_f64(timestamp: f64) -> Option<Self> {
(MIN_EPOCH as f64..MAX_EPOCH as f64)
.contains(&timestamp)
.then(|| Instant {
secs: (timestamp.floor() as i64 + UNIX_EPOCH_INSTANT),
nanos: (timestamp * 1_000_000_000_f64).rem_euclid(1_000_000_000_f64) as u32,
})
}

pub(crate) fn from_timestamp_millis(timestamp: i64) -> Option<Self> {
let secs = timestamp / 1_000 + UNIX_EPOCH_INSTANT;
((MIN_INSTANT..=MAX_INSTANT).contains(&secs)).then_some(Instant {
let secs = timestamp.div_euclid(1_000) + UNIX_EPOCH_INSTANT;
((MIN_INSTANT..=MAX_INSTANT).contains(&secs)).then(|| Instant {
secs,
nanos: (timestamp % 1_000) as u32 * 1_000_000,
nanos: timestamp.rem_euclid(1_000) as u32 * 1_000_000,
})
}

pub(crate) fn from_timestamp_nanos(timestamp: i128) -> Option<Self> {
i64::try_from(timestamp / 1_000_000_000)
i64::try_from(timestamp.div_euclid(1_000_000_000))
.ok()
.map(|secs| secs + UNIX_EPOCH_INSTANT)
.map(|s| s + UNIX_EPOCH_INSTANT)
.filter(|s| (MIN_INSTANT..=MAX_INSTANT).contains(s))
.map(|secs| Instant {
secs,
nanos: (timestamp % 1_000_000_000) as u32,
nanos: timestamp.rem_euclid(1_000_000_000) as u32,
})
}

Expand Down Expand Up @@ -486,10 +497,13 @@ unsafe fn timestamp_nanos(slf: *mut PyObject, _: *mut PyObject) -> PyReturn {
}

unsafe fn from_timestamp(cls: *mut PyObject, ts: *mut PyObject) -> PyReturn {
Instant::from_timestamp(
ts.to_i64()?
.ok_or_type_err("Timestamp must be an integer")?,
)
match ts.to_i64()? {
Some(ts) => Instant::from_timestamp(ts),
None => Instant::from_timestamp_f64(
ts.to_f64()?
.ok_or_type_err("Timestamp must be an integer or float")?,
),
}
.ok_or_value_err("Timestamp out of range")?
.to_obj(cls.cast())
}
Expand Down
20 changes: 12 additions & 8 deletions src/offset_datetime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -908,12 +908,16 @@ unsafe fn from_timestamp(
let state = State::for_type(cls);
let offset_secs =
check_from_timestamp_args_return_offset("from_timestamp", args, kwargs, state)?;
Instant::from_timestamp(
args[0]
.to_i64()?
.ok_or_value_err("timestamp must be an integer")?,
)
.ok_or_value_err("timestamp is out of range")?

match args[0].to_i64()? {
Some(ts) => Instant::from_timestamp(ts),
None => Instant::from_timestamp_f64(
args[0]
.to_f64()?
.ok_or_type_err("Timestamp must be an integer or float")?,
),
}
.ok_or_value_err("Timestamp is out of range")?
.shift_secs_unchecked(offset_secs as i64)
.to_datetime()
.with_offset_unchecked(offset_secs)
Expand All @@ -932,7 +936,7 @@ unsafe fn from_timestamp_millis(
Instant::from_timestamp_millis(
args[0]
.to_i64()?
.ok_or_value_err("timestamp must be an integer")?,
.ok_or_type_err("timestamp must be an integer")?,
)
.ok_or_value_err("timestamp is out of range")?
.shift_secs_unchecked(offset_secs as i64)
Expand All @@ -953,7 +957,7 @@ unsafe fn from_timestamp_nanos(
Instant::from_timestamp_nanos(
args[0]
.to_i128()?
.ok_or_value_err("timestamp must be an integer")?,
.ok_or_type_err("timestamp must be an integer")?,
)
.ok_or_value_err("timestamp is out of range")?
.shift_secs_unchecked(offset_secs as i64)
Expand Down
11 changes: 7 additions & 4 deletions src/system_datetime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -575,10 +575,13 @@ unsafe fn __reduce__(slf: *mut PyObject, _: *mut PyObject) -> PyReturn {
}

unsafe fn from_timestamp(cls: *mut PyObject, arg: *mut PyObject) -> PyReturn {
Instant::from_timestamp(
arg.to_i64()?
.ok_or_type_err("argument must be an integer")?,
)
match arg.to_i64()? {
Some(ts) => Instant::from_timestamp(ts),
None => Instant::from_timestamp_f64(
arg.to_f64()?
.ok_or_type_err("Timestamp must be an integer or float")?,
),
}
.ok_or_value_err("timestamp is out of range")
.and_then(|inst| inst.to_system_tz(State::for_type(cls.cast()).py_api))?
.to_obj(cls.cast())
Expand Down
14 changes: 9 additions & 5 deletions src/zoned_datetime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -946,11 +946,15 @@ unsafe fn from_timestamp(
let zoneinfo =
check_from_timestamp_args_return_zoneinfo(args, kwargs, state, "from_timestamp")?;
defer_decref!(zoneinfo);
Instant::from_timestamp(
args[0]
.to_i64()?
.ok_or_type_err("timestamp must be an integer")?,
)

match args[0].to_i64()? {
Some(ts) => Instant::from_timestamp(ts),
None => Instant::from_timestamp_f64(
args[0]
.to_f64()?
.ok_or_type_err("Timestamp must be an integer or float")?,
),
}
.ok_or_value_err("timestamp is out of range")?
.to_tz(state.py_api, zoneinfo)?
.to_obj(cls)
Expand Down
Loading

0 comments on commit c952cd2

Please sign in to comment.