Skip to content

Commit d6bc235

Browse files
Merge pull request #213 from niccokunzmann/issue-211-pagination
Issue 211 pagination
2 parents d74eade + de11d67 commit d6bc235

File tree

15 files changed

+899
-117
lines changed

15 files changed

+899
-117
lines changed

README.rst

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,55 @@ The result is an iterator that returns the events in order.
282282
for event in recurring_ical_events.of(an_icalendar_object).after(datetime.datetime.now()):
283283
print(event["DTSTART"]) # The start is ordered
284284
285+
Pagination
286+
**********
287+
288+
Pagination allows you to chop the resulting components into chunks of a certain size.
289+
290+
.. code-block: python
291+
292+
# we get a calendar with 10 events and 2 events per page
293+
>>> ten_events = recurring_ical_events.example_calendar("event_10_times")
294+
>>> pages = recurring_ical_events.of(ten_events).paginate(2)
295+
296+
# we can iterate over the pages with 2 events each
297+
>>> for i, last_page in enumerate(pages):
298+
... print(f"page: {i}")
299+
... for event in last_page:
300+
... print(f"-> {event.start}")
301+
... if i == 1: break
302+
page: 0
303+
-> 2020-01-13 07:45:00+01:00
304+
-> 2020-01-14 07:45:00+01:00
305+
page: 1
306+
-> 2020-01-15 07:45:00+01:00
307+
-> 2020-01-16 07:45:00+01:00
308+
309+
If you run a web service and you would like to continue pagination after a certain page,
310+
this can be done, too. Just hand someone the ``next_page_id`` and continue from there on.
311+
312+
.. code-block: python
313+
314+
# resume the same query from the next page
315+
>>> pages = recurring_ical_events.of(ten_events).paginate(2, next_page_id = last_page.next_page_id)
316+
>>> for i, last_page in enumerate(pages):
317+
... print(f"page: {i + 2}")
318+
... for event in last_page:
319+
... print(f"-> {event.start}")
320+
... if i == 1: break
321+
page: 2
322+
-> 2020-01-17 07:45:00+01:00
323+
-> 2020-01-18 07:45:00+01:00
324+
page: 3
325+
-> 2020-01-19 07:45:00+01:00
326+
-> 2020-01-20 07:45:00+01:00
327+
328+
The ``last_page.next_page_id`` is a string so that it can be used easily.
329+
It is tested against malicious modification and can safely be passed from a third party source.
330+
331+
Additionally to the page size, you can also pass a ``start`` and an ``end`` to the pages so that
332+
all components are visible within that time.
333+
285334
Different Components, not just Events
286335
*************************************
287336

@@ -620,9 +669,10 @@ To release new versions,
620669
Changelog
621670
---------
622671

623-
- v3.4.2
672+
- v3.5.0
624673

625674
- Restructure module into package with a file structure.
675+
- Add pagination, see `Issue 211 <https://github.com/niccokunzmann/python-recurring-ical-events/issues/211>`_
626676

627677
- v3.4.1
628678

recurring_ical_events/examples.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Functionality for smaller examples."""
2+
23
import icalendar
34

45
from recurring_ical_events.constants import CALENDARS

recurring_ical_events/occurrence.py

Lines changed: 74 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,85 @@
11
"""Occurrences of events and other components."""
2+
23
from __future__ import annotations
34

4-
from typing import TYPE_CHECKING
5+
from datetime import date, datetime, timedelta
6+
from typing import TYPE_CHECKING, NamedTuple, Optional
57

68
from icalendar import Alarm
79

810
from recurring_ical_events.adapters.component import ComponentAdapter
9-
from recurring_ical_events.types import ComponentID
1011
from recurring_ical_events.util import (
1112
cached_property,
1213
make_comparable,
1314
time_span_contains_event,
1415
)
1516

1617
if TYPE_CHECKING:
17-
import datetime
18-
1918
from icalendar import Alarm
2019
from icalendar.cal import Component
2120

2221
from recurring_ical_events.adapters.component import ComponentAdapter
23-
from recurring_ical_events.types import ComponentID, RecurrenceIDs, Time
22+
from recurring_ical_events.types import UID, RecurrenceIDs, Time
23+
24+
25+
class OccurrenceID(NamedTuple):
26+
"""The ID of a component's occurrence to identify it clearly.
27+
28+
Attributes:
29+
name: The name of the component, e.g. "VEVENT"
30+
uid: The UID of the component.
31+
recurrence_id: The Recurrence-ID of the component in UTC but without tzinfo.
32+
start: The start of the component
33+
"""
34+
35+
name: str
36+
uid: UID
37+
recurrence_id: Optional[Time]
38+
start: Time
39+
40+
def to_string(self) -> str:
41+
"""Return a string representation of this id."""
42+
return "#".join(
43+
[
44+
self.name,
45+
self.recurrence_id.isoformat() if self.recurrence_id else "",
46+
self.start.isoformat(),
47+
self.uid,
48+
]
49+
)
50+
51+
@staticmethod
52+
def _dt_from_string(iso_string: str) -> Time:
53+
"""Create a datetime from the string representation."""
54+
if len(iso_string) == 10:
55+
return date.fromisoformat(iso_string)
56+
return datetime.fromisoformat(iso_string)
57+
58+
@classmethod
59+
def from_string(cls, string_id: str) -> OccurrenceID:
60+
"""Parse a string and return the component id."""
61+
name, recurrence_id, start, uid = string_id.split("#", 3)
62+
return cls(
63+
name,
64+
uid,
65+
cls._dt_from_string(recurrence_id) if recurrence_id else None,
66+
cls._dt_from_string(start),
67+
)
68+
69+
@classmethod
70+
def from_occurrence(
71+
cls, name: str, uid: str, recurrence_ids: RecurrenceIDs, start: Time
72+
):
73+
"""Create a new OccurrenceID from the given values.
74+
75+
Args:
76+
name: The component name.
77+
uid: The UID string.
78+
recurrence_ids: The recurrence ID tuple.
79+
This is expected as UTC with tzinfo being None.
80+
start: start time of the component either with or without timezone
81+
"""
82+
return cls(name, uid, recurrence_ids[0] if recurrence_ids else None, start)
2483

2584

2685
class Occurrence:
@@ -30,7 +89,7 @@ def __init__(
3089
self,
3190
adapter: ComponentAdapter,
3291
start: Time | None = None,
33-
end: Time | None | datetime.timedelta = None,
92+
end: Time | None | timedelta = None,
3493
):
3594
"""Create an event repetition.
3695
@@ -61,13 +120,13 @@ def __lt__(self, other: Occurrence) -> bool:
61120
return self_start < other_start
62121

63122
@cached_property
64-
def id(self) -> ComponentID:
123+
def id(self) -> OccurrenceID:
65124
"""The id of the component."""
66-
recurrence_id = (*self._adapter.recurrence_ids, self.start)[0]
67-
return (
125+
return OccurrenceID.from_occurrence(
68126
self._adapter.component_name(),
69127
self._adapter.uid,
70-
recurrence_id,
128+
self._adapter.recurrence_ids,
129+
self.start,
71130
)
72131

73132
def __hash__(self) -> int:
@@ -102,7 +161,7 @@ class AlarmOccurrence(Occurrence):
102161

103162
def __init__(
104163
self,
105-
trigger: datetime.datetime,
164+
trigger: datetime,
106165
alarm: Alarm,
107166
parent: ComponentAdapter | Occurrence,
108167
) -> None:
@@ -122,12 +181,12 @@ def as_component(self, keep_recurrence_attributes):
122181
return parent
123182

124183
@cached_property
125-
def id(self) -> ComponentID:
184+
def id(self) -> OccurrenceID:
126185
"""The id of the component."""
127-
return (
186+
return OccurrenceID.from_occurrence(
128187
self.parent.component_name(),
129188
self.parent.uid,
130-
*self.parent.recurrence_ids[:1],
189+
self.parent.recurrence_ids,
131190
self.start,
132191
)
133192

@@ -142,4 +201,5 @@ def __repr__(self) -> str:
142201
__all__ = [
143202
"Occurrence",
144203
"AlarmOccurrence",
204+
"OccurrenceID",
145205
]

recurring_ical_events/pages.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Pagination for recurring ical events.
2+
3+
See https://github.com/niccokunzmann/python-recurring-ical-events/issues/211
4+
"""
5+
6+
from __future__ import annotations
7+
8+
from typing import TYPE_CHECKING, Iterator, Optional
9+
10+
from recurring_ical_events.util import compare_greater
11+
12+
if TYPE_CHECKING:
13+
from icalendar import Component
14+
15+
from recurring_ical_events.occurrence import Occurrence
16+
from recurring_ical_events.types import Time
17+
18+
19+
class Page:
20+
"""One page in a series of pages."""
21+
22+
def __init__(self, components: list[Component], next_page_id: str = ""):
23+
""" "Create a new page."""
24+
self._components = components
25+
self._next_page_id = next_page_id
26+
27+
@property
28+
def components(self) -> list[Component]:
29+
"""All the components of one page."""
30+
return self._components
31+
32+
def has_next_page(self) -> bool:
33+
"""Wether there is a page following this one."""
34+
return self.next_page_id != ""
35+
36+
@property
37+
def next_page_id(self) -> str:
38+
"""Return the id of the next page or ''."""
39+
return self._next_page_id
40+
41+
def __len__(self) -> int:
42+
"""The number of components."""
43+
return len(self.components)
44+
45+
def is_last(self):
46+
"""Wether this is the last page and there is no other page following."""
47+
return self._next_page_id == ""
48+
49+
def __iter__(self) -> Iterator[Component]:
50+
"""Return an iterator over the components."""
51+
return iter(self.components)
52+
53+
54+
class Pages:
55+
"""A pagination configuration to iterate over pages."""
56+
57+
def __init__(
58+
self,
59+
occurrence_iterator: Iterator[Occurrence],
60+
size: int,
61+
stop: Optional[Time] = None,
62+
keep_recurrence_attributes: bool = False, # noqa: FBT001
63+
):
64+
"""Create a new paginated iterator over components."""
65+
self._iterator = occurrence_iterator
66+
self._stop = stop
67+
self._size = size
68+
if self._size <= 0:
69+
raise ValueError(
70+
f"A page must have at least one component, not {self._size}."
71+
)
72+
self._keep_recurrence_attributes = keep_recurrence_attributes
73+
self._next_occurrence: Optional[Occurrence] = None
74+
for occurrence in self._iterator:
75+
if self._stop is None or compare_greater(self._stop, occurrence.start):
76+
self._next_occurrence = occurrence
77+
break
78+
79+
@property
80+
def size(self) -> int:
81+
"""The maximum number of components per page."""
82+
return self._size
83+
84+
def generate_next_page(self) -> Page:
85+
"""Generate the next page.
86+
87+
In contrast to next(self), this does not raise StopIteration.
88+
But it works the same: the next page is generated and returned.
89+
"""
90+
for page in self:
91+
return page
92+
return Page([])
93+
94+
def __next__(self) -> Page:
95+
"""Return the next page."""
96+
if self._next_occurrence is None:
97+
raise StopIteration
98+
last_occurrence = self._next_occurrence
99+
occurrences = [last_occurrence]
100+
for occurrence in self._iterator:
101+
if self._stop is not None and compare_greater(occurrence.start, self._stop):
102+
break
103+
last_occurrence = occurrence
104+
if len(occurrences) < self._size:
105+
occurrences.append(occurrence)
106+
else:
107+
break
108+
if occurrences[-1] == last_occurrence:
109+
self._next_occurrence = None
110+
else:
111+
self._next_occurrence = last_occurrence
112+
return self._create_page_from_occurrences(occurrences)
113+
114+
def _create_page_from_occurrences(self, occurrences: list[Occurrence]) -> Page:
115+
"""Create a new page from the occurrences listed."""
116+
return Page(
117+
[
118+
occurrence.as_component(self._keep_recurrence_attributes)
119+
for occurrence in occurrences
120+
],
121+
next_page_id=self._next_occurrence.id.to_string()
122+
if self._next_occurrence is not None
123+
else "",
124+
)
125+
126+
def __iter__(self) -> Pages:
127+
"""Return the iterator."""
128+
return self
129+
130+
131+
__all__ = ["Page", "Pages"]

0 commit comments

Comments
 (0)