Skip to content

Commit

Permalink
Start testing the query range extension based on the THISANDFUTURE
Browse files Browse the repository at this point in the history
  • Loading branch information
niccokunzmann committed Sep 26, 2024
1 parent f9304e2 commit 464453a
Show file tree
Hide file tree
Showing 4 changed files with 176 additions and 7 deletions.
57 changes: 56 additions & 1 deletion recurring_ical_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,9 +340,13 @@ def rrule_between(
"""No repetition."""
yield from []

has_core = False

class RecurrenceRules:
"""A strategy if we have an actual core with recurrences."""

has_core = True

def __init__(self, core: ComponentAdapter):
self.core = core
# Setup complete. We create the attribtues
Expand Down Expand Up @@ -600,6 +604,24 @@ def __init__(self, components: Sequence[ComponentAdapter]):
)
self.this_and_future.sort()

# get the span extension
self._subtract_from_start = self._add_to_stop = datetime.timedelta(0)
for adapter in self.this_and_future_components:
subtract_from_start, add_to_stop = adapter.extend_query_span_by
self._subtract_from_start = max(
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]:
"""All components that influence future events."""
if self.recurrence.has_core:
yield self.recurrence.core
for recurrence_id in self.this_and_future:
yield self.recurrence_id_to_modification[recurrence_id]

def get_component_for_recurrence_id(
self, recurrence_id: RecurrenceID
) -> ComponentAdapter:
Expand All @@ -614,6 +636,13 @@ def get_component_for_recurrence_id(
component = self.recurrence_id_to_modification[modification_id]
return component

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

def between(self, span_start: Time, span_stop: Time) -> Generator[Occurrence]:
"""Components between the start (inclusive) and end (exclusive).
Expand All @@ -623,7 +652,7 @@ 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):
for start in self.rrule_between(span_start, span_stop):
recurrence_ids = to_recurrence_ids(start)
if (
start in returned_starts
Expand Down Expand Up @@ -820,6 +849,32 @@ def is_in_span(self, span_start: Time, span_stop: Time) -> bool:
"""Return whether the component is in the span."""
return time_span_contains_event(span_start, span_stop, self.start, self.end)

@cached_property
def extend_query_span_by(self) -> tuple[datetime.timedelta, datetime.timedelta]:
"""Calculate how much we extend the query span.
If an event is long, we need to extend the query span by the event's duration.
If an event has moved, we need to make sure that that is included, too.
This is so that the RECURRENCE-ID falls within the modified span.
Imagine if the span is exactly a second. How much would we need to query
forward and backward to capture the recurrence id?
Returns two positive spans: (subtract_from_start, add_to_stop)
"""
subtract_from_start = self.duration
add_to_stop = datetime.timedelta(0)
recurrence_id_prop = self._component.get("RECURRENCE-ID")
if recurrence_id_prop:
start, end, recurrence_id = make_comparable(
(self.start, self.end, recurrence_id_prop.dt)
)
if start < recurrence_id:
add_to_stop = recurrence_id - start
if start > recurrence_id:
add_to_stop = end - recurrence_id
return subtract_from_start, add_to_stop


class EventAdapter(ComponentAdapter):
"""An icalendar event adapter."""
Expand Down
13 changes: 12 additions & 1 deletion test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,17 @@ def __repr__(self):
return f"{self.__class__.__name__}({self.tzp.__name__})"


_calendar_names = []
for calendar_path in CALENDARS_FOLDER.iterdir():
content = calendar_path.read_bytes()

@property
def get_calendar(self, content=content): # noqa: PLR0206
return self.get_calendar(content)

attribute_name = calendar_path.stem.replace("-", "_")
attribute_name = calendar_path.stem
setattr(ICSCalendars, attribute_name, get_calendar)
_calendar_names.append(attribute_name)


class Calendars(ICSCalendars):
Expand Down Expand Up @@ -181,3 +183,12 @@ def env_for_doctest(monkeypatch):
"print": doctest_print,
"CALENDARS": CALENDARS_FOLDER,
}


# remove invalid names
_calendar_names.remove("end_before_start_event")
_calendar_names.sort()
@pytest.fixture(scope="module", params=_calendar_names)
def calendar_name(request) -> str:
"""All the calendar names."""
return request.param
4 changes: 2 additions & 2 deletions test/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,14 @@ def test_exdate_date(calendars):


@pytest.mark.parametrize(
"date,count",
("date", "count"),
[
("20240923", 0),
("20240924", 3),
("20240925", 0),
("20240926", 3),
("20240927", 0),
]
],
)
def test_same_events_at_same_time(calendars, date, count):
"""Make sure that events can be moved to the same time."""
Expand Down
109 changes: 106 additions & 3 deletions test/test_issue_75_range_parameter.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,16 @@
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.
- https://www.rfc-editor.org/rfc/rfc5545.html#section-3.2.13
"""

import pytest
from datetime import time
from datetime import timedelta as td

import pytest

from recurring_ical_events import EventAdapter


@pytest.mark.parametrize(
Expand Down Expand Up @@ -92,11 +98,13 @@ def test_issue_75_RANGE_BETWEEN_parameter(calendars, start, end, summary, total)
@pytest.mark.parametrize(
("date", "start", "end"),
[
# 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
# 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 modification itself
]
((2024, 9, 24, 14, 22), (14, 22), (16, 13)), # The recurrence after this moved
],
)
def test_the_length_of_modified_events(calendars, date, start, end):
"""There should be one event exactly starting and ending at these times."""
Expand All @@ -106,3 +114,98 @@ def test_the_length_of_modified_events(calendars, date, start, end):
event = events[0]
assert event["DTSTART"].dt.time() == time(*start)
assert event["DTEND"].dt.time() == time(*end)


@pytest.mark.parametrize(
("calendar", "event_index", "expected_start_delta", "expected_end_delta"),
[
# no recurrence id means 0
("issue_62_moved_event", 1, td(0), td(0)),
# we moved 31 -> 17; 31-17
("issue_62_moved_event", 0, td(0), td(14)),
# 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)),
# we move to the front, so we should still add the duration
("same_event_recurring_at_same_time", 2, td(hours=1), td(0)),
],
)
def test_span_extension(
calendars, calendar, event_index, expected_start_delta, expected_end_delta
):
"""If we have an event that is moved with THISANDFUTURE,
other events move, too.
This requires us to extend the range which we query:
- If an event moves forward, we need to extend the span to the back ...
- If an event moves backward, we need to extend the span to the front ...
... in order to capture the recurrences from the rrule that would yield
the occurrence.
If the length is extended, we can shorten the span
If the length is reduced, we have to extend the span
This tests the adapter to yield the correct values for the given types
of moves.
We only have to extend the range for THISANDFUTURE events because
we iterate over all modifications either way.
TODO: However, for optimization, one could approach to create ranges that
specify how to extend and contract the spans.
This test has to test of types of recurrence id, start and end.
- date
- datetime without tzinfo
- datetime with UTC
- datetime with tzinfo other than UTC
>.The default value type is DATE-TIME. The value type can
be set to a DATE value type. This property MUST have the same
value type as the "DTSTART" property contained within the
recurring component. Furthermore, this property MUST be specified
as a date with local time if and only if the "DTSTART" property
contained within the recurring component is specified as a date
with local time.
- https://www.rfc-editor.org/rfc/rfc5545.html#section-3.8.4.4
moves must include:
- time forward
- time backward
- several days forward
- several days backward
Assumptions
-----------
This test is for a rought estimate. We can extend the range by +1 day into each direction.
This will allow us to capture everything.
Future examples and tests may help us improve the situation by narrowing it further down.
Safe:
- move 1 h forward -> END: add 1 day for timezone + 1 day for timedelta without timezone involvement (round up)
"""
cal = calendars.raw[calendar]
event = list(cal.walk("VEVENT"))[event_index]
adapter = EventAdapter(event)
assert adapter.duration >= td(0)
start_delta, end_delta = adapter.extend_query_span_by
assert start_delta >= expected_start_delta
assert end_delta >= expected_end_delta


def test_can_calculate_query_span_extension_on_all_events(calendars, calendar_name):
"""Check that the calclulation succeeds."""
for i, event in enumerate(calendars.raw[calendar_name].walk("VEVENT")):
adapter = EventAdapter(event)
start_delta, end_delta = adapter.extend_query_span_by
message = f"{calendar_name}.VEVENT[{i}]"
assert isinstance(start_delta, td), message
assert isinstance(end_delta, td), message
assert start_delta >= td(0), message
assert end_delta >= td(0), message


def _test_deletion_of_THISANDFUTURE_by_SEQUENCE():
"""We need to make sure that the components we have only work on what is actual."""
assert False, "TODO"

0 comments on commit 464453a

Please sign in to comment.