From e0097c9c483f7dd309987e954a3bfa768c7ec9be Mon Sep 17 00:00:00 2001 From: ghreprimand Date: Fri, 5 Jun 2026 02:18:26 -0500 Subject: [PATCH] Strip tz in _parse_dt dateutil fallback (naive-datetime contract) (#2557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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> --- routes/calendar_routes.py | 12 ++++++- tests/test_calendar_parse_dt_naive.py | 46 +++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 tests/test_calendar_parse_dt_naive.py diff --git a/routes/calendar_routes.py b/routes/calendar_routes.py index 788a6ea..80bbe4d 100644 --- a/routes/calendar_routes.py +++ b/routes/calendar_routes.py @@ -399,7 +399,17 @@ def _parse_dt(s: str) -> datetime: # Last resort: dateutil's fuzzy parser try: 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: raise ValueError(f"could not parse datetime: {s!r}") diff --git a/tests/test_calendar_parse_dt_naive.py b/tests/test_calendar_parse_dt_naive.py new file mode 100644 index 0000000..b70ea0b --- /dev/null +++ b/tests/test_calendar_parse_dt_naive.py @@ -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)