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