Skip to content

Commit 44da05e

Browse files
Strip datetime conversions from the wire codec
encode_value and decode_value now deal only in wire primitives (bool/int/float/str/bytes/None). ValueType.ISO8601 encodes/decodes as raw text, matching the C reference (src/tuple.c uses text__encode/text__decode for DQLITE_ISO8601) and Go's wire codec, which returns raw values from the codec and lets the database/sql driver layer (Rows.Next) turn them into time.Time. Previously the wire layer over-reached: it auto-parsed ISO8601 strings into datetime.datetime and silently assigned UTC to naive values. That violated the layering — the comment already added to UNIXTIME ("See issue 006") explicitly notes this kind of conversion belongs in the driver layer — and broke SQLAlchemy's sqlite.DATETIME result processor, which expects a str and calls fromisoformat on it. Drops _format_datetime_iso8601 and _parse_iso8601 helpers along with the datetime import. Callers that previously relied on datetime auto-inference (e.g. encode_value(datetime(...))) must now stringify at the driver layer; python-dqlite-dbapi does exactly that. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1d50a7f commit 44da05e

3 files changed

Lines changed: 48 additions & 386 deletions

File tree

src/dqlitewire/types.py

Lines changed: 20 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,14 @@
22
33
All multi-byte integers are little-endian.
44
Text is null-terminated UTF-8, padded to 8-byte boundary.
5+
6+
The codec deals only in wire primitives (int, float, str, bool, bytes, None).
7+
Higher-level conversions — like ``DQLITE_ISO8601`` → ``datetime.datetime`` or
8+
``DQLITE_UNIXTIME`` → epoch-based ``datetime.datetime`` — belong in the
9+
driver/DBAPI layer, matching the split used by the C reference client and
10+
by Go's ``database/sql`` driver.
511
"""
612

7-
import datetime
813
import struct
914
from typing import Any
1015

@@ -203,39 +208,6 @@ def decode_blob(data: bytes | memoryview) -> tuple[bytes, int]:
203208
return bytes(data[8 : 8 + length]), total_size
204209

205210

206-
def _format_datetime_iso8601(value: datetime.datetime) -> str:
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-
- Naive datetimes (tzinfo is None) are rejected: silently assuming UTC
217-
hides real bugs in callers that used ``datetime.now()``/``utcnow()``
218-
in a local-time context.
219-
"""
220-
if value.tzinfo is None:
221-
raise EncodeError(
222-
"Naive datetime has no timezone; pass an aware datetime "
223-
"(e.g. datetime.now(datetime.UTC) or "
224-
"dt.replace(tzinfo=datetime.UTC))."
225-
)
226-
formatted = f"{value.year:04d}" + value.strftime("-%m-%d %H:%M:%S")
227-
if value.microsecond:
228-
formatted += f".{value.microsecond:06d}"
229-
utcoffset = value.utcoffset()
230-
assert utcoffset is not None # noqa: S101 - guarded above
231-
total_seconds = int(utcoffset.total_seconds())
232-
sign = "+" if total_seconds >= 0 else "-"
233-
hours, remainder = divmod(abs(total_seconds), 3600)
234-
minutes = remainder // 60
235-
formatted += f"{sign}{hours:02d}:{minutes:02d}"
236-
return formatted
237-
238-
239211
def encode_value(value: Any, value_type: ValueType | None = None) -> tuple[bytes, ValueType]:
240212
"""Encode a Python value to wire format.
241213
@@ -257,19 +229,17 @@ def encode_value(value: Any, value_type: ValueType | None = None) -> tuple[bytes
257229
value_type = ValueType.INTEGER
258230
elif isinstance(value, float):
259231
value_type = ValueType.FLOAT
260-
elif isinstance(value, datetime.datetime):
261-
value_type = ValueType.ISO8601
262-
value = _format_datetime_iso8601(value)
263-
elif isinstance(value, datetime.date):
264-
# Must come after datetime.datetime check (datetime is a subclass of date)
265-
value_type = ValueType.ISO8601
266-
value = value.isoformat()
267232
elif isinstance(value, str):
268233
value_type = ValueType.TEXT
269234
elif isinstance(value, bytes):
270235
value_type = ValueType.BLOB
271236
else:
272-
raise EncodeError(f"Cannot infer type for value: {type(value)}")
237+
raise EncodeError(
238+
f"Cannot infer wire type for value of type {type(value).__name__!r}. "
239+
f"The wire codec only accepts bool, int, float, str, bytes, or None. "
240+
f"Callers passing datetime/date/etc. must convert to str (for ISO8601) "
241+
f"or int (for UNIXTIME) at the driver layer."
242+
)
273243

274244
if value_type == ValueType.BOOLEAN:
275245
if not isinstance(value, (bool, int)):
@@ -293,13 +263,6 @@ def encode_value(value: Any, value_type: ValueType | None = None) -> tuple[bytes
293263
raise EncodeError(f"Expected int or float for FLOAT, got {type(value).__name__}")
294264
return encode_double(float(value)), value_type
295265
elif value_type in (ValueType.TEXT, ValueType.ISO8601):
296-
# ISO8601 accepts datetime objects and converts them to string.
297-
# This is needed when encode_row_values passes a datetime with
298-
# explicit ISO8601 type (the auto-inference path converts earlier).
299-
if value_type == ValueType.ISO8601 and isinstance(value, datetime.datetime):
300-
value = _format_datetime_iso8601(value)
301-
elif value_type == ValueType.ISO8601 and isinstance(value, datetime.date):
302-
value = value.isoformat()
303266
if not isinstance(value, str):
304267
raise EncodeError(f"Expected str for {value_type.name}, got {type(value).__name__}")
305268
return encode_text(value), value_type
@@ -318,37 +281,6 @@ def encode_value(value: Any, value_type: ValueType | None = None) -> tuple[bytes
318281
raise EncodeError(f"Unknown value type: {value_type}")
319282

320283

321-
def _parse_iso8601(text: str) -> datetime.datetime:
322-
"""Parse an ISO 8601 datetime string, trying multiple formats.
323-
324-
Matches Go's iso8601Formats parsing which tries 9 patterns including
325-
with/without timezone, with/without fractional seconds, T vs space
326-
separator, and date-only.
327-
"""
328-
# Strip trailing Z (Go does this before parsing)
329-
if text.endswith("Z"):
330-
text = text[:-1] + "+00:00"
331-
332-
# Try Python's fromisoformat first — handles most formats since Python 3.11
333-
try:
334-
dt = datetime.datetime.fromisoformat(text)
335-
# Go parses all formats without explicit timezone in UTC
336-
if dt.tzinfo is None:
337-
dt = dt.replace(tzinfo=datetime.UTC)
338-
return dt
339-
except ValueError:
340-
pass
341-
342-
# Fallback: date-only format
343-
try:
344-
d = datetime.date.fromisoformat(text)
345-
return datetime.datetime(d.year, d.month, d.day, tzinfo=datetime.UTC)
346-
except ValueError:
347-
pass
348-
349-
raise DecodeError(f"Cannot parse ISO 8601 datetime: {text!r}")
350-
351-
352284
def decode_value(data: bytes | memoryview, value_type: ValueType) -> tuple[Any, int]:
353285
"""Decode a value from wire format.
354286
@@ -360,21 +292,18 @@ def decode_value(data: bytes | memoryview, value_type: ValueType) -> tuple[Any,
360292
return decode_int64(data), 8
361293
elif value_type == ValueType.UNIXTIME:
362294
# Return raw int64 to preserve round-trip identity at the wire level.
363-
# Previously returned datetime.datetime, which caused type-changing
364-
# re-encode (UNIXTIME → ISO8601). See issue 006.
365-
# Note: Go's Rows.Next() converts this to time.Time, but that
366-
# conversion belongs in a higher-level client layer, not the wire
367-
# protocol codec.
295+
# Higher-level clients (like the dqlite DBAPI) turn this into a
296+
# datetime, matching what Go's Rows.Next() does in the database/sql
297+
# driver layer. See issue 006.
368298
return decode_int64(data), 8
369299
elif value_type == ValueType.FLOAT:
370300
return decode_double(data), 8
371-
elif value_type == ValueType.TEXT:
301+
elif value_type in (ValueType.TEXT, ValueType.ISO8601):
302+
# ISO8601 is treated as text at the wire level — the C reference
303+
# uses text__encode / text__decode for DQLITE_ISO8601 (see dqlite
304+
# src/tuple.c) and Go returns the raw string from the codec.
305+
# Parsing to datetime belongs in the driver/DBAPI layer.
372306
return decode_text(data)
373-
elif value_type == ValueType.ISO8601:
374-
text, consumed = decode_text(data)
375-
if not text:
376-
return None, consumed
377-
return _parse_iso8601(text), consumed
378307
elif value_type == ValueType.BLOB:
379308
return decode_blob(data)
380309
elif value_type == ValueType.NULL:

tests/test_messages_responses.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -772,18 +772,16 @@ def test_unixtime_column(self) -> None:
772772
assert decoded.column_types == [ValueType.UNIXTIME]
773773

774774
def test_iso8601_column(self) -> None:
775-
"""ISO8601 values go through _parse_iso8601 on decode."""
776-
from datetime import UTC, datetime
777-
778-
dt = datetime(2024, 6, 15, 10, 30, 0, tzinfo=UTC)
775+
"""ISO8601 values round-trip as raw text at the wire level."""
776+
iso = "2024-06-15 10:30:00+00:00"
779777
resp = RowsResponse(
780778
column_names=["ts"],
781779
column_types=[ValueType.ISO8601],
782-
rows=[[dt]],
780+
rows=[[iso]],
783781
)
784782
data = resp.encode()
785783
decoded = RowsResponse.decode_body(data[HEADER_SIZE:])
786-
assert decoded.rows[0][0] == dt
784+
assert decoded.rows[0][0] == iso
787785

788786
def test_blob_column(self) -> None:
789787
"""BLOB has variable-length encoding with padding in row context."""
@@ -798,9 +796,7 @@ def test_blob_column(self) -> None:
798796

799797
def test_mixed_all_types(self) -> None:
800798
"""Every value type in a single row exercises full encoding path."""
801-
from datetime import UTC, datetime
802-
803-
dt = datetime(2024, 1, 1, tzinfo=UTC)
799+
iso = "2024-01-01 00:00:00+00:00"
804800
resp = RowsResponse(
805801
column_names=["i", "f", "t", "b", "n", "ut", "iso", "bo"],
806802
column_types=[
@@ -826,7 +822,7 @@ def test_mixed_all_types(self) -> None:
826822
]
827823
],
828824
rows=[
829-
[42, 3.14, "hello", b"\x00\x01", None, 1700000000, dt, True],
825+
[42, 3.14, "hello", b"\x00\x01", None, 1700000000, iso, True],
830826
],
831827
)
832828
data = resp.encode()
@@ -838,7 +834,7 @@ def test_mixed_all_types(self) -> None:
838834
assert row[3] == b"\x00\x01"
839835
assert row[4] is None
840836
assert row[5] == 1700000000
841-
assert row[6] == dt
837+
assert row[6] == iso
842838
assert row[7] is True
843839

844840
def test_blob_multiple_sizes(self) -> None:

0 commit comments

Comments
 (0)