Skip to content

Commit a0a90cd

Browse files
fix: reject naive datetime params; require timezone-aware
_format_datetime_iso8601 silently stamped naive datetimes with +00:00 (UTC). That hides real bugs in callers that used datetime.now() or utcnow() in a local-time context — the value goes into the database as a different instant than intended. Raise EncodeError on naive datetime and hint at the fix (datetime.now(datetime.UTC) / dt.replace(tzinfo=datetime.UTC)). Update the tests that exercised the previous UTC-assumption behaviour to pass aware datetimes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0a5553c commit a0a90cd

2 files changed

Lines changed: 32 additions & 34 deletions

File tree

src/dqlitewire/types.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -213,21 +213,26 @@ def _format_datetime_iso8601(value: datetime.datetime) -> str:
213213
``.1`` which was ambiguous between ``0.1 s`` and ``100000 µs`` to
214214
lenient parsers and broke direct string equality with
215215
``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.
216219
"""
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+
)
217226
formatted = f"{value.year:04d}" + value.strftime("-%m-%d %H:%M:%S")
218227
if value.microsecond:
219228
formatted += f".{value.microsecond:06d}"
220229
utcoffset = value.utcoffset()
221-
if utcoffset is not None:
222-
total_seconds = int(utcoffset.total_seconds())
223-
sign = "+" if total_seconds >= 0 else "-"
224-
hours, remainder = divmod(abs(total_seconds), 3600)
225-
minutes = remainder // 60
226-
formatted += f"{sign}{hours:02d}:{minutes:02d}"
227-
else:
228-
# Naive datetime: assume UTC to match Go's always-offset format.
229-
# (See #137 for a separate branch that rejects naive instead.)
230-
formatted += "+00:00"
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}"
231236
return formatted
232237

233238

tests/test_types.py

Lines changed: 17 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -682,11 +682,11 @@ def test_datetime_auto_detection(self) -> None:
682682
assert decoded.hour == 10
683683
assert decoded.second == 45
684684

685-
def test_naive_datetime_includes_utc_offset(self) -> None:
686-
"""Naive datetime should include +00:00 offset to match Go's format."""
687-
from datetime import datetime
685+
def test_aware_utc_datetime_includes_offset(self) -> None:
686+
"""Aware UTC datetime should include +00:00 offset to match Go's format."""
687+
from datetime import UTC, datetime
688688

689-
dt = datetime(2024, 1, 15, 10, 30, 45) # naive, no tzinfo
689+
dt = datetime(2024, 1, 15, 10, 30, 45, tzinfo=UTC)
690690
encoded, vtype = encode_value(dt)
691691
assert vtype == ValueType.ISO8601
692692
# Check the raw encoded text contains +00:00
@@ -885,35 +885,28 @@ def test_encode_value_false_as_explicit_integer(self) -> None:
885885
class TestNaiveDatetimeISO8601:
886886
"""166: naive datetime 'assume UTC' fallback path is untested."""
887887

888-
def test_naive_datetime_assumes_utc(self) -> None:
889-
"""Naive datetime is encoded as UTC (+00:00)."""
888+
def test_naive_datetime_raises(self) -> None:
889+
"""Naive datetime params are rejected (no silent UTC assumption)."""
890890
import datetime
891891

892892
naive = datetime.datetime(2024, 6, 15, 12, 30, 45) # noqa: DTZ001
893-
encoded, vtype = encode_value(naive, ValueType.ISO8601)
894-
assert vtype == ValueType.ISO8601
895-
decoded, _ = decode_value(encoded, ValueType.ISO8601)
896-
assert decoded.utcoffset() == datetime.timedelta(0)
897-
assert decoded.year == 2024
898-
assert decoded.month == 6
899-
assert decoded.hour == 12
893+
with pytest.raises(EncodeError, match="[Nn]aive"):
894+
encode_value(naive, ValueType.ISO8601)
900895

901-
def test_naive_datetime_with_microseconds(self) -> None:
902-
"""Naive datetime with fractional seconds encodes correctly."""
896+
def test_aware_utc_datetime_with_microseconds(self) -> None:
897+
"""Aware UTC datetime with fractional seconds encodes correctly."""
903898
import datetime
904899

905-
naive = datetime.datetime(2024, 1, 1, 0, 0, 0, 123456) # noqa: DTZ001
906-
encoded, _ = encode_value(naive, ValueType.ISO8601)
900+
dt = datetime.datetime(2024, 1, 1, 0, 0, 0, 123456, tzinfo=datetime.UTC)
901+
encoded, _ = encode_value(dt, ValueType.ISO8601)
907902
decoded, _ = decode_value(encoded, ValueType.ISO8601)
908903
assert decoded.microsecond == 123456
909904

910-
def test_naive_datetime_roundtrip_becomes_aware(self) -> None:
911-
"""Naive datetime round-trips as timezone-aware UTC."""
905+
def test_utc_datetime_roundtrips(self) -> None:
906+
"""Aware UTC datetime round-trips as timezone-aware UTC."""
912907
import datetime
913908

914-
naive = datetime.datetime(2024, 6, 15, 12, 30, 45) # noqa: DTZ001
915-
assert naive.tzinfo is None
916-
encoded, _ = encode_value(naive, ValueType.ISO8601)
909+
dt = datetime.datetime(2024, 6, 15, 12, 30, 45, tzinfo=datetime.UTC)
910+
encoded, _ = encode_value(dt, ValueType.ISO8601)
917911
decoded, _ = decode_value(encoded, ValueType.ISO8601)
918-
assert decoded.tzinfo is not None
919-
assert decoded.replace(tzinfo=None) == naive
912+
assert decoded == dt

0 commit comments

Comments
 (0)