Skip to content

Commit b367f92

Browse files
committed
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.
1 parent 2de6273 commit b367f92

2 files changed

Lines changed: 51 additions & 3 deletions

File tree

msgpack/ext.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import struct
33
from collections import namedtuple
44

5+
_EPOCH = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)
6+
57

68
class ExtType(namedtuple("ExtType", "code data")):
79
"""ExtType represents ext type in msgpack."""
@@ -156,8 +158,7 @@ def to_datetime(self):
156158
157159
:rtype: `datetime.datetime`
158160
"""
159-
utc = datetime.timezone.utc
160-
return datetime.datetime.fromtimestamp(0, utc) + datetime.timedelta(
161+
return _EPOCH + datetime.timedelta(
161162
seconds=self.seconds, microseconds=self.nanoseconds // 1000
162163
)
163164

@@ -167,4 +168,17 @@ def from_datetime(dt):
167168
168169
:rtype: Timestamp
169170
"""
170-
return Timestamp(seconds=int(dt.timestamp() // 1), nanoseconds=dt.microsecond * 1000)
171+
# Use integer timedelta arithmetic (like the Cython packer) rather than
172+
# ``int(dt.timestamp() // 1)``. ``datetime.timestamp()`` returns a float
173+
# that cannot hold microsecond precision for datetimes far from the epoch,
174+
# so it may round the whole-second part up while the exact ``microsecond``
175+
# is still used for the nanoseconds -- producing a Timestamp one second in
176+
# the future (and OverflowError near datetime.max).
177+
if dt.tzinfo is None:
178+
# Match datetime.timestamp(): a naive datetime is treated as local time.
179+
dt = dt.astimezone()
180+
delta = dt - _EPOCH
181+
return Timestamp(
182+
seconds=delta.days * 86400 + delta.seconds,
183+
nanoseconds=delta.microseconds * 1000,
184+
)

test/test_timestamp.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,40 @@ def test_timestamp_datetime():
111111
assert ts_pre_epoch.to_datetime() == pre_epoch
112112

113113

114+
def test_from_datetime_far_future_precision():
115+
# Regression: Timestamp.from_datetime used the float datetime.timestamp(),
116+
# which cannot hold microsecond precision far from the epoch. It rounded the
117+
# whole-second part up by one while still taking the exact microsecond for the
118+
# nanoseconds, yielding a Timestamp one second in the future -- and raised
119+
# OverflowError near datetime.max. It must instead match the integer
120+
# arithmetic used by the Cython packer.
121+
utc = datetime.timezone.utc
122+
epoch = datetime.datetime(1970, 1, 1, tzinfo=utc)
123+
124+
for dt in [
125+
datetime.datetime(2515, 1, 1, 0, 0, 0, 999999, tzinfo=utc),
126+
datetime.datetime(3000, 1, 1, 0, 0, 0, 999999, tzinfo=utc),
127+
datetime.datetime(5000, 6, 15, 12, 30, 45, 123456, tzinfo=utc),
128+
# Near datetime.max: previously raised OverflowError.
129+
datetime.datetime(9999, 12, 31, 23, 59, 59, 999999, tzinfo=utc),
130+
]:
131+
ts = Timestamp.from_datetime(dt)
132+
# Round-trips exactly (was one second in the future).
133+
assert ts.to_datetime() == dt
134+
# Whole-second part is exact, computed independently of the implementation.
135+
assert ts.seconds == (dt - epoch) // datetime.timedelta(seconds=1)
136+
assert ts.nanoseconds == dt.microsecond * 1000
137+
# The pure-Python path agrees with packing the datetime directly (the
138+
# reference path), i.e. no off-by-one-second divergence.
139+
assert msgpack.packb(ts) == msgpack.packb(dt, datetime=True)
140+
141+
# Explicit value: 3000-01-01T00:00:00.999999Z is 32503680000 s after the
142+
# epoch; the float path produced 32503680001 (one second in the future).
143+
ts = Timestamp.from_datetime(datetime.datetime(3000, 1, 1, 0, 0, 0, 999999, tzinfo=utc))
144+
assert ts.seconds == 32503680000
145+
assert ts.nanoseconds == 999999000
146+
147+
114148
def test_unpack_datetime():
115149
t = Timestamp(42, 14)
116150
utc = datetime.timezone.utc

0 commit comments

Comments
 (0)