Stop one recurring event from taking its whole calendar down
An RRULE was parsed by splitting on ";" and "=", which leaves every value a str. icalendar accepts that at add() time and only rejects it inside to_ical(), where vRecur.types maps UNTIL to vDDDTypes and that demands a date. So a single "repeats until <date>" event — exactly what the web editor writes — made build_ics raise ValueError, and with it every REPORT, every event GET, the whole-collection GET and the .ics export for that calendar. PROPFIND never builds ICS, so discovery kept working. That is the asymmetry behind the Home Assistant symptom: the calendar is found under its correct name, then every update fails and the entity sits permanently unavailable. It behaved identically through the token URL and the login URL because both reach the same collection dispatch. icalendar parses its own format correctly, so the rule now goes through vRecur.from_ical. That parser can raise where the hand-rolled one silently accepted anything, so an unreadable stored rule now drops the recurrence and logs it instead of failing the calendar — the same tolerance the DTSTART and DTEND parsing already has. caldav_client carried a second copy of the same parser with the same defect, used when pushing events to external CalDAV servers. Deleted; both directions now share one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
28c24bc3cd
commit
aa795fde76
@@ -4,7 +4,11 @@ from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import caldav
|
||||
from icalendar import Calendar, Event, vDatetime, vRecur
|
||||
from icalendar import Calendar, Event, vDatetime
|
||||
|
||||
# One parser for both directions: this file used to carry its own copy with
|
||||
# the same UNTIL defect.
|
||||
from ical_io import _rrule_str_to_vrecur as _parse_rrule_str
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -306,20 +310,6 @@ def delete_event(url: str, username: str, password: str, event_url: str,
|
||||
resource.delete()
|
||||
|
||||
|
||||
def _parse_rrule_str(rrule_str: str) -> vRecur:
|
||||
"""Parse an RRULE string like 'FREQ=WEEKLY;BYDAY=MO,WE' into a vRecur."""
|
||||
params = {}
|
||||
for part in rrule_str.split(";"):
|
||||
if "=" not in part:
|
||||
continue
|
||||
key, val = part.split("=", 1)
|
||||
if "," in val:
|
||||
params[key] = val.split(",")
|
||||
else:
|
||||
params[key] = val
|
||||
return vRecur(params)
|
||||
|
||||
|
||||
def _parse_dt(s: str) -> datetime:
|
||||
s = s.replace("Z", "+00:00")
|
||||
dt = datetime.fromisoformat(s)
|
||||
|
||||
+15
-8
@@ -135,13 +135,17 @@ def parse_ics(raw: bytes) -> dict:
|
||||
|
||||
|
||||
def _rrule_str_to_vrecur(rrule_str: str) -> vRecur:
|
||||
params = {}
|
||||
for part in rrule_str.split(";"):
|
||||
if "=" not in part:
|
||||
continue
|
||||
key, val = part.split("=", 1)
|
||||
params[key] = val.split(",") if "," in val else val
|
||||
return vRecur(params)
|
||||
"""Parse an RRULE string into a vRecur with correctly typed values.
|
||||
|
||||
Hand-splitting on ";" and "=" leaves every value a str, which icalendar
|
||||
accepts silently here and then rejects inside ``to_ical()``: vRecur.types
|
||||
maps UNTIL to vDDDTypes, and that demands a date. A single "repeats until
|
||||
<date>" event therefore made the whole calendar fail to serialise — a 500
|
||||
on REPORT, on GET and on export, while PROPFIND kept working because it
|
||||
never builds ICS. Letting icalendar parse its own format gives UNTIL as a
|
||||
datetime and COUNT/INTERVAL as ints.
|
||||
"""
|
||||
return vRecur(vRecur.from_ical(rrule_str))
|
||||
|
||||
|
||||
def _parse_iso(s: str) -> datetime:
|
||||
@@ -198,7 +202,10 @@ def build_ics(calendar, events, *, name_cache: dict | None = None,
|
||||
if ev.color:
|
||||
item.add("x-calendarr-color", ev.color)
|
||||
if ev.rrule:
|
||||
item.add("rrule", _rrule_str_to_vrecur(ev.rrule))
|
||||
try:
|
||||
item.add("rrule", _rrule_str_to_vrecur(ev.rrule))
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("Skipping unparsable RRULE %r on event %s", ev.rrule, ev.id)
|
||||
|
||||
# ORGANIZER from the creator (local user or imported name).
|
||||
organizer_name = None
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""RRULE serialisation.
|
||||
|
||||
An RRULE whose values stayed strings serialised fine until ``to_ical()``, where
|
||||
icalendar rejects a str for UNTIL. One "repeats until <date>" event therefore
|
||||
returned 500 for every REPORT, GET and export of its calendar while PROPFIND
|
||||
kept succeeding — which is how a Home Assistant calendar ends up discovered
|
||||
under the right name and permanently unavailable.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from ical_io import _rrule_str_to_vrecur
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rule", [
|
||||
"FREQ=WEEKLY;UNTIL=20260501T235959Z",
|
||||
"FREQ=DAILY;UNTIL=20260501T235959Z;INTERVAL=2",
|
||||
"FREQ=WEEKLY;BYDAY=MO,WE;UNTIL=20260501T235959Z",
|
||||
])
|
||||
def test_until_survives_serialisation(rule):
|
||||
"""The regression: these used to raise inside to_ical()."""
|
||||
out = _rrule_str_to_vrecur(rule).to_ical().decode()
|
||||
assert "UNTIL=20260501T235959Z" in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rule", [
|
||||
"FREQ=WEEKLY;BYDAY=MO",
|
||||
"FREQ=DAILY;COUNT=5",
|
||||
"FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2",
|
||||
"FREQ=MONTHLY;BYMONTHDAY=15",
|
||||
])
|
||||
def test_rules_without_until_still_round_trip(rule):
|
||||
"""These always worked; pin them so the fix did not narrow what is accepted."""
|
||||
out = _rrule_str_to_vrecur(rule).to_ical().decode()
|
||||
for part in rule.split(";"):
|
||||
key, val = part.split("=", 1)
|
||||
assert key in out
|
||||
for v in val.split(","):
|
||||
assert v in out
|
||||
|
||||
|
||||
def test_count_and_interval_become_integers():
|
||||
"""icalendar types these; the hand-rolled parser left them as strings."""
|
||||
rec = _rrule_str_to_vrecur("FREQ=DAILY;COUNT=5;INTERVAL=2")
|
||||
assert rec["COUNT"] == [5]
|
||||
assert rec["INTERVAL"] == [2]
|
||||
|
||||
|
||||
def test_malformed_rule_raises_rather_than_corrupting():
|
||||
"""build_ics catches this and drops the recurrence; it must not pass silently."""
|
||||
with pytest.raises((ValueError, TypeError)):
|
||||
_rrule_str_to_vrecur("FREQ=WEEKLY;UNTIL=not-a-date").to_ical()
|
||||
Reference in New Issue
Block a user