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/forms/forms.py
CHANGED
|
@@ -2,21 +2,37 @@
|
|
|
2
2
|
Form classes
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
5
7
|
import copy
|
|
6
8
|
from functools import cached_property
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
7
10
|
|
|
8
11
|
from plain.exceptions import NON_FIELD_ERRORS
|
|
12
|
+
from plain.internal import internalcode
|
|
13
|
+
from plain.utils.datastructures import MultiValueDict
|
|
9
14
|
|
|
10
15
|
from .exceptions import ValidationError
|
|
11
16
|
from .fields import Field, FileField
|
|
12
17
|
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from plain.http import Request
|
|
20
|
+
|
|
21
|
+
from .boundfield import BoundField
|
|
22
|
+
|
|
13
23
|
__all__ = ("BaseForm", "Form")
|
|
14
24
|
|
|
15
25
|
|
|
26
|
+
@internalcode
|
|
16
27
|
class DeclarativeFieldsMetaclass(type):
|
|
17
28
|
"""Collect Fields declared on the base classes."""
|
|
18
29
|
|
|
19
|
-
def __new__(
|
|
30
|
+
def __new__(
|
|
31
|
+
mcs: type[DeclarativeFieldsMetaclass],
|
|
32
|
+
name: str,
|
|
33
|
+
bases: tuple[type, ...],
|
|
34
|
+
attrs: dict[str, Any],
|
|
35
|
+
) -> type:
|
|
20
36
|
# Collect fields from current class and remove them from attrs.
|
|
21
37
|
attrs["declared_fields"] = {
|
|
22
38
|
key: attrs.pop(key)
|
|
@@ -27,19 +43,19 @@ class DeclarativeFieldsMetaclass(type):
|
|
|
27
43
|
new_class = super().__new__(mcs, name, bases, attrs)
|
|
28
44
|
|
|
29
45
|
# Walk through the MRO.
|
|
30
|
-
declared_fields = {}
|
|
46
|
+
declared_fields: dict[str, Field] = {}
|
|
31
47
|
for base in reversed(new_class.__mro__):
|
|
32
48
|
# Collect fields from base class.
|
|
33
49
|
if hasattr(base, "declared_fields"):
|
|
34
|
-
declared_fields.update(base
|
|
50
|
+
declared_fields.update(getattr(base, "declared_fields"))
|
|
35
51
|
|
|
36
52
|
# Field shadowing.
|
|
37
53
|
for attr, value in base.__dict__.items():
|
|
38
54
|
if value is None and attr in declared_fields:
|
|
39
55
|
declared_fields.pop(attr)
|
|
40
56
|
|
|
41
|
-
new_class
|
|
42
|
-
new_class
|
|
57
|
+
setattr(new_class, "base_fields", declared_fields)
|
|
58
|
+
setattr(new_class, "declared_fields", declared_fields)
|
|
43
59
|
|
|
44
60
|
return new_class
|
|
45
61
|
|
|
@@ -52,22 +68,29 @@ class BaseForm:
|
|
|
52
68
|
class.
|
|
53
69
|
"""
|
|
54
70
|
|
|
55
|
-
|
|
71
|
+
# Set by DeclarativeFieldsMetaclass
|
|
72
|
+
base_fields: dict[str, Field]
|
|
73
|
+
|
|
74
|
+
prefix: str | None = None
|
|
56
75
|
|
|
57
76
|
def __init__(
|
|
58
77
|
self,
|
|
59
78
|
*,
|
|
60
|
-
request,
|
|
61
|
-
auto_id="id_%s",
|
|
62
|
-
prefix=None,
|
|
63
|
-
initial=None,
|
|
79
|
+
request: Request,
|
|
80
|
+
auto_id: str | bool = "id_%s",
|
|
81
|
+
prefix: str | None = None,
|
|
82
|
+
initial: dict[str, Any] | None = None,
|
|
64
83
|
):
|
|
65
|
-
|
|
66
|
-
self.files = request.files
|
|
67
|
-
|
|
84
|
+
# Forms can handle both JSON and form data
|
|
68
85
|
self.is_json_request = request.headers.get("Content-Type", "").startswith(
|
|
69
86
|
"application/json"
|
|
70
87
|
)
|
|
88
|
+
if self.is_json_request:
|
|
89
|
+
self.data = request.json_data
|
|
90
|
+
self.files = MultiValueDict()
|
|
91
|
+
else:
|
|
92
|
+
self.data = request.form_data
|
|
93
|
+
self.files = request.files
|
|
71
94
|
|
|
72
95
|
self.is_bound = bool(self.data or self.files)
|
|
73
96
|
|
|
@@ -75,17 +98,19 @@ class BaseForm:
|
|
|
75
98
|
if prefix is not None:
|
|
76
99
|
self.prefix = prefix
|
|
77
100
|
self.initial = initial or {}
|
|
78
|
-
self._errors
|
|
101
|
+
self._errors: dict[str, list[str]] | None = (
|
|
102
|
+
None # Stores the errors after clean() has been called.
|
|
103
|
+
)
|
|
79
104
|
|
|
80
105
|
# The base_fields class attribute is the *class-wide* definition of
|
|
81
106
|
# fields. Because a particular *instance* of the class might want to
|
|
82
107
|
# alter self.fields, we create self.fields here by copying base_fields.
|
|
83
108
|
# Instances should always modify self.fields; they should not modify
|
|
84
109
|
# self.base_fields.
|
|
85
|
-
self.fields = copy.deepcopy(self.base_fields)
|
|
86
|
-
self._bound_fields_cache = {}
|
|
110
|
+
self.fields: dict[str, Field] = copy.deepcopy(self.base_fields)
|
|
111
|
+
self._bound_fields_cache: dict[str, BoundField] = {}
|
|
87
112
|
|
|
88
|
-
def __repr__(self):
|
|
113
|
+
def __repr__(self) -> str:
|
|
89
114
|
if self._errors is None:
|
|
90
115
|
is_valid = "Unknown"
|
|
91
116
|
else:
|
|
@@ -97,17 +122,17 @@ class BaseForm:
|
|
|
97
122
|
fields=";".join(self.fields),
|
|
98
123
|
)
|
|
99
124
|
|
|
100
|
-
def _bound_items(self):
|
|
125
|
+
def _bound_items(self) -> Any:
|
|
101
126
|
"""Yield (name, bf) pairs, where bf is a BoundField object."""
|
|
102
127
|
for name in self.fields:
|
|
103
128
|
yield name, self[name]
|
|
104
129
|
|
|
105
|
-
def __iter__(self):
|
|
130
|
+
def __iter__(self) -> Any:
|
|
106
131
|
"""Yield the form's fields as BoundField objects."""
|
|
107
132
|
for name in self.fields:
|
|
108
133
|
yield self[name]
|
|
109
134
|
|
|
110
|
-
def __getitem__(self, name):
|
|
135
|
+
def __getitem__(self, name: str) -> BoundField:
|
|
111
136
|
"""Return a BoundField with the given name."""
|
|
112
137
|
try:
|
|
113
138
|
field = self.fields[name]
|
|
@@ -124,17 +149,18 @@ class BaseForm:
|
|
|
124
149
|
return self._bound_fields_cache[name]
|
|
125
150
|
|
|
126
151
|
@property
|
|
127
|
-
def errors(self):
|
|
152
|
+
def errors(self) -> dict[str, list[str]]:
|
|
128
153
|
"""Return an error dict for the data provided for the form."""
|
|
129
154
|
if self._errors is None:
|
|
130
155
|
self.full_clean()
|
|
156
|
+
assert self._errors is not None, "full_clean should initialize _errors"
|
|
131
157
|
return self._errors
|
|
132
158
|
|
|
133
|
-
def is_valid(self):
|
|
159
|
+
def is_valid(self) -> bool:
|
|
134
160
|
"""Return True if the form has no errors, or False otherwise."""
|
|
135
161
|
return self.is_bound and not self.errors
|
|
136
162
|
|
|
137
|
-
def add_prefix(self, field_name):
|
|
163
|
+
def add_prefix(self, field_name: str) -> str:
|
|
138
164
|
"""
|
|
139
165
|
Return the field name with a prefix appended, if this Form has a
|
|
140
166
|
prefix set.
|
|
@@ -144,7 +170,7 @@ class BaseForm:
|
|
|
144
170
|
return f"{self.prefix}-{field_name}" if self.prefix else field_name
|
|
145
171
|
|
|
146
172
|
@property
|
|
147
|
-
def non_field_errors(self):
|
|
173
|
+
def non_field_errors(self) -> list[str]:
|
|
148
174
|
"""
|
|
149
175
|
Return a list of errors that aren't associated with a particular
|
|
150
176
|
field -- i.e., from Form.clean(). Return an empty list if there
|
|
@@ -155,7 +181,7 @@ class BaseForm:
|
|
|
155
181
|
[],
|
|
156
182
|
)
|
|
157
183
|
|
|
158
|
-
def add_error(self, field, error):
|
|
184
|
+
def add_error(self, field: str | None, error: ValidationError) -> None:
|
|
159
185
|
"""
|
|
160
186
|
Update the content of `self._errors`.
|
|
161
187
|
|
|
@@ -179,6 +205,7 @@ class BaseForm:
|
|
|
179
205
|
f"`ValidationError`, not `{type(error).__name__}`."
|
|
180
206
|
)
|
|
181
207
|
|
|
208
|
+
error_dict: dict[str, Any]
|
|
182
209
|
if hasattr(error, "error_dict"):
|
|
183
210
|
if field is not None:
|
|
184
211
|
raise TypeError(
|
|
@@ -186,45 +213,48 @@ class BaseForm:
|
|
|
186
213
|
"argument contains errors for multiple fields."
|
|
187
214
|
)
|
|
188
215
|
else:
|
|
189
|
-
|
|
216
|
+
error_dict = error.error_dict
|
|
190
217
|
else:
|
|
191
|
-
|
|
218
|
+
error_dict = {field or NON_FIELD_ERRORS: error.error_list}
|
|
192
219
|
|
|
193
220
|
class ValidationErrors(list):
|
|
194
|
-
def __iter__(self):
|
|
221
|
+
def __iter__(self) -> Any:
|
|
195
222
|
for err in super().__iter__():
|
|
196
223
|
# TODO make sure this works...
|
|
197
224
|
yield next(iter(err))
|
|
198
225
|
|
|
199
|
-
for
|
|
200
|
-
|
|
201
|
-
|
|
226
|
+
for field_key, error_list in error_dict.items():
|
|
227
|
+
# Accessing self.errors ensures _errors is initialized
|
|
228
|
+
if field_key not in self.errors:
|
|
229
|
+
if field_key != NON_FIELD_ERRORS and field_key not in self.fields:
|
|
202
230
|
raise ValueError(
|
|
203
|
-
f"'{self.__class__.__name__}' has no field named '{
|
|
231
|
+
f"'{self.__class__.__name__}' has no field named '{field_key}'."
|
|
204
232
|
)
|
|
205
|
-
self._errors
|
|
233
|
+
assert self._errors is not None, "errors property initializes _errors"
|
|
234
|
+
self._errors[field_key] = ValidationErrors()
|
|
206
235
|
|
|
207
|
-
self._errors
|
|
236
|
+
assert self._errors is not None, "errors property initializes _errors"
|
|
237
|
+
self._errors[field_key].extend(error_list)
|
|
208
238
|
|
|
209
239
|
# The field had an error, so removed it from the final data
|
|
210
240
|
# (we use getattr here so errors can be added to uncleaned forms)
|
|
211
|
-
if
|
|
212
|
-
del self.cleaned_data[
|
|
241
|
+
if field_key in getattr(self, "cleaned_data", {}):
|
|
242
|
+
del self.cleaned_data[field_key]
|
|
213
243
|
|
|
214
|
-
def full_clean(self):
|
|
244
|
+
def full_clean(self) -> None:
|
|
215
245
|
"""
|
|
216
246
|
Clean all of self.data and populate self._errors and self.cleaned_data.
|
|
217
247
|
"""
|
|
218
248
|
self._errors = {}
|
|
219
249
|
if not self.is_bound: # Stop further processing.
|
|
220
|
-
return
|
|
250
|
+
return None
|
|
221
251
|
self.cleaned_data = {}
|
|
222
252
|
|
|
223
253
|
self._clean_fields()
|
|
224
254
|
self._clean_form()
|
|
225
255
|
self._post_clean()
|
|
226
256
|
|
|
227
|
-
def _field_data_value(self, field, html_name):
|
|
257
|
+
def _field_data_value(self, field: Field, html_name: str) -> Any:
|
|
228
258
|
if hasattr(self, f"parse_{html_name}"):
|
|
229
259
|
# Allow custom parsing from form data/files at the form level
|
|
230
260
|
return getattr(self, f"parse_{html_name}")()
|
|
@@ -234,7 +264,7 @@ class BaseForm:
|
|
|
234
264
|
else:
|
|
235
265
|
return field.value_from_form_data(self.data, self.files, html_name)
|
|
236
266
|
|
|
237
|
-
def _clean_fields(self):
|
|
267
|
+
def _clean_fields(self) -> None:
|
|
238
268
|
for name, bf in self._bound_items():
|
|
239
269
|
field = bf.field
|
|
240
270
|
|
|
@@ -252,7 +282,7 @@ class BaseForm:
|
|
|
252
282
|
except ValidationError as e:
|
|
253
283
|
self.add_error(name, e)
|
|
254
284
|
|
|
255
|
-
def _clean_form(self):
|
|
285
|
+
def _clean_form(self) -> None:
|
|
256
286
|
try:
|
|
257
287
|
cleaned_data = self.clean()
|
|
258
288
|
except ValidationError as e:
|
|
@@ -261,14 +291,14 @@ class BaseForm:
|
|
|
261
291
|
if cleaned_data is not None:
|
|
262
292
|
self.cleaned_data = cleaned_data
|
|
263
293
|
|
|
264
|
-
def _post_clean(self):
|
|
294
|
+
def _post_clean(self) -> None:
|
|
265
295
|
"""
|
|
266
296
|
An internal hook for performing additional cleaning after form cleaning
|
|
267
297
|
is complete. Used for model validation in model forms.
|
|
268
298
|
"""
|
|
269
299
|
pass
|
|
270
300
|
|
|
271
|
-
def clean(self):
|
|
301
|
+
def clean(self) -> dict[str, Any]:
|
|
272
302
|
"""
|
|
273
303
|
Hook for doing any extra form-wide cleaning after Field.clean() has been
|
|
274
304
|
called on every field. Any ValidationError raised by this method will
|
|
@@ -278,10 +308,10 @@ class BaseForm:
|
|
|
278
308
|
return self.cleaned_data
|
|
279
309
|
|
|
280
310
|
@cached_property
|
|
281
|
-
def changed_data(self):
|
|
311
|
+
def changed_data(self) -> list[str]:
|
|
282
312
|
return [name for name, bf in self._bound_items() if bf._has_changed()]
|
|
283
313
|
|
|
284
|
-
def get_initial_for_field(self, field, field_name):
|
|
314
|
+
def get_initial_for_field(self, field: Field, field_name: str) -> Any:
|
|
285
315
|
"""
|
|
286
316
|
Return initial data for field on form. Use initial data from the form
|
|
287
317
|
or the field, in that order. Evaluate callable values.
|
plain/http/README.md
CHANGED
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
# HTTP
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Request and response handling for Plain applications.**
|
|
4
4
|
|
|
5
5
|
- [Overview](#overview)
|
|
6
|
+
- [Request](#request)
|
|
7
|
+
- [Headers](#headers)
|
|
8
|
+
- [Query parameters](#query-parameters)
|
|
9
|
+
- [Body data](#body-data)
|
|
10
|
+
- [Content negotiation](#content-negotiation)
|
|
11
|
+
- [Cookies](#cookies)
|
|
12
|
+
- [Response](#response)
|
|
13
|
+
- [Response types](#response-types)
|
|
14
|
+
- [Setting cookies](#setting-cookies)
|
|
15
|
+
- [Default response headers](#default-response-headers)
|
|
16
|
+
- [Content Security Policy (CSP)](#content-security-policy-csp)
|
|
17
|
+
- [Middleware](#middleware)
|
|
18
|
+
- [Exceptions](#exceptions)
|
|
19
|
+
- [FAQs](#faqs)
|
|
20
|
+
- [Installation](#installation)
|
|
6
21
|
|
|
7
22
|
## Overview
|
|
8
23
|
|
|
9
|
-
|
|
24
|
+
You interact with [`Request`](./request.py#Request) and [`Response`](./response.py#Response) objects in your views and middleware.
|
|
10
25
|
|
|
11
26
|
```python
|
|
12
27
|
from plain.views import View
|
|
@@ -14,17 +29,349 @@ from plain.http import Response
|
|
|
14
29
|
|
|
15
30
|
class ExampleView(View):
|
|
16
31
|
def get(self):
|
|
17
|
-
#
|
|
18
|
-
|
|
32
|
+
# Access a request header
|
|
33
|
+
user_agent = self.request.headers.get("User-Agent")
|
|
19
34
|
|
|
20
|
-
#
|
|
21
|
-
|
|
35
|
+
# Access a query parameter
|
|
36
|
+
page = self.request.query_params.get("page", "1")
|
|
22
37
|
|
|
23
|
-
#
|
|
38
|
+
# Create and return a response
|
|
24
39
|
response = Response("Hello, world!", status_code=200)
|
|
40
|
+
response.headers["X-Custom-Header"] = "Custom Value"
|
|
41
|
+
return response
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Request
|
|
45
|
+
|
|
46
|
+
The [`Request`](./request.py#Request) object provides access to all incoming HTTP request data.
|
|
47
|
+
|
|
48
|
+
### Headers
|
|
49
|
+
|
|
50
|
+
Access request headers through the `headers` property. Header names are case-insensitive.
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
content_type = self.request.headers.get("Content-Type")
|
|
54
|
+
auth = self.request.headers.get("authorization") # Case-insensitive
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Query parameters
|
|
58
|
+
|
|
59
|
+
Query string parameters are available as a [`QueryDict`](./request.py#QueryDict) through `query_params`.
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
# URL: /search?q=plain&page=2
|
|
63
|
+
query = self.request.query_params.get("q") # "plain"
|
|
64
|
+
page = self.request.query_params.get("page", "1") # "2"
|
|
65
|
+
|
|
66
|
+
# For parameters with multiple values (?tags=python&tags=web)
|
|
67
|
+
tags = self.request.query_params.getlist("tags") # ["python", "web"]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Body data
|
|
71
|
+
|
|
72
|
+
Access request body data based on the content type.
|
|
73
|
+
|
|
74
|
+
**JSON data:**
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
# Returns dict, raises BadRequestError400 for invalid JSON
|
|
78
|
+
data = self.request.json_data
|
|
79
|
+
name = data.get("name")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Form data:**
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
# For application/x-www-form-urlencoded or multipart/form-data
|
|
86
|
+
form = self.request.form_data
|
|
87
|
+
email = form.get("email")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**File uploads:**
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
# For multipart/form-data requests
|
|
94
|
+
uploaded_file = self.request.files.get("document")
|
|
95
|
+
if uploaded_file:
|
|
96
|
+
content = uploaded_file.read()
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**Raw body:**
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
raw_bytes = self.request.body
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Content negotiation
|
|
106
|
+
|
|
107
|
+
Check what content types the client accepts.
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
# Check if client accepts JSON
|
|
111
|
+
if self.request.accepts("application/json"):
|
|
112
|
+
return JsonResponse({"message": "Hello"})
|
|
113
|
+
|
|
114
|
+
# Get preferred type from options
|
|
115
|
+
preferred = self.request.get_preferred_type("text/html", "application/json")
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Cookies
|
|
119
|
+
|
|
120
|
+
Read cookies from the request.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
session_id = self.request.cookies.get("session_id")
|
|
124
|
+
|
|
125
|
+
# Read a signed cookie (returns None if signature is invalid)
|
|
126
|
+
user_id = self.request.get_signed_cookie("user_id", default=None)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Response
|
|
130
|
+
|
|
131
|
+
The [`Response`](./response.py#Response) class creates HTTP responses with string or bytes content.
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from plain.http import Response
|
|
135
|
+
|
|
136
|
+
# Basic response
|
|
137
|
+
response = Response("Hello, world!")
|
|
138
|
+
|
|
139
|
+
# With status code and headers
|
|
140
|
+
response = Response(
|
|
141
|
+
content="Created!",
|
|
142
|
+
status_code=201,
|
|
143
|
+
headers={"X-Custom": "value"},
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Set content type
|
|
147
|
+
response = Response("<h1>Hello</h1>", content_type="text/html")
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Response types
|
|
151
|
+
|
|
152
|
+
Plain provides specialized response classes for common use cases.
|
|
153
|
+
|
|
154
|
+
**JSON responses:**
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from plain.http import JsonResponse
|
|
158
|
+
|
|
159
|
+
return JsonResponse({"name": "Plain", "version": "1.0"})
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**Redirects:**
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from plain.http import RedirectResponse
|
|
166
|
+
|
|
167
|
+
return RedirectResponse("/new-location")
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
**File downloads:**
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
from plain.http import FileResponse
|
|
174
|
+
|
|
175
|
+
# Serve a file
|
|
176
|
+
return FileResponse(open("report.pdf", "rb"))
|
|
177
|
+
|
|
178
|
+
# Force download with custom filename
|
|
179
|
+
return FileResponse(
|
|
180
|
+
open("report.pdf", "rb"),
|
|
181
|
+
as_attachment=True,
|
|
182
|
+
filename="monthly-report.pdf",
|
|
183
|
+
)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
**Streaming responses:**
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from plain.http import StreamingResponse
|
|
190
|
+
|
|
191
|
+
def generate_data():
|
|
192
|
+
for i in range(1000):
|
|
193
|
+
yield f"Line {i}\n"
|
|
194
|
+
|
|
195
|
+
return StreamingResponse(generate_data(), content_type="text/plain")
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Other response types include [`NotModifiedResponse`](./response.py#NotModifiedResponse) (304) and [`NotAllowedResponse`](./response.py#NotAllowedResponse) (405).
|
|
25
199
|
|
|
26
|
-
|
|
27
|
-
response.headers["Example-Header"] = "Example Value"
|
|
200
|
+
### Setting cookies
|
|
28
201
|
|
|
202
|
+
Set cookies on the response.
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
response = Response("Welcome!")
|
|
206
|
+
response.set_cookie("session_id", "abc123", httponly=True, secure=True)
|
|
207
|
+
|
|
208
|
+
# With expiration
|
|
209
|
+
response.set_cookie("remember_me", "yes", max_age=86400 * 30) # 30 days
|
|
210
|
+
|
|
211
|
+
# Signed cookie (tamper-proof)
|
|
212
|
+
response.set_signed_cookie("user_id", "42", httponly=True)
|
|
213
|
+
|
|
214
|
+
# Delete a cookie
|
|
215
|
+
response.delete_cookie("old_cookie")
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Default response headers
|
|
219
|
+
|
|
220
|
+
Plain applies default headers from `DEFAULT_RESPONSE_HEADERS` in settings to all responses. You can customize these per-view.
|
|
221
|
+
|
|
222
|
+
**Override a default header:**
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
response = Response("content")
|
|
226
|
+
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
**Remove a default header:**
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
response = Response("content")
|
|
233
|
+
response.headers["X-Frame-Options"] = None # Removes the header
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
**Extend a default header:**
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
from plain.runtime import settings
|
|
240
|
+
|
|
241
|
+
if csp := settings.DEFAULT_RESPONSE_HEADERS.get("Content-Security-Policy"):
|
|
242
|
+
csp = csp.format(request=self.request)
|
|
243
|
+
response.headers["Content-Security-Policy"] = f"{csp}; script-src https://cdn.example.com"
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Content Security Policy (CSP)
|
|
247
|
+
|
|
248
|
+
Plain includes built-in support for Content Security Policy through nonces. Each request generates a unique cryptographically secure nonce available via `request.csp_nonce`.
|
|
249
|
+
|
|
250
|
+
**Configure CSP in settings:**
|
|
251
|
+
|
|
252
|
+
```python
|
|
253
|
+
# app/settings.py
|
|
254
|
+
DEFAULT_RESPONSE_HEADERS = {
|
|
255
|
+
"Content-Security-Policy": (
|
|
256
|
+
"default-src 'self'; "
|
|
257
|
+
"script-src 'self' 'nonce-{request.csp_nonce}'; "
|
|
258
|
+
"style-src 'self' 'nonce-{request.csp_nonce}'; "
|
|
259
|
+
"img-src 'self' data:; "
|
|
260
|
+
"font-src 'self'; "
|
|
261
|
+
"connect-src 'self'; "
|
|
262
|
+
"frame-ancestors 'self'; "
|
|
263
|
+
"base-uri 'self'; "
|
|
264
|
+
"form-action 'self'"
|
|
265
|
+
),
|
|
266
|
+
"X-Frame-Options": "DENY",
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
The `{request.csp_nonce}` placeholder is replaced with a unique nonce for each request.
|
|
271
|
+
|
|
272
|
+
**Use nonces in templates:**
|
|
273
|
+
|
|
274
|
+
```html
|
|
275
|
+
<script nonce="{{ request.csp_nonce }}">
|
|
276
|
+
console.log("This script is allowed by CSP");
|
|
277
|
+
</script>
|
|
278
|
+
|
|
279
|
+
<style nonce="{{ request.csp_nonce }}">
|
|
280
|
+
.example { color: red; }
|
|
281
|
+
</style>
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
External scripts and stylesheets loaded from `'self'` don't need nonces:
|
|
285
|
+
|
|
286
|
+
```html
|
|
287
|
+
<script src="/assets/app.js"></script>
|
|
288
|
+
<link rel="stylesheet" href="/assets/app.css">
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Use [Google's CSP Evaluator](https://csp-evaluator.withgoogle.com/) to analyze your CSP policy.
|
|
292
|
+
|
|
293
|
+
## Middleware
|
|
294
|
+
|
|
295
|
+
Create custom middleware by subclassing [`HttpMiddleware`](./middleware.py#HttpMiddleware).
|
|
296
|
+
|
|
297
|
+
```python
|
|
298
|
+
from plain.http import HttpMiddleware, Request, Response
|
|
299
|
+
|
|
300
|
+
class TimingMiddleware(HttpMiddleware):
|
|
301
|
+
def process_request(self, request: Request) -> Response:
|
|
302
|
+
import time
|
|
303
|
+
start = time.time()
|
|
304
|
+
|
|
305
|
+
response = self.get_response(request)
|
|
306
|
+
|
|
307
|
+
duration = time.time() - start
|
|
308
|
+
response.headers["X-Request-Duration"] = f"{duration:.3f}s"
|
|
29
309
|
return response
|
|
30
310
|
```
|
|
311
|
+
|
|
312
|
+
## Exceptions
|
|
313
|
+
|
|
314
|
+
Raise exceptions to return specific HTTP error responses.
|
|
315
|
+
|
|
316
|
+
```python
|
|
317
|
+
from plain.http import NotFoundError404, ForbiddenError403, BadRequestError400
|
|
318
|
+
|
|
319
|
+
# Return 404
|
|
320
|
+
raise NotFoundError404("Page not found")
|
|
321
|
+
|
|
322
|
+
# Return 403
|
|
323
|
+
raise ForbiddenError403("Access denied")
|
|
324
|
+
|
|
325
|
+
# Return 400
|
|
326
|
+
raise BadRequestError400("Invalid input")
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Additional exceptions include [`SuspiciousOperationError400`](./exceptions.py#SuspiciousOperationError400), [`TooManyFieldsSentError400`](./exceptions.py#TooManyFieldsSentError400), [`TooManyFilesSentError400`](./exceptions.py#TooManyFilesSentError400), and [`RequestDataTooBigError400`](./exceptions.py#RequestDataTooBigError400).
|
|
330
|
+
|
|
331
|
+
## FAQs
|
|
332
|
+
|
|
333
|
+
#### How do I access the client's IP address?
|
|
334
|
+
|
|
335
|
+
Use `request.client_ip`. If you're behind a proxy, enable `HTTP_X_FORWARDED_FOR` in settings.
|
|
336
|
+
|
|
337
|
+
```python
|
|
338
|
+
ip = self.request.client_ip
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
#### How do I build an absolute URL?
|
|
342
|
+
|
|
343
|
+
Use `request.build_absolute_uri()`.
|
|
344
|
+
|
|
345
|
+
```python
|
|
346
|
+
# Current page
|
|
347
|
+
url = self.request.build_absolute_uri()
|
|
348
|
+
|
|
349
|
+
# Specific path
|
|
350
|
+
url = self.request.build_absolute_uri("/api/users")
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
#### How do I check if the request is HTTPS?
|
|
354
|
+
|
|
355
|
+
Use `request.is_https()` or check `request.scheme`.
|
|
356
|
+
|
|
357
|
+
```python
|
|
358
|
+
if self.request.is_https():
|
|
359
|
+
# Secure connection
|
|
360
|
+
pass
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
#### What's the difference between QueryDict and a regular dict?
|
|
364
|
+
|
|
365
|
+
[`QueryDict`](./request.py#QueryDict) handles multiple values for the same key (common in query strings and form data). Use `get()` for a single value or `getlist()` for all values.
|
|
366
|
+
|
|
367
|
+
#### How do I handle large file uploads?
|
|
368
|
+
|
|
369
|
+
Configure `DATA_UPLOAD_MAX_MEMORY_SIZE` in settings. For very large files, consider streaming the upload instead of loading it into memory.
|
|
370
|
+
|
|
371
|
+
## Installation
|
|
372
|
+
|
|
373
|
+
The `plain.http` module is included with Plain by default. No additional installation is required.
|
|
374
|
+
|
|
375
|
+
```python
|
|
376
|
+
from plain.http import Request, Response, JsonResponse
|
|
377
|
+
```
|