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

107 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-10-16 22:03 -0500

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 # 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 

130class 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