Skip to content

Commit 659ee98

Browse files
authored
Fix Timestamp.from_datetime() precision for far-future datetimes (#710)
`Timestamp.from_datetime()` computes the whole-second part from the float `datetime.timestamp()`. A float64 cannot hold microsecond precision for datetimes far from the epoch, so `timestamp()` rounds the seconds up while the exact `microsecond` is still used for the nanoseconds. The result is a `Timestamp` one second in the future, and near `datetime.max` it raises `OverflowError`: ```python >>> import datetime as dt >>> from msgpack.ext import Timestamp >>> d = dt.datetime(3000, 1, 1, 0, 0, 0, 999999, tzinfo=dt.timezone.utc) >>> Timestamp.from_datetime(d).to_datetime() datetime.datetime(3000, 1, 1, 0, 0, 1, 999999, tzinfo=datetime.timezone.utc) # +1 second >>> Timestamp.from_datetime(dt.datetime(9999, 12, 31, 23, 59, 59, 999999, tzinfo=dt.timezone.utc)) OverflowError: date value out of range ``` The Cython packer already uses integer `timedelta` arithmetic and is correct. This makes the pure-Python `from_datetime()` do the same, so the two paths agree and the round-trip invariant `from_datetime(d).to_datetime() == d` holds for far-future datetimes. Naive datetimes keep their existing local-time interpretation (matching `datetime.timestamp()`). Follow-up to #662, which fixed the pre-epoch rounding direction in the same method. Existing tests are unchanged; I added a regression test covering the far-future round-trip, the exact seconds/nanoseconds, agreement with packing the datetime directly, and the former `OverflowError` case. Full suite is green on both the C-extension and pure-Python paths.
1 parent d5e380f commit 659ee98

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)