1import collections.abc
2import inspect
3import warnings
4from math import ceil
5
6from plain.utils.functional import cached_property
7from plain.utils.inspect import method_has_no_args
8
9
10class UnorderedObjectListWarning(RuntimeWarning):
11 pass
12
13
14class InvalidPage(Exception):
15 pass
16
17
18class PageNotAnInteger(InvalidPage):
19 pass
20
21
22class EmptyPage(InvalidPage):
23 pass
24
25
26class Paginator:
27 def __init__(self, object_list, per_page, orphans=0, allow_empty_first_page=True):
28 self.object_list = object_list
29 self._check_object_list_is_ordered()
30 self.per_page = int(per_page)
31 self.orphans = int(orphans)
32 self.allow_empty_first_page = allow_empty_first_page
33
34 def __iter__(self):
35 for page_number in self.page_range:
36 yield self.page(page_number)
37
38 def validate_number(self, number):
39 """Validate the given 1-based page number."""
40 try:
41 if isinstance(number, float) and not number.is_integer():
42 raise ValueError
43 number = int(number)
44 except (TypeError, ValueError):
45 raise PageNotAnInteger("That page number is not an integer")
46 if number < 1:
47 raise EmptyPage("That page number is less than 1")
48 if number > self.num_pages:
49 raise EmptyPage("That page contains no results")
50 return number
51
52 def get_page(self, number):
53 """
54 Return a valid page, even if the page argument isn't a number or isn't
55 in range.
56 """
57 try:
58 number = self.validate_number(number)
59 except PageNotAnInteger:
60 number = 1
61 except EmptyPage:
62 number = self.num_pages
63 return self.page(number)
64
65 def page(self, number):
66 """Return a Page object for the given 1-based page number."""
67 number = self.validate_number(number)
68 bottom = (number - 1) * self.per_page
69 top = bottom + self.per_page
70 if top + self.orphans >= self.count:
71 top = self.count
72 return self._get_page(self.object_list[bottom:top], number, self)
73
74 def _get_page(self, *args, **kwargs):
75 """
76 Return an instance of a single page.
77
78 This hook can be used by subclasses to use an alternative to the
79 standard :cls:`Page` object.
80 """
81 return Page(*args, **kwargs)
82
83 @cached_property
84 def count(self):
85 """Return the total number of objects, across all pages."""
86 c = getattr(self.object_list, "count", None)
87 if callable(c) and not inspect.isbuiltin(c) and method_has_no_args(c):
88 return c()
89 return len(self.object_list)
90
91 @cached_property
92 def num_pages(self):
93 """Return the total number of pages."""
94 if self.count == 0 and not self.allow_empty_first_page:
95 return 0
96 hits = max(1, self.count - self.orphans)
97 return ceil(hits / self.per_page)
98
99 @property
100 def page_range(self):
101 """
102 Return a 1-based range of pages for iterating through within
103 a template for loop.
104 """
105 return range(1, self.num_pages + 1)
106
107 def _check_object_list_is_ordered(self):
108 """
109 Warn if self.object_list is unordered (typically a QuerySet).
110 """
111 ordered = getattr(self.object_list, "ordered", None)
112 if ordered is not None and not ordered:
113 obj_list_repr = (
114 f"{self.object_list.model} {self.object_list.__class__.__name__}"
115 if hasattr(self.object_list, "model")
116 else f"{self.object_list!r}"
117 )
118 warnings.warn(
119 "Pagination may yield inconsistent results with an unordered "
120 f"object_list: {obj_list_repr}.",
121 UnorderedObjectListWarning,
122 stacklevel=3,
123 )
124
125
126class Page(collections.abc.Sequence):
127 def __init__(self, object_list, number, paginator):
128 self.object_list = object_list
129 self.number = number
130 self.paginator = paginator
131
132 def __repr__(self):
133 return f"<Page {self.number} of {self.paginator.num_pages}>"
134
135 def __len__(self):
136 return len(self.object_list)
137
138 def __getitem__(self, index):
139 if not isinstance(index, int | slice):
140 raise TypeError(
141 f"Page indices must be integers or slices, not {type(index).__name__}."
142 )
143 # The object_list is converted to a list so that if it was a QuerySet
144 # it won't be a database hit per __getitem__.
145 if not isinstance(self.object_list, list):
146 self.object_list = list(self.object_list)
147 return self.object_list[index]
148
149 def has_next(self):
150 return self.number < self.paginator.num_pages
151
152 def has_previous(self):
153 return self.number > 1
154
155 def has_other_pages(self):
156 return self.has_previous() or self.has_next()
157
158 def next_page_number(self):
159 return self.paginator.validate_number(self.number + 1)
160
161 def previous_page_number(self):
162 return self.paginator.validate_number(self.number - 1)
163
164 def start_index(self):
165 """
166 Return the 1-based index of the first object on this page,
167 relative to total objects in the paginator.
168 """
169 # Special case, return zero if no items.
170 if self.paginator.count == 0:
171 return 0
172 return (self.paginator.per_page * (self.number - 1)) + 1
173
174 def end_index(self):
175 """
176 Return the 1-based index of the last object on this page,
177 relative to total objects found (hits).
178 """
179 # Special case for the last page because there can be orphans.
180 if self.number == self.paginator.num_pages:
181 return self.paginator.count
182 return self.number * self.paginator.per_page