diff --git a/recurring_ical_events.py b/recurring_ical_events.py index ec74ce2..b664817 100644 --- a/recurring_ical_events.py +++ b/recurring_ical_events.py @@ -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 @@ -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: @@ -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). @@ -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 @@ -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.""" diff --git a/test/conftest.py b/test/conftest.py index 01d8874..7b577c9 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -68,6 +68,7 @@ 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() @@ -75,8 +76,9 @@ def __repr__(self): 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): @@ -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 diff --git a/test/test_examples.py b/test/test_examples.py index efba14c..d7a112e 100644 --- a/test/test_examples.py +++ b/test/test_examples.py @@ -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.""" diff --git a/test/test_issue_75_range_parameter.py b/test/test_issue_75_range_parameter.py index 539b73c..12bfdee 100644 --- a/test/test_issue_75_range_parameter.py +++ b/test/test_issue_75_range_parameter.py @@ -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( @@ -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.""" @@ -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"