Plain
Plain is a web framework for building products with Python.
With the core plain
package you can build an app that:
- Matches URL patterns to Python views
- Handles HTTP requests and responses
- Renders HTML templates with Jinja
- Processes user input via forms
- Has a CLI interface
- Serves static assets (CSS, JS, images)
- Can be modified with middleware
- Integrates first-party and third-party packages
- Has a preflight check system
With the official Plain ecosystem packages you can:
- Integrate a full-featured database ORM
- Use a built-in user authentication system
- Lint and format code
- Run a database-backed cache
- Send emails
- Streamline local development
- Manage feature flags
- Integrate HTMX
- Style with Tailwind CSS
- Add OAuth login and API access
- Run tests with pytest
- Run a background job worker
- Build staff tooling and admin dashboards
Learn more at plainframework.com.
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