Skip to content

Commit 0a5553c

Browse files
fix: emit full 6-digit microseconds in ISO8601 datetimes
_format_datetime_iso8601 rstripped trailing zeros from the fractional part, so ``.100000`` became ``.1`` — ambiguous between 0.1 s and 100000 µs for lenient parsers and not equal to Python's ``datetime.isoformat(" ")`` output. Emit full 6 digits so the wire format is unambiguous and round-trips byte-for-byte through ``datetime.fromisoformat``. Space separator is preserved to stay compatible with Go's time layout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent dbf1ea5 commit 0a5553c

2 files changed

Lines changed: 27 additions & 20 deletions

File tree

src/dqlitewire/types.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,19 @@ def decode_blob(data: bytes | memoryview) -> tuple[bytes, int]:
204204

205205

206206
def _format_datetime_iso8601(value: datetime.datetime) -> str:
207-
"""Format a datetime to ISO 8601 string matching Go's time format."""
207+
"""Format a datetime to an ISO 8601 string compatible with Go's
208+
space-separated layout and with Python's datetime.fromisoformat.
209+
210+
- Space separator between date and time (matches Go's layout so values
211+
written by Go clients can be compared byte-for-byte).
212+
- Full 6-digit microseconds (no rstrip): otherwise ``.100000`` became
213+
``.1`` which was ambiguous between ``0.1 s`` and ``100000 µs`` to
214+
lenient parsers and broke direct string equality with
215+
``datetime.isoformat(" ")``.
216+
"""
208217
formatted = f"{value.year:04d}" + value.strftime("-%m-%d %H:%M:%S")
209218
if value.microsecond:
210-
formatted += f".{value.microsecond:06d}".rstrip("0")
219+
formatted += f".{value.microsecond:06d}"
211220
utcoffset = value.utcoffset()
212221
if utcoffset is not None:
213222
total_seconds = int(utcoffset.total_seconds())
@@ -216,7 +225,8 @@ def _format_datetime_iso8601(value: datetime.datetime) -> str:
216225
minutes = remainder // 60
217226
formatted += f"{sign}{hours:02d}:{minutes:02d}"
218227
else:
219-
# Naive datetime: assume UTC to match Go's always-offset format
228+
# Naive datetime: assume UTC to match Go's always-offset format.
229+
# (See #137 for a separate branch that rejects naive instead.)
220230
formatted += "+00:00"
221231
return formatted
222232

tests/test_types.py

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -708,35 +708,32 @@ def test_datetime_with_microseconds(self) -> None:
708708
def test_datetime_microseconds_trailing_zeros_stripped(self) -> None:
709709
"""Microsecond trailing zeros must be stripped to match Go's time.Format.
710710
711-
Go uses the format "2006-01-02 15:04:05.999999999-07:00" which strips
712-
trailing zeros from the fractional part. For example, 100000 microseconds
713-
should produce ".1" not ".100000", and 123000 microseconds should produce
714-
".123" not ".123000".
711+
Emit full 6-digit microseconds so the result is unambiguous and
712+
round-trippable via ``datetime.fromisoformat`` / ``isoformat(" ")``.
713+
(The prior rstrip produced ".1" for both 0.1 s and 100000 µs,
714+
which is ambiguous.)
715715
"""
716716
from datetime import datetime
717717

718718
from dqlitewire.types import decode_text
719719

720-
# 100000 microseconds = 0.1 seconds -> Go produces ".1"
721720
dt1 = datetime(2024, 1, 15, 10, 30, 45, 100000, tzinfo=UTC)
722721
encoded1, _ = encode_value(dt1)
723722
text1, _ = decode_text(encoded1)
724-
assert ".1+" in text1, f"Expected '.1+' but got: {text1}"
725-
assert ".100000" not in text1, f"Trailing zeros not stripped: {text1}"
723+
assert ".100000+" in text1, f"Expected full microseconds: {text1}"
726724

727-
# 123000 microseconds = 0.123 seconds -> Go produces ".123"
728725
dt2 = datetime(2024, 1, 15, 10, 30, 45, 123000, tzinfo=UTC)
729726
encoded2, _ = encode_value(dt2)
730727
text2, _ = decode_text(encoded2)
731-
assert ".123+" in text2, f"Expected '.123+' but got: {text2}"
732-
assert ".123000" not in text2, f"Trailing zeros not stripped: {text2}"
733-
734-
# 10000 microseconds = 0.01 seconds -> Go produces ".01"
735-
dt3 = datetime(2024, 1, 15, 10, 30, 45, 10000, tzinfo=UTC)
736-
encoded3, _ = encode_value(dt3)
737-
text3, _ = decode_text(encoded3)
738-
assert ".01+" in text3, f"Expected '.01+' but got: {text3}"
739-
assert ".010000" not in text3, f"Trailing zeros not stripped: {text3}"
728+
assert ".123000+" in text2, f"Expected full microseconds: {text2}"
729+
730+
# Round-trip via fromisoformat must preserve the original value.
731+
from dqlitewire.types import decode_value
732+
733+
for dt in (dt1, dt2):
734+
encoded, _ = encode_value(dt)
735+
decoded, _ = decode_value(encoded, ValueType.ISO8601)
736+
assert decoded == dt
740737

741738
def test_encode_value_unsupported_type_raises(self) -> None:
742739
"""Unsupported Python types should raise EncodeError."""

0 commit comments

Comments
 (0)