ical.store

Library for managing the lifecycle of components in a calendar.

A store is like a manager for events within a Calendar, updating the necessary properties such as modification times, sequence numbers, and ids. This higher level API is a more convenient API than working with the lower level objects directly.

  1"""Library for managing the lifecycle of components in a calendar.
  2
  3A store is like a manager for events within a Calendar, updating the necessary
  4properties such as modification times, sequence numbers, and ids. This higher
  5level API is a more convenient API than working with the lower level objects
  6directly.
  7"""
  8
  9# pylint: disable=unnecessary-lambda
 10
 11from __future__ import annotations
 12
 13import datetime
 14import logging
 15from collections.abc import Callable, Iterable, Generator
 16from typing import Any, TypeVar, Generic, cast
 17
 18from .calendar import Calendar
 19from .component import validate_recurrence_dates
 20from .event import Event
 21from .exceptions import StoreError, TodoStoreError, EventStoreError
 22from .iter import RulesetIterable, as_rrule
 23from .list import todo_list_view
 24from .timezone import Timezone
 25from .todo import Todo, TodoStatus
 26from .types import Range, Recur, RecurrenceId, RelationshipType, Period
 27from .tzif.timezoneinfo import TimezoneInfoError
 28from .util import dtstamp_factory, local_timezone, normalize_datetime
 29
 30
 31_LOGGER = logging.getLogger(__name__)
 32
 33
 34__all__ = [
 35    "EventStore",
 36    "EventStoreError",
 37    "TodoStore",
 38    "TodoStoreError",
 39    "StoreError",
 40]
 41
 42_T = TypeVar("_T", bound="Event | Todo")
 43# We won't be able to edit dates more than 100 years in the future, but this
 44# should be sufficient for most use cases.
 45_MAX_SCAN_DATE = datetime.date.today() + datetime.timedelta(days=365 * 100)
 46
 47
 48def _ensure_timezone(
 49    dtvalue: datetime.datetime | datetime.date | None, timezones: list[Timezone]
 50) -> Timezone | None:
 51    """Create a timezone object for the specified date if it does not already exist."""
 52    if (
 53        not isinstance(dtvalue, datetime.datetime)
 54        or not dtvalue.utcoffset()
 55        or not dtvalue.tzinfo
 56    ):
 57        return None
 58
 59    # Verify this timezone does not already exist. The number of timezones
 60    # in a calendar is typically very small so iterate over the whole thing
 61    # to avoid any synchronization/cache issues.
 62    key = str(dtvalue.tzinfo)
 63    for timezone in timezones:
 64        if timezone.tz_id == key:
 65            return None
 66
 67    try:
 68        return Timezone.from_tzif(key)
 69    except TimezoneInfoError as err:
 70        raise EventStoreError(
 71            f"No timezone information available for event: {key}"
 72        ) from err
 73
 74
 75def _match_item(item: _T, uid: str, recurrence_id: str | None) -> bool:
 76    """Return True if the item is an instance of a recurring event."""
 77    if item.uid != uid:
 78        return False
 79    if recurrence_id is None:
 80        # Match all items with the specified uids
 81        return True
 82    # Match a single item with the specified recurrence_id. If the item is an
 83    # edited instance match return it
 84    if item.recurrence_id == recurrence_id:
 85        _LOGGER.debug("Matched exact recurrence_id: %s", item)
 86        return True
 87    # Otherwise, determine if this instance is in the series
 88    _LOGGER.debug(
 89        "Expanding item %s %s to look for match of %s", uid, item.dtstart, recurrence_id
 90    )
 91    dtstart = RecurrenceId.to_value(recurrence_id)
 92    if isinstance(dtstart, datetime.datetime) and isinstance(
 93        item.dtstart, datetime.datetime
 94    ):
 95        if dtstart.tzinfo is not None:
 96            # The recurrence_id carries an explicit TZID: the datetime is already
 97            # tz-aware, so no further adjustment is needed for comparison.
 98            pass
 99        elif item.dtstart.tzinfo is not None:
100            # No explicit TZID was supplied; fall back to the item's own timezone
101            # so the wall-clock time comparison is correct.
102            dtstart = dtstart.replace(tzinfo=item.dtstart.tzinfo)
103    for dt in cast(Any, item).as_rrule() or ():
104        if isinstance(dt, datetime.datetime):
105            if dt.date() > _MAX_SCAN_DATE:
106                _LOGGER.debug("Aborting scan, date %s is beyond max scan date", dt)
107                break
108        elif dt > _MAX_SCAN_DATE:
109            _LOGGER.debug("Aborting scan, date %s is beyond max scan date", dt)
110            break
111        if dt == dtstart:
112            _LOGGER.debug("Found expanded recurrence_id: %s", dt)
113            return True
114    return False
115
116
117def _match_items(
118    items: list[_T], uid: str, recurrence_id: str | None
119) -> Generator[tuple[int, _T], None, None]:
120    """Return items from the list that match the uid and recurrence_id."""
121    for index, item in enumerate(items):
122        if _match_item(item, uid, recurrence_id):
123            yield index, item
124
125
126def _prepare_update(
127    store_item: Event | Todo,
128    item: Event | Todo,
129    recurrence_id: str | None = None,
130    recurrence_range: Range = Range.NONE,
131) -> dict[str, Any]:
132    """Prepare an update to an existing event or todo."""
133    partial_update = item.model_dump(
134        exclude_unset=True,
135        exclude={"dtstamp", "uid", "sequence", "created", "last_modified"},
136    )
137    _LOGGER.debug("Preparing update update=%s", item)
138    update = {
139        "created": store_item.dtstamp,
140        "sequence": (store_item.sequence + 1) if store_item.sequence else 1,
141        "last_modified": item.dtstamp,
142        **partial_update,
143        "dtstamp": item.dtstamp,
144    }
145    if (
146        isinstance(item, Todo)
147        and isinstance(store_item, Todo)
148        and item.status
149        and not item.completed
150    ):
151        if (
152            store_item.status != TodoStatus.COMPLETED
153            and item.status == TodoStatus.COMPLETED
154        ):
155            update["completed"] = item.dtstamp
156        if store_item.completed and item.status != TodoStatus.COMPLETED:
157            update["completed"] = None
158    if rrule := update.get("rrule"):
159        update["rrule"] = Recur.model_validate(rrule)
160    if recurrence_id and store_item.rrule:
161        # Forking a new event off the old event preserves the original uid and
162        # recurrence_id.
163        update.update(
164            {
165                "uid": store_item.uid,
166                "recurrence_id": recurrence_id,
167            }
168        )
169        if recurrence_range == Range.NONE:
170            # The new event copied from the original is a single instance,
171            # which is not recurring.
172            update["rrule"] = None
173        else:
174            # Overwriting with a new recurring event
175            update["created"] = item.dtstamp
176
177            # Adjust start and end time of the event
178            dtstart: datetime.datetime | datetime.date = RecurrenceId.to_value(
179                recurrence_id
180            )
181            if item.dtstart:
182                dtstart = item.dtstart
183            update["dtstart"] = dtstart
184            # Event either has a duration (which should already be set) or has
185            # an explicit end which needs to be realigned to new start time.
186            if isinstance(store_item, Event) and store_item.dtend:
187                update["dtend"] = dtstart + store_item.computed_duration
188    return update
189
190
191def _prune_invalid_exdates(
192    exdate: list[datetime.date | datetime.datetime],
193    rrule: Recur | None,
194    rdate: list[datetime.date | datetime.datetime | Period],
195    dtstart: datetime.date | datetime.datetime | None,
196) -> list[datetime.date | datetime.datetime]:
197    """Remove EXDATE values that are no longer valid occurrences of the recurrence rule."""
198    if not dtstart or not (occurrences_iter := as_rrule(rrule, rdate, [], dtstart)):
199        return exdate
200
201    tzinfo = dtstart.tzinfo if isinstance(dtstart, datetime.datetime) else None
202    exdate_normalized = {ex: normalize_datetime(ex, tzinfo=tzinfo) for ex in exdate}
203    max_exdate_normalized = max(exdate_normalized.values())
204    allowed_occurrences = set()
205    for occ in occurrences_iter:
206        occ_normalized = normalize_datetime(occ, tzinfo=tzinfo)
207        if occ_normalized > max_exdate_normalized:
208            break
209        allowed_occurrences.add(occ_normalized)
210
211    return [ex for ex in exdate if exdate_normalized[ex] in allowed_occurrences]
212
213
214class GenericStore(Generic[_T]):
215    """A store manages the lifecycle of items on a Calendar."""
216
217    def __init__(
218        self,
219        items: list[_T],
220        timezones: list[Timezone],
221        exc: type[StoreError],
222        dtstamp_fn: Callable[[], datetime.datetime] = lambda: dtstamp_factory(),
223        tzinfo: datetime.tzinfo | None = None,
224    ):
225        """Initialize the EventStore."""
226        self._items = items
227        self._timezones = timezones
228        self._exc = exc
229        self._dtstamp_fn = dtstamp_fn
230        self._tzinfo = tzinfo or local_timezone()
231
232    def add(self, item: _T) -> _T:
233        """Add the specified item to the calendar.
234
235        This will handle assigning modification dates, sequence numbers, etc
236        if those fields are unset.
237
238        The store will ensure the `ical.calendar.Calendar` has the necessary
239        `ical.timezone.Timezone` needed to fully specify the time information
240        when encoded.
241        """
242        update: dict[str, Any] = {}
243        if not item.created:
244            update["created"] = item.dtstamp
245        if item.sequence is None:
246            update["sequence"] = 0
247        if isinstance(item, Todo) and not item.dtstart:
248            if item.due:
249                update["dtstart"] = item.due - datetime.timedelta(days=1)
250            else:
251                update["dtstart"] = datetime.datetime.now(tz=self._tzinfo)
252        if (
253            isinstance(item, Todo)
254            and not item.completed
255            and item.status == TodoStatus.COMPLETED
256        ):
257            update["completed"] = item.dtstamp
258        new_item = cast(_T, item.copy_and_validate(update=update))
259
260        # The store can only manage cascading deletes for some relationship types
261        for relation in new_item.related_to or ():
262            if relation.reltype != RelationshipType.PARENT:
263                raise self._exc(f"Unsupported relationship type {relation.reltype}")
264
265        _LOGGER.debug("Adding item: %s", new_item)
266        self._ensure_timezone(item.dtstart)
267        if isinstance(item, Event) and item.dtend:
268            self._ensure_timezone(item.dtend)
269        self._items.append(new_item)
270        return new_item
271
272    def delete(
273        self,
274        uid: str,
275        recurrence_id: str | None = None,
276        recurrence_range: Range = Range.NONE,
277    ) -> None:
278        """Delete the item from the calendar.
279
280        This method is used to delete an existing item. For a recurring item
281        either the whole item or instances of an item may be deleted. To
282        delete the complete range of a recurring item, the `uid` property
283        for the item must be specified and the `recurrence_id` should not
284        be specified. To delete an individual instance of the item the
285        `recurrence_id` must be specified.
286
287        When deleting individual instances, the range property may specify
288        if deletion of just a specific instance, or a range of instances.
289        """
290        items_to_delete: list[_T] = [
291            item for _, item in _match_items(self._items, uid, recurrence_id)
292        ]
293        if not items_to_delete:
294            raise self._exc(
295                f"No existing item with uid/recurrence_id: {uid}/{recurrence_id}"
296            )
297
298        for store_item in items_to_delete:
299            self._apply_delete(store_item, recurrence_id, recurrence_range)
300
301    def _apply_delete(
302        self,
303        store_item: _T,
304        recurrence_id: str | None = None,
305        recurrence_range: Range = Range.NONE,
306    ) -> None:
307        if (
308            recurrence_id
309            and recurrence_range == Range.THIS_AND_FUTURE
310            and RecurrenceId.to_value(recurrence_id) == store_item.dtstart
311        ):
312            # Editing the first instance and all forward is the same as editing the
313            # entire series so don't bother forking a new event
314            recurrence_id = None
315
316        children = []
317        for event in self._items:
318            for relation in event.related_to or ():
319                if (
320                    relation.reltype == RelationshipType.PARENT
321                    and relation.uid == store_item.uid
322                ):
323                    children.append(event)
324        for child in children:
325            self._items.remove(child)
326
327        # Deleting all instances in the series
328        if not recurrence_id or not store_item.rrule:
329            self._items.remove(store_item)
330            return
331
332        exdate = RecurrenceId.to_value(recurrence_id)
333        if recurrence_range == Range.NONE:
334            # A single recurrence instance is removed. Add an exclusion to
335            # to the event.
336            if (
337                isinstance(exdate, datetime.datetime)
338                and isinstance(store_item.dtstart, datetime.datetime)
339                and exdate.tzinfo is None
340                and store_item.dtstart.tzinfo is not None
341            ):
342                # No explicit TZID was carried by the recurrence_id, so align
343                # the exdate to the item's timezone for a correct comparison.
344                exdate = exdate.replace(tzinfo=store_item.dtstart.tzinfo)
345            store_item.exdate.append(exdate)
346            return
347
348        # Assumes any recurrence deletion is valid, and that overwriting
349        # the "until" value will not produce more instances. UNTIL is
350        # inclusive so it can't include the specified exdate. FREQ=DAILY
351        # is the lowest frequency supported so subtracting one day is
352        # safe and works for both dates and datetimes.
353        store_item.rrule.count = None
354        if (
355            isinstance(exdate, datetime.datetime)
356            and isinstance(store_item.dtstart, datetime.datetime)
357            and store_item.dtstart.tzinfo
358        ):
359            exdate = exdate.astimezone(datetime.timezone.utc)
360        store_item.rrule.until = exdate - datetime.timedelta(days=1)
361        now = self._dtstamp_fn()
362        store_item.dtstamp = now
363        store_item.last_modified = now
364
365    def edit(
366        self,
367        uid: str,
368        item: _T,
369        recurrence_id: str | None = None,
370        recurrence_range: Range = Range.NONE,
371    ) -> None:
372        """Update the item with the specified uid.
373
374        The specified item should be created with minimal fields, just
375        including the fields that should be updated. The default fields such
376        as `uid` and `dtstamp` may be used to set the uid for a new created item
377        when updating a recurring item, or for any modification times.
378
379        For a recurring item, either the whole item or individual instances
380        of the item may be edited. To edit the complete range of a recurring
381        item the `uid` property must be specified and the `recurrence_id` should
382        not be specified. To edit an individual instances of the item the
383        `recurrence_id` must be specified. The `recurrence_range` determines if
384        just that individual instance is updated or all items following as well.
385
386        The store will ensure the `ical.calendar.Calendar` has the necessary
387        `ical.timezone.Timezone` needed to fully specify the item time information
388        when encoded.
389        """
390        items_to_edit: list[tuple[int, _T]] = [
391            (store_idx, store_itm)
392            for store_idx, store_itm in _match_items(self._items, uid, recurrence_id)
393        ]
394        if not items_to_edit:
395            raise self._exc(
396                f"No existing item with uid/recurrence_id: {uid}/{recurrence_id}"
397            )
398
399        for store_index, store_item in items_to_edit:
400            self._apply_edit(
401                store_index, store_item, item, recurrence_id, recurrence_range
402            )
403
404    def _apply_edit(
405        self,
406        store_index: int,
407        store_item: _T,
408        item: _T,
409        recurrence_id: str | None = None,
410        recurrence_range: Range = Range.NONE,
411    ) -> None:
412        if (
413            recurrence_id
414            and recurrence_range == Range.THIS_AND_FUTURE
415            and RecurrenceId.to_value(recurrence_id) == store_item.dtstart
416        ):
417            # Editing the first instance and all forward is the same as editing the
418            # entire series so don't bother forking a new item
419            recurrence_id = None
420
421        update = _prepare_update(store_item, item, recurrence_id, recurrence_range)
422        if recurrence_range == Range.NONE:
423            # Changing the recurrence rule of a single item in the middle of the series
424            # is not allowed. It is allowed to convert a single instance item to recurring.
425            if item.rrule and store_item.rrule:
426                if item.rrule.as_rrule_str() != store_item.rrule.as_rrule_str():
427                    raise self._exc(
428                        f"Can't update single instance with rrule (rrule={item.rrule})"
429                    )
430                item.rrule = None
431
432        # Make a deep copy since deletion may update this objects recurrence rules
433        new_item = cast(_T, store_item.copy_and_validate(update=update))
434        if (
435            recurrence_id
436            and new_item.rrule
437            and new_item.rrule.count
438            and store_item.dtstart
439        ):
440            # The recurring item count needs to skip any items that
441            # come before the start of the new item. Use a RulesetIterable
442            # to handle workarounds for dateutil.rrule limitations.
443            dtstart: datetime.date | datetime.datetime = update["dtstart"]
444            ruleset = RulesetIterable(
445                store_item.dtstart,
446                [new_item.rrule.as_rrule(store_item.dtstart)],
447                [],
448                [],
449            )
450            for dtvalue in ruleset:
451                if dtvalue >= dtstart:
452                    break
453                new_item.rrule.count = new_item.rrule.count - 1
454
455        # The store can only manage cascading deletes for some relationship types
456        for relation in new_item.related_to or ():
457            if relation.reltype != RelationshipType.PARENT:
458                raise self._exc(f"Unsupported relationship type {relation.reltype}")
459
460        self._ensure_timezone(new_item.dtstart)
461        if isinstance(new_item, Event) and new_item.dtend:
462            self._ensure_timezone(new_item.dtend)
463
464        # Clean up EXDATE entries that are no longer valid occurrences of the recurrence rule
465        if new_item.exdate and (new_item.rrule or new_item.rdate):
466            new_item.exdate = _prune_invalid_exdates(
467                new_item.exdate, new_item.rrule, new_item.rdate, new_item.dtstart
468            )
469
470        # Editing a single instance of a recurring item is like deleting that instance
471        # then adding a new instance on the specified date. If recurrence id is not
472        # specified then the entire item is replaced.
473        self.delete(
474            store_item.uid,
475            recurrence_id=recurrence_id,
476            recurrence_range=recurrence_range,
477        )
478        self._items.insert(store_index, new_item)
479
480    def _ensure_timezone(
481        self, dtvalue: datetime.datetime | datetime.date | None
482    ) -> None:
483        if (new_timezone := _ensure_timezone(dtvalue, self._timezones)) is not None:
484            self._timezones.append(new_timezone)
485
486
487class EventStore(GenericStore[Event]):
488    """An event store manages the lifecycle of events on a Calendar.
489
490    An `ical.calendar.Calendar` is a lower level object that can be directly
491    manipulated to add/remove an `ical.event.Event`. That is, it does not
492    handle updating timestamps, incrementing sequence numbers, or managing
493    lifecycle of a recurring event during an update.
494
495
496    Here is an example for setting up an `EventStore`:
497
498    ```python
499    import datetime
500    from ical.calendar import Calendar
501    from ical.event import Event
502    from ical.store import EventStore
503    from ical.types import Recur
504
505    calendar = Calendar()
506    store = EventStore(calendar)
507
508    event = Event(
509        summary="Event summary",
510        start="2022-07-03",
511        end="2022-07-04",
512        rrule=Recur.from_rrule("FREQ=WEEKLY;COUNT=3"),
513    )
514    store.add(event)
515    ```
516
517    This will add events to the calendar:
518    ```python3
519    for event in calendar.timeline:
520        print(event.summary, event.uid, event.recurrence_id, event.dtstart)
521    ```
522    With output like this:
523    ```
524    Event summary a521cf45-2c02-11ed-9e5c-066a07ffbaf5 20220703 2022-07-03
525    Event summary a521cf45-2c02-11ed-9e5c-066a07ffbaf5 20220710 2022-07-10
526    Event summary a521cf45-2c02-11ed-9e5c-066a07ffbaf5 20220717 2022-07-17
527    ```
528
529    You may also delete an event, or a specific instance of a recurring event:
530    ```python
531    # Delete a single instance of the recurring event
532    store.delete(uid=event.uid, recurrence_id="20220710")
533    ```
534
535    Then viewing the store using the `print` example removes the individual
536    instance in the event:
537    ```
538    Event summary a521cf45-2c02-11ed-9e5c-066a07ffbaf5 20220703 2022-07-03
539    Event summary a521cf45-2c02-11ed-9e5c-066a07ffbaf5 20220717 2022-07-17
540    ```
541
542    Editing an event is also supported:
543    ```python
544    store.edit("event-uid-1", Event(summary="New Summary"))
545    ```
546    """
547
548    def __init__(
549        self,
550        calendar: Calendar,
551        dtstamp_fn: Callable[[], datetime.datetime] = lambda: dtstamp_factory(),
552    ):
553        """Initialize the EventStore."""
554        super().__init__(
555            calendar.events,
556            calendar.timezones,
557            EventStoreError,
558            dtstamp_fn,
559            tzinfo=None,
560        )
561
562
563class TodoStore(GenericStore[Todo]):
564    """A To-do store manages the lifecycle of to-dos on a Calendar."""
565
566    def __init__(
567        self,
568        calendar: Calendar,
569        tzinfo: datetime.tzinfo | None = None,
570        dtstamp_fn: Callable[[], datetime.datetime] = lambda: dtstamp_factory(),
571    ):
572        """Initialize the TodoStore."""
573        super().__init__(
574            calendar.todos,
575            calendar.timezones,
576            TodoStoreError,
577            dtstamp_fn,
578            tzinfo=tzinfo,
579        )
580        self._calendar = calendar
581
582    def todo_list(self, dtstart: datetime.datetime | None = None) -> Iterable[Todo]:
583        """Return a list of all todos on the calendar.
584
585        This view accounts for recurring todos.
586        """
587        return todo_list_view(self._calendar.todos, dtstart)
class EventStore(ical.store.GenericStore[ical.event.Event]):
488class EventStore(GenericStore[Event]):
489    """An event store manages the lifecycle of events on a Calendar.
490
491    An `ical.calendar.Calendar` is a lower level object that can be directly
492    manipulated to add/remove an `ical.event.Event`. That is, it does not
493    handle updating timestamps, incrementing sequence numbers, or managing
494    lifecycle of a recurring event during an update.
495
496
497    Here is an example for setting up an `EventStore`:
498
499    ```python
500    import datetime
501    from ical.calendar import Calendar
502    from ical.event import Event
503    from ical.store import EventStore
504    from ical.types import Recur
505
506    calendar = Calendar()
507    store = EventStore(calendar)
508
509    event = Event(
510        summary="Event summary",
511        start="2022-07-03",
512        end="2022-07-04",
513        rrule=Recur.from_rrule("FREQ=WEEKLY;COUNT=3"),
514    )
515    store.add(event)
516    ```
517
518    This will add events to the calendar:
519    ```python3
520    for event in calendar.timeline:
521        print(event.summary, event.uid, event.recurrence_id, event.dtstart)
522    ```
523    With output like this:
524    ```
525    Event summary a521cf45-2c02-11ed-9e5c-066a07ffbaf5 20220703 2022-07-03
526    Event summary a521cf45-2c02-11ed-9e5c-066a07ffbaf5 20220710 2022-07-10
527    Event summary a521cf45-2c02-11ed-9e5c-066a07ffbaf5 20220717 2022-07-17
528    ```
529
530    You may also delete an event, or a specific instance of a recurring event:
531    ```python
532    # Delete a single instance of the recurring event
533    store.delete(uid=event.uid, recurrence_id="20220710")
534    ```
535
536    Then viewing the store using the `print` example removes the individual
537    instance in the event:
538    ```
539    Event summary a521cf45-2c02-11ed-9e5c-066a07ffbaf5 20220703 2022-07-03
540    Event summary a521cf45-2c02-11ed-9e5c-066a07ffbaf5 20220717 2022-07-17
541    ```
542
543    Editing an event is also supported:
544    ```python
545    store.edit("event-uid-1", Event(summary="New Summary"))
546    ```
547    """
548
549    def __init__(
550        self,
551        calendar: Calendar,
552        dtstamp_fn: Callable[[], datetime.datetime] = lambda: dtstamp_factory(),
553    ):
554        """Initialize the EventStore."""
555        super().__init__(
556            calendar.events,
557            calendar.timezones,
558            EventStoreError,
559            dtstamp_fn,
560            tzinfo=None,
561        )

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:

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:

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:

# 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:

store.edit("event-uid-1", Event(summary="New Summary"))
EventStore( calendar: ical.calendar.Calendar, dtstamp_fn: Callable[[], datetime.datetime] = <function EventStore.<lambda>>)
549    def __init__(
550        self,
551        calendar: Calendar,
552        dtstamp_fn: Callable[[], datetime.datetime] = lambda: dtstamp_factory(),
553    ):
554        """Initialize the EventStore."""
555        super().__init__(
556            calendar.events,
557            calendar.timezones,
558            EventStoreError,
559            dtstamp_fn,
560            tzinfo=None,
561        )

Initialize the EventStore.

Inherited Members
GenericStore
add
delete
edit
class EventStoreError(ical.store.StoreError):
85class EventStoreError(StoreError):
86    """Exception raised by EventStore operations.
87
88    Raised by :class:`ical.store.EventStore` when an event operation fails.
89    """

Exception raised by EventStore operations.

Raised by ical.store.EventStore when an event operation fails.

class TodoStore(ical.store.GenericStore[ical.todo.Todo]):
564class TodoStore(GenericStore[Todo]):
565    """A To-do store manages the lifecycle of to-dos on a Calendar."""
566
567    def __init__(
568        self,
569        calendar: Calendar,
570        tzinfo: datetime.tzinfo | None = None,
571        dtstamp_fn: Callable[[], datetime.datetime] = lambda: dtstamp_factory(),
572    ):
573        """Initialize the TodoStore."""
574        super().__init__(
575            calendar.todos,
576            calendar.timezones,
577            TodoStoreError,
578            dtstamp_fn,
579            tzinfo=tzinfo,
580        )
581        self._calendar = calendar
582
583    def todo_list(self, dtstart: datetime.datetime | None = None) -> Iterable[Todo]:
584        """Return a list of all todos on the calendar.
585
586        This view accounts for recurring todos.
587        """
588        return todo_list_view(self._calendar.todos, dtstart)

A To-do store manages the lifecycle of to-dos on a Calendar.

TodoStore( calendar: ical.calendar.Calendar, tzinfo: datetime.tzinfo | None = None, dtstamp_fn: Callable[[], datetime.datetime] = <function TodoStore.<lambda>>)
567    def __init__(
568        self,
569        calendar: Calendar,
570        tzinfo: datetime.tzinfo | None = None,
571        dtstamp_fn: Callable[[], datetime.datetime] = lambda: dtstamp_factory(),
572    ):
573        """Initialize the TodoStore."""
574        super().__init__(
575            calendar.todos,
576            calendar.timezones,
577            TodoStoreError,
578            dtstamp_fn,
579            tzinfo=tzinfo,
580        )
581        self._calendar = calendar

Initialize the TodoStore.

def todo_list( self, dtstart: datetime.datetime | None = None) -> Iterable[ical.todo.Todo]:
583    def todo_list(self, dtstart: datetime.datetime | None = None) -> Iterable[Todo]:
584        """Return a list of all todos on the calendar.
585
586        This view accounts for recurring todos.
587        """
588        return todo_list_view(self._calendar.todos, dtstart)

Return a list of all todos on the calendar.

This view accounts for recurring todos.

Inherited Members
GenericStore
add
delete
edit
class TodoStoreError(ical.store.StoreError):
92class TodoStoreError(StoreError):
93    """Exception raised by TodoStore operations.
94
95    Raised by :class:`ical.store.TodoStore` when a todo operation fails.
96    """

Exception raised by TodoStore operations.

Raised by ical.store.TodoStore when a todo operation fails.

class StoreError(ical.exceptions.CalendarError):
76class StoreError(CalendarError):
77    """Exception raised by store operations.
78
79    Raised when a store operation fails, for example when trying to edit
80    or delete an event that does not exist, or when timezone information
81    cannot be resolved for a datetime value being added to the calendar.
82    """

Exception raised by store operations.

Raised when a store operation fails, for example when trying to edit or delete an event that does not exist, or when timezone information cannot be resolved for a datetime value being added to the calendar.