Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions tests/test_icalendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -1507,6 +1507,161 @@ def test_expand_with_rdate(self):
self.assertEqual(event_dates, expected_dates)


class MixedDateDatetimeTests(unittest.TestCase):
"""Test handling of mixed date/datetime types in EXDATE/RDATE.

These tests cover issue #528 where mixed types would cause TypeError
when dateutil tried to compare date and datetime objects.
"""

def test_datetime_dtstart_with_date_exdate(self):
"""Test DTSTART is datetime, EXDATE is date (VALUE=DATE)."""
from icalendar import Calendar

# This reproduces the case where EXDATE;VALUE=DATE is used with
# a datetime DTSTART, which is technically non-compliant but happens
# in practice
test_ical = b"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
UID:[email protected]
DTSTART:20240101T100000Z
DTEND:20240101T110000Z
RRULE:FREQ=DAILY;COUNT=5
EXDATE;VALUE=DATE:20240102
SUMMARY:Event with datetime DTSTART and date EXDATE
END:VEVENT
END:VCALENDAR"""

cal = Calendar.from_ical(test_ical)
start = datetime(2024, 1, 1, tzinfo=timezone.utc)
end = datetime(2024, 1, 10, tzinfo=timezone.utc)

# This should not raise TypeError
expanded = expand_calendar_rrule(cal, start, end)

# Verify we got the expected events (5 total - 1 excluded = 4)
events = [comp for comp in expanded.walk() if comp.name == "VEVENT"]
self.assertEqual(len(events), 4)

# Verify Jan 2 is excluded
event_dates = sorted([ev["DTSTART"].dt for ev in events])
excluded_date = datetime(2024, 1, 2, 10, 0, 0, tzinfo=timezone.utc)
self.assertNotIn(excluded_date, event_dates)

def test_date_dtstart_with_datetime_exdate(self):
"""Test DTSTART is date, EXDATE is datetime."""
from icalendar import Calendar

# This reproduces the case where EXDATE is a full datetime but
# DTSTART is just a date (all-day event)
test_ical = b"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
UID:[email protected]
DTSTART;VALUE=DATE:20240101
RRULE:FREQ=DAILY;COUNT=5
EXDATE:20240102T100000Z
SUMMARY:Event with date DTSTART and datetime EXDATE
END:VEVENT
END:VCALENDAR"""

cal = Calendar.from_ical(test_ical)
start = datetime(2024, 1, 1, tzinfo=timezone.utc)
end = datetime(2024, 1, 10, tzinfo=timezone.utc)

# This should not raise TypeError
expanded = expand_calendar_rrule(cal, start, end)

# Verify we got the expected events (5 total - 1 excluded = 4)
events = [comp for comp in expanded.walk() if comp.name == "VEVENT"]
self.assertEqual(len(events), 4)

# Verify Jan 2 is excluded (the date portion should match)
event_dates = sorted([ev["DTSTART"].dt for ev in events])
# When DTSTART is a date, occurrences are datetimes at midnight
# Convert to just dates for comparison since these are all-day events
event_date_only = [
d.date() if isinstance(d, datetime) else d for d in event_dates
]
self.assertNotIn(date(2024, 1, 2), event_date_only)

def test_datetime_dtstart_with_date_rdate(self):
"""Test DTSTART is datetime, RDATE is date (VALUE=DATE)."""
from icalendar import Calendar

test_ical = b"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
UID:[email protected]
DTSTART:20240101T100000Z
DTEND:20240101T110000Z
RRULE:FREQ=WEEKLY;COUNT=2
RDATE;VALUE=DATE:20240110
SUMMARY:Event with datetime DTSTART and date RDATE
END:VEVENT
END:VCALENDAR"""

cal = Calendar.from_ical(test_ical)
start = datetime(2024, 1, 1, tzinfo=timezone.utc)
end = datetime(2024, 1, 31, tzinfo=timezone.utc)

# This should not raise TypeError
expanded = expand_calendar_rrule(cal, start, end)

# Verify we got the expected events (2 from RRULE + 1 from RDATE = 3)
events = [comp for comp in expanded.walk() if comp.name == "VEVENT"]
self.assertEqual(len(events), 3)

# Verify Jan 10 is included from RDATE
event_dates = sorted([ev["DTSTART"].dt for ev in events])
rdate_occurrence = datetime(2024, 1, 10, 10, 0, 0, tzinfo=timezone.utc)
self.assertIn(rdate_occurrence, event_dates)

def test_multiple_mixed_exdates(self):
"""Test multiple EXDATE properties with mixed types."""
from icalendar import Calendar

test_ical = b"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
UID:[email protected]
DTSTART:20240101T100000Z
DTEND:20240101T110000Z
RRULE:FREQ=DAILY;COUNT=10
EXDATE;VALUE=DATE:20240102
EXDATE:20240103T100000Z
EXDATE;VALUE=DATE:20240105
SUMMARY:Event with multiple mixed EXDATEs
END:VEVENT
END:VCALENDAR"""

cal = Calendar.from_ical(test_ical)
start = datetime(2024, 1, 1, tzinfo=timezone.utc)
end = datetime(2024, 1, 15, tzinfo=timezone.utc)

# This should not raise TypeError
expanded = expand_calendar_rrule(cal, start, end)

# Verify we got the expected events (10 total - 3 excluded = 7)
events = [comp for comp in expanded.walk() if comp.name == "VEVENT"]
self.assertEqual(len(events), 7)

# Verify the excluded dates
event_dates = sorted([ev["DTSTART"].dt for ev in events])
excluded_dates = [
datetime(2024, 1, 2, 10, 0, 0, tzinfo=timezone.utc),
datetime(2024, 1, 3, 10, 0, 0, tzinfo=timezone.utc),
datetime(2024, 1, 5, 10, 0, 0, tzinfo=timezone.utc),
]
for excluded in excluded_dates:
self.assertNotIn(excluded, event_dates)


class LimitCalendarRecurrenceSetTests(unittest.TestCase):
def test_limit_recurrence_set_basic(self):
"""Test basic functionality of limit_calendar_recurrence_set."""
Expand Down
94 changes: 90 additions & 4 deletions xandikos/icalendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -1513,38 +1513,124 @@ def as_tz_aware_ts(dt: datetime | date, default_timezone: str | timezone) -> dat
return _dt


def _normalize_to_dtstart_type(
dt_value: date | datetime, dtstart: date | datetime
) -> date | datetime:
"""Normalize a date/datetime value to match the type of DTSTART.

This is necessary because dateutil.rrule cannot compare date and datetime objects.
When adding EXDATE/RDATE values to an rruleset, they must be the same type as DTSTART.

Args:
dt_value: The date or datetime value to normalize
dtstart: The DTSTART value to match the type of

Returns:
The normalized value matching DTSTART's type
"""
# If both are the same type, check timezone compatibility
if type(dt_value) is type(dtstart):
# Both are datetimes - ensure timezone awareness matches
if isinstance(dt_value, datetime) and isinstance(dtstart, datetime):
if dt_value.tzinfo is not None and dtstart.tzinfo is None:
# Converting aware to naive - extract date and use dtstart's time
return datetime.combine(dt_value.date(), dtstart.time())
elif dt_value.tzinfo is None and dtstart.tzinfo is not None:
# Make dt_value aware to match dtstart
return dt_value.replace(tzinfo=dtstart.tzinfo)
return dt_value

# If DTSTART is a date (not datetime), convert datetime to date
if isinstance(dtstart, date) and not isinstance(dtstart, datetime):
if isinstance(dt_value, datetime):
return dt_value.date()
return dt_value

# If DTSTART is a datetime, convert date to datetime
if isinstance(dtstart, datetime):
if isinstance(dt_value, date) and not isinstance(dt_value, datetime):
# Convert date to datetime, matching DTSTART's time and timezone
# This ensures that EXDATE;VALUE=DATE:20240102 matches the occurrence
# at DTSTART's time (e.g., 10:00:00), not just midnight
if dtstart.tzinfo is not None:
# Use the same time and timezone as DTSTART
return datetime.combine(dt_value, dtstart.time()).replace(
tzinfo=dtstart.tzinfo
)
else:
# Naive datetime - use DTSTART's time
return datetime.combine(dt_value, dtstart.time())
# dt_value is already a datetime, but check timezone awareness
if isinstance(dt_value, datetime):
if dt_value.tzinfo is not None and dtstart.tzinfo is None:
# Converting aware datetime to naive datetime
# First extract the date to handle timezone properly, then
# combine with dtstart's time to create a naive datetime
return datetime.combine(dt_value.date(), dtstart.time())
elif dt_value.tzinfo is None and dtstart.tzinfo is not None:
# Make dt_value aware to match dtstart
return dt_value.replace(tzinfo=dtstart.tzinfo)
return dt_value

return dt_value


def rruleset_from_comp(comp: Component) -> dateutil.rrule.rruleset:
dtstart = comp["DTSTART"].dt
rrulestr = comp["RRULE"].to_ical().decode("utf-8")
rrule = dateutil.rrule.rrulestr(rrulestr, dtstart=dtstart)
rs = dateutil.rrule.rruleset()
rs.rrule(rrule) # type: ignore

# dateutil.rrule internally converts date objects to datetime objects.
# To determine what type the rrule will generate, we check the first occurrence.
# This is necessary because EXDATE/RDATE values must match the generated type.
first_occurrence = next(iter(rrule), None)
effective_dtstart: datetime
if first_occurrence is not None:
effective_dtstart = first_occurrence
elif isinstance(dtstart, date) and not isinstance(dtstart, datetime):
# dateutil.rrule converts date to datetime at midnight
effective_dtstart = datetime.combine(dtstart, datetime.min.time())
else:
# dtstart must be a datetime at this point
assert isinstance(dtstart, datetime)
effective_dtstart = dtstart

if "EXDATE" in comp:
exdate_prop = comp["EXDATE"]
# EXDATE can be either:
# 1. A single vDDDLists (one EXDATE property with one or more dates)
# 2. A list of vDDDLists (multiple EXDATE properties)
# Extract the actual datetime/date values from the .dts list(s)
# and normalize them to match what the rrule will generate
if isinstance(exdate_prop, list):
for exdate_list in exdate_prop:
for exdate in exdate_list.dts:
rs.exdate(exdate.dt)
normalized = _normalize_to_dtstart_type(
exdate.dt, effective_dtstart
)
rs.exdate(normalized)
else:
for exdate in exdate_prop.dts:
rs.exdate(exdate.dt)
normalized = _normalize_to_dtstart_type(exdate.dt, effective_dtstart)
rs.exdate(normalized)
if "RDATE" in comp:
rdate_prop = comp["RDATE"]
# RDATE can be either:
# 1. A single vDDDLists (one RDATE property with one or more dates)
# 2. A list of vDDDLists (multiple RDATE properties)
# Extract the actual datetime/date values from the .dts list(s)
# and normalize them to match what the rrule will generate
if isinstance(rdate_prop, list):
for rdate_list in rdate_prop:
for rdate in rdate_list.dts:
rs.rdate(rdate.dt)
normalized = _normalize_to_dtstart_type(rdate.dt, effective_dtstart)
rs.rdate(normalized)
else:
for rdate in rdate_prop.dts:
rs.rdate(rdate.dt)
normalized = _normalize_to_dtstart_type(rdate.dt, effective_dtstart)
rs.rdate(normalized)
if "EXRULE" in comp:
exrulestr = comp["EXRULE"].to_ical().decode("utf-8")
exrule = dateutil.rrule.rrulestr(exrulestr, dtstart=dtstart)
Expand Down
Loading