diff --git a/docs/conf.py b/docs/conf.py index 28116cd6..d7215a94 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -269,7 +269,7 @@ # The format is a list of tuples containing the path and title. # epub_pre_files = [] -# HTML files shat should be inserted after the pages created by sphinx. +# HTML files that should be inserted after the pages created by sphinx. # The format is a list of tuples containing the path and title. # epub_post_files = [] 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 diff --git a/test/test_unpack.py b/test/test_unpack.py index ae6ea10c..5811c27f 100644 --- a/test/test_unpack.py +++ b/test/test_unpack.py @@ -90,10 +90,10 @@ def test_unpacker_tell_read_bytes(): objects = 1, "abc", "ghi" packed = b"\x01\x02\xa3abc\xa3def\xa3ghi" raw_data = b"\x02", b"\xa3def", b"" - lenghts = 1, 4, 999 + lengths = 1, 4, 999 positions = 1, 6, 14 unpacker = Unpacker(BytesIO(packed)) - for obj, unp, pos, n, raw in zip(objects, unpacker, positions, lenghts, raw_data): + for obj, unp, pos, n, raw in zip(objects, unpacker, positions, lengths, raw_data): assert obj == unp assert pos == unpacker.tell() assert unpacker.read_bytes(n) == raw