Skip to content
Open
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
20 changes: 17 additions & 3 deletions msgpack/ext.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import struct
from collections import namedtuple

_EPOCH = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)


class ExtType(namedtuple("ExtType", "code data")):
"""ExtType represents ext type in msgpack."""
Expand Down Expand Up @@ -156,8 +158,7 @@ def to_datetime(self):

:rtype: `datetime.datetime`
"""
utc = datetime.timezone.utc
return datetime.datetime.fromtimestamp(0, utc) + datetime.timedelta(
return _EPOCH + datetime.timedelta(
seconds=self.seconds, microseconds=self.nanoseconds // 1000
)

Expand All @@ -167,4 +168,17 @@ def from_datetime(dt):

:rtype: Timestamp
"""
return Timestamp(seconds=int(dt.timestamp() // 1), nanoseconds=dt.microsecond * 1000)
# Use integer timedelta arithmetic (like the Cython packer) rather than
# ``int(dt.timestamp() // 1)``. ``datetime.timestamp()`` returns a float
# that cannot hold microsecond precision for datetimes far from the epoch,
# so it may round the whole-second part up while the exact ``microsecond``
# is still used for the nanoseconds -- producing a Timestamp one second in
# the future (and OverflowError near datetime.max).
if dt.tzinfo is None:
# Match datetime.timestamp(): a naive datetime is treated as local time.
dt = dt.astimezone()
delta = dt - _EPOCH
return Timestamp(
seconds=delta.days * 86400 + delta.seconds,
nanoseconds=delta.microseconds * 1000,
)
34 changes: 34 additions & 0 deletions test/test_timestamp.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,40 @@ def test_timestamp_datetime():
assert ts_pre_epoch.to_datetime() == pre_epoch


def test_from_datetime_far_future_precision():
# Regression: Timestamp.from_datetime used the float datetime.timestamp(),
# which cannot hold microsecond precision far from the epoch. It rounded the
# whole-second part up by one while still taking the exact microsecond for the
# nanoseconds, yielding a Timestamp one second in the future -- and raised
# OverflowError near datetime.max. It must instead match the integer
# arithmetic used by the Cython packer.
utc = datetime.timezone.utc
epoch = datetime.datetime(1970, 1, 1, tzinfo=utc)

for dt in [
datetime.datetime(2515, 1, 1, 0, 0, 0, 999999, tzinfo=utc),
datetime.datetime(3000, 1, 1, 0, 0, 0, 999999, tzinfo=utc),
datetime.datetime(5000, 6, 15, 12, 30, 45, 123456, tzinfo=utc),
# Near datetime.max: previously raised OverflowError.
datetime.datetime(9999, 12, 31, 23, 59, 59, 999999, tzinfo=utc),
]:
ts = Timestamp.from_datetime(dt)
# Round-trips exactly (was one second in the future).
assert ts.to_datetime() == dt
# Whole-second part is exact, computed independently of the implementation.
assert ts.seconds == (dt - epoch) // datetime.timedelta(seconds=1)
assert ts.nanoseconds == dt.microsecond * 1000
# The pure-Python path agrees with packing the datetime directly (the
# reference path), i.e. no off-by-one-second divergence.
assert msgpack.packb(ts) == msgpack.packb(dt, datetime=True)

# Explicit value: 3000-01-01T00:00:00.999999Z is 32503680000 s after the
# epoch; the float path produced 32503680001 (one second in the future).
ts = Timestamp.from_datetime(datetime.datetime(3000, 1, 1, 0, 0, 0, 999999, tzinfo=utc))
assert ts.seconds == 32503680000
assert ts.nanoseconds == 999999000


def test_unpack_datetime():
t = Timestamp(42, 14)
utc = datetime.timezone.utc
Expand Down
Loading