plain 0.68.0__py3-none-any.whl → 0.103.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plain/CHANGELOG.md +684 -1
- plain/README.md +1 -1
- plain/agents/.claude/rules/plain.md +88 -0
- plain/agents/.claude/skills/plain-install/SKILL.md +26 -0
- plain/agents/.claude/skills/plain-upgrade/SKILL.md +35 -0
- plain/assets/compile.py +25 -12
- plain/assets/finders.py +24 -17
- plain/assets/fingerprints.py +10 -7
- plain/assets/urls.py +1 -1
- plain/assets/views.py +47 -33
- plain/chores/README.md +25 -23
- plain/chores/__init__.py +2 -1
- plain/chores/core.py +27 -0
- plain/chores/registry.py +23 -36
- plain/cli/README.md +185 -16
- plain/cli/__init__.py +2 -1
- plain/cli/agent.py +234 -0
- plain/cli/build.py +7 -8
- plain/cli/changelog.py +11 -5
- plain/cli/chores.py +32 -34
- plain/cli/core.py +110 -26
- plain/cli/docs.py +98 -21
- plain/cli/formatting.py +40 -17
- plain/cli/install.py +10 -54
- plain/cli/{agent/llmdocs.py → llmdocs.py} +45 -26
- plain/cli/output.py +6 -2
- plain/cli/preflight.py +27 -75
- plain/cli/print.py +4 -4
- plain/cli/registry.py +96 -10
- plain/cli/{agent/request.py → request.py} +67 -33
- plain/cli/runtime.py +45 -0
- plain/cli/scaffold.py +2 -7
- plain/cli/server.py +153 -0
- plain/cli/settings.py +53 -49
- plain/cli/shell.py +15 -12
- plain/cli/startup.py +9 -8
- plain/cli/upgrade.py +17 -104
- plain/cli/urls.py +12 -7
- plain/cli/utils.py +3 -3
- plain/csrf/README.md +65 -40
- plain/csrf/middleware.py +53 -43
- plain/debug.py +5 -2
- plain/exceptions.py +22 -114
- plain/forms/README.md +453 -24
- plain/forms/__init__.py +55 -4
- plain/forms/boundfield.py +15 -8
- plain/forms/exceptions.py +1 -1
- plain/forms/fields.py +346 -143
- plain/forms/forms.py +75 -45
- plain/http/README.md +356 -9
- plain/http/__init__.py +41 -26
- plain/http/cookie.py +15 -7
- plain/http/exceptions.py +65 -0
- plain/http/middleware.py +32 -0
- plain/http/multipartparser.py +99 -88
- plain/http/request.py +362 -250
- plain/http/response.py +99 -197
- plain/internal/__init__.py +8 -1
- plain/internal/files/base.py +35 -19
- plain/internal/files/locks.py +19 -11
- plain/internal/files/move.py +8 -3
- plain/internal/files/temp.py +25 -6
- plain/internal/files/uploadedfile.py +47 -28
- plain/internal/files/uploadhandler.py +64 -58
- plain/internal/files/utils.py +24 -10
- plain/internal/handlers/base.py +34 -23
- plain/internal/handlers/exception.py +68 -65
- plain/internal/handlers/wsgi.py +65 -54
- plain/internal/middleware/headers.py +37 -11
- plain/internal/middleware/hosts.py +11 -8
- plain/internal/middleware/https.py +17 -7
- plain/internal/middleware/slash.py +14 -9
- plain/internal/reloader.py +77 -0
- plain/json.py +2 -1
- plain/logs/README.md +161 -62
- plain/logs/__init__.py +1 -1
- plain/logs/{loggers.py → app.py} +71 -67
- plain/logs/configure.py +63 -14
- plain/logs/debug.py +17 -6
- plain/logs/filters.py +15 -0
- plain/logs/formatters.py +7 -4
- plain/packages/README.md +105 -23
- plain/packages/config.py +15 -7
- plain/packages/registry.py +27 -16
- plain/paginator.py +31 -21
- plain/preflight/README.md +209 -24
- plain/preflight/__init__.py +1 -0
- plain/preflight/checks.py +3 -1
- plain/preflight/files.py +3 -1
- plain/preflight/registry.py +26 -11
- plain/preflight/results.py +15 -7
- plain/preflight/security.py +15 -13
- plain/preflight/settings.py +54 -0
- plain/preflight/urls.py +4 -1
- plain/runtime/README.md +115 -47
- plain/runtime/__init__.py +10 -6
- plain/runtime/global_settings.py +34 -25
- plain/runtime/secret.py +20 -0
- plain/runtime/user_settings.py +110 -38
- plain/runtime/utils.py +1 -1
- plain/server/LICENSE +35 -0
- plain/server/README.md +155 -0
- plain/server/__init__.py +9 -0
- plain/server/app.py +52 -0
- plain/server/arbiter.py +555 -0
- plain/server/config.py +118 -0
- plain/server/errors.py +31 -0
- plain/server/glogging.py +292 -0
- plain/server/http/__init__.py +12 -0
- plain/server/http/body.py +283 -0
- plain/server/http/errors.py +155 -0
- plain/server/http/message.py +400 -0
- plain/server/http/parser.py +70 -0
- plain/server/http/unreader.py +88 -0
- plain/server/http/wsgi.py +421 -0
- plain/server/pidfile.py +92 -0
- plain/server/sock.py +240 -0
- plain/server/util.py +317 -0
- plain/server/workers/__init__.py +6 -0
- plain/server/workers/base.py +304 -0
- plain/server/workers/sync.py +212 -0
- plain/server/workers/thread.py +399 -0
- plain/server/workers/workertmp.py +50 -0
- plain/signals/README.md +170 -1
- plain/signals/__init__.py +0 -1
- plain/signals/dispatch/dispatcher.py +49 -27
- plain/signing.py +131 -35
- plain/templates/README.md +211 -20
- plain/templates/jinja/__init__.py +13 -5
- plain/templates/jinja/environments.py +5 -4
- plain/templates/jinja/extensions.py +12 -5
- plain/templates/jinja/filters.py +7 -2
- plain/templates/jinja/globals.py +2 -2
- plain/test/README.md +184 -22
- plain/test/client.py +340 -222
- plain/test/encoding.py +9 -6
- plain/test/exceptions.py +7 -2
- plain/urls/README.md +157 -73
- plain/urls/converters.py +18 -15
- plain/urls/exceptions.py +2 -2
- plain/urls/patterns.py +38 -22
- plain/urls/resolvers.py +35 -25
- plain/urls/utils.py +5 -1
- plain/utils/README.md +250 -3
- plain/utils/cache.py +17 -11
- plain/utils/crypto.py +21 -5
- plain/utils/datastructures.py +89 -56
- plain/utils/dateparse.py +9 -6
- plain/utils/deconstruct.py +15 -7
- plain/utils/decorators.py +5 -1
- plain/utils/dotenv.py +373 -0
- plain/utils/duration.py +8 -4
- plain/utils/encoding.py +14 -7
- plain/utils/functional.py +66 -49
- plain/utils/hashable.py +5 -1
- plain/utils/html.py +36 -22
- plain/utils/http.py +16 -9
- plain/utils/inspect.py +14 -6
- plain/utils/ipv6.py +7 -3
- plain/utils/itercompat.py +6 -1
- plain/utils/module_loading.py +7 -3
- plain/utils/regex_helper.py +37 -23
- plain/utils/safestring.py +14 -6
- plain/utils/text.py +41 -23
- plain/utils/timezone.py +33 -22
- plain/utils/tree.py +35 -19
- plain/validators.py +94 -52
- plain/views/README.md +156 -79
- plain/views/__init__.py +0 -1
- plain/views/base.py +25 -18
- plain/views/errors.py +13 -5
- plain/views/exceptions.py +4 -1
- plain/views/forms.py +6 -6
- plain/views/objects.py +52 -49
- plain/views/redirect.py +18 -15
- plain/views/templates.py +5 -3
- plain/wsgi.py +3 -1
- {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/METADATA +4 -2
- plain-0.103.0.dist-info/RECORD +198 -0
- {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/WHEEL +1 -1
- plain-0.103.0.dist-info/entry_points.txt +2 -0
- plain/AGENTS.md +0 -18
- plain/cli/agent/__init__.py +0 -20
- plain/cli/agent/docs.py +0 -80
- plain/cli/agent/md.py +0 -87
- plain/cli/agent/prompt.py +0 -45
- plain/csrf/views.py +0 -31
- plain/logs/utils.py +0 -46
- plain/templates/AGENTS.md +0 -3
- plain-0.68.0.dist-info/RECORD +0 -169
- plain-0.68.0.dist-info/entry_points.txt +0 -5
- {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/licenses/LICENSE +0 -0
plain/views/README.md
CHANGED
|
@@ -3,21 +3,25 @@
|
|
|
3
3
|
**Take a request, return a response.**
|
|
4
4
|
|
|
5
5
|
- [Overview](#overview)
|
|
6
|
-
- [HTTP methods
|
|
6
|
+
- [HTTP methods map to class methods](#http-methods-map-to-class-methods)
|
|
7
7
|
- [Return types](#return-types)
|
|
8
|
-
- [
|
|
9
|
-
- [
|
|
8
|
+
- [TemplateView](#templateview)
|
|
9
|
+
- [FormView](#formview)
|
|
10
10
|
- [Object views](#object-views)
|
|
11
|
-
- [
|
|
11
|
+
- [DetailView](#detailview)
|
|
12
|
+
- [CreateView](#createview)
|
|
13
|
+
- [UpdateView](#updateview)
|
|
14
|
+
- [DeleteView](#deleteview)
|
|
15
|
+
- [ListView](#listview)
|
|
16
|
+
- [RedirectView](#redirectview)
|
|
17
|
+
- [ResponseException](#responseexception)
|
|
12
18
|
- [Error views](#error-views)
|
|
13
|
-
- [
|
|
14
|
-
- [
|
|
19
|
+
- [FAQs](#faqs)
|
|
20
|
+
- [Installation](#installation)
|
|
15
21
|
|
|
16
22
|
## Overview
|
|
17
23
|
|
|
18
|
-
Plain views are
|
|
19
|
-
with a straightforward API that keeps simple views simple,
|
|
20
|
-
but gives you the power of a full class to handle more complex cases.
|
|
24
|
+
Plain views are class-based, with a straightforward API that keeps simple views simple while giving you the full power of a class for complex cases.
|
|
21
25
|
|
|
22
26
|
```python
|
|
23
27
|
from plain.views import View
|
|
@@ -28,12 +32,11 @@ class ExampleView(View):
|
|
|
28
32
|
return "<html><body>Hello, world!</body></html>"
|
|
29
33
|
```
|
|
30
34
|
|
|
31
|
-
|
|
35
|
+
You can return strings, dicts, lists, integers (status codes), or full `Response` objects. Plain automatically converts them to the appropriate HTTP response.
|
|
32
36
|
|
|
33
|
-
|
|
37
|
+
## HTTP methods map to class methods
|
|
34
38
|
|
|
35
|
-
|
|
36
|
-
Plain will return a `405 Method Not Allowed` response.
|
|
39
|
+
The HTTP method of the request maps directly to a class method of the same name. Define only the methods you want to support.
|
|
37
40
|
|
|
38
41
|
```python
|
|
39
42
|
from plain.views import View
|
|
@@ -54,18 +57,15 @@ class ExampleView(View):
|
|
|
54
57
|
|
|
55
58
|
def delete(self):
|
|
56
59
|
pass
|
|
57
|
-
|
|
58
|
-
def trace(self):
|
|
59
|
-
pass
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
If a request comes in for a method your view doesn't implement, Plain returns a `405 Method Not Allowed` response automatically.
|
|
63
|
+
|
|
64
|
+
The [base `View` class](./base.py#View) provides default `options` and `head` behavior, but you can override these too.
|
|
64
65
|
|
|
65
66
|
## Return types
|
|
66
67
|
|
|
67
|
-
|
|
68
|
-
you don't need to instantiate a `Response` object.
|
|
68
|
+
You can return common Python types directly from view methods without wrapping them in a `Response` object.
|
|
69
69
|
|
|
70
70
|
```python
|
|
71
71
|
class JsonView(View):
|
|
@@ -81,11 +81,18 @@ class HtmlView(View):
|
|
|
81
81
|
class StatusCodeView(View):
|
|
82
82
|
def get(self):
|
|
83
83
|
return 204 # No content
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TupleView(View):
|
|
87
|
+
def get(self):
|
|
88
|
+
return (201, {"id": 123}) # Status code + data
|
|
84
89
|
```
|
|
85
90
|
|
|
86
|
-
|
|
91
|
+
Returning `None` triggers a 404 response, which is useful when an object isn't found.
|
|
87
92
|
|
|
88
|
-
|
|
93
|
+
## TemplateView
|
|
94
|
+
|
|
95
|
+
For rendering templates, use [`TemplateView`](./templates.py#TemplateView). This is the base class for most other built-in view classes.
|
|
89
96
|
|
|
90
97
|
```python
|
|
91
98
|
from plain.views import TemplateView
|
|
@@ -100,9 +107,7 @@ class ExampleView(TemplateView):
|
|
|
100
107
|
return context
|
|
101
108
|
```
|
|
102
109
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
Template views that don't need any custom context can use `TemplateView.as_view()` directly in the URL route.
|
|
110
|
+
For simple pages that don't need custom context, you can configure `TemplateView` directly in your URL routes.
|
|
106
111
|
|
|
107
112
|
```python
|
|
108
113
|
from plain.views import TemplateView
|
|
@@ -115,9 +120,9 @@ class AppRouter(Router):
|
|
|
115
120
|
]
|
|
116
121
|
```
|
|
117
122
|
|
|
118
|
-
##
|
|
123
|
+
## FormView
|
|
119
124
|
|
|
120
|
-
|
|
125
|
+
[`FormView`](./forms.py#FormView) handles displaying and processing [forms](/plain/plain/forms/README.md).
|
|
121
126
|
|
|
122
127
|
```python
|
|
123
128
|
from plain.views import FormView
|
|
@@ -130,11 +135,11 @@ class ExampleView(FormView):
|
|
|
130
135
|
success_url = "." # Redirect to the same page
|
|
131
136
|
|
|
132
137
|
def form_valid(self, form):
|
|
133
|
-
# Do
|
|
138
|
+
# Do additional processing here
|
|
134
139
|
return super().form_valid(form)
|
|
135
140
|
```
|
|
136
141
|
|
|
137
|
-
|
|
142
|
+
The form is automatically available in your template as `form`.
|
|
138
143
|
|
|
139
144
|
```html
|
|
140
145
|
{% extends "base.html" %}
|
|
@@ -147,7 +152,7 @@ Rendering forms is done directly in the HTML.
|
|
|
147
152
|
<div>{{ error }}</div>
|
|
148
153
|
{% endfor %}
|
|
149
154
|
|
|
150
|
-
<!-- Render form fields
|
|
155
|
+
<!-- Render form fields -->
|
|
151
156
|
<label for="{{ form.email.html_id }}">Email</label>
|
|
152
157
|
<input
|
|
153
158
|
type="email"
|
|
@@ -169,10 +174,14 @@ Rendering forms is done directly in the HTML.
|
|
|
169
174
|
|
|
170
175
|
## Object views
|
|
171
176
|
|
|
172
|
-
|
|
177
|
+
Plain provides views for standard CRUD operations. Each requires you to implement `get_object()` or `get_objects()` to control what data is accessed.
|
|
178
|
+
|
|
179
|
+
### DetailView
|
|
180
|
+
|
|
181
|
+
[`DetailView`](./objects.py#DetailView) displays a single object.
|
|
173
182
|
|
|
174
183
|
```python
|
|
175
|
-
from plain.views import DetailView
|
|
184
|
+
from plain.views import DetailView
|
|
176
185
|
|
|
177
186
|
|
|
178
187
|
class ExampleDetailView(DetailView):
|
|
@@ -183,12 +192,32 @@ class ExampleDetailView(DetailView):
|
|
|
183
192
|
id=self.url_kwargs["id"],
|
|
184
193
|
user=self.request.user, # Limit access
|
|
185
194
|
)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The object is available in your template as `object`. You can also set `context_object_name` for a more descriptive name.
|
|
198
|
+
|
|
199
|
+
### CreateView
|
|
200
|
+
|
|
201
|
+
[`CreateView`](./objects.py#CreateView) displays a form and creates a new object on successful submission.
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
from plain.views import CreateView
|
|
205
|
+
from .forms import CustomCreateForm
|
|
186
206
|
|
|
187
207
|
|
|
188
208
|
class ExampleCreateView(CreateView):
|
|
189
209
|
template_name = "create.html"
|
|
190
210
|
form_class = CustomCreateForm
|
|
191
211
|
success_url = "."
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### UpdateView
|
|
215
|
+
|
|
216
|
+
[`UpdateView`](./objects.py#UpdateView) displays a form pre-populated with an existing object and saves changes on submission.
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
from plain.views import UpdateView
|
|
220
|
+
from .forms import CustomUpdateForm
|
|
192
221
|
|
|
193
222
|
|
|
194
223
|
class ExampleUpdateView(UpdateView):
|
|
@@ -199,22 +228,35 @@ class ExampleUpdateView(UpdateView):
|
|
|
199
228
|
def get_object(self):
|
|
200
229
|
return MyObjectClass.query.get(
|
|
201
230
|
id=self.url_kwargs["id"],
|
|
202
|
-
user=self.request.user,
|
|
231
|
+
user=self.request.user,
|
|
203
232
|
)
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### DeleteView
|
|
236
|
+
|
|
237
|
+
[`DeleteView`](./objects.py#DeleteView) confirms deletion of an object. POST to delete, no form class needed.
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
from plain.views import DeleteView
|
|
204
241
|
|
|
205
242
|
|
|
206
243
|
class ExampleDeleteView(DeleteView):
|
|
207
244
|
template_name = "delete.html"
|
|
208
|
-
success_url = "
|
|
209
|
-
|
|
210
|
-
# No form class necessary.
|
|
211
|
-
# Just POST to this view to delete the object.
|
|
245
|
+
success_url = "/list/"
|
|
212
246
|
|
|
213
247
|
def get_object(self):
|
|
214
248
|
return MyObjectClass.query.get(
|
|
215
249
|
id=self.url_kwargs["id"],
|
|
216
|
-
user=self.request.user,
|
|
250
|
+
user=self.request.user,
|
|
217
251
|
)
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### ListView
|
|
255
|
+
|
|
256
|
+
[`ListView`](./objects.py#ListView) displays a collection of objects.
|
|
257
|
+
|
|
258
|
+
```python
|
|
259
|
+
from plain.views import ListView
|
|
218
260
|
|
|
219
261
|
|
|
220
262
|
class ExampleListView(ListView):
|
|
@@ -222,16 +264,44 @@ class ExampleListView(ListView):
|
|
|
222
264
|
|
|
223
265
|
def get_objects(self):
|
|
224
266
|
return MyObjectClass.query.filter(
|
|
225
|
-
user=self.request.user,
|
|
267
|
+
user=self.request.user,
|
|
226
268
|
)
|
|
227
269
|
```
|
|
228
270
|
|
|
229
|
-
|
|
271
|
+
The objects are available in your template as `objects`.
|
|
230
272
|
|
|
231
|
-
|
|
232
|
-
a view can raise a [`ResponseException`](./exceptions.py#ResponseException) to immediately exit and return the wrapped response.
|
|
273
|
+
## RedirectView
|
|
233
274
|
|
|
234
|
-
|
|
275
|
+
[`RedirectView`](./redirect.py#RedirectView) redirects to another URL.
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
from plain.views import RedirectView
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class ExampleRedirectView(RedirectView):
|
|
282
|
+
url = "/new-location/"
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Set `status_code = 301` for permanent redirects (default is 302).
|
|
286
|
+
|
|
287
|
+
For simple redirects, configure the view directly in your URL routes.
|
|
288
|
+
|
|
289
|
+
```python
|
|
290
|
+
from plain.views import RedirectView
|
|
291
|
+
from plain.urls import path, Router
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class AppRouter(Router):
|
|
295
|
+
routes = [
|
|
296
|
+
path("/old-location/", RedirectView.as_view(url="/new-location/", status_code=301)),
|
|
297
|
+
]
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
You can also redirect to a named URL using `url_name`, or preserve query parameters with `preserve_query_params=True`.
|
|
301
|
+
|
|
302
|
+
## ResponseException
|
|
303
|
+
|
|
304
|
+
At any point during request handling, you can raise a [`ResponseException`](./exceptions.py#ResponseException) to immediately return a response. This is useful for authorization checks or rate limiting in nested helper functions.
|
|
235
305
|
|
|
236
306
|
```python
|
|
237
307
|
from plain.views import DetailView
|
|
@@ -241,7 +311,7 @@ from plain.http import Response
|
|
|
241
311
|
|
|
242
312
|
class ExampleView(DetailView):
|
|
243
313
|
def get_object(self):
|
|
244
|
-
if self.request.user.exceeds_rate_limit:
|
|
314
|
+
if self.request.user and self.request.user.exceeds_rate_limit:
|
|
245
315
|
raise ResponseException(
|
|
246
316
|
Response("Rate limit exceeded", status_code=429)
|
|
247
317
|
)
|
|
@@ -251,60 +321,67 @@ class ExampleView(DetailView):
|
|
|
251
321
|
|
|
252
322
|
## Error views
|
|
253
323
|
|
|
254
|
-
|
|
324
|
+
HTTP errors are rendered using templates. Create templates for the errors users see.
|
|
325
|
+
|
|
326
|
+
- `templates/404.html` - Page not found
|
|
327
|
+
- `templates/403.html` - Forbidden
|
|
328
|
+
- `templates/500.html` - Server error
|
|
329
|
+
|
|
330
|
+
Plain looks for `{status_code}.html` templates, then returns a plain HTTP response if not found. Most apps only need these three templates.
|
|
331
|
+
|
|
332
|
+
Templates receive `status_code` and `exception` in context.
|
|
333
|
+
|
|
334
|
+
Your `500.html` template should be self-contained. Avoid extending base templates or accessing the database/session, since server errors can occur during middleware or template rendering. `404.html` and `403.html` can safely extend base templates since they occur during view execution after middleware runs.
|
|
255
335
|
|
|
256
|
-
|
|
336
|
+
## FAQs
|
|
337
|
+
|
|
338
|
+
#### How do I exempt a view from CSRF protection?
|
|
339
|
+
|
|
340
|
+
Use the `CSRF_EXEMPT_PATHS` setting to specify path patterns that should bypass CSRF protection. For example:
|
|
257
341
|
|
|
258
342
|
```python
|
|
259
343
|
# app/settings.py
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
344
|
+
CSRF_EXEMPT_PATHS = [
|
|
345
|
+
r"^/api/", # Exempt all API routes
|
|
346
|
+
r"^/webhooks/", # Exempt webhook endpoints
|
|
347
|
+
]
|
|
263
348
|
```
|
|
264
349
|
|
|
265
|
-
|
|
266
|
-
# app/errors.py
|
|
267
|
-
from plain.views import View
|
|
350
|
+
#### How do I access URL parameters?
|
|
268
351
|
|
|
352
|
+
URL parameters are available via `self.url_kwargs` (keyword arguments) and `self.url_args` (positional arguments).
|
|
269
353
|
|
|
270
|
-
|
|
354
|
+
```python
|
|
355
|
+
class ExampleView(View):
|
|
271
356
|
def get(self):
|
|
272
|
-
|
|
273
|
-
|
|
357
|
+
user_id = self.url_kwargs["id"]
|
|
358
|
+
return f"User ID: {user_id}"
|
|
274
359
|
```
|
|
275
360
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
```python
|
|
279
|
-
from plain.views import RedirectView
|
|
361
|
+
#### How do I access the request object?
|
|
280
362
|
|
|
363
|
+
The request is available as `self.request` after the view is set up.
|
|
281
364
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
365
|
+
```python
|
|
366
|
+
class ExampleView(View):
|
|
367
|
+
def get(self):
|
|
368
|
+
return f"Path: {self.request.path}"
|
|
285
369
|
```
|
|
286
370
|
|
|
287
|
-
|
|
371
|
+
#### Can I customize view initialization?
|
|
372
|
+
|
|
373
|
+
Yes, define your own `__init__` method to accept custom arguments passed via `as_view()`.
|
|
288
374
|
|
|
289
375
|
```python
|
|
290
|
-
|
|
291
|
-
|
|
376
|
+
class CustomView(View):
|
|
377
|
+
def __init__(self, feature_enabled=False):
|
|
378
|
+
self.feature_enabled = feature_enabled
|
|
292
379
|
|
|
293
380
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
path("/old-location/", RedirectView.as_view(url="/new-location/", permanent=True)),
|
|
297
|
-
]
|
|
381
|
+
# In URLs
|
|
382
|
+
path("/custom/", CustomView.as_view(feature_enabled=True))
|
|
298
383
|
```
|
|
299
384
|
|
|
300
|
-
##
|
|
385
|
+
## Installation
|
|
301
386
|
|
|
302
|
-
|
|
303
|
-
from plain.views import View
|
|
304
|
-
from plain.views.csrf import CsrfExemptViewMixin
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
class ExemptView(CsrfExemptViewMixin, View):
|
|
308
|
-
def post(self):
|
|
309
|
-
return "Hello, world!"
|
|
310
|
-
```
|
|
387
|
+
Views are included with the core `plain` package. No additional installation is required.
|
plain/views/__init__.py
CHANGED
plain/views/base.py
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import logging
|
|
4
|
+
from collections.abc import Callable
|
|
2
5
|
from http import HTTPMethod
|
|
6
|
+
from typing import Any, Self
|
|
3
7
|
|
|
4
8
|
from opentelemetry import trace
|
|
5
9
|
from opentelemetry.semconv._incubating.attributes.code_attributes import (
|
|
@@ -8,12 +12,12 @@ from opentelemetry.semconv._incubating.attributes.code_attributes import (
|
|
|
8
12
|
)
|
|
9
13
|
|
|
10
14
|
from plain.http import (
|
|
11
|
-
HttpRequest,
|
|
12
15
|
JsonResponse,
|
|
16
|
+
NotAllowedResponse,
|
|
17
|
+
NotFoundError404,
|
|
18
|
+
Request,
|
|
13
19
|
Response,
|
|
14
20
|
ResponseBase,
|
|
15
|
-
ResponseNotAllowed,
|
|
16
|
-
ResponseNotFound,
|
|
17
21
|
)
|
|
18
22
|
from plain.utils.decorators import classonlymethod
|
|
19
23
|
|
|
@@ -26,14 +30,14 @@ tracer = trace.get_tracer("plain")
|
|
|
26
30
|
|
|
27
31
|
|
|
28
32
|
class View:
|
|
29
|
-
request:
|
|
30
|
-
url_args: tuple
|
|
31
|
-
url_kwargs: dict
|
|
33
|
+
request: Request
|
|
34
|
+
url_args: tuple[Any, ...]
|
|
35
|
+
url_kwargs: dict[str, Any]
|
|
32
36
|
|
|
33
37
|
# View.as_view(example="foo") usage can be customized by defining your own __init__ method.
|
|
34
38
|
# def __init__(self, *args, **kwargs):
|
|
35
39
|
|
|
36
|
-
def setup(self, request:
|
|
40
|
+
def setup(self, request: Request, *url_args: object, **url_kwargs: object) -> None:
|
|
37
41
|
if hasattr(self, "get") and not hasattr(self, "head"):
|
|
38
42
|
self.head = self.get
|
|
39
43
|
|
|
@@ -42,8 +46,12 @@ class View:
|
|
|
42
46
|
self.url_kwargs = url_kwargs
|
|
43
47
|
|
|
44
48
|
@classonlymethod
|
|
45
|
-
def as_view(
|
|
46
|
-
|
|
49
|
+
def as_view(
|
|
50
|
+
cls: type[Self], *init_args: object, **init_kwargs: object
|
|
51
|
+
) -> Callable[[Request, Any, Any], ResponseBase]:
|
|
52
|
+
def view(
|
|
53
|
+
request: Request, *url_args: object, **url_kwargs: object
|
|
54
|
+
) -> ResponseBase:
|
|
47
55
|
with tracer.start_as_current_span(
|
|
48
56
|
f"{cls.__name__}",
|
|
49
57
|
kind=trace.SpanKind.INTERNAL,
|
|
@@ -62,11 +70,11 @@ class View:
|
|
|
62
70
|
)
|
|
63
71
|
return response
|
|
64
72
|
|
|
65
|
-
view.view_class = cls
|
|
73
|
+
view.view_class = cls # type: ignore[attr-defined]
|
|
66
74
|
|
|
67
75
|
return view
|
|
68
76
|
|
|
69
|
-
def get_request_handler(self) ->
|
|
77
|
+
def get_request_handler(self) -> Callable[[], Any] | None:
|
|
70
78
|
"""Return the handler for the current request method."""
|
|
71
79
|
|
|
72
80
|
if not self.request.method:
|
|
@@ -84,16 +92,16 @@ class View:
|
|
|
84
92
|
self.request.path,
|
|
85
93
|
extra={"status_code": 405, "request": self.request},
|
|
86
94
|
)
|
|
87
|
-
return
|
|
95
|
+
return NotAllowedResponse(self._allowed_methods())
|
|
88
96
|
|
|
89
97
|
try:
|
|
90
|
-
result = handler()
|
|
98
|
+
result: Any = handler()
|
|
91
99
|
except ResponseException as e:
|
|
92
100
|
return e.response
|
|
93
101
|
|
|
94
102
|
return self.convert_value_to_response(result)
|
|
95
103
|
|
|
96
|
-
def convert_value_to_response(self, value) -> ResponseBase:
|
|
104
|
+
def convert_value_to_response(self, value: Any) -> ResponseBase:
|
|
97
105
|
"""Convert a return value to a Response."""
|
|
98
106
|
if isinstance(value, ResponseBase):
|
|
99
107
|
return value
|
|
@@ -102,8 +110,7 @@ class View:
|
|
|
102
110
|
return Response(status_code=value)
|
|
103
111
|
|
|
104
112
|
if value is None:
|
|
105
|
-
|
|
106
|
-
return ResponseNotFound()
|
|
113
|
+
raise NotFoundError404
|
|
107
114
|
|
|
108
115
|
status_code = 200
|
|
109
116
|
|
|
@@ -113,8 +120,8 @@ class View:
|
|
|
113
120
|
"Tuple response must be of length 2 (status_code, value)"
|
|
114
121
|
)
|
|
115
122
|
|
|
116
|
-
status_code = value[0]
|
|
117
|
-
value = value[1]
|
|
123
|
+
status_code: int = value[0]
|
|
124
|
+
value: Any = value[1]
|
|
118
125
|
|
|
119
126
|
if isinstance(value, str):
|
|
120
127
|
return Response(value, status_code=status_code)
|
plain/views/errors.py
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
1
6
|
from plain.http import ResponseBase
|
|
2
7
|
from plain.templates import TemplateFileMissing
|
|
3
8
|
|
|
@@ -7,7 +12,9 @@ from .templates import TemplateView
|
|
|
7
12
|
class ErrorView(TemplateView):
|
|
8
13
|
status_code: int
|
|
9
14
|
|
|
10
|
-
def __init__(
|
|
15
|
+
def __init__(
|
|
16
|
+
self, *, status_code: int | None = None, exception: Any | None = None
|
|
17
|
+
) -> None:
|
|
11
18
|
# Allow creating an ErrorView with a status code
|
|
12
19
|
# e.g. ErrorView.as_view(status_code=404)
|
|
13
20
|
self.status_code = status_code or self.status_code
|
|
@@ -15,16 +22,17 @@ class ErrorView(TemplateView):
|
|
|
15
22
|
# Allow creating an ErrorView with an exception
|
|
16
23
|
self.exception = exception
|
|
17
24
|
|
|
18
|
-
def get_template_context(self):
|
|
25
|
+
def get_template_context(self) -> dict:
|
|
19
26
|
context = super().get_template_context()
|
|
20
27
|
context["status_code"] = self.status_code
|
|
21
28
|
context["exception"] = self.exception
|
|
22
29
|
return context
|
|
23
30
|
|
|
24
31
|
def get_template_names(self) -> list[str]:
|
|
25
|
-
|
|
32
|
+
# Try specific status code template (e.g. "404.html")
|
|
33
|
+
return [f"{self.status_code}.html"]
|
|
26
34
|
|
|
27
|
-
def get_request_handler(self):
|
|
35
|
+
def get_request_handler(self) -> Callable[[], Any]:
|
|
28
36
|
return self.get # All methods (post, patch, etc.) will use the get()
|
|
29
37
|
|
|
30
38
|
def get_response(self) -> ResponseBase:
|
|
@@ -33,7 +41,7 @@ class ErrorView(TemplateView):
|
|
|
33
41
|
response.status_code = self.status_code
|
|
34
42
|
return response
|
|
35
43
|
|
|
36
|
-
def get(self):
|
|
44
|
+
def get(self) -> ResponseBase | int:
|
|
37
45
|
try:
|
|
38
46
|
return super().get()
|
|
39
47
|
except TemplateFileMissing:
|
plain/views/exceptions.py
CHANGED
plain/views/forms.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
from collections.abc import Callable
|
|
2
|
-
from typing import TYPE_CHECKING
|
|
2
|
+
from typing import TYPE_CHECKING, Any
|
|
3
3
|
|
|
4
4
|
from plain.exceptions import ImproperlyConfigured
|
|
5
|
-
from plain.http import
|
|
5
|
+
from plain.http import RedirectResponse, Response
|
|
6
6
|
|
|
7
7
|
from .templates import TemplateView
|
|
8
8
|
|
|
@@ -25,7 +25,7 @@ class FormView(TemplateView):
|
|
|
25
25
|
)
|
|
26
26
|
return self.form_class(**self.get_form_kwargs())
|
|
27
27
|
|
|
28
|
-
def get_form_kwargs(self) -> dict:
|
|
28
|
+
def get_form_kwargs(self) -> dict[str, Any]:
|
|
29
29
|
"""Return the keyword arguments for instantiating the form."""
|
|
30
30
|
return {
|
|
31
31
|
"initial": {},
|
|
@@ -40,7 +40,7 @@ class FormView(TemplateView):
|
|
|
40
40
|
|
|
41
41
|
def form_valid(self, form: "BaseForm") -> Response:
|
|
42
42
|
"""If the form is valid, redirect to the supplied URL."""
|
|
43
|
-
return
|
|
43
|
+
return RedirectResponse(self.get_success_url(form))
|
|
44
44
|
|
|
45
45
|
def form_invalid(self, form: "BaseForm") -> Response:
|
|
46
46
|
"""If the form is invalid, render the invalid form."""
|
|
@@ -48,9 +48,9 @@ class FormView(TemplateView):
|
|
|
48
48
|
**self.get_template_context(),
|
|
49
49
|
"form": form,
|
|
50
50
|
}
|
|
51
|
-
return self.get_template().render(context)
|
|
51
|
+
return Response(self.get_template().render(context))
|
|
52
52
|
|
|
53
|
-
def get_template_context(self) -> dict:
|
|
53
|
+
def get_template_context(self) -> dict[str, Any]:
|
|
54
54
|
"""Insert the form into the context dict."""
|
|
55
55
|
context = super().get_template_context()
|
|
56
56
|
context["form"] = self.get_form()
|