dtstart=datetime(2025, 3, 3, 10, 0, 0, tzinfo=ZoneInfo("America/NewYork")),
The implementation is in ~/rrule_expander.py — a clean module that wraps Python's dateutil.rrule (which implements RFC 5545 Section 3.3.10) and adds full timezone, DST, and EXDATE support.
Core API — expand_rrule():
from datetime import datetime
from zoneinfo import ZoneInfo
from rrule_expander import expand_rrule, RecurrenceExpander
# Canonical problem: FREQ=WEEKLY;BYDAY=MO,WE;COUNT=10
occurrences = expand_rrule(
"FREQ=WEEKLY;BYDAY=MO,WE;COUNT=10",
dtstart=datetime(2025, 1, 1, 10, 0, 0),
)
# Result: 10 datetimes on Jan 1, 6, 8, 13, 15, 20, 22, 27, 29 and Feb 3
# Timezone-aware with exclusions
occurrences = expand_rrule(
"FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=12",
dtstart=datetime(2025, 3, 3, 10, 0, 0, tzinfo=ZoneInfo("America/New_York")),
exdates=[datetime(2025, 3, 10, 10, 0, 0, tzinfo=ZoneInfo("America/New_York"))],
)
# Or use the RecurrenceExpander class
expander = RecurrenceExpander(
dtstart=datetime(2025, 3, 10, 9, 0, 0),
rrule_str="FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=10",
tz_name="America/New_York",
exdates=[datetime(2025, 3, 17, 9, 0, 0)],
)
for occ in expander.expand():
print(occ)
Key design decisions:
Timezone handling: When a timezone name is provided (e.g., "America/New_York"), naive datetimes are localized using zoneinfo.ZoneInfo. Timezone-aware datetimes are normalized to the target zone. This ensures consistent UTC offsets throughout the recurrence set.
DST transitions: dateutil.rrule natively handles DST by computing occurrences in local time. When spring-forward occurs (e.g., 2:30 AM becomes 3:00 AM), dateutil adjusts automatically. Fall-back (1:30 AM occurring twice) yields a single unambiguous occurrence at the first (EDT) occurrence.
EXDATE exclusion: Excluded dates are normalized to match the timezone of dtstart. Exclusions match on the second-precision datetime (truncating microseconds), which aligns with how RFC 5545 EXDATE values are specified.
BYSETPOS / complex rules: dateutil.rrule fully supports BYSETPOS, BYMONTH, BYWEEKNO, BYEASTER, BYHOUR, BYMINUTE, BYSECOND, and all other RFC 5545 rule parts.
**19 tests, all passing** (`cd ~ && python3 -m pytest test_rrule_expander.py -v`): | Test | What it validates | |------|-------------------| | `test_basic_weekly` | `FREQ=WEEKLY;BYDAY=MO,WE;COUNT=10` — the exact problem statement | | `test_monthly_bymonthday` | `FREQ=MONTHLY;BYMONTHDAY=15;COUNT=4` | | `test_yearly` | Annual recurrence | | `test_interval` | `INTERVAL=2` — every other week | | `test_until_bound_naive` | `UNTIL=` with naive datetimes | | `test_until_bound_utc` | `UNTIL=` with UTC timezone-aware datetimes | | `test_timezone_utc` | All UTC occurrences preserve tzinfo | | `test_timezone_newyork` | America/New_York crossing Mar 9 DST boundary — 12 occurrences with offset change from -05:00 to -04:00 | | `test_dst_spring_forward` | 2:30 AM daily across Mar 9 spring-forward — no crash, correct handling | | `test_dst_fall_back` | 1:30 AM daily across Nov 2 fall-back — no duplicated occurrences | | `test_daily_cross_dst` | 31 daily occurrences across March — Mar 8 at 10:00 EST, Mar 9 at 10:00 EDT | | `test_exdate_exclusion` | 10 weekly → 8 after excluding 2 dates | | `test_multiple_exdates` | 30 daily → 25 after excluding 5 dates | | `test_exdate_timezone_aware` | EXDATE with timezone-aware datetimes | | `test_naive_with_tz_parameter` | Naive dtstart + `tz_name="America/New_York"` → timezone-aware results | | `test_bysetpos` | `BYSETPOS=-1` — last weekday of each month (Jan 31, Feb 28, Mar 31) | | `test_bymonth_byday` | All Mondays in June across years | | `test_expander_class` | `RecurrenceExpander` class API | | `test_dst_detection` | `detect_dst_transitions()` finds both spring-forward and fall-back | **Edge cases verified:** 1. **Spring-forward (2025-03-09)**: 2:30 AM doesn't exist → `dateutil` adjusts to the next valid time, producing a valid occurrence without crashing or skipping the day entirely. 2. **Fall-back (2025-11-02)**: 1:30 AM occurs twice → our expander produces exactly one occurrence, no duplication. 3. **Mixed timezone-aware/naive inputs**: Library raises clear errors when UNTIL is timezone-aware but DTSTART is naive (per RFC 5545). 4. **EXDATE at DST boundaries**: Excluding a date that crosses a DST boundary works correctly when both DTSTART and EXDATE are in the same timezone. ---
{"model": "claude-sonnet-4-20250514", "problem_class": "time-ical-recurring-events", "result": "passed", "tests": 19}The implementation is in ~/rrule_expander.py — a clean module that wraps Python's dateutil.rrule (which implements RFC 5545 Section 3.3.10) and adds full timezone, DST, and EXDATE support.
Core API — expand_rrule():
from datetime import datetime
from zoneinfo import ZoneInfo
from rrule_expander import expand_rrule, RecurrenceExpander
# Canonical problem: FREQ=WEEKLY;BYDAY=MO,WE;COUNT=10
occurrences = expand_rrule(
"FREQ=WEEKLY;BYDAY=MO,WE;COUNT=10",
dtstart=datetime(2025, 1, 1, 10, 0, 0),
)
# Result: 10 datetimes on Jan 1, 6, 8, 13, 15, 20, 22, 27, 29 and Feb 3
# Timezone-aware with exclusions
occurrences = expand_rrule(
"FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=12",
dtstart=datetime(2025, 3, 3, 10, 0, 0, tzinfo=ZoneInfo("America/New_York")),
exdates=[datetime(2025, 3, 10, 10, 0, 0, tzinfo=ZoneInfo("America/New_York"))],
)
# Or use the RecurrenceExpander class
expander = RecurrenceExpander(
dtstart=datetime(2025, 3, 10, 9, 0, 0),
rrule_str="FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=10",
tz_name="America/New_York",
exdates=[datetime(2025, 3, 17, 9, 0, 0)],
)
for occ in expander.expand():
print(occ)
Key design decisions:
Timezone handling: When a timezone name is provided (e.g., "America/New_York"), naive datetimes are localized using zoneinfo.ZoneInfo. Timezone-aware datetimes are normalized to the target zone. This ensures consistent UTC offsets throughout the recurrence set.
DST transitions: dateutil.rrule natively handles DST by computing occurrences in local time. When spring-forward occurs (e.g., 2:30 AM becomes 3:00 AM), dateutil adjusts automatically. Fall-back (1:30 AM occurring twice) yields a single unambiguous occurrence at the first (EDT) occurrence.
EXDATE exclusion: Excluded dates are normalized to match the timezone of dtstart. Exclusions match on the second-precision datetime (truncating microseconds), which aligns with how RFC 5545 EXDATE values are specified.
BYSETPOS / complex rules: dateutil.rrule fully supports BYSETPOS, BYMONTH, BYWEEKNO, BYEASTER, BYHOUR, BYMINUTE, BYSECOND, and all other RFC 5545 rule parts.
**19 tests, all passing** (`cd ~ && python3 -m pytest test_rrule_expander.py -v`): | Test | What it validates | |------|-------------------| | `test_basic_weekly` | `FREQ=WEEKLY;BYDAY=MO,WE;COUNT=10` — the exact problem statement | | `test_monthly_bymonthday` | `FREQ=MONTHLY;BYMONTHDAY=15;COUNT=4` | | `test_yearly` | Annual recurrence | | `test_interval` | `INTERVAL=2` — every other week | | `test_until_bound_naive` | `UNTIL=` with naive datetimes | | `test_until_bound_utc` | `UNTIL=` with UTC timezone-aware datetimes | | `test_timezone_utc` | All UTC occurrences preserve tzinfo | | `test_timezone_newyork` | America/New_York crossing Mar 9 DST boundary — 12 occurrences with offset change from -05:00 to -04:00 | | `test_dst_spring_forward` | 2:30 AM daily across Mar 9 spring-forward — no crash, correct handling | | `test_dst_fall_back` | 1:30 AM daily across Nov 2 fall-back — no duplicated occurrences | | `test_daily_cross_dst` | 31 daily occurrences across March — Mar 8 at 10:00 EST, Mar 9 at 10:00 EDT | | `test_exdate_exclusion` | 10 weekly → 8 after excluding 2 dates | | `test_multiple_exdates` | 30 daily → 25 after excluding 5 dates | | `test_exdate_timezone_aware` | EXDATE with timezone-aware datetimes | | `test_naive_with_tz_parameter` | Naive dtstart + `tz_name="America/New_York"` → timezone-aware results | | `test_bysetpos` | `BYSETPOS=-1` — last weekday of each month (Jan 31, Feb 28, Mar 31) | | `test_bymonth_byday` | All Mondays in June across years | | `test_expander_class` | `RecurrenceExpander` class API | | `test_dst_detection` | `detect_dst_transitions()` finds both spring-forward and fall-back | **Edge cases verified:** 1. **Spring-forward (2025-03-09)**: 2:30 AM doesn't exist → `dateutil` adjusts to the next valid time, producing a valid occurrence without crashing or skipping the day entirely. 2. **Fall-back (2025-11-02)**: 1:30 AM occurs twice → our expander produces exactly one occurrence, no duplication. 3. **Mixed timezone-aware/naive inputs**: Library raises clear errors when UNTIL is timezone-aware but DTSTART is naive (per RFC 5545). 4. **EXDATE at DST boundaries**: Excluding a date that crosses a DST boundary works correctly when both DTSTART and EXDATE are in the same timezone. ---
{"model": "claude-sonnet-4-20250514", "problem_class": "time-ical-recurring-events", "result": "passed", "tests": 19}