From 9e0d2078b4afc5dfb2eee92a4b1f1d5006a7e3f6 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 3 Feb 2024 14:55:52 -0800 Subject: [PATCH] Update event and todo store to share all instances --- ical/store.py | 395 +++++++++++++++++++------------------------- tests/test_store.py | 6 +- 2 files changed, 176 insertions(+), 225 deletions(-) diff --git a/ical/store.py b/ical/store.py index 1c6f909..d89d9be 100644 --- a/ical/store.py +++ b/ical/store.py @@ -13,7 +13,7 @@ import datetime import logging from collections.abc import Callable, Iterable -from typing import Any, TypeVar +from typing import Any, TypeVar, Generic, cast from .calendar import Calendar from .event import Event @@ -37,7 +37,6 @@ ] _T = TypeVar("_T", bound="Event | Todo") -_ItemType = Event | Todo def _lookup_by_uid(uid: str, items: Iterable[_T]) -> tuple[int | None, _T | None]: @@ -48,7 +47,9 @@ def _lookup_by_uid(uid: str, items: Iterable[_T]) -> tuple[int | None, _T | None return None, None -def _ensure_timezone(dtstart: datetime.datetime | datetime.date | None, timezones: list[Timezone]) -> Timezone | None: +def _ensure_timezone( + dtstart: datetime.datetime | datetime.date | None, timezones: list[Timezone] +) -> Timezone | None: """Create a timezone object for the specified date if it does not already exist.""" if ( not isinstance(dtstart, datetime.datetime) @@ -73,14 +74,6 @@ def _ensure_timezone(dtstart: datetime.datetime | datetime.date | None, timezone ) from err -def _ensure_calendar_timezone(dtstart: datetime.datetime | datetime.date | None, calendar: Calendar) -> None: - """Ensure the calendar has the necessary timezone for the specified item.""" - if ( - new_timezone := _ensure_timezone(dtstart, calendar.timezones) - ) is not None: - calendar.timezones.append(new_timezone) - - def _prepare_update( store_item: Event | Todo, item: Event | Todo, @@ -131,99 +124,48 @@ def _prepare_update( return update -class EventStore: - """An event store manages the lifecycle of events on a Calendar. - - An `ical.calendar.Calendar` is a lower level object that can be directly - manipulated to add/remove an `ical.event.Event`. That is, it does not - handle updating timestamps, incrementing sequence numbers, or managing - lifecycle of a recurring event during an update. - - - Here is an example for setting up an `EventStore`: - - ```python - import datetime - from ical.calendar import Calendar - from ical.event import Event - from ical.store import EventStore - from ical.types import Recur - - calendar = Calendar() - store = EventStore(calendar) - - event = Event( - summary="Event summary", - start="2022-07-03", - end="2022-07-04", - rrule=Recur.from_rrule("FREQ=WEEKLY;COUNT=3"), - ) - store.add(event) - ``` - - This will add events to the calendar: - ```python3 - for event in calendar.timeline: - print(event.summary, event.uid, event.recurrence_id, event.dtstart) - ``` - With output like this: - ``` - Event summary a521cf45-2c02-11ed-9e5c-066a07ffbaf5 20220703 2022-07-03 - Event summary a521cf45-2c02-11ed-9e5c-066a07ffbaf5 20220710 2022-07-10 - Event summary a521cf45-2c02-11ed-9e5c-066a07ffbaf5 20220717 2022-07-17 - ``` - - You may also delete an event, or a specific instance of a recurring event: - ```python - # Delete a single instance of the recurring event - store.delete(uid=event.uid, recurrence_id="20220710") - ``` - - Then viewing the store using the `print` example removes the individual - instance in the event: - ``` - Event summary a521cf45-2c02-11ed-9e5c-066a07ffbaf5 20220703 2022-07-03 - Event summary a521cf45-2c02-11ed-9e5c-066a07ffbaf5 20220717 2022-07-17 - ``` - """ +class GenericStore(Generic[_T]): + """A a store manages the lifecycle of items on a Calendar.""" def __init__( self, - calendar: Calendar, + items: list[_T], + timezones: list[Timezone], + exc: type[StoreError], dtstamp_fn: Callable[[], datetime.datetime] = lambda: dtstamp_factory(), ): """Initialize the EventStore.""" - self._calendar = calendar + self._items = items + self._timezones = timezones + self._exc = exc self._dtstamp_fn = dtstamp_fn - def add(self, event: Event) -> Event: - """Add the specified event to the calendar. + def add(self, item: _T) -> _T: + """Add the specified item to the calendar. This will handle assigning modification dates, sequence numbers, etc if those fields are unset. The store will ensure the `ical.calendar.Calendar` has the necessary - `ical.timezone.Timezone` needed to fully specify the event time information + `ical.timezone.Timezone` needed to fully specify the time information when encoded. """ update: dict[str, Any] = {} - if not event.created: - update["created"] = event.dtstamp - if event.sequence is None: + if not item.created: + update["created"] = item.dtstamp + if item.sequence is None: update["sequence"] = 0 - new_event = event.copy(update=update) + new_item = cast(_T, item.copy(update=update)) # The store can only manage cascading deletes for some relationship types - for relation in new_event.related_to or (): + for relation in new_item.related_to or (): if relation.reltype != RelationshipType.PARENT: - raise EventStoreError( - f"Unsupported relationship type {relation.reltype}" - ) + raise self._exc(f"Unsupported relationship type {relation.reltype}") - _LOGGER.debug("Adding event: %s", new_event) - _ensure_calendar_timezone(event.dtstart, self._calendar) - self._calendar.events.append(new_event) - return new_event + _LOGGER.debug("Adding item: %s", new_item) + self._ensure_timezone(item.dtstart) + self._items.append(new_item) + return new_item def delete( self, @@ -231,53 +173,53 @@ def delete( recurrence_id: str | None = None, recurrence_range: Range = Range.NONE, ) -> None: - """Delete the event from the calendar. + """Delete the item from the calendar. - This method is used to delete an existing event. For a recurring event - either the whole event or instances of an event may be deleted. To - delete the complete range of a recurring event, the `uid` property - for the event must be specified and the `recurrence_id` should not - be specified. To delete an individual instances of the event the + This method is used to delete an existing item. For a recurring item + either the whole item or instances of an item may be deleted. To + delete the complete range of a recurring item, the `uid` property + for the item must be specified and the `recurrence_id` should not + be specified. To delete an individual instances of the item the `recurrence_id` must be specified. When deleting individual instances, the range property may specify if deletion of just a specific instance, or a range of instances. """ - _, store_event = _lookup_by_uid(uid, self._calendar.events) - if store_event is None: - raise EventStoreError(f"No existing event with uid: {uid}") + _, store_item = _lookup_by_uid(uid, self._items) + if store_item is None: + raise self._exc(f"No existing item with uid: {uid}") if ( recurrence_id and recurrence_range == Range.THIS_AND_FUTURE - and RecurrenceId.to_value(recurrence_id) == store_event.dtstart + and RecurrenceId.to_value(recurrence_id) == store_item.dtstart ): # Editing the first instance and all forward is the same as editing the # entire series so don't bother forking a new event recurrence_id = None children = [] - for event in self._calendar.events: + for event in self._items: for relation in event.related_to or (): if relation.reltype == RelationshipType.PARENT and relation.uid == uid: children.append(event) for child in children: - self._calendar.events.remove(child) + self._items.remove(child) # Deleting all instances in the series if not recurrence_id: - self._calendar.events.remove(store_event) + self._items.remove(store_item) return # Deleting one or more instances in the recurrence - if not store_event.rrule: + if not store_item.rrule: raise EventStoreError("Specified recurrence_id but event is not recurring") exdate = RecurrenceId.to_value(recurrence_id) if recurrence_range == Range.NONE: # A single recurrence instance is removed. Add an exclusion to # to the event. - store_event.exdate.append(exdate) + store_item.exdate.append(exdate) return # Assumes any recurrence deletion is valid, and that overwriting @@ -285,182 +227,191 @@ def delete( # inclusive so it can't include the specified exdate. FREQ=DAILY # is the lowest frequency supported so subtracting one day is # safe and works for both dates and datetimes. - store_event.rrule.count = None - store_event.rrule.until = exdate - datetime.timedelta(days=1) + store_item.rrule.count = None + store_item.rrule.until = exdate - datetime.timedelta(days=1) now = self._dtstamp_fn() - store_event.dtstamp = now - store_event.last_modified = now + store_item.dtstamp = now + store_item.last_modified = now def edit( self, uid: str, - event: Event, + item: _T, recurrence_id: str | None = None, recurrence_range: Range = Range.NONE, ) -> None: - """Update the event with the specified uid. + """Update the item with the specified uid. - The specified event should be created with minimal fields, just + The specified item should be created with minimal fields, just including the fields that should be updated. The default fields such - as `uid` and `dtstamp` may be used to set the uid for a new created event - when updating a recurring event, or for any modification times. - - Example usage: - ```python - store.edit("event-uid-1", Event(summary="New Summary")) - ``` - - For a recurring event, either the whole event or individual instances - of the event may be edited. To edit the complete range of a recurring - event the `uid` property must be specified and the `recurrence_id` should - not be specified. To edit an individual instances of the event the + as `uid` and `dtstamp` may be used to set the uid for a new created item + when updating a recurring item, or for any modification times. + + For a recurring item, either the whole item or individual instances + of the item may be edited. To edit the complete range of a recurring + item the `uid` property must be specified and the `recurrence_id` should + not be specified. To edit an individual instances of the item the `recurrence_id` must be specified. The `recurrence_range` determines if - just that individual instance is updated or all events following as well. + just that individual instance is updated or all items following as well. The store will ensure the `ical.calendar.Calendar` has the necessary - `ical.timezone.Timezone` needed to fully specify the event time information + `ical.timezone.Timezone` needed to fully specify the item time information when encoded. """ - _, store_event = _lookup_by_uid(uid, self._calendar.events) - if store_event is None: - raise EventStoreError(f"No existing event with uid: {uid}") + store_index, store_item = _lookup_by_uid(uid, self._items) + if store_index is None or store_item is None: + raise EventStoreError(f"No existing item with uid: {uid}") if ( recurrence_id and recurrence_range == Range.THIS_AND_FUTURE - and RecurrenceId.to_value(recurrence_id) == store_event.dtstart + and RecurrenceId.to_value(recurrence_id) == store_item.dtstart ): # Editing the first instance and all forward is the same as editing the - # entire series so don't bother forking a new event + # entire series so don't bother forking a new item recurrence_id = None - update = _prepare_update(store_event, event, recurrence_id, recurrence_range) + update = _prepare_update(store_item, item, recurrence_id, recurrence_range) if recurrence_range == Range.NONE: - # Changing the recurrence rule of a single event in the middle of the series - # is not allowed. It is allowed to convert a single instance event to recurring. - if event.rrule and store_event.rrule: - if event.rrule.as_rrule_str() != store_event.rrule.as_rrule_str(): - raise EventStoreError( - f"Can't update single instance with rrule (rrule={event.rrule})" + # Changing the recurrence rule of a single item in the middle of the series + # is not allowed. It is allowed to convert a single instance item to recurring. + if item.rrule and store_item.rrule: + if item.rrule.as_rrule_str() != store_item.rrule.as_rrule_str(): + raise self._exc( + f"Can't update single instance with rrule (rrule={item.rrule})" ) - event.rrule = None + item.rrule = None # Make a deep copy since deletion may update this objects recurrence rules - new_event = store_event.copy(update=update, deep=True) - if recurrence_id and new_event.rrule and new_event.rrule.count: - # The recurring event count needs to skip any events that - # come before the start of the new event. Use a RulesetIterable + new_item = cast(_T, store_item.copy(update=update, deep=True)) + if ( + recurrence_id + and new_item.rrule + and new_item.rrule.count + and store_item.dtstart + ): + # The recurring item count needs to skip any items that + # come before the start of the new item. Use a RulesetIterable # to handle workarounds for dateutil.rrule limitations. dtstart: datetime.date | datetime.datetime = update["dtstart"] ruleset = RulesetIterable( - store_event.dtstart, - [new_event.rrule.as_rrule(store_event.dtstart)], + store_item.dtstart, + [new_item.rrule.as_rrule(store_item.dtstart)], [], [], ) for dtvalue in ruleset: if dtvalue >= dtstart: break - new_event.rrule.count = new_event.rrule.count - 1 + new_item.rrule.count = new_item.rrule.count - 1 # The store can only manage cascading deletes for some relationship types - for relation in new_event.related_to or (): + for relation in new_item.related_to or (): if relation.reltype != RelationshipType.PARENT: - raise EventStoreError( - f"Unsupported relationship type {relation.reltype}" - ) + raise self._exc(f"Unsupported relationship type {relation.reltype}") - _ensure_calendar_timezone(event.dtstart, self._calendar) + self._ensure_timezone(new_item.dtstart) - # Editing a single instance of a recurring event is like deleting that instance + # Editing a single instance of a recurring item is like deleting that instance # then adding a new instance on the specified date. If recurrence id is not - # specified then the entire event is replaced. + # specified then the entire item is replaced. self.delete(uid, recurrence_id=recurrence_id, recurrence_range=recurrence_range) - if recurrence_id: - self.add(new_event) - else: - self._calendar.events.append(new_event) + self._items.insert(store_index, new_item) + def _ensure_timezone( + self, dtstart: datetime.datetime | datetime.date | None + ) -> None: + if (new_timezone := _ensure_timezone(dtstart, self._timezones)) is not None: + self._timezones.append(new_timezone) -class TodoStore: - """A To-do store manages the lifecycle of to-dos on a Calendar.""" - def __init__( - self, - calendar: Calendar, - dtstamp_fn: Callable[[], datetime.datetime] = lambda: dtstamp_factory(), - ): - """Initialize the TodoStore.""" - self._calendar = calendar - self._dtstamp_fn = dtstamp_fn +class EventStore(GenericStore[Event]): + """An event store manages the lifecycle of events on a Calendar. - def add(self, todo: Todo) -> Todo: - """Add the specified todo to the calendar.""" - update: dict[str, Any] = {} - if not todo.created: - update["created"] = todo.dtstamp - if todo.sequence is None: - update["sequence"] = 0 - new_todo = todo.copy(update=update) + An `ical.calendar.Calendar` is a lower level object that can be directly + manipulated to add/remove an `ical.event.Event`. That is, it does not + handle updating timestamps, incrementing sequence numbers, or managing + lifecycle of a recurring event during an update. - # The store can only manage cascading deletes for some relationship types - for relation in new_todo.related_to or (): - if relation.reltype != RelationshipType.PARENT: - raise TodoStoreError( - f"Unsupported relationship type {relation.reltype}" - ) - _LOGGER.debug("Adding todo: %s", new_todo) - _ensure_calendar_timezone(todo.dtstart, self._calendar) - self._calendar.todos.append(new_todo) - return new_todo + Here is an example for setting up an `EventStore`: - def delete( - self, - uid: str, - ) -> None: - """Delete the todo from the calendar.""" - store_index, store_todo = _lookup_by_uid(uid, self._calendar.todos) - if store_todo is None: - raise TodoStoreError(f"No existing todo with uid: {uid}") - removals = [store_todo] - - for todo in self._calendar.todos: - for relation in todo.related_to or (): - if relation.reltype == RelationshipType.PARENT and relation.uid == uid: - removals.append(todo) + ```python + import datetime + from ical.calendar import Calendar + from ical.event import Event + from ical.store import EventStore + from ical.types import Recur - for todo in removals: - self._calendar.todos.remove(todo) + calendar = Calendar() + store = EventStore(calendar) - def edit( + event = Event( + summary="Event summary", + start="2022-07-03", + end="2022-07-04", + rrule=Recur.from_rrule("FREQ=WEEKLY;COUNT=3"), + ) + store.add(event) + ``` + + This will add events to the calendar: + ```python3 + for event in calendar.timeline: + print(event.summary, event.uid, event.recurrence_id, event.dtstart) + ``` + With output like this: + ``` + Event summary a521cf45-2c02-11ed-9e5c-066a07ffbaf5 20220703 2022-07-03 + Event summary a521cf45-2c02-11ed-9e5c-066a07ffbaf5 20220710 2022-07-10 + Event summary a521cf45-2c02-11ed-9e5c-066a07ffbaf5 20220717 2022-07-17 + ``` + + You may also delete an event, or a specific instance of a recurring event: + ```python + # Delete a single instance of the recurring event + store.delete(uid=event.uid, recurrence_id="20220710") + ``` + + Then viewing the store using the `print` example removes the individual + instance in the event: + ``` + Event summary a521cf45-2c02-11ed-9e5c-066a07ffbaf5 20220703 2022-07-03 + Event summary a521cf45-2c02-11ed-9e5c-066a07ffbaf5 20220717 2022-07-17 + ``` + + Editing an event is also supported: + ```python + store.edit("event-uid-1", Event(summary="New Summary")) + ``` + """ + + def __init__( self, - uid: str, - todo: Todo, - ) -> None: - """Update the todo with the specified uid.""" - store_index, store_todo = _lookup_by_uid(uid, self._calendar.todos) - if store_todo is None or store_index is None: - raise TodoStoreError(f"No existing todo with uid: {uid}") - - partial_update = todo.dict(exclude_unset=True) - update = { - "created": store_todo.dtstamp, - "sequence": (store_todo.sequence + 1) if store_todo.sequence else 1, - "last_modified": todo.dtstamp, - **partial_update, - "dtstamp": todo.dtstamp, - } - # Make a deep copy since deletion may update this objects recurrence rules - new_todo = store_todo.copy(update=update, deep=True) + calendar: Calendar, + dtstamp_fn: Callable[[], datetime.datetime] = lambda: dtstamp_factory(), + ): + """Initialize the EventStore.""" + super().__init__( + calendar.events, + calendar.timezones, + EventStoreError, + dtstamp_fn, + ) - # The store can only manage cascading deletes for some relationship types - for relation in new_todo.related_to or (): - if relation.reltype != RelationshipType.PARENT: - raise TodoStoreError( - f"Unsupported relationship type {relation.reltype}" - ) - _ensure_calendar_timezone(todo.dtstart, self._calendar) - self._calendar.todos.pop(store_index) - self._calendar.todos.insert(store_index, new_todo) +class TodoStore(GenericStore[Todo]): + """A To-do store manages the lifecycle of to-dos on a Calendar.""" + + def __init__( + self, + calendar: Calendar, + dtstamp_fn: Callable[[], datetime.datetime] = lambda: dtstamp_factory(), + ): + """Initialize the TodoStore.""" + super().__init__( + calendar.todos, + calendar.timezones, + TodoStoreError, + dtstamp_fn, + ) diff --git a/tests/test_store.py b/tests/test_store.py index d16ed9d..f082bca 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -1071,10 +1071,10 @@ def test_invalid_uid( store: EventStore, ) -> None: """Test iteration over an empty calendar.""" - with pytest.raises(StoreError, match=r"No existing event with uid"): + with pytest.raises(StoreError, match=r"No existing item with uid"): store.edit("invalid", Event(summary="example summary")) - with pytest.raises(StoreError, match=r"No existing event with uid"): + with pytest.raises(StoreError, match=r"No existing item with uid"): store.delete("invalid") @@ -1095,7 +1095,7 @@ def test_invalid_recurrence_id( with pytest.raises(StoreError, match=r"event is not recurring"): store.edit( - "mock-uid-1", recurrence_id="invalid", event=Event(summary="invalid") + "mock-uid-1", Event(summary="tuesday"), recurrence_id="invalid" )