Strip tz in _parse_dt dateutil fallback (naive-datetime contract) (#2557)
_parse_dt documents that it returns naive datetimes (CalendarEvent.dtstart is naive) and every return path strips tz — except the last-resort dateutil fallback, which returned dateutil's value verbatim. An offset-bearing non-ISO input (e.g. RFC-2822 'Mon, 05 Jan 2026 14:00:00 +0900', which fromisoformat rejects but dateutil parses) leaked a tz-aware datetime into the naive dtstart column via create_event/update_event -> _parse_dt_pair. On read-back, _expand_rrule compares ev.dtstart against naive window bounds and raised 'can't compare offset-naive and offset-aware datetimes' (500 / no events). Normalize the fallback to UTC-naive, mirroring the fromisoformat branch. Naive inputs are unchanged. (cherry picked from commit b03b6b91df21c1a3ad3c447f23f35b8b19e6d1b1) Co-authored-by: ghreprimand <203024559+ghreprimand@users.noreply.github.com>
This commit is contained in:
@@ -399,7 +399,17 @@ def _parse_dt(s: str) -> datetime:
|
|||||||
# Last resort: dateutil's fuzzy parser
|
# Last resort: dateutil's fuzzy parser
|
||||||
try:
|
try:
|
||||||
from dateutil import parser as _du
|
from dateutil import parser as _du
|
||||||
return _du.parse(s)
|
parsed = _du.parse(s)
|
||||||
|
# Strip tz like every other return path above — this function's
|
||||||
|
# contract is naive datetimes (CalendarEvent.dtstart is naive). An
|
||||||
|
# offset-bearing non-ISO input (e.g. RFC-2822 "Mon, 05 Jan 2026
|
||||||
|
# 14:00:00 +0900") otherwise leaked tz-aware into the naive column and
|
||||||
|
# crashed read-back comparisons in _expand_rrule with "can't compare
|
||||||
|
# offset-naive and offset-aware datetimes".
|
||||||
|
if parsed.tzinfo is not None:
|
||||||
|
from datetime import timezone as _tz
|
||||||
|
return parsed.astimezone(_tz.utc).replace(tzinfo=None)
|
||||||
|
return parsed
|
||||||
except Exception:
|
except Exception:
|
||||||
raise ValueError(f"could not parse datetime: {s!r}")
|
raise ValueError(f"could not parse datetime: {s!r}")
|
||||||
|
|
||||||
|
|||||||
46
tests/test_calendar_parse_dt_naive.py
Normal file
46
tests/test_calendar_parse_dt_naive.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Regression: _parse_dt's dateutil fallback must return naive datetimes.
|
||||||
|
|
||||||
|
_parse_dt documents that it returns local-naive datetimes to match the DB
|
||||||
|
schema (CalendarEvent.dtstart is naive), and every return path strips tz —
|
||||||
|
except the last-resort dateutil branch, which returned dateutil's value
|
||||||
|
verbatim. An offset-bearing non-ISO input (e.g. RFC-2822
|
||||||
|
"Mon, 05 Jan 2026 14:00:00 +0900", which datetime.fromisoformat rejects but
|
||||||
|
dateutil parses) therefore leaked a tz-aware datetime into the naive dtstart
|
||||||
|
column. On read-back, _expand_rrule compares ev.dtstart against naive window
|
||||||
|
bounds and raises "can't compare offset-naive and offset-aware datetimes".
|
||||||
|
|
||||||
|
The fallback now normalizes to UTC and strips tz, exactly like the ISO path.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.test_null_owner_gates import _import_calendar_helpers
|
||||||
|
|
||||||
|
# Inputs datetime.fromisoformat() rejects (so they hit the dateutil fallback)
|
||||||
|
# but that carry a numeric UTC offset dateutil resolves to tz-aware.
|
||||||
|
_OFFSET_NONISO = [
|
||||||
|
"Mon, 05 Jan 2026 14:00:00 +0900",
|
||||||
|
"January 5, 2026 14:00 +0900",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("s", _OFFSET_NONISO)
|
||||||
|
def test_parse_dt_dateutil_fallback_returns_naive(s):
|
||||||
|
cal = _import_calendar_helpers()
|
||||||
|
d = cal._parse_dt(s)
|
||||||
|
assert d.tzinfo is None, f"{s!r} leaked tz-aware: {d!r}"
|
||||||
|
# +0900 14:00 -> 05:00 UTC, naive.
|
||||||
|
assert (d.hour, d.minute) == (5, 0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("s", _OFFSET_NONISO)
|
||||||
|
def test_parse_dt_pair_fallback_returns_naive(s):
|
||||||
|
cal = _import_calendar_helpers()
|
||||||
|
dt, _is_utc = cal._parse_dt_pair(s)
|
||||||
|
assert dt.tzinfo is None, f"{s!r} leaked tz-aware via _parse_dt_pair: {dt!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_dt_naive_input_unchanged():
|
||||||
|
cal = _import_calendar_helpers()
|
||||||
|
d = cal._parse_dt("January 5, 2026 14:00") # no offset -> stays as parsed
|
||||||
|
assert d.tzinfo is None
|
||||||
|
assert (d.hour, d.minute) == (14, 0)
|
||||||
Reference in New Issue
Block a user