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

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>>)
464    def __init__(
465        self,
466        calendar: Calendar,
467        dtstamp_fn: Callable[[], datetime.datetime] = lambda: dtstamp_factory(),
468    ):
469        """Initialize the EventStore."""
470        super().__init__(
471            calendar.events,
472            calendar.timezones,
473            EventStoreError,
474            dtstamp_fn,
475            tzinfo=None,
476        )

Initialize the EventStore.

Inherited Members
GenericStore
add
delete
edit
class EventStoreError(ical.store.StoreError):
40class EventStoreError(StoreError):
41    """Exception thrown by the EventStore."""

Exception thrown by the EventStore.

class TodoStore(ical.store.GenericStore[ical.todo.Todo]):
479class TodoStore(GenericStore[Todo]):
480    """A To-do store manages the lifecycle of to-dos on a Calendar."""
481
482    def __init__(
483        self,
484        calendar: Calendar,
485        tzinfo: datetime.tzinfo | None = None,
486        dtstamp_fn: Callable[[], datetime.datetime] = lambda: dtstamp_factory(),
487    ):
488        """Initialize the TodoStore."""
489        super().__init__(
490            calendar.todos,
491            calendar.timezones,
492            TodoStoreError,
493            dtstamp_fn,
494            tzinfo=tzinfo,
495        )
496        self._calendar = calendar
497
498    def todo_list(self, dtstart: datetime.datetime | None = None) -> Iterable[Todo]:
499        """Return a list of all todos on the calendar.
500
501        This view accounts for recurring todos.
502        """
503        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>>)
482    def __init__(
483        self,
484        calendar: Calendar,
485        tzinfo: datetime.tzinfo | None = None,
486        dtstamp_fn: Callable[[], datetime.datetime] = lambda: dtstamp_factory(),
487    ):
488        """Initialize the TodoStore."""
489        super().__init__(
490            calendar.todos,
491            calendar.timezones,
492            TodoStoreError,
493            dtstamp_fn,
494            tzinfo=tzinfo,
495        )
496        self._calendar = calendar

Initialize the TodoStore.

def todo_list( self, dtstart: datetime.datetime | None = None) -> Iterable[ical.todo.Todo]:
498    def todo_list(self, dtstart: datetime.datetime | None = None) -> Iterable[Todo]:
499        """Return a list of all todos on the calendar.
500
501        This view accounts for recurring todos.
502        """
503        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):
44class TodoStoreError(StoreError):
45    """Exception thrown by the TodoStore."""

Exception thrown by the TodoStore.

class StoreError(ical.exceptions.CalendarError):
36class StoreError(CalendarError):
37    """Exception thrown by a Store."""

Exception thrown by a Store.