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