From b367f922c5c0c9aecfb125c9dbdb5c5cdfe6911e Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Wed, 1 Jul 2026 17:43:18 +0200 Subject: [PATCH] Fix Timestamp.from_datetime() precision for far-future datetimes from_datetime() derived the whole-second part from the float datetime.timestamp(), which cannot hold microsecond precision far from the epoch. It rounded the seconds up while still taking the exact microsecond for the nanoseconds, so the result was one second in the future (and raised OverflowError near datetime.max). Use integer timedelta arithmetic, matching the Cython packer, so the pure-Python path agrees with the reference implementation. --- msgpack/ext.py | 20 +++++++++++++++++--- test/test_timestamp.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/msgpack/ext.py b/msgpack/ext.py index 92ea4530..abaaad90 100644 --- a/msgpack/ext.py +++ b/msgpack/ext.py @@ -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.""" @@ -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 ) @@ -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, + ) diff --git a/test/test_timestamp.py b/test/test_timestamp.py index 7c8e3e83..2083356a 100644 --- a/test/test_timestamp.py +++ b/test/test_timestamp.py @@ -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