22
33All multi-byte integers are little-endian.
44Text 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
813import struct
914from 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-
239211def 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-
352284def 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 :
0 commit comments