Coverage for /Users/davegaeddert/Development/dropseed/plain/plain/plain/paginator.py: 38%

1 import collections.abc

2 import inspect

3 import warnings

4 from math import ceil

5

6 from plain.utils.functional import cached_property

7 from plain.utils.inspect import method_has_no_args

8

9

10 class UnorderedObjectListWarning(RuntimeWarning):

11 pass

12

13

14 class InvalidPage(Exception):

15 pass

16

17

18 class PageNotAnInteger(InvalidPage):

19 pass

20

21

22 class EmptyPage(InvalidPage):

23 pass

24

25

26 class Paginator:

27 # Translators: String used to replace omitted page numbers in elided page

28 # range generated by paginators, e.g. [1, 2, '…', 5, 6, 7, '…', 9, 10].

29 ELLIPSIS = "…"

30

31 def __init__(self, object_list, per_page, orphans=0, allow_empty_first_page=True):

32 self.object_list = object_list

33 self._check_object_list_is_ordered()

34 self.per_page = int(per_page)

35 self.orphans = int(orphans)

36 self.allow_empty_first_page = allow_empty_first_page

37

38 def __iter__(self):

39 for page_number in self.page_range:

40 yield self.page(page_number)

41

42 def validate_number(self, number):

43 """Validate the given 1-based page number."""

44 try:

45 if isinstance(number, float) and not number.is_integer():

46 raise ValueError

47 number = int(number)

48 except (TypeError, ValueError):

49 raise PageNotAnInteger("That page number is not an integer")

50 if number < 1:

51 raise EmptyPage("That page number is less than 1")

52 if number > self.num_pages:

53 raise EmptyPage("That page contains no results")

54 return number

55

56 def get_page(self, number):

57 """

58 Return a valid page, even if the page argument isn't a number or isn't

59 in range.

60 """

61 try:

62 number = self.validate_number(number)

63 except PageNotAnInteger:

64 number = 1

65 except EmptyPage:

66 number = self.num_pages

67 return self.page(number)

68

69 def page(self, number):

70 """Return a Page object for the given 1-based page number."""

71 number = self.validate_number(number)

72 bottom = (number - 1) * self.per_page

73 top = bottom + self.per_page

74 if top + self.orphans >= self.count:

75 top = self.count

76 return self._get_page(self.object_list[bottom: top], number, self)

77

78 def _get_page(self, *args, **kwargs):

79 """

80 Return an instance of a single page.

81

82 This hook can be used by subclasses to use an alternative to the

83 standard :cls:`Page` object.

84 """

85 return Page(*args, **kwargs)

86

87 @cached_property

88 def count(self):

89 """Return the total number of objects, across all pages."""

90 c = getattr(self.object_list, "count", None)

91 if callable(c) and not inspect.isbuiltin(c) and method_has_no_args(c):

92 return c()

93 return len(self.object_list)

94

95 @cached_property

96 def num_pages(self):

97 """Return the total number of pages."""

98 if self.count == 0 and not self.allow_empty_first_page:

99 return 0

100 hits = max(1, self.count - self.orphans)

101 return ceil(hits / self.per_page)

102

103 @property

104 def page_range(self):

105 """

106 Return a 1-based range of pages for iterating through within

107 a template for loop.

108 """

109 return range(1, self.num_pages + 1)

110

111 def _check_object_list_is_ordered(self):

112 """

113 Warn if self.object_list is unordered (typically a QuerySet).

114 """

115 ordered = getattr(self.object_list, "ordered", None)

116 if ordered is not None and not ordered:

117 obj_list_repr = (

118 f"{self.object_list.model} {self.object_list.__class__.__name__}"

119 if hasattr(self.object_list, "model")

120 else f"{self.object_list! r}"

121 )

122 warnings.warn(

123 "Pagination may yield inconsistent results with an unordered "

124 f"object_list: {obj_list_repr}.",

125 UnorderedObjectListWarning,

126 stacklevel=3,

127 )

128

129

130 class Page(collections.abc.Sequence):

131 def __init__(self, object_list, number, paginator):

132 self.object_list = object_list

133 self.number = number

134 self.paginator = paginator

135

136 def __repr__(self):

137 return f"<Page {self.number} of {self.paginator.num_pages}>"

138

139 def __len__(self):

140 return len(self.object_list)

141

142 def __getitem__(self, index):

143 if not isinstance(index, int | slice):

144 raise TypeError(

145 "Page indices must be integers or slices, not %s."

146 % type(index).__name__

147 )

148 # The object_list is converted to a list so that if it was a QuerySet

149 # it won't be a database hit per __getitem__.

150 if not isinstance(self.object_list, list):

151 self.object_list = list(self.object_list)

152 return self.object_list[index]

153

154 def has_next(self):

155 return self.number < self.paginator.num_pages

156

157 def has_previous(self):

158 return self.number > 1

159

160 def has_other_pages(self):

161 return self.has_previous() or self.has_next()

162

163 def next_page_number(self):

164 return self.paginator.validate_number(self.number + 1)

165

166 def previous_page_number(self):

167 return self.paginator.validate_number(self.number - 1)

168

169 def start_index(self):

170 """

171 Return the 1-based index of the first object on this page,

172 relative to total objects in the paginator.

173 """

174 # Special case, return zero if no items.

175 if self.paginator.count == 0:

176 return 0

177 return (self.paginator.per_page * (self.number - 1)) + 1

178

179 def end_index(self):

180 """

181 Return the 1-based index of the last object on this page,

182 relative to total objects found (hits).

183 """

184 # Special case for the last page because there can be orphans.

185 if self.number == self.paginator.num_pages:

186 return self.paginator.count

187 return self.number * self.paginator.per_page