Skip to content

Commit

Permalink
Merge pull request #181 from jabiertxof/fixIssue75
Browse files Browse the repository at this point in the history
Add RECURRENCE-ID with range parameter.
  • Loading branch information
niccokunzmann authored Sep 26, 2024
2 parents ebbbcf5 + 2ab8833 commit 678696d
Show file tree
Hide file tree
Showing 5 changed files with 162 additions and 10 deletions.
51 changes: 42 additions & 9 deletions recurring_ical_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ class NoRecurrence:
replace_ends: dict[RecurrenceID, Time] = {}

def as_occurrence(
self, start: Time, stop: Time, occurrence: type[Occurrence]
self, start: Time, stop: Time, occurrence: type[Occurrence], core: ComponentAdapter
) -> Occurrence:
raise NotImplementedError("This code should never be reached.")

Expand Down Expand Up @@ -499,6 +499,7 @@ 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)
Expand Down Expand Up @@ -546,11 +547,11 @@ def convert_to_original_type(self, date):
return date

def as_occurrence(
self, start: Time, stop: Time, occurrence: type[Occurrence]
self, start: Time, stop: Time, occurrence: type[Occurrence], core: ComponentAdapter
) -> Occurrence:
"""Return this as an occurrence at a specific time."""
return occurrence(
self.core,
core,
self.convert_to_original_type(start),
self.convert_to_original_type(stop),
)
Expand Down Expand Up @@ -595,7 +596,19 @@ def between(self, span_start: Time, span_stop: Time) -> Generator[Occurrence]:
returned_modifications: set[ComponentAdapter] = set()
# NOTE: If in the following line, we get an error, datetime and date
# may still be mixed because RDATE, EXDATE, start and rule.
for start in self.recurrence.rrule_between(span_start, span_stop):
prev_adapter = None
starts = sorted(self.recurrence.rrule_between(span_start, span_stop))
if type(self.recurrence) != Series.NoRecurrence:
mindatetime = convert_to_datetime(DATE_MIN_DT, self.recurrence.tzinfo)
rangestarts = mindatetime
for modification in self.modifications:
if modification.thisandfuture and convert_to_datetime(span_start, self.recurrence.tzinfo) > modification.start:
if modification.start > rangestarts:
prev_adapter = modification
rangestarts = modification.start
if rangestarts != mindatetime:
starts = [starts[0], rangestarts, * starts[1:]]
for start in starts:
recurrence_ids = to_recurrence_ids(start)
if (
start in returned_starts
Expand All @@ -606,20 +619,30 @@ def between(self, span_start: Time, span_stop: Time) -> Generator[Occurrence]:
adapter: ComponentAdapter = get_any(
self.recurrence_id_to_modification, recurrence_ids, self.recurrence.core
)
if adapter is self.recurrence.core:
if starts[0] != start and prev_adapter and adapter is self.recurrence.core:
start_dt = datetime.datetime.combine(start.date(), prev_adapter.start.time())
stop = get_any(
self.recurrence.replace_ends,
recurrence_ids,
normalize_pytz(start_dt + prev_adapter.duration),
)
occurrence = self.recurrence.as_occurrence(start_dt, stop, self.occurrence, prev_adapter)
elif adapter is self.recurrence.core:
stop = get_any(
self.recurrence.replace_ends,
recurrence_ids,
normalize_pytz(start + self.recurrence.core.duration),
)
occurrence = self.recurrence.as_occurrence(start, stop, self.occurrence)
occurrence = self.recurrence.as_occurrence(start, stop, self.occurrence, self.recurrence.core)
returned_starts.add(start)
else:
# We found a modification
if adapter in returned_modifications:
continue
returned_modifications.add(adapter)
occurrence = self.occurrence(adapter)
if adapter.thisandfuture:
prev_adapter = adapter
if occurrence.is_in_span(span_start, span_stop):
yield occurrence
for modification in self.modifications:
Expand Down Expand Up @@ -650,7 +673,7 @@ def __repr__(self):
class ComponentAdapter(ABC):
"""A unified interface to work with icalendar components."""

ATTRIBUTES_TO_DELETE_ON_COPY = ["RRULE", "RDATE", "EXDATE"]
ATTRIBUTES_TO_DELETE_ON_COPY = ["RRULE", "RDATE", "EXDATE", "RECURRENCE-ID"]

@staticmethod
@abstractmethod
Expand Down Expand Up @@ -720,6 +743,18 @@ def recurrence_ids(self) -> RecurrenceIDs:
return ()
return to_recurrence_ids(recurrence_id.dt)

@cached_property
def thisandfuture(self) -> bool:

"""The recurrence ids has a thisand future range property"""
recurrence_id = self._component.get("RECURRENCE-ID")
if recurrence_id is None:
return False
if "RANGE" in recurrence_id.params:
return recurrence_id.params["RANGE"] == "THISANDFUTURE"
return False


def is_modification(self) -> bool:
"""Whether the adapter is a modification."""
return bool(self.recurrence_ids)
Expand Down Expand Up @@ -912,8 +947,6 @@ def end(self) -> Time:
class Occurrence:
"""A repetition of an event."""

ATTRIBUTES_TO_DELETE_ON_COPY = ["RRULE", "RDATE", "EXDATE"]

def __init__(
self,
adapter: ComponentAdapter,
Expand Down
37 changes: 37 additions & 0 deletions test/calendars/issue_75_range_parameter.ics
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:RESERVAS 1.0//EN
BEGIN:VEVENT
UID:210
DTSTART:20240901T120000Z
DTEND:20240901T140000Z
RRULE:FREQ=DAILY;INTERVAL=2;UNTIL=20250920
RDATE:20240924T090000Z
SEQUENCE:0
SUMMARY:ORIGINAL EVENT
END:VEVENT
BEGIN:VEVENT
UID:210
RECURRENCE-ID;RANGE=THISANDFUTURE:20240913T120000Z
DTSTART:20240913T090000Z
DTEND:20240913T160000Z
SEQUENCE:1
SUMMARY:MODIFIED EVENT
END:VEVENT
BEGIN:VEVENT
UID:210
RECURRENCE-ID:20240915T120000Z
DTSTART:20240915T170000Z
DTEND:20240915T190000Z
SEQUENCE:1
SUMMARY:MODIFIED EVENT
END:VEVENT
BEGIN:VEVENT
UID:210
RECURRENCE-ID;RANGE=THISANDFUTURE:20240922T120000Z
DTSTART:20240922T142200Z
DTEND:20240922T161300Z
SEQUENCE:1
SUMMARY:EDITED EVENT
END:VEVENT
END:VCALENDAR
1 change: 0 additions & 1 deletion test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import time
from datetime import timezone
from pathlib import Path

import dateutil
import icalendar
import pytest
Expand Down
15 changes: 15 additions & 0 deletions test/py.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# shim for pylib going away
# if pylib is installed this file will get skipped
# (`py/__init__.py` has higher precedence)
from __future__ import annotations

import sys

import _pytest._py.error as error
import _pytest._py.path as path


sys.modules["py.error"] = error
sys.modules["py.path"] = path

__all__ = ["error", "path"]
68 changes: 68 additions & 0 deletions test/test_issue_75_range_parameter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@

"""This tests the range parameter for ics file.
see https://github.com/niccokunzmann/python-recurring-ical-events/issues/75
Description: This parameter can be specified on a property that
specifies a recurrence identifier. The parameter specifies the
effective range of recurrence instances that is specified by the
property. The effective range is from the recurrence identifier
specified by the property. If this parameter is not specified on
an allowed property, then the default range is the single instance
specified by the recurrence identifier value of the property. The
parameter value can only be "THISANDFUTURE" to indicate a range
defined by the recurrence identifier and all subsequent instances.
The value "THISANDPRIOR" is deprecated by this revision of
iCalendar and MUST NOT be generated by applications.
"""

import pytest


@pytest.mark.parametrize(
("date", "summary"),
[
("20240901", "ORIGINAL EVENT"),
("20240911", "ORIGINAL EVENT"),
("20240913", "MODIFIED EVENT"),
("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"),
],
)
def test_issue_75_RANGE_AT_parameter(calendars, date, summary):
events = calendars.issue_75_range_parameter.at(date)
assert len(events) == 1, f"Expecting one event at {date}"
event = events[0]
assert str(event["SUMMARY"]) == summary

@pytest.mark.parametrize(
("start", "end", "summary","total"),
[
("20240901T000000Z", "20240911T235959Z", "ORIGINAL EVENT",6),
("20240901T000000Z", "20240913T000000Z", "ORIGINAL EVENT",6),
("20240901T000000Z", "20240913T235959Z", "MODIFIED EVENT",7),
("20240901T000000Z", "20240915T235959Z", "MODIFIED EVENT",8), # Normal recurrence-id
("20240901T000000Z", "20240917T235959Z", "MODIFIED EVENT",9),
("20240901T000000Z", "20240919T235959Z", "MODIFIED EVENT",10),
("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),
("20240913T000000Z", "20240922T000000Z", "MODIFIED EVENT",5), # out of query bounds
("20240913T000000Z", "20240922T235959Z", "EDITED EVENT",6), # out of query bounds
("20240924T000000Z", "20240925T235959Z", "EDITED EVENT",2), # out of query bounds
],
)
def test_issue_75_RANGE_BETWEEN_parameter(calendars, start, end, summary, total):
events = calendars.issue_75_range_parameter.between(start,end)
assert len(events) == total, f"Expecting {total} events at range {start}, {end}, get {len(events)}"
event = events[-1]
assert str(event["SUMMARY"]) == summary

# TODO: Test DTSTART and DTEND

0 comments on commit 678696d

Please sign in to comment.