diff --git a/README.rst b/README.rst index 52a125e..fa5db4a 100644 --- a/README.rst +++ b/README.rst @@ -305,9 +305,7 @@ Here is a template code for choosing the supported types of components: If a type of component is not listed here, it can be added. Please create an issue for this in the source code repository. -You can create subclasses and customizations. - - +For further customization, please refer to the section on how to extend the default functionality. Speed ***** @@ -332,19 +330,153 @@ Passing ``skip_bad_series=True`` as ``of()`` argument will totally skip theses e .. code:: Python + # Create a calendar that contains broken events. >>> calendar_file = CALENDARS / "bad_rrule_missing_until_event.ics" >>> calendar_with_bad_event = icalendar.Calendar.from_ical(calendar_file.read_bytes()) - # default: error + # By default, broken events result in errors. >>> recurring_ical_events.of(calendar_with_bad_event, skip_bad_series=False).count() Traceback (most recent call last): ... recurring_ical_events.BadRuleStringFormat: UNTIL parameter is missing: FREQ=WEEKLY;BYDAY=TH;WKST=SU;UNTL=20191023 - # skip the bad events + # With skip_bad_series=True we skip the series that we cannot handle. >>> recurring_ical_events.of(calendar_with_bad_event, skip_bad_series=True).count() 0 +Architecture +------------ + +.. image:: img/architecture.png + :alt: Architecture Diagram showing the components interacting + +Each icalendar **Calendar** can contain Events, Journal entries, +TODOs and others, called **Components**. +Those entries are grouped by their ``UID``. +Such a ``UID`` defines a **Series** of **Occurrences** that take place at +a given time. +Since each **Component** is different, the **ComponentAdapter** offers a unified +interface to interact with them. +The **Calendar** gets filtered and for each ``UID``, +a **Series** can use one or more **ComponentAdapters** to create +**Occurrences** of what happens in a time span. +These **Occurrences** are used internally and convert to **Components** for further use. + +Extending ``recurring-ical-events`` +*********************************** + +All the functionality of ``recurring-ical-events`` can be extended and modified. +To understand where to extend, have a look at the `Architecture`_. + +The first place for extending is the collection of components. +Components are collected into a ``Series``. +A series belongs together because all components have the same ``UID``. +In this example, we collect one VEVENT which matches a certain UID: + +.. code:: Python + + >>> from recurring_ical_events import SelectComponents, EventAdapter, Series + >>> from icalendar.cal import Component + >>> from typing import Sequence + + # create the calendar + >>> calendar_file = CALENDARS / "machbar_16_feb_2019.ics" + >>> machbar_calendar = icalendar.Calendar.from_ical(calendar_file.read_bytes()) + + # Create a collector of components that searches for an event with a specific UID + >>> class CollectOneUIDEvent(SelectComponents): + ... def __init__(self, uid:str) -> None: + ... self.uid = uid + ... def collect_series_from(self, source: Component, suppress_errors: tuple) -> Sequence[Series]: + ... components : list[Component] = [] + ... for component in source.walk("VEVENT"): + ... if component.get("UID") == self.uid: + ... components.append(EventAdapter(component)) + ... return [Series(components)] if components else [] + + # collect only one UID: 4mm2ak3in2j3pllqdk1ubtbp9p@google.com + >>> one_uid = CollectOneUIDEvent("4mm2ak3in2j3pllqdk1ubtbp9p@google.com") + >>> uid_query = recurring_ical_events.of(machbar_calendar, components=[one_uid]) + >>> uid_query.count() # the event has no recurrence and thus there is only one + 1 + +Several ways of extending the functionality have been created to override internals. +These can be subclassed or composed. + +Below, you can choose to collect all components. Subclasses can be created for the +``Series`` and the ``Occurrence``. + +.. code:: Python + + >>> from recurring_ical_events import AllKnownComponents, Series, Occurrence + + # we create a calendar with one event + >>> calendar_file = CALENDARS / "one_event.ics" + >>> one_event = icalendar.Calendar.from_ical(calendar_file.read_bytes()) + + # You can override the Occurrence and Series classes for all computable components + >>> select_all_known = AllKnownComponents(series=Series, occurrence=Occurrence) + >>> select_all_known.names # these are the supported types of components + ['VEVENT', 'VTODO', 'VJOURNAL'] + >>> query_all_known = recurring_ical_events.of(one_event, components=[select_all_known]) + + # There should be exactly one event. + >>> query_all_known.count() + 1 + +This example shows that the behavior for specific types of components can be extended. +Additional to the series, you can change the ``ComponentAdapter`` that provides +a unified interface for all the components with the same name (``VEVENT`` for example). + +.. code:: Python + + >>> from recurring_ical_events import ComponentsWithName, EventAdapter, JournalAdapter, TodoAdapter + + # You can also choose to select only specific subcomponents by their name. + # The default arguments are added to show the extensibility. + >>> select_events = ComponentsWithName("VEVENT", adapter=EventAdapter, series=Series, occurrence=Occurrence) + >>> select_todos = ComponentsWithName("VTODO", adapter=TodoAdapter, series=Series, occurrence=Occurrence) + >>> select_journals = ComponentsWithName("VJOURNAL", adapter=JournalAdapter, series=Series, occurrence=Occurrence) + + # There should be one event happening and nothing else + >>> recurring_ical_events.of(one_event, components=[select_events]).count() + 1 + >>> recurring_ical_events.of(one_event, components=[select_todos]).count() + 0 + >>> recurring_ical_events.of(one_event, components=[select_journals]).count() + 0 + +So, if you would like to modify all events that are returned by the query, +you can do that subclassing the ``Occurrence`` class. + + +.. code:: Python + + # This occurence changes adds a new attribute to the resulting events + >>> class MyOccurrence(Occurrence): + ... """An occurrence that modifies the component.""" + ... def as_component(self, keep_recurrence_attributes: bool) -> Component: + ... """Return a shallow copy of the source component and modify some attributes.""" + ... component = super().as_component(keep_recurrence_attributes) + ... component["X-MY-ATTRIBUTE"] = "my occurrence" + ... return component + >>> query = recurring_ical_events.of(one_event, components=[ComponentsWithName("VEVENT", occurrence=MyOccurrence)]) + >>> event = next(query.all()) + >>> event["X-MY-ATTRIBUTE"] + 'my occurrence' + +This library allows extension of functionality during the selection of components to calculate using these classes: + +* ``ComponentsWithName`` - for components of a certain name +* ``AllKnownComponents`` - for all components known +* ``SelectComponents`` - the interface to provide + +You can further customize behaviour by subclassing these: + +* ``ComponentAdapter`` such as ``EventAdapter``, ``JournalAdapter`` or ``TodoAdapter``. +* ``Series`` +* ``Occurrence`` +* ``CalendarQuery`` Version Fixing ************** @@ -429,23 +561,6 @@ To release new versions, 6. notify the issues about their release -Architecture ------------- - -.. image:: img/architecture.png - :alt: Architecture Diagram showing the components interacting - -Each icalendar **Calendar** can contain Events, Journal entries, -TODOs and others, called **Components**. -Those entries are grouped by their ``UID``. -Such a ``UID`` defines a **Series** of **Occurrences** that take place at -a given time. -Since each **Component** is different, the **ComponentAdapter** offers a unified -interface to interact with them. -The **Calendar** gets filtered and for each ``UID``, -a **Series** can use one or more **ComponentAdapters** to create -**Occurrences** of what happens in a time span. -These **Occurrences** are used internally and convert to **Components** for further use. Changelog --------- diff --git a/recurring_ical_events.py b/recurring_ical_events.py index a6588aa..648215b 100644 --- a/recurring_ical_events.py +++ b/recurring_ical_events.py @@ -17,6 +17,7 @@ import contextlib import datetime import re +import sys from abc import ABC, abstractmethod from collections import defaultdict from functools import wraps @@ -24,6 +25,7 @@ import x_wr_timezone from dateutil.rrule import rruleset, rrulestr +from icalendar.cal import Component from icalendar.prop import vDDDTypes if TYPE_CHECKING: @@ -305,6 +307,11 @@ def get_any(dictionary: dict, keys: Sequence[object], default: object = None): class Series: """Base class for components that result in a series of occurrences.""" + @property + def occurrence(self) -> type[Occurrence]: + """A way to override the occurrence class.""" + return Occurrence + class NoRecurrence: """A strategy to deal with not having a core with rrules.""" @@ -554,6 +561,7 @@ def __init__(self, components: Sequence[ComponentAdapter]): self.recurrence_id_to_modification: dict[ RecurrenceID, ComponentAdapter ] = {} # RECURRENCE-ID -> adapter + self._uid = components[0].uid core: ComponentAdapter | None = None for component in components: if component.is_modification(): @@ -575,7 +583,10 @@ def __init__(self, components: Sequence[ComponentAdapter]): ) def between(self, span_start: Time, span_stop: Time) -> Generator[Occurrence]: - """components between the start (inclusive) and end (exclusive)""" + """Components between the start (inclusive) and end (exclusive). + + The result does not need to be ordered. + """ returned_starts: set[Time] = set() returned_modifications: set[ComponentAdapter] = set() # NOTE: If in the following line, we get an error, datetime and date @@ -597,14 +608,14 @@ def between(self, span_start: Time, span_stop: Time) -> Generator[Occurrence]: recurrence_ids, normalize_pytz(start + self.recurrence.core.duration), ) - occurrence = self.recurrence.as_occurrence(start, stop, Occurrence) + occurrence = self.recurrence.as_occurrence(start, stop, self.occurrence) returned_starts.add(start) else: # We found a modification if adapter in returned_modifications: continue returned_modifications.add(adapter) - occurrence = Occurrence(adapter) + occurrence = self.occurrence(adapter) if occurrence.is_in_span(span_start, span_stop): yield occurrence for modification in self.modifications: @@ -617,12 +628,12 @@ def between(self, span_start: Time, span_stop: Time) -> Generator[Occurrence]: continue if modification.is_in_span(span_start, span_stop): returned_modifications.add(modification) - yield Occurrence(modification) + yield self.occurrence(modification) @property def uid(self): """The UID that identifies this series.""" - return getattr(self.recurrence, "uid", "invalid") + return self._uid def __repr__(self): """A string representation.""" @@ -671,23 +682,16 @@ def uid(self) -> UID: return self._component.get("UID", str(id(self._component))) @classmethod - def collect_components( + def collect_series_from( cls, source: Component, suppress_errors: tuple[Exception] ) -> Sequence[Series]: - """Collect all components from the source component. + """Collect all components for this adapter. - suppress_errors - a list of errors that should be suppressed. - A Series of events with such an error is removed from all results. + This is a shortcut. """ - components: dict[str, list[Component]] = defaultdict(list) # UID -> components - for component in source.walk(cls.component_name()): - adapter = cls(component) - components[adapter.uid].append(adapter) - result = [] - for components in components.values(): - with contextlib.suppress(suppress_errors): - result.append(Series(components)) - return result + return ComponentsWithName(cls.component_name(), cls).collect_series_from( + source, suppress_errors + ) def as_component(self, start: Time, stop: Time, keep_recurrence_attributes: bool): # noqa: FBT001 """Create a shallow copy of the source event and modify some attributes.""" @@ -958,18 +962,27 @@ def __eq__(self, other: Occurrence) -> bool: return self.id == other.id -class CalendarQuery: - """A calendar that can unfold its events at a certain time. +class SelectComponents(ABC): + """Abstract class to select components from a calendar.""" - Functions like at(), between() and after() can be used to query the - selected components. If any malformed icalendar information is found, - an InvalidCalendar exception is raised. For other bad arguments, you - should expect a ValueError. + @abstractmethod + def collect_series_from( + self, source: Component, suppress_errors: tuple[Exception] + ) -> Sequence[Series]: + """Collect all components from the source grouped together into a series. - suppressed_errors - a list of errors to suppress when - skip_bad_series is True + suppress_errors - a list of errors that should be suppressed. + A Series of events with such an error is removed from all results. + """ - component_adapters - a list of component adapters + +class ComponentsWithName(SelectComponents): + """This is a component collecttion strategy. + + Components can be collected in different ways. + This class allows extension of the functionality by + - subclassing to filter the resulting components + - composition to combine collection behavior (see AllKnownComponents) """ component_adapters = [EventAdapter, TodoAdapter, JournalAdapter] @@ -981,13 +994,128 @@ def _component_adapters(self) -> dict[str : type[ComponentAdapter]]: adapter.component_name(): adapter for adapter in self.component_adapters } + def __init__( + self, + name: str, + adapter: type[ComponentAdapter] | None = None, + series: type[Series] = Series, + occurrence: type[Occurrence] = Occurrence, + ) -> None: + """Create a new way of collecting components. + + name - the name of the component to collect ("VEVENT", "VTODO", "VJOURNAL") + adapter - the adapter to use for these components with that name + series - the series class that hold a series of components + occurrence - the occurrence class that creates the resulting components + """ + if adapter is None: + if name not in self._component_adapters: + raise ValueError( + f'"{name}" is an unknown name for a ' + 'recurring component. ' + f"I only know these: { ', '.join(self._component_adapters)}." + ) + adapter = self._component_adapters[name] + if occurrence is not Occurrence: + _occurrence = occurrence + + class series(series): # noqa: N801 + occurrence = _occurrence + + self._name = name + self._series = series + self._adapter = adapter + + def collect_series_from( + self, source: Component, suppress_errors: tuple[Exception] + ) -> Sequence[Series]: + """Collect all components from the source component. + + suppress_errors - a list of errors that should be suppressed. + A Series of events with such an error is removed from all results. + """ + components: dict[str, list[Component]] = defaultdict(list) # UID -> components + for component in source.walk(self._name): + adapter = self._adapter(component) + components[adapter.uid].append(adapter) + result = [] + for components in components.values(): + with contextlib.suppress(suppress_errors): + result.append(self._series(components)) + return result + + +class AllKnownComponents(SelectComponents): + """Group all known components into series.""" + + @property + def _component_adapters(self) -> Sequence[ComponentAdapter]: + """Return all known component adapters.""" + return ComponentsWithName.component_adapters + + @property + def names(self) -> list[str]: + """Return the names of the components to collect.""" + return [adapter.component_name() for adapter in self._component_adapters] + + def __init__( + self, + series: type[Series] = Series, + occurrence: type[Occurrence] = Occurrence, + collector: type[ComponentsWithName] = ComponentsWithName, + ) -> None: + """Collect all known components and overide the series and occurrence. + + series - the Series class to override that is queried for Occurrences + occurrence - the occurrence class that creates the resulting components + collector - if you want to override the SelectComponentsByName class + """ + self._series = series + self._occurrence = occurrence + self._collector = collector + + def collect_series_from( + self, source: Component, suppress_errors: tuple[Exception] + ) -> Sequence[Series]: + """Collect the components from the source groups into a series.""" + result = [] + for name in self.names: + collector = self._collector( + name, series=self._series, occurrence=self._occurrence + ) + result.extend(collector.collect_series_from(source, suppress_errors)) + return result + + +if sys.version_info >= (3, 10): + T_COMPONENTS = Sequence[str | type[ComponentAdapter] | SelectComponents] +else: + # see https://github.com/python/cpython/issues/86399#issuecomment-1093889925 + T_COMPONENTS = Sequence[str] + + +class CalendarQuery: + """A calendar that can unfold its events at a certain time. + + Functions like at(), between() and after() can be used to query the + selected components. If any malformed icalendar information is found, + an InvalidCalendar exception is raised. For other bad arguments, you + should expect a ValueError. + + suppressed_errors - a list of errors to suppress when + skip_bad_series is True + + component_adapters - a list of component adapters + """ + suppressed_errors = [BadRuleStringFormat, PeriodEndBeforeStart] + ComponentsWithName = ComponentsWithName def __init__( self, calendar: Component, keep_recurrence_attributes: bool = False, # noqa: FBT001 - components: Sequence[str | type[ComponentAdapter]] = ("VEVENT",), + components: T_COMPONENTS = ("VEVENT",), skip_bad_series: bool = False, # noqa: FBT001 ): """Create an unfoldable calendar from a given calendar. @@ -1008,17 +1136,11 @@ def __init__( self._skip_errors = tuple(self.suppressed_errors) if skip_bad_series else () for component_adapter_id in components: if isinstance(component_adapter_id, str): - if component_adapter_id not in self._component_adapters: - raise ValueError( - f'"{component_adapter_id}" is an unknown name for a ' - 'recurring component. ' - f"I only know these: { ', '.join(self._component_adapters)}." - ) - component_adapter = self._component_adapters[component_adapter_id] + component_adapter = self.ComponentsWithName(component_adapter_id) else: component_adapter = component_adapter_id self.series.extend( - component_adapter.collect_components(calendar, self._skip_errors) + component_adapter.collect_series_from(calendar, self._skip_errors) ) @staticmethod @@ -1158,8 +1280,9 @@ def count(self) -> int: def of( a_calendar: Component, keep_recurrence_attributes=False, - components: Sequence[str | type[ComponentAdapter]] = ("VEVENT",), + components: T_COMPONENTS = ("VEVENT",), skip_bad_series: bool = False, # noqa: FBT001 + calendar_query: type[CalendarQuery] = CalendarQuery, ) -> CalendarQuery: """Unfold recurring events of a_calendar @@ -1167,10 +1290,13 @@ def of( - keep_recurrence_attributes - whether to keep attributes that are only used to calculate the recurrence. - components is a list of component type names of which the recurrences - should be returned. + should be returned. This can also be instances of SelectComponents. + - skip_bad_series - whether to skip a series of components that contains + errors. + - calendar_query - The calendar query class to use. """ a_calendar = x_wr_timezone.to_standard(a_calendar) - return CalendarQuery( + return calendar_query( a_calendar, keep_recurrence_attributes, components, skip_bad_series ) @@ -1191,4 +1317,7 @@ def of( "Time", "RecurrenceID", "RecurrenceIDs", + "SelectComponents", + "ComponentsWithName", + "T_COMPONENTS", ] diff --git a/test/test_extend_classes.py b/test/test_extend_classes.py new file mode 100644 index 0000000..2a49ed6 --- /dev/null +++ b/test/test_extend_classes.py @@ -0,0 +1,128 @@ +"""This tests extneding and modifying the behaviour of recurring ical events.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Sequence + +import pytest +from icalendar.cal import Component + +from recurring_ical_events import ( + AllKnownComponents, + EventAdapter, + JournalAdapter, + Occurrence, + SelectComponents, + Series, + TodoAdapter, + of, + ComponentsWithName +) + +if TYPE_CHECKING: + from icalendar.cal import Component + + +class SelectUID1(SelectComponents): + """Collect only one UID.""" + + def __init__(self, uid: str) -> None: + self.uid = uid + + def collect_series_from( + self, source: Component, suppress_errors: tuple[Exception] + ) -> Sequence[Series]: + return [ + series + for adapter in [EventAdapter, JournalAdapter, TodoAdapter] + for series in adapter.collect_series_from(source, suppress_errors) + if series.uid == self.uid + ] + + +class SelectUID2(AllKnownComponents): + def __init__(self, uid: str) -> None: + super().__init__() + self.uid = uid + + def collect_series_from( + self, source: Component, suppress_errors: tuple[Exception] + ) -> Sequence[Series]: + return [ + series + for series in super().collect_series_from(source, suppress_errors) + if series.uid == self.uid + ] + + +class SelectUID3(SelectComponents): + def __init__(self, uid: str) -> None: + self.uid = uid + + def collect_series_from( + self, + source: Component, + suppress_errors: tuple[Exception], # noqa: ARG002 + ) -> Sequence[Series]: + components: list[Component] = [] + for component in source.walk("VEVENT"): + if component.get("UID") == self.uid: + components.append(EventAdapter(component)) # noqa: PERF401 + print(components) + return [Series(components)] if components else [] + + +@pytest.mark.parametrize("collector", [SelectUID1, SelectUID2, SelectUID3]) +def test_collect_only_one_uid(calendars, collector): + """Test that only one UID is used.""" + uid = "4mm2ak3in2j3pllqdk1ubtbp9p@google.com" + query = of(calendars.raw.machbar_16_feb_2019, components=[collector(uid)]) + assert query.count() == 1 + + +class MyOccurrence(Occurrence): + """An occurrence that modifies the component.""" + + def as_component(self, keep_recurrence_attributes: bool) -> Component: # noqa: FBT001 + """Return a shallow copy of the source component and modify some attributes.""" + component = super().as_component(keep_recurrence_attributes) + component["X-MY-ATTRIBUTE"] = "my occurrence" + return component + + +def test_added_attributes(calendars): + """Test that attributes are added.""" + query = of( + calendars.raw.one_event, + components=[AllKnownComponents(occurrence=MyOccurrence)], + ) + event = next(query.all()) + assert event["X-MY-ATTRIBUTE"] == "my occurrence" + + +@pytest.mark.parametrize( + ("calendar", "count", "collector"), + [ + # all + ("one_event", 1, AllKnownComponents()), + ("issue_97_simple_todo", 1, AllKnownComponents()), + ("issue_97_simple_journal", 1, AllKnownComponents()), + # events + ("one_event", 1, ComponentsWithName("VEVENT")), + ("issue_97_simple_todo", 0, ComponentsWithName("VEVENT")), + ("issue_97_simple_journal", 0, ComponentsWithName("VEVENT")), + # todos + ("one_event", 0, ComponentsWithName("VTODO")), + ("issue_97_simple_todo", 1, ComponentsWithName("VTODO")), + ("issue_97_simple_journal", 0, ComponentsWithName("VTODO")), + # journals + ("one_event", 0, ComponentsWithName("VJOURNAL")), + ("issue_97_simple_todo", 0, ComponentsWithName("VJOURNAL")), + ("issue_97_simple_journal", 1, ComponentsWithName("VJOURNAL")), + ] +) +def test_we_collect_all_components(calendars, calendar, count, collector:SelectComponents): + """Check that the calendars have the right amount of series collected.""" + series = collector.collect_series_from(calendars.raw[calendar], []) + print(series) + assert len(series) == count diff --git a/test/test_with_doctest.py b/test/test_with_doctest.py index 86b8d17..290788b 100644 --- a/test/test_with_doctest.py +++ b/test/test_with_doctest.py @@ -55,6 +55,6 @@ def test_documentation_file(document, env_for_doctest): assert test_result.failed == 0, f"{test_result.failed} errors in {document.name}" -def test_can_import_zoneinfo(env_for_doctest): +def test_can_import_zoneinfo(env_for_doctest): # noqa: ARG001 """Allow importing zoneinfo for tests.""" assert "zoneinfo" in sys.modules