Skip to content

Commit bc8ed1f

Browse files
Reject tz-aware datetime / time when utcoffset() returns None
The datetime / time ISO 8601 encoders previously fell through to the naive format when an aware value's tzinfo subclass returned ``None`` from ``utcoffset(...)``. The branch was conservatively documented as "broken tzinfo subclass" but the silent demotion lost the user's tz-awareness intent without warning, and was asymmetric with the sibling ``_format_utc_offset`` rejection behaviour. Raise ``DataError`` in both branches so the caller sees the broken tzinfo contract immediately. Update the broken-tzinfo encoder pin tests to assert the new rejection contract; drop the silent-naive round-trip pins. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7b4558b commit bc8ed1f

2 files changed

Lines changed: 29 additions & 51 deletions

File tree

src/dqlitedbapi/types.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -333,10 +333,16 @@ def _iso8601_from_datetime(value: datetime.datetime | datetime.date) -> str:
333333
if value.tzinfo is None:
334334
return base
335335
# tzinfo is set (checked above), so utcoffset() returns timedelta.
336-
# A None here would indicate a broken tzinfo subclass; be explicit.
336+
# A None here means the tzinfo subclass declared itself but
337+
# cannot resolve an offset for this datetime — be explicit
338+
# and reject rather than silently demoting to naive (which
339+
# would lose the user's tz-awareness intent without warning).
337340
offset = value.utcoffset()
338341
if offset is None:
339-
return base
342+
raise DataError(
343+
f"datetime is tz-aware but tzinfo.utcoffset() returned None for "
344+
f"{value!r}; cannot encode without a resolvable UTC offset"
345+
)
340346
return base + _format_utc_offset(offset)
341347
# datetime.date (must come after datetime check — datetime is a subclass).
342348
return value.isoformat()
@@ -359,7 +365,10 @@ def _iso8601_from_time(value: datetime.time) -> str:
359365
return base
360366
offset = value.utcoffset()
361367
if offset is None:
362-
return base
368+
raise DataError(
369+
f"time is tz-aware but tzinfo.utcoffset() returned None for "
370+
f"{value!r}; cannot encode without a resolvable UTC offset"
371+
)
363372
return base + _format_utc_offset(offset)
364373

365374

tests/test_iso8601_encoder.py

Lines changed: 17 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -204,61 +204,30 @@ def tzname(self, dt: datetime.datetime | None) -> str:
204204

205205

206206
class TestIso8601EncoderBrokenTzinfo:
207-
"""Pin the ``utcoffset() is None`` fallback branch on both encoders.
208-
209-
Sibling coverage: naive / aware-with-positive-offset / aware-with-
210-
negative-offset / aware-with-sub-minute-offset / microseconds are
211-
all already pinned above. The missing case is an aware value whose
212-
tzinfo declines to produce an offset — the encoder must degrade to
213-
the naive format, not crash.
207+
"""Pin the ``utcoffset() is None`` rejection branch on both encoders.
208+
209+
A tzinfo subclass that declares itself but cannot resolve an
210+
offset for the given datetime/time is a broken contract. Cycle 22
211+
flipped this from silent demotion (encoded as naive, losing the
212+
user's tz-awareness intent) to a hard ``DataError``. Pin the new
213+
contract so a regression that re-introduces silent demotion is
214+
caught.
214215
"""
215216

216-
def test_datetime_broken_tzinfo_returns_naive_format(self) -> None:
217-
value = datetime.datetime(2024, 5, 1, 12, 30, 45, tzinfo=_AbstractTz())
218-
assert _iso8601_from_datetime(value) == "2024-05-01 12:30:45"
219-
220-
def test_datetime_broken_tzinfo_preserves_microseconds(self) -> None:
221-
# Pins the ordering of the microsecond append and the
222-
# offset-fallback branch. A regression that swapped the two
223-
# would fail here but not in the bare-naive test above.
224-
value = datetime.datetime(2024, 5, 1, 12, 30, 45, 123456, tzinfo=_AbstractTz())
225-
assert _iso8601_from_datetime(value) == "2024-05-01 12:30:45.123456"
226-
227-
def test_datetime_broken_tzinfo_round_trips_as_naive(self) -> None:
228-
# Ties the "abstract tzinfo degrades to naive" encoder contract
229-
# to the decode side: feed the encoded text back through the
230-
# decoder and confirm the result has no tzinfo. A future
231-
# regression that emitted "+00:00" for broken tzinfo would
232-
# decode back to an aware UTC datetime and fail this pin.
233-
from dqlitedbapi.types import _datetime_from_iso8601
217+
def test_datetime_broken_tzinfo_raises_data_error(self) -> None:
218+
from dqlitedbapi.exceptions import DataError
234219

235-
original = datetime.datetime(2024, 5, 1, 12, 30, 45, 123456, tzinfo=_AbstractTz())
236-
encoded = _iso8601_from_datetime(original)
237-
decoded = _datetime_from_iso8601(encoded)
238-
assert decoded is not None
239-
assert decoded.tzinfo is None
240-
assert decoded == datetime.datetime(2024, 5, 1, 12, 30, 45, 123456)
220+
value = datetime.datetime(2024, 5, 1, 12, 30, 45, tzinfo=_AbstractTz())
221+
with pytest.raises(DataError, match="utcoffset"):
222+
_iso8601_from_datetime(value)
241223

242-
def test_time_broken_tzinfo_returns_naive_format(self) -> None:
224+
def test_time_broken_tzinfo_raises_data_error(self) -> None:
225+
from dqlitedbapi.exceptions import DataError
243226
from dqlitedbapi.types import _iso8601_from_time
244227

245228
value = datetime.time(12, 30, 45, tzinfo=_AbstractTz())
246-
assert _iso8601_from_time(value) == "12:30:45"
247-
248-
def test_time_broken_tzinfo_preserves_microseconds(self) -> None:
249-
from dqlitedbapi.types import _iso8601_from_time
250-
251-
value = datetime.time(12, 30, 45, 123456, tzinfo=_AbstractTz())
252-
assert _iso8601_from_time(value) == "12:30:45.123456"
253-
254-
def test_time_broken_tzinfo_round_trips_through_fromisoformat(self) -> None:
255-
from dqlitedbapi.types import _iso8601_from_time
256-
257-
original = datetime.time(12, 30, 45, 123456, tzinfo=_AbstractTz())
258-
encoded = _iso8601_from_time(original)
259-
decoded = datetime.time.fromisoformat(encoded)
260-
assert decoded.tzinfo is None
261-
assert decoded == datetime.time(12, 30, 45, 123456)
229+
with pytest.raises(DataError, match="utcoffset"):
230+
_iso8601_from_time(value)
262231

263232

264233
class TestConvertBindParamTime:

0 commit comments

Comments
 (0)