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
1 change: 1 addition & 0 deletions AUTHORS.txt
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,4 @@ Pi R
Alnoman Kamil - noman [at] kamil [dot] gr - https://kamil.gr
Leonardo Taccari - iamleot [at] gmail [dot] com
Jorenar - dev [at] jorenar [dot] com - https://jorenar.com
Ben Finney - ben+python [at] benfinney [dot] id [dot] au
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ Package maintainers and users who have to manually update their installation
may want to subscribe to `GitHub's tag feed
<https://github.com/geier/khal/tags.atom>`_.

unreleased
* FIX Correctly expand recurring events with EXDATE values that are date-only.

0.14.1
======
2026-08-21
Expand Down
30 changes: 20 additions & 10 deletions khal/icalendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,11 @@ def expand(
events_tz = getattr(dtstart_prop.dt, "tzinfo", None) if not allday else None

def sanitize_datetime(date: dt.date) -> dt.date:
if allday and isinstance(date, dt.datetime):
date = date.date()
if events_tz is not None:
date = events_tz.localize(date)
if isinstance(date, dt.datetime):
if allday:
date = date.date()
if events_tz is not None:
date = events_tz.localize(date)
return date

rrule_param = vevent.get("RRULE")
Expand Down Expand Up @@ -350,14 +351,23 @@ def get_dates(vevent, key):

# remove excluded dates
if expand:
for date in get_dates(vevent, "EXDATE") or ():
try:
dtstartl.remove(date)
except KeyError:
for exdate in get_dates(vevent, "EXDATE") or ():
exdate_date = exdate.date() if isinstance(exdate, dt.datetime) else exdate
if not any(start_datetime.date() == exdate_date for start_datetime in dtstartl):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If start_datetime is an instance of date, then this fails (date.date() doesn't exist).

I think you can repro with:

DTSTART;VALUE=DATE:20140701
RRULE:FREQ=DAILY;COUNT=10
EXDATE:20140703

Ideally, add a test and assert the behaviour.

# The excluded date matches none of the instances.
logger.warning(
f"In event {href}, excluded instance starting at {date} "
"not found, event might be invalid."
f"In event {href},"
f" excluded instance starting at {exdate_date}"
" not found, event might be invalid."
)
else:
# The excluded date matches one or more instances. Remove
# those from the set.
dtstartl = {
start_datetime
for start_datetime in dtstartl
if (start_datetime.date() != exdate_date)
}

dtstartend = [(start, start + duration) for start in dtstartl]
# not necessary, but I prefer deterministic output
Expand Down
48 changes: 48 additions & 0 deletions tests/event_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -834,3 +834,51 @@ def test_partstat_deligated():
_get_text("event_dt_partstat"), addresses=["iamboss@example.com"], **EVENT_KWARGS
)
assert event.partstat == "ACCEPTED"


def test_event_exdate_with_timezone_values_datetime():
"""Should parse EXDATE with timezone, and datetime values."""
event_identifier = "event_rrule_exdate_timezone_datetime"
ics_text = _get_text(event_identifier)
test_event = Event.fromString(
ics_text,
addresses=["exdate@example.com"],
**EVENT_KWARGS,
)
assert test_event.uid == event_identifier


def test_event_exdate_with_timezone_values_date():
"""Should parse EXDATE with timezone, and date values."""
event_identifier = "event_rrule_exdate_timezone_date"
ics_text = _get_text(event_identifier)
test_event = Event.fromString(
ics_text,
addresses=["exdate@example.com"],
**EVENT_KWARGS,
)
assert test_event.uid == event_identifier


def test_event_exdate_no_timezone_values_datetime():
"""Should parse EXDATE without timezone, and datetime values."""
event_identifier = "event_rrule_exdate_notz_datetime"
ics_text = _get_text(event_identifier)
test_event = Event.fromString(
ics_text,
addresses=["exdate@example.com"],
**EVENT_KWARGS,
)
assert test_event.uid == event_identifier


def test_event_exdate_no_timezone_values_date():
"""Should parse EXDATE without timezone, and date values."""
event_identifier = "event_rrule_exdate_notz_date"
ics_text = _get_text(event_identifier)
test_event = Event.fromString(
ics_text,
addresses=["exdate@example.com"],
**EVENT_KWARGS,
)
assert test_event.uid == event_identifier
10 changes: 10 additions & 0 deletions tests/ics/event_rrule_exdate_notz_date.ics
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
UID:event_rrule_exdate_notz_date
SUMMARY:Monthly Report
RRULE:FREQ=MONTHLY;BYDAY=+4SA;UNTIL=20301231T060000Z
DTSTART;TZID=Europe/Berlin:20240629T070000
DTEND;TZID=Europe/Berlin:20240629T120000
EXDATE:20241227,20251227,20261226,20271225,20281223,20291229,20301228
END:VEVENT
END:VCALENDAR
10 changes: 10 additions & 0 deletions tests/ics/event_rrule_exdate_notz_datetime.ics
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
UID:event_rrule_exdate_notz_datetime
SUMMARY:Monthly Report
RRULE:FREQ=MONTHLY;BYDAY=+4SA;UNTIL=20301231T060000Z
DTSTART;TZID=Europe/Berlin:20240629T070000
DTEND;TZID=Europe/Berlin:20240629T120000
EXDATE:20241227T070000,20251227T070000,20261226T070000,20271225T070000,20281223T070000,20291229T070000,20301228T070000
END:VEVENT
END:VCALENDAR
10 changes: 10 additions & 0 deletions tests/ics/event_rrule_exdate_timezone_date.ics
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
UID:event_rrule_exdate_timezone_date
SUMMARY:Monthly Report
RRULE:FREQ=MONTHLY;BYDAY=+4SA;UNTIL=20301231T060000Z
DTSTART;TZID=Europe/Berlin:20240629T070000
DTEND;TZID=Europe/Berlin:20240629T120000
EXDATE;TZID=Europe/Berlin:20241227,20251227,20261226,20271225,20281223,20291229,20301228
END:VEVENT
END:VCALENDAR
10 changes: 10 additions & 0 deletions tests/ics/event_rrule_exdate_timezone_datetime.ics
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
UID:event_rrule_exdate_timezone_datetime
SUMMARY:Monthly Report
RRULE:FREQ=MONTHLY;BYDAY=+4SA;UNTIL=20301231T060000Z
DTSTART;TZID=Europe/Berlin:20240629T070000
DTEND;TZID=Europe/Berlin:20240629T120000
EXDATE;TZID=Europe/Berlin:20241227T070000,20251227T070000,20261226T070000,20271225T070000,20281223T070000,20291229T070000,20301228T070000
END:VEVENT
END:VCALENDAR
32 changes: 32 additions & 0 deletions tests/khalendar_utils_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,38 @@ def test_expand_dtzb(self):
dtstart = icalendar_helpers.expand(vevent, berlin)
assert dtstart == self.dstartend

def test_exdate_timezone_datetime_values(self):
"""Should expand EXDATE with timezone, and datetime values."""
event_identifier = "event_rrule_exdate_timezone_datetime"
vevent = _get_vevent_file(event_identifier)
datetime_range_sequence = icalendar_helpers.expand(vevent, berlin)
start_date_sequence = [start.date() for (start, end) in datetime_range_sequence]
assert not any(exdate.date() in start_date_sequence for exdate in vevent.exdates)

def test_exdate_timezone_date_values(self):
"""Should expand EXDATE with timezone, and date values."""
event_identifier = "event_rrule_exdate_timezone_date"
vevent = _get_vevent_file(event_identifier)
datetime_range_sequence = icalendar_helpers.expand(vevent, berlin)
start_date_sequence = [start.date() for (start, end) in datetime_range_sequence]
assert not any(exdate in start_date_sequence for exdate in vevent.exdates)

def test_exdate_no_timezone_datetime_values(self):
"""Should expand EXDATE without timezone, and datetime values."""
event_identifier = "event_rrule_exdate_notz_datetime"
vevent = _get_vevent_file(event_identifier)
datetime_range_sequence = icalendar_helpers.expand(vevent, berlin)
start_date_sequence = [start.date() for (start, end) in datetime_range_sequence]
assert not any(exdate.date() in start_date_sequence for exdate in vevent.exdates)

def test_exdate_no_timezone_date_values(self):
"""Should expand EXDATE without timezone, and date values."""
event_identifier = "event_rrule_exdate_notz_date"
vevent = _get_vevent_file(event_identifier)
datetime_range_sequence = icalendar_helpers.expand(vevent, berlin)
start_date_sequence = [start.date() for (start, end) in datetime_range_sequence]
assert not any(exdate in start_date_sequence for exdate in vevent.exdates)

def test_expand_invalid_exdate(self):
"""testing if we can expand an event with EXDATEs that do not much
its RRULE"""
Expand Down
Loading