icalendar Event with RRULE, DTSTART and timezone-aware dt: working versions (tested 2026-10-16)
Build RFC 5545 VEVENTs with icalendar: timezone-aware DTSTART, a DAILY RRULE, a UTC UNTIL and an embedded VTIMEZONE, all tested offline with pytest.
passed6 passed · 0 failed2026-10-16 09:57 UTC- Status
- passed
- Last passed
- 2026-10-16 09:57 UTC
- Tests
- 6 / 6
- Language
- Python
- Runtime
- Python 3.13.16
pinned versions2
icalendar@7.3.0pytest@9.1.1
run it
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
test environment
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38883899161
The code
Exactly the files that passed. Your agent can pull them with get_recipe("icalendar-event-rrule") (install), or see them on GitHub.
requirements.txt
icalendar==7.3.0 pytest==9.1.1
events.py
from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo
from icalendar import Calendar, Event, Timezone
def new_calendar() -> Calendar:
cal = Calendar()
cal.add("prodid", "-//ProvenPin//icalendar-event-rrule//EN")
cal.add("version", "2.0")
return cal
def add_timezone(cal: Calendar, tzid: str, first: date, last: date) -> None:
cal.add_component(
Timezone.from_tzinfo(ZoneInfo(tzid), first_date=first, last_date=last)
)
def daily_event(
uid: str,
summary: str,
dtstart: date | datetime,
count: int = 3,
until: date | datetime | None = None,
) -> Event:
event = Event()
event.add("uid", uid)
event.add("summary", summary)
event.add("dtstart", dtstart)
rule: dict = {"freq": "daily"}
if until is not None:
rule["until"] = until
else:
rule["count"] = count
event.add("rrule", rule)
return event
def utc_until(dt: datetime) -> datetime:
return dt.astimezone(timezone.utc)
tests/test_events.py
from datetime import date, datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from icalendar import Calendar
from events import add_timezone, daily_event, new_calendar, utc_until
LON = ZoneInfo("Europe/London")
def test_aware_dtstart_has_tzid_naive_is_floating():
aware = daily_event("1@x", "S", datetime(2026, 1, 1, 9, 0, tzinfo=LON)).to_ical()
assert b"DTSTART;TZID=Europe/London:20260101T090000" in aware
naive = daily_event("2@x", "S", datetime(2026, 1, 1, 9, 0)).to_ical()
assert b"DTSTART:20260101T090000" in naive
assert b"TZID" not in naive
def test_rrule_roundtrip_decodes_to_lists():
event = daily_event("1@x", "S", datetime(2026, 1, 1, tzinfo=timezone.utc), count=3)
assert b"RRULE:FREQ=DAILY;COUNT=3" in event.to_ical()
parsed = Calendar.from_ical(event.to_ical()).walk("VEVENT")[0]
rule = parsed.rrules[0]
assert rule["FREQ"] == ["DAILY"]
assert rule["COUNT"] == [3]
def test_until_must_be_utc():
start = datetime(2026, 1, 1, 9, 0, tzinfo=LON)
until = datetime(2026, 1, 8, 9, 0, tzinfo=LON)
wrong = daily_event("1@x", "S", start, until=until).to_ical()
assert b"UNTIL=20260108T090000" in wrong
assert b"UNTIL=20260108T090000Z" not in wrong
right = daily_event("1@x", "S", start, until=utc_until(until)).to_ical()
assert b"UNTIL=20260108T090000Z" in right
def test_all_day_until_type_must_match_dtstart():
event = daily_event("1@x", "S", date(2026, 1, 1), until=date(2026, 1, 8))
ics = event.to_ical()
assert b"DTSTART;VALUE=DATE:20260101" in ics
assert b"RRULE:FREQ=DAILY;UNTIL=20260108" in ics
mixed = daily_event(
"1@x", "S", date(2026, 1, 1), until=datetime(2026, 1, 8, tzinfo=timezone.utc)
).to_ical()
assert b"UNTIL=20260108T000000Z" in mixed
def test_decoded_dtstart_is_timezone_aware():
start = datetime(2026, 6, 1, 9, 0, tzinfo=LON)
parsed = Calendar.from_ical(daily_event("1@x", "S", start).to_ical())
got = parsed.walk("VEVENT")[0].decoded("dtstart")
assert got == start
assert got.tzinfo is not None
assert got.utcoffset() == timedelta(hours=1)
def test_tzid_reference_needs_embedded_vtimezone():
cal = new_calendar()
cal.add_component(daily_event("1@x", "S", datetime(2026, 1, 1, 9, 0, tzinfo=LON)))
assert b"TZID=Europe/London" in cal.to_ical()
assert b"BEGIN:VTIMEZONE" not in cal.to_ical()
add_timezone(cal, "Europe/London", date(2026, 1, 1), date(2027, 1, 1))
ics = cal.to_ical()
assert b"BEGIN:VTIMEZONE" in ics
assert b"BEGIN:DAYLIGHT" in ics
assert b"BEGIN:STANDARD" in ics
pytest.ini
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
~/.cursor/mcp.json
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }.vscode/mcp.json
{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }~/.codeium/windsurf/mcp_config.json
{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }MCP settings
{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.