Summary
When using FREQ=YEARLY;BYMONTHDAY=N without an explicit BYMONTH, dateutil expands the recurrence to day N of every month (producing 11-12 occurrences per year). Per RFC 5545 section 3.3.10, the missing BYMONTH should be derived from DTSTART, producing exactly one occurrence per year.
RFC 5545 Reference
RFC 5545 section 3.3.10 states:
"Information, not contained in the rule, necessary to determine the various recurrence instance start time and dates are derived from the Start Time (DTSTART) component attribute."
"Similarly, if the BYMINUTE, BYHOUR, BYDAY, BYMONTHDAY, or BYMONTH rule part were missing, the appropriate minute, hour, day, or month would have been retrieved from the DTSTART property."
Reproduction
from dateutil.rrule import rrule, YEARLY
from datetime import datetime
dtstart = datetime(2010, 3, 30) # March 30, 2010
rr = rrule(YEARLY, bymonthday=30, dtstart=dtstart, count=12)
print("Actual output:")
for d in rr:
print(f" {d.date()}")
Actual Output
2010-03-30
2010-04-30
2010-05-30
2010-06-30
2010-07-30
2010-08-30
2010-09-30
2010-10-30
2010-11-30
2010-12-30
2011-01-30
2011-03-30
(Note: February is skipped since it has no 30th day, which is correct per RFC 5545)
Expected Output (per RFC 5545)
2010-03-30
2011-03-30
2012-03-30
2013-03-30
2014-03-30
2015-03-30
2016-03-30
2017-03-30
2018-03-30
2019-03-30
2020-03-30
2021-03-30
Since BYMONTH is absent and DTSTART is March 30, the month should be inferred as March, producing only March 30 each year.
Root Cause
In rrule.py lines 504-514, the logic that infers bymonth from dtstart.month is guarded by a condition that requires bymonthday to also be None:
if (byweekno is None and byyearday is None and bymonthday is None and
byweekday is None and byeaster is None):
if freq == YEARLY:
if bymonth is None:
bymonth = dtstart.month # This inference only happens when bymonthday is None
Per RFC 5545, BYMONTH should be inferred from DTSTART whenever it's missing, regardless of whether BYMONTHDAY is present.
Real-World Impact
This causes interoperability issues with CalDAV calendars. For example:
- A birthday event created in Fastmail (or other standards-compliant calendar servers) for "March 30" exports as
RRULE:FREQ=YEARLY;BYMONTHDAY=30 with DTSTART:20100330
- Fastmail, Apple Calendar, Google Calendar, and other clients correctly show this event only on March 30
- Applications using dateutil (like khal) incorrectly display the birthday on the 30th of every month
Environment
- dateutil version: 2.9.0.post0
- Python version: 3.12.x
Related
Suggested Fix
Modify the condition in rrule.py to infer bymonth from DTSTART whenever freq == YEARLY, bymonth is None, and no byyearday is specified (since BYYEARDAY defines the complete day-of-year and doesn't need month inference):
# When FREQ=YEARLY, infer bymonth from dtstart if not explicitly provided
# (unless byyearday is specified, which defines the full day-of-year)
if freq == YEARLY and bymonth is None and byyearday is None:
bymonth = dtstart.month
self._original_rule['bymonth'] = None
I'm happy to submit a PR with this fix and corresponding tests if the approach is acceptable.
Summary
When using
FREQ=YEARLY;BYMONTHDAY=Nwithout an explicitBYMONTH, dateutil expands the recurrence to day N of every month (producing 11-12 occurrences per year). Per RFC 5545 section 3.3.10, the missingBYMONTHshould be derived fromDTSTART, producing exactly one occurrence per year.RFC 5545 Reference
RFC 5545 section 3.3.10 states:
Reproduction
Actual Output
(Note: February is skipped since it has no 30th day, which is correct per RFC 5545)
Expected Output (per RFC 5545)
Since
BYMONTHis absent andDTSTARTis March 30, the month should be inferred as March, producing only March 30 each year.Root Cause
In
rrule.pylines 504-514, the logic that infersbymonthfromdtstart.monthis guarded by a condition that requiresbymonthdayto also beNone:Per RFC 5545,
BYMONTHshould be inferred fromDTSTARTwhenever it's missing, regardless of whetherBYMONTHDAYis present.Real-World Impact
This causes interoperability issues with CalDAV calendars. For example:
RRULE:FREQ=YEARLY;BYMONTHDAY=30withDTSTART:20100330Environment
Related
Suggested Fix
Modify the condition in
rrule.pyto inferbymonthfrom DTSTART wheneverfreq == YEARLY,bymonth is None, and nobyyeardayis specified (sinceBYYEARDAYdefines the complete day-of-year and doesn't need month inference):I'm happy to submit a PR with this fix and corresponding tests if the approach is acceptable.