Skip to content

Commit

Permalink
Move RDATE and correct tests
Browse files Browse the repository at this point in the history
This also improves the query range extension to front and back for the RRULE
  • Loading branch information
niccokunzmann committed Sep 27, 2024
1 parent 74328d8 commit 43cb6b0
Show file tree
Hide file tree
Showing 3 changed files with 111 additions and 40 deletions.
80 changes: 57 additions & 23 deletions recurring_ical_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,9 @@ def __init__(self, core: ComponentAdapter):
self.check_exdates_datetime: set[RecurrenceID] = set() # should be in UTC
self.check_exdates_date: set[datetime.date] = set() # should be in UTC
self.rdates: set[Time] = set()
self.replace_ends: dict[RecurrenceID, Time] = {} # for periods, in UTC
self.replace_ends: dict[
RecurrenceID, datetime.timedelta
] = {} # for periods, in UTC
# fill the attributes
for exdate in self.core.exdates:
self.exdates.add(exdate)
Expand All @@ -370,9 +372,9 @@ def __init__(self, core: ComponentAdapter):
self.rdates.add(rdate[0])
for recurrence_id in to_recurrence_ids(rdate[0]):
self.replace_ends[recurrence_id] = (
normalize_pytz(rdate[0] + rdate[1])
rdate[1]
if isinstance(rdate[1], datetime.timedelta)
else rdate[1]
else rdate[1] - rdate[0]
)
else:
# we have a date/datetime
Expand All @@ -385,8 +387,9 @@ def __init__(self, core: ComponentAdapter):
self.make_all_dates_comparable()

# Calculate the rules with the same timezones
self.rule_set = rruleset(cache=True)
self.rrules = []
rule_set = rruleset(cache=True)
rule_set.until = None
self.rrules = [rule_set]
last_until: Time | None = None
for rrule_string in self.core.rrules:
rule = self.create_rule_with_start(rrule_string)
Expand All @@ -399,10 +402,10 @@ def __init__(self, core: ComponentAdapter):
for exdate in self.exdates:
self.check_exdates_datetime.add(exdate)
for rdate in self.rdates:
self.rule_set.rdate(rdate)
rule_set.rdate(rdate)

if not last_until or not compare_greater(self.start, last_until):
self.rule_set.rdate(self.start)
rule_set.rdate(self.start)

@property
def extend_query_span_by(self) -> tuple[datetime.timedelta, datetime.timedelta]:
Expand Down Expand Up @@ -512,9 +515,6 @@ def make_all_dates_comparable(self):

def rrule_between(self, span_start: Time, span_stop: Time) -> Generator[Time]:
"""Recalculate the rrules so that minor mistakes are corrected."""
# TODO: optimize and only return what is in the span

yield from self.rule_set
# make dates comparable, rrule converts them to datetimes
span_start_dt = convert_to_datetime(span_start, self.tzinfo)
span_stop_dt = convert_to_datetime(span_stop, self.tzinfo)
Expand Down Expand Up @@ -620,9 +620,6 @@ def compute_span_extension(self):
subtract_from_start, self._subtract_from_start
)
self._add_to_stop = max(add_to_stop, self._add_to_stop)
print(
f"self._subtract_from_start = {self._subtract_from_start} self._add_to_stop = {self._add_to_stop}"
)

@property
def this_and_future_components(self) -> Generator[ComponentAdapter]:
Expand All @@ -644,13 +641,17 @@ def get_component_for_recurrence_id(
for modification_id in self.this_and_future:
if modification_id < recurrence_id:
component = self.recurrence_id_to_modification[modification_id]
else:
break
return component

def rrule_between(self, span_start: Time, span_stop: Time) -> Generator[Time]:
"""Modify the rrule generation span and yield recurrences."""
expanded_start = normalize_pytz(span_start - self._subtract_from_start)
expanded_stop = normalize_pytz(span_stop + self._add_to_stop)
yield from self.recurrence.rrule_between(
normalize_pytz(span_start - self._subtract_from_start),
normalize_pytz(span_stop + self._add_to_stop),
expanded_start,
expanded_stop,
)

def between(self, span_start: Time, span_stop: Time) -> Generator[Occurrence]:
Expand All @@ -675,16 +676,24 @@ def between(self, span_start: Time, span_stop: Time) -> Generator[Occurrence]:
)
if adapter is self.recurrence.core:
# We have no modification for this recurrence, so we record the date
stop = get_any(
self.recurrence.replace_ends,
recurrence_ids,
normalize_pytz(start + self.recurrence.core.duration),
)
returned_starts.add(start)
# This component is the base for this occurrence.
# It usually is the core. However, we may also find a modification
# with RANGE=THISANDFUTURE.
component = self.get_component_for_recurrence_id(recurrence_ids[0])
occurrence_start = normalize_pytz(start + component.move_recurrences_by)
# Consider the RDATE with a PERIOD value
occurrence_end = normalize_pytz(
occurrence_start
+ get_any(
self.recurrence.replace_ends,
recurrence_ids,
component.duration,
)
)
occurrence = self.recurrence.as_occurrence(
start, stop, self.occurrence, component
occurrence_start, occurrence_end, self.occurrence, component
)
returned_starts.add(start)
else:
# We found a modification, so we record the modification
if adapter in returned_modifications:
Expand Down Expand Up @@ -882,9 +891,34 @@ def extend_query_span_by(self) -> tuple[datetime.timedelta, datetime.timedelta]:
if start < recurrence_id:
add_to_stop = recurrence_id - start
if start > recurrence_id:
add_to_stop = end - recurrence_id
subtract_from_start = end - recurrence_id
return subtract_from_start, add_to_stop

@cached_property
def move_recurrences_by(self) -> datetime.timedelta:
"""Occurrences of this component should be moved by this amount.
Usually, the occurrence starts at the new start time.
However, if we have a RANGE=THISANDFUTURE, we need to move the occurrence.
RFC 5545:
When the given recurrence instance is
rescheduled, all subsequent instances are also rescheduled by the
same time difference. For instance, if the given recurrence
instance is rescheduled to start 2 hours later, then all
subsequent instances are also rescheduled 2 hours later.
Similarly, if the duration of the given recurrence instance is
modified, then all subsequence instances are also modified to have
this same duration.
"""
if self.this_and_future:
recurrence_id_prop = self._component.get("RECURRENCE-ID")
assert recurrence_id_prop, "RANGE=THISANDFUTURE implies RECURRENCE-ID."
start, recurrence_id = make_comparable((self.start, recurrence_id_prop.dt))
return start - recurrence_id
return datetime.timedelta(0)


class EventAdapter(ComponentAdapter):
"""An icalendar event adapter."""
Expand Down
8 changes: 6 additions & 2 deletions test/calendars/issue_75_range_parameter.ics
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ UID:210
DTSTART:20240901T120000Z
DTEND:20240901T140000Z
RRULE:FREQ=DAILY;INTERVAL=2;UNTIL=20250920
RDATE:20240924T090000Z
RDATE:20240914T090000Z
SEQUENCE:0
SUMMARY:ORIGINAL EVENT
DESCRIPTION: 2 hours long
END:VEVENT
BEGIN:VEVENT
UID:210
Expand All @@ -17,6 +18,7 @@ DTSTART:20240913T090000Z
DTEND:20240913T160000Z
SEQUENCE:1
SUMMARY:MODIFIED EVENT
DESCRIPTION: move -3h, make 7 hours long
END:VEVENT
BEGIN:VEVENT
UID:210
Expand All @@ -25,13 +27,15 @@ DTSTART:20240915T170000Z
DTEND:20240915T190000Z
SEQUENCE:1
SUMMARY:MODIFIED EVENT
DESCRIPTION: move +5h, 2 hours long
END:VEVENT
BEGIN:VEVENT
UID:210
RECURRENCE-ID;RANGE=THISANDFUTURE:20240922T120000Z
RECURRENCE-ID;RANGE=THISANDFUTURE:20240921T120000Z
DTSTART:20240922T142200Z
DTEND:20240922T161300Z
SEQUENCE:1
SUMMARY:EDITED EVENT
DESCRIPTION: moved +1 day +2h +22min, 1 hour 51min long
END:VEVENT
END:VCALENDAR
63 changes: 48 additions & 15 deletions test/test_issue_75_range_parameter.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,29 @@

from datetime import time
from datetime import timedelta as td
from typing import TYPE_CHECKING

import pytest

from recurring_ical_events import EventAdapter

if TYPE_CHECKING:
from calendar import Calendar


@pytest.mark.parametrize(
("date", "summary"),
[
("20240901", "ORIGINAL EVENT"),
("20240911", "ORIGINAL EVENT"),
("20240913", "MODIFIED EVENT"),
("20240914", "MODIFIED EVENT"), # RDATE
("20240915", "MODIFIED EVENT"), # Normal recurrence-id
("20240917", "MODIFIED EVENT"),
("20240919", "MODIFIED EVENT"),
("20240921", "MODIFIED EVENT"),
("20240922", "EDITED EVENT"),
("20240924", "EDITED EVENT"), # RDATE
("20240925", "EDITED EVENT"),
("20240924", "EDITED EVENT"),
("20240926", "EDITED EVENT"),
],
)
def test_issue_75_RANGE_AT_parameter(calendars, date, summary):
Expand All @@ -51,21 +55,22 @@ def test_issue_75_RANGE_AT_parameter(calendars, date, summary):
("20240901T000000Z", "20240911T235959Z", "ORIGINAL EVENT", 6),
("20240901T000000Z", "20240913T000000Z", "ORIGINAL EVENT", 6),
("20240901T000000Z", "20240913T235959Z", "MODIFIED EVENT", 7),
("20240901T000000Z", "20240914T235959Z", "MODIFIED EVENT", 8), # RDATE
(
"20240901T000000Z",
"20240915T235959Z",
"MODIFIED EVENT",
8,
9,
), # Normal recurrence-id
("20240901T000000Z", "20240917T235959Z", "MODIFIED EVENT", 9),
("20240901T000000Z", "20240919T235959Z", "MODIFIED EVENT", 10),
("20240901T000000Z", "20240917T235959Z", "MODIFIED EVENT", 10),
("20240901T000000Z", "20240919T235959Z", "MODIFIED EVENT", 11),
("20240901T000000Z", "20240921T235959Z", "MODIFIED EVENT", 11),
("20240901T000000Z", "20240922T000000Z", "MODIFIED EVENT", 11),
("20240901T000000Z", "20240922T235959Z", "EDITED EVENT", 12),
("20240901T000000Z", "20240923T000000Z", "EDITED EVENT", 12),
("20240901T000000Z", "20240923T235959Z", "EDITED EVENT", 13),
("20240901T000000Z", "20240924T235959Z", "EDITED EVENT", 14), # RDATE
("20240901T000000Z", "20240925T235959Z", "EDITED EVENT", 15),
("20240901T000000Z", "20240923T235959Z", "EDITED EVENT", 12),
("20240901T000000Z", "20240924T235959Z", "EDITED EVENT", 13),
("20240901T000000Z", "20240925T235959Z", "EDITED EVENT", 13),
(
"20240913T000000Z",
"20240922T000000Z",
Expand All @@ -82,7 +87,7 @@ def test_issue_75_RANGE_AT_parameter(calendars, date, summary):
"20240924T000000Z",
"20240925T235959Z",
"EDITED EVENT",
2,
1,
), # out of query bounds
],
)
Expand All @@ -100,7 +105,7 @@ def test_issue_75_RANGE_BETWEEN_parameter(calendars, start, end, summary, total)
[
# moved by 3 hours forward
((2024, 9, 13, 9), (9, 0), (16, 0)), # The modification itself
((2024, 9, 15, 9), (9, 0), (16, 0)), # The recurrence after this moved
((2024, 9, 17, 9), (9, 0), (16, 0)), # The recurrence after this moved
# moved by 2h22m backward
((2024, 9, 22, 14, 22), (14, 22), (16, 13)), # The modification itself
((2024, 9, 24, 14, 22), (14, 22), (16, 13)), # The recurrence after this moved
Expand All @@ -126,9 +131,16 @@ def test_the_length_of_modified_events(calendars, date, start, end):
# we have a duration added on top
("one_event", 0, td(minutes=30), td(0)),
# we move to a later date, +1 day
("same_event_recurring_at_same_time", 1, td(0), td(days=1, hours=1)),
("same_event_recurring_at_same_time", 1, td(days=1, hours=1), td(0)),
# we move to the front, so we should still add the duration
("same_event_recurring_at_same_time", 2, td(hours=1), td(0)),
("same_event_recurring_at_same_time", 2, td(0), td(hours=1)),
# we moved with the THISANDFUTURE
(
"issue_75_range_parameter",
3,
td(days=1, hours=2, minutes=22) + td(hours=1, minutes=51),
td(0),
),
],
)
def test_span_extension(
Expand Down Expand Up @@ -210,7 +222,28 @@ def test_deletion_of_THISANDFUTURE_by_SEQUENCE():
"""We need to make sure that the components we have only work on what is actual."""
pytest.skip("TODO")


def test_RDATE_with_PERIOD():
"""When an RDATE has a PERIOD, we can assume that that defines the new length.
"""
"""When an RDATE has a PERIOD, we can assume that that defines the new length."""
pytest.skip("TODO")


@pytest.mark.parametrize(
("calendar_name", "event_index", "delta"),
[
("one_event", 0, td(0)),
("same_event_recurring_at_same_time", 0, td(0)),
("issue_75_range_parameter", 1, td(hours=-3)),
("issue_75_range_parameter", 3, td(days=1, hours=2, minutes=22)),
],
)
def test_move_by_time(calendars, calendar_name, event_index, delta):
"""Check the moving of events."""
cal: Calendar = calendars.raw[calendar_name]
event = list(cal.walk("VEVENT"))[event_index]
adapter = EventAdapter(event)
assert adapter.move_recurrences_by == delta


# TODO: Test event with DTSTART = DATE - does it occur properly as it is
# one day long, I believe. Loot at the RFC 5545.

0 comments on commit 43cb6b0

Please sign in to comment.