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/exceptions.py
CHANGED
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
Global Plain exception and warning classes.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
5
7
|
import operator
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from typing import Any
|
|
6
10
|
|
|
7
11
|
from plain.utils.hashable import make_hashable
|
|
8
12
|
|
|
9
|
-
# MARK: Configuration and Registry
|
|
13
|
+
# MARK: Configuration and Package Registry
|
|
10
14
|
|
|
11
15
|
|
|
12
16
|
class PackageRegistryNotReady(Exception):
|
|
@@ -21,94 +25,6 @@ class ImproperlyConfigured(Exception):
|
|
|
21
25
|
pass
|
|
22
26
|
|
|
23
27
|
|
|
24
|
-
# MARK: Model and Field Errors
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
class FieldDoesNotExist(Exception):
|
|
28
|
-
"""The requested model field does not exist"""
|
|
29
|
-
|
|
30
|
-
pass
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
class FieldError(Exception):
|
|
34
|
-
"""Some kind of problem with a model field."""
|
|
35
|
-
|
|
36
|
-
pass
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
class ObjectDoesNotExist(Exception):
|
|
40
|
-
"""The requested object does not exist"""
|
|
41
|
-
|
|
42
|
-
pass
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
class MultipleObjectsReturned(Exception):
|
|
46
|
-
"""The query returned multiple objects when only one was expected."""
|
|
47
|
-
|
|
48
|
-
pass
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
# MARK: Security and Suspicious Operations
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
class SuspiciousOperation(Exception):
|
|
55
|
-
"""The user did something suspicious"""
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
class SuspiciousMultipartForm(SuspiciousOperation):
|
|
59
|
-
"""Suspect MIME request in multipart form data"""
|
|
60
|
-
|
|
61
|
-
pass
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
class SuspiciousFileOperation(SuspiciousOperation):
|
|
65
|
-
"""A Suspicious filesystem operation was attempted"""
|
|
66
|
-
|
|
67
|
-
pass
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
class TooManyFieldsSent(SuspiciousOperation):
|
|
71
|
-
"""
|
|
72
|
-
The number of fields in a GET or POST request exceeded
|
|
73
|
-
settings.DATA_UPLOAD_MAX_NUMBER_FIELDS.
|
|
74
|
-
"""
|
|
75
|
-
|
|
76
|
-
pass
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
class TooManyFilesSent(SuspiciousOperation):
|
|
80
|
-
"""
|
|
81
|
-
The number of fields in a GET or POST request exceeded
|
|
82
|
-
settings.DATA_UPLOAD_MAX_NUMBER_FILES.
|
|
83
|
-
"""
|
|
84
|
-
|
|
85
|
-
pass
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
class RequestDataTooBig(SuspiciousOperation):
|
|
89
|
-
"""
|
|
90
|
-
The size of the request (excluding any file uploads) exceeded
|
|
91
|
-
settings.DATA_UPLOAD_MAX_MEMORY_SIZE.
|
|
92
|
-
"""
|
|
93
|
-
|
|
94
|
-
pass
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
# MARK: HTTP and Request Errors
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
class BadRequest(Exception):
|
|
101
|
-
"""The request is malformed and cannot be processed."""
|
|
102
|
-
|
|
103
|
-
pass
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
class PermissionDenied(Exception):
|
|
107
|
-
"""The user did not have permission to do that"""
|
|
108
|
-
|
|
109
|
-
pass
|
|
110
|
-
|
|
111
|
-
|
|
112
28
|
# MARK: Validation
|
|
113
29
|
|
|
114
30
|
NON_FIELD_ERRORS = "__all__"
|
|
@@ -117,7 +33,12 @@ NON_FIELD_ERRORS = "__all__"
|
|
|
117
33
|
class ValidationError(Exception):
|
|
118
34
|
"""An error while validating data."""
|
|
119
35
|
|
|
120
|
-
def __init__(
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
message: str | list[Any] | dict[str, Any] | ValidationError,
|
|
39
|
+
code: str | None = None,
|
|
40
|
+
params: dict[str, Any] | None = None,
|
|
41
|
+
):
|
|
121
42
|
"""
|
|
122
43
|
The `message` argument can be a single error, a list of errors, or a
|
|
123
44
|
dictionary that maps field names to lists of errors. What we define as
|
|
@@ -161,12 +82,14 @@ class ValidationError(Exception):
|
|
|
161
82
|
self.error_list = [self]
|
|
162
83
|
|
|
163
84
|
@property
|
|
164
|
-
def messages(self):
|
|
85
|
+
def messages(self) -> list[str]:
|
|
165
86
|
if hasattr(self, "error_dict"):
|
|
166
|
-
return sum(dict(self).values(), [])
|
|
87
|
+
return sum(dict(self).values(), []) # type: ignore[arg-type]
|
|
167
88
|
return list(self)
|
|
168
89
|
|
|
169
|
-
def update_error_dict(
|
|
90
|
+
def update_error_dict(
|
|
91
|
+
self, error_dict: dict[str, list[ValidationError]]
|
|
92
|
+
) -> dict[str, list[ValidationError]]:
|
|
170
93
|
if hasattr(self, "error_dict"):
|
|
171
94
|
for field, error_list in self.error_dict.items():
|
|
172
95
|
error_dict.setdefault(field, []).extend(error_list)
|
|
@@ -174,7 +97,7 @@ class ValidationError(Exception):
|
|
|
174
97
|
error_dict.setdefault(NON_FIELD_ERRORS, []).extend(self.error_list)
|
|
175
98
|
return error_dict
|
|
176
99
|
|
|
177
|
-
def __iter__(self):
|
|
100
|
+
def __iter__(self) -> Iterator[tuple[str, list[str]] | str]:
|
|
178
101
|
if hasattr(self, "error_dict"):
|
|
179
102
|
for field, errors in self.error_dict.items():
|
|
180
103
|
yield field, list(ValidationError(errors))
|
|
@@ -185,20 +108,20 @@ class ValidationError(Exception):
|
|
|
185
108
|
message %= error.params
|
|
186
109
|
yield str(message)
|
|
187
110
|
|
|
188
|
-
def __str__(self):
|
|
111
|
+
def __str__(self) -> str:
|
|
189
112
|
if hasattr(self, "error_dict"):
|
|
190
|
-
return repr(dict(self))
|
|
113
|
+
return repr(dict(self)) # type: ignore[arg-type]
|
|
191
114
|
return repr(list(self))
|
|
192
115
|
|
|
193
|
-
def __repr__(self):
|
|
116
|
+
def __repr__(self) -> str:
|
|
194
117
|
return f"ValidationError({self})"
|
|
195
118
|
|
|
196
|
-
def __eq__(self, other):
|
|
119
|
+
def __eq__(self, other: object) -> bool:
|
|
197
120
|
if not isinstance(other, ValidationError):
|
|
198
121
|
return NotImplemented
|
|
199
122
|
return hash(self) == hash(other)
|
|
200
123
|
|
|
201
|
-
def __hash__(self):
|
|
124
|
+
def __hash__(self) -> int:
|
|
202
125
|
if hasattr(self, "message"):
|
|
203
126
|
return hash(
|
|
204
127
|
(
|
|
@@ -210,18 +133,3 @@ class ValidationError(Exception):
|
|
|
210
133
|
if hasattr(self, "error_dict"):
|
|
211
134
|
return hash(make_hashable(self.error_dict))
|
|
212
135
|
return hash(tuple(sorted(self.error_list, key=operator.attrgetter("message"))))
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
# MARK: Database
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
class EmptyResultSet(Exception):
|
|
219
|
-
"""A database query predicate is impossible."""
|
|
220
|
-
|
|
221
|
-
pass
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
class FullResultSet(Exception):
|
|
225
|
-
"""A database query predicate is matches everything."""
|
|
226
|
-
|
|
227
|
-
pass
|
plain/forms/README.md
CHANGED
|
@@ -1,14 +1,27 @@
|
|
|
1
1
|
# Forms
|
|
2
2
|
|
|
3
|
-
**HTML form handling and
|
|
3
|
+
**HTML form handling, validation, and data parsing.**
|
|
4
4
|
|
|
5
5
|
- [Overview](#overview)
|
|
6
|
+
- [Fields](#fields)
|
|
7
|
+
- [Text fields](#text-fields)
|
|
8
|
+
- [Numeric fields](#numeric-fields)
|
|
9
|
+
- [Date and time fields](#date-and-time-fields)
|
|
10
|
+
- [Choice fields](#choice-fields)
|
|
11
|
+
- [File fields](#file-fields)
|
|
12
|
+
- [Other fields](#other-fields)
|
|
13
|
+
- [Validation](#validation)
|
|
14
|
+
- [Field-level validation](#field-level-validation)
|
|
15
|
+
- [Form-level validation](#form-level-validation)
|
|
16
|
+
- [Custom error messages](#custom-error-messages)
|
|
17
|
+
- [Rendering forms in templates](#rendering-forms-in-templates)
|
|
18
|
+
- [JSON data](#json-data)
|
|
19
|
+
- [FAQs](#faqs)
|
|
20
|
+
- [Installation](#installation)
|
|
6
21
|
|
|
7
22
|
## Overview
|
|
8
23
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
With forms, you will typically use one of the built-in view classes to tie everything together.
|
|
24
|
+
You can define a form by subclassing `Form` and declaring fields as class attributes. Each field handles parsing, validation, and type coercion for a specific input type.
|
|
12
25
|
|
|
13
26
|
```python
|
|
14
27
|
from plain import forms
|
|
@@ -23,52 +36,468 @@ class ContactForm(forms.Form):
|
|
|
23
36
|
class ContactView(FormView):
|
|
24
37
|
form_class = ContactForm
|
|
25
38
|
template_name = "contact.html"
|
|
39
|
+
|
|
40
|
+
def form_valid(self, form):
|
|
41
|
+
# form.cleaned_data contains validated data
|
|
42
|
+
email = form.cleaned_data["email"]
|
|
43
|
+
message = form.cleaned_data["message"]
|
|
44
|
+
# Do something with the data...
|
|
45
|
+
return super().form_valid(form)
|
|
26
46
|
```
|
|
27
47
|
|
|
28
|
-
|
|
48
|
+
When the form is submitted, you access validated data through `form.cleaned_data`. Each field converts the raw input to an appropriate Python type (strings, integers, dates, etc.).
|
|
29
49
|
|
|
30
|
-
|
|
31
|
-
{% extends "base.html" %}
|
|
50
|
+
## Fields
|
|
32
51
|
|
|
33
|
-
|
|
52
|
+
All fields accept these common parameters:
|
|
53
|
+
|
|
54
|
+
- `required` - Whether the field is required (default: `True`)
|
|
55
|
+
- `initial` - Initial value for unbound forms
|
|
56
|
+
- `error_messages` - Dict of custom error messages
|
|
57
|
+
- `validators` - List of additional validator functions
|
|
58
|
+
|
|
59
|
+
### Text fields
|
|
60
|
+
|
|
61
|
+
**[`CharField`](./fields.py#CharField)** accepts text input with optional length constraints.
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
name = forms.CharField(max_length=100, min_length=2)
|
|
65
|
+
bio = forms.CharField(required=False, strip=True) # strip=True is the default
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**[`EmailField`](./fields.py#EmailField)** validates email addresses.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
email = forms.EmailField()
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**[`URLField`](./fields.py#URLField)** validates URLs and normalizes them (adds `http://` if missing).
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
website = forms.URLField(required=False)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**[`RegexField`](./fields.py#RegexField)** validates against a regular expression.
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
phone = forms.RegexField(regex=r"^\d{3}-\d{4}$")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Numeric fields
|
|
87
|
+
|
|
88
|
+
**[`IntegerField`](./fields.py#IntegerField)** parses integers with optional min/max/step validation.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
age = forms.IntegerField(min_value=0, max_value=150)
|
|
92
|
+
quantity = forms.IntegerField(min_value=1, step_size=1)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**[`FloatField`](./fields.py#FloatField)** parses floating-point numbers.
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
price = forms.FloatField(min_value=0)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**[`DecimalField`](./fields.py#DecimalField)** parses `Decimal` values with precision control.
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
amount = forms.DecimalField(max_digits=10, decimal_places=2)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Date and time fields
|
|
108
|
+
|
|
109
|
+
**[`DateField`](./fields.py#DateField)** parses dates in various formats (e.g., `2024-01-15`, `01/15/2024`).
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
birthday = forms.DateField()
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**[`TimeField`](./fields.py#TimeField)** parses times (e.g., `14:30`, `14:30:59`).
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
start_time = forms.TimeField()
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**[`DateTimeField`](./fields.py#DateTimeField)** parses combined date and time values.
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
scheduled_at = forms.DateTimeField()
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**[`DurationField`](./fields.py#DurationField)** parses time durations into `timedelta` objects.
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
duration = forms.DurationField() # e.g., "1 day, 2:30:00"
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Choice fields
|
|
134
|
+
|
|
135
|
+
**[`ChoiceField`](./fields.py#ChoiceField)** validates against a list of choices.
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
PRIORITY_CHOICES = [
|
|
139
|
+
("low", "Low"),
|
|
140
|
+
("medium", "Medium"),
|
|
141
|
+
("high", "High"),
|
|
142
|
+
]
|
|
143
|
+
priority = forms.ChoiceField(choices=PRIORITY_CHOICES)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
You can also use Python enums directly.
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from enum import Enum
|
|
150
|
+
|
|
151
|
+
class Priority(Enum):
|
|
152
|
+
LOW = "low"
|
|
153
|
+
MEDIUM = "medium"
|
|
154
|
+
HIGH = "high"
|
|
155
|
+
|
|
156
|
+
priority = forms.ChoiceField(choices=Priority)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**[`TypedChoiceField`](./fields.py#TypedChoiceField)** coerces the value to a specific type after validation.
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
year = forms.TypedChoiceField(
|
|
163
|
+
choices=[(str(y), str(y)) for y in range(2020, 2030)],
|
|
164
|
+
coerce=int,
|
|
165
|
+
)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
**[`MultipleChoiceField`](./fields.py#MultipleChoiceField)** allows selecting multiple options.
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
tags = forms.MultipleChoiceField(choices=[("a", "A"), ("b", "B"), ("c", "C")])
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### File fields
|
|
175
|
+
|
|
176
|
+
**[`FileField`](./fields.py#FileField)** handles file uploads.
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
document = forms.FileField(max_length=255) # max_length applies to filename
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
**[`ImageField`](./fields.py#ImageField)** validates that the upload is a valid image (requires Pillow).
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
avatar = forms.ImageField(required=False)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Other fields
|
|
189
|
+
|
|
190
|
+
**[`BooleanField`](./fields.py#BooleanField)** parses boolean values (handles HTML checkbox behavior).
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
subscribe = forms.BooleanField(required=False) # unchecked = False
|
|
194
|
+
terms = forms.BooleanField() # must be checked
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**[`NullBooleanField`](./fields.py#NullBooleanField)** allows `True`, `False`, or `None`.
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
preference = forms.NullBooleanField()
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
**[`UUIDField`](./fields.py#UUIDField)** parses UUID strings into `uuid.UUID` objects.
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
token = forms.UUIDField()
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**[`JSONField`](./fields.py#JSONField)** parses and validates JSON strings.
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
config = forms.JSONField()
|
|
213
|
+
metadata = forms.JSONField(indent=2, sort_keys=True) # for display formatting
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## Validation
|
|
217
|
+
|
|
218
|
+
### Field-level validation
|
|
219
|
+
|
|
220
|
+
You can add custom validation for a specific field by defining a `clean_<fieldname>` method. This runs after the field's built-in validation.
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
class SignupForm(forms.Form):
|
|
224
|
+
username = forms.CharField(max_length=30)
|
|
225
|
+
email = forms.EmailField()
|
|
226
|
+
|
|
227
|
+
def clean_username(self):
|
|
228
|
+
username = self.cleaned_data["username"]
|
|
229
|
+
if username.lower() in ["admin", "root", "system"]:
|
|
230
|
+
raise forms.ValidationError("This username is reserved.")
|
|
231
|
+
return username.lower() # Return the cleaned value
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### Form-level validation
|
|
235
|
+
|
|
236
|
+
Override the `clean()` method for validation that involves multiple fields.
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
class PasswordForm(forms.Form):
|
|
240
|
+
password = forms.CharField()
|
|
241
|
+
password_confirm = forms.CharField()
|
|
242
|
+
|
|
243
|
+
def clean(self):
|
|
244
|
+
cleaned_data = super().clean()
|
|
245
|
+
password = cleaned_data.get("password")
|
|
246
|
+
confirm = cleaned_data.get("password_confirm")
|
|
247
|
+
|
|
248
|
+
if password and confirm and password != confirm:
|
|
249
|
+
raise forms.ValidationError("Passwords do not match.")
|
|
250
|
+
|
|
251
|
+
return cleaned_data
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Errors raised in `clean()` are stored in `form.non_field_errors` since they are not associated with a specific field.
|
|
34
255
|
|
|
256
|
+
### Custom error messages
|
|
257
|
+
|
|
258
|
+
You can customize error messages per field.
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
email = forms.EmailField(
|
|
262
|
+
error_messages={
|
|
263
|
+
"required": "We need your email address.",
|
|
264
|
+
"invalid": "Please enter a valid email.",
|
|
265
|
+
}
|
|
266
|
+
)
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## Rendering forms in templates
|
|
270
|
+
|
|
271
|
+
Forms provide access to field data through [`BoundField`](./boundfield.py#BoundField) objects. You render the HTML inputs yourself, giving you full control over markup and styling.
|
|
272
|
+
|
|
273
|
+
```html
|
|
35
274
|
<form method="post">
|
|
36
|
-
<!--
|
|
275
|
+
<!-- Non-field errors (from form.clean()) -->
|
|
37
276
|
{% for error in form.non_field_errors %}
|
|
38
|
-
<div>{{ error }}</div>
|
|
277
|
+
<div class="error">{{ error }}</div>
|
|
39
278
|
{% endfor %}
|
|
40
279
|
|
|
41
280
|
<div>
|
|
42
281
|
<label for="{{ form.email.html_id }}">Email</label>
|
|
43
282
|
<input
|
|
44
|
-
required
|
|
45
283
|
type="email"
|
|
46
284
|
name="{{ form.email.html_name }}"
|
|
47
285
|
id="{{ form.email.html_id }}"
|
|
48
|
-
value="{{ form.email.value }}"
|
|
286
|
+
value="{{ form.email.value }}"
|
|
287
|
+
{% if form.email.field.required %}required{% endif %}>
|
|
49
288
|
|
|
50
|
-
{%
|
|
51
|
-
<div>{{
|
|
52
|
-
{%
|
|
289
|
+
{% for error in form.email.errors %}
|
|
290
|
+
<div class="field-error">{{ error }}</div>
|
|
291
|
+
{% endfor %}
|
|
53
292
|
</div>
|
|
54
293
|
|
|
55
294
|
<div>
|
|
56
295
|
<label for="{{ form.message.html_id }}">Message</label>
|
|
57
296
|
<textarea
|
|
58
|
-
required
|
|
59
|
-
rows="10"
|
|
60
297
|
name="{{ form.message.html_name }}"
|
|
61
|
-
id="{{ form.message.html_id }}"
|
|
298
|
+
id="{{ form.message.html_id }}"
|
|
299
|
+
{% if form.message.field.required %}required{% endif %}>{{ form.message.value }}</textarea>
|
|
62
300
|
|
|
63
|
-
{%
|
|
64
|
-
<div>{{
|
|
65
|
-
{%
|
|
301
|
+
{% for error in form.message.errors %}
|
|
302
|
+
<div class="field-error">{{ error }}</div>
|
|
303
|
+
{% endfor %}
|
|
66
304
|
</div>
|
|
67
305
|
|
|
68
|
-
<button type="submit">
|
|
306
|
+
<button type="submit">Send</button>
|
|
69
307
|
</form>
|
|
308
|
+
```
|
|
70
309
|
|
|
71
|
-
|
|
310
|
+
Each bound field provides:
|
|
311
|
+
|
|
312
|
+
- `html_name` - The input's `name` attribute
|
|
313
|
+
- `html_id` - The input's `id` attribute
|
|
314
|
+
- `value` - The current value (initial or submitted)
|
|
315
|
+
- `errors` - List of validation error messages
|
|
316
|
+
- `field` - The underlying [`Field`](./fields.py#Field) instance
|
|
317
|
+
- `initial` - The field's initial value
|
|
318
|
+
|
|
319
|
+
For large applications, you can reduce repetition by creating reusable patterns with Jinja [includes](https://jinja.palletsprojects.com/en/stable/templates/#include), [macros](https://jinja.palletsprojects.com/en/stable/templates/#macros), or [plain.elements](/plain-elements/README.md).
|
|
320
|
+
|
|
321
|
+
## JSON data
|
|
322
|
+
|
|
323
|
+
Forms automatically handle JSON request bodies when the `Content-Type` header is `application/json`. The same form class works for both HTML form submissions and JSON API requests.
|
|
324
|
+
|
|
325
|
+
```python
|
|
326
|
+
class ApiForm(forms.Form):
|
|
327
|
+
name = forms.CharField()
|
|
328
|
+
count = forms.IntegerField()
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
For HTML form data:
|
|
332
|
+
|
|
333
|
+
```
|
|
334
|
+
POST /submit
|
|
335
|
+
Content-Type: application/x-www-form-urlencoded
|
|
336
|
+
|
|
337
|
+
name=Example&count=42
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
For JSON data:
|
|
341
|
+
|
|
342
|
+
```
|
|
343
|
+
POST /submit
|
|
344
|
+
Content-Type: application/json
|
|
345
|
+
|
|
346
|
+
{"name": "Example", "count": 42}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Both will validate the same way and populate `cleaned_data` with the same values.
|
|
350
|
+
|
|
351
|
+
## FAQs
|
|
352
|
+
|
|
353
|
+
#### How do I make a field optional?
|
|
354
|
+
|
|
355
|
+
Set `required=False` on the field.
|
|
356
|
+
|
|
357
|
+
```python
|
|
358
|
+
notes = forms.CharField(required=False)
|
|
72
359
|
```
|
|
73
360
|
|
|
74
|
-
|
|
361
|
+
#### How do I pre-populate a form with existing data?
|
|
362
|
+
|
|
363
|
+
Pass an `initial` dict when creating the form in your view.
|
|
364
|
+
|
|
365
|
+
```python
|
|
366
|
+
form = ContactForm(request=request, initial={"email": user.email})
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
#### How do I access the raw submitted data?
|
|
370
|
+
|
|
371
|
+
Use `form.data` to access the raw data dict before validation.
|
|
372
|
+
|
|
373
|
+
```python
|
|
374
|
+
if form.is_bound:
|
|
375
|
+
raw_email = form.data.get("email")
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
#### How do I add custom validators to a field?
|
|
379
|
+
|
|
380
|
+
Pass a list of validator functions to the `validators` parameter.
|
|
381
|
+
|
|
382
|
+
```python
|
|
383
|
+
from plain.validators import MinLengthValidator
|
|
384
|
+
|
|
385
|
+
username = forms.CharField(validators=[MinLengthValidator(3)])
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
#### Why is my checkbox field always `False`?
|
|
389
|
+
|
|
390
|
+
HTML checkboxes don't submit any value when unchecked. `BooleanField` handles this by returning `False` when the field is missing from form data. Make sure you use `required=False` if the checkbox is optional.
|
|
391
|
+
|
|
392
|
+
#### How do I handle multiple forms on one page?
|
|
393
|
+
|
|
394
|
+
Use the `prefix` parameter to namespace each form's fields.
|
|
395
|
+
|
|
396
|
+
```python
|
|
397
|
+
contact_form = ContactForm(request=request, prefix="contact")
|
|
398
|
+
signup_form = SignupForm(request=request, prefix="signup")
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
This prefixes field names like `contact-email` and `signup-email`.
|
|
402
|
+
|
|
403
|
+
## Installation
|
|
404
|
+
|
|
405
|
+
Add `plain.forms` to your `INSTALLED_PACKAGES` in `app/settings.py`.
|
|
406
|
+
|
|
407
|
+
```python
|
|
408
|
+
INSTALLED_PACKAGES = [
|
|
409
|
+
# ...
|
|
410
|
+
"plain.forms",
|
|
411
|
+
]
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Create a form class in your app.
|
|
415
|
+
|
|
416
|
+
```python
|
|
417
|
+
# app/forms.py
|
|
418
|
+
from plain import forms
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class ContactForm(forms.Form):
|
|
422
|
+
name = forms.CharField(max_length=100)
|
|
423
|
+
email = forms.EmailField()
|
|
424
|
+
message = forms.CharField()
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
Use the form with a view. The [`FormView`](/plain-views/README.md) base class handles GET/POST logic automatically.
|
|
428
|
+
|
|
429
|
+
```python
|
|
430
|
+
# app/views.py
|
|
431
|
+
from plain.views import FormView
|
|
432
|
+
|
|
433
|
+
from .forms import ContactForm
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class ContactView(FormView):
|
|
437
|
+
form_class = ContactForm
|
|
438
|
+
template_name = "contact.html"
|
|
439
|
+
|
|
440
|
+
def form_valid(self, form):
|
|
441
|
+
# Process the validated data
|
|
442
|
+
name = form.cleaned_data["name"]
|
|
443
|
+
email = form.cleaned_data["email"]
|
|
444
|
+
message = form.cleaned_data["message"]
|
|
445
|
+
# Send email, save to database, etc.
|
|
446
|
+
return super().form_valid(form)
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Create the template to render the form.
|
|
450
|
+
|
|
451
|
+
```html
|
|
452
|
+
<!-- app/templates/contact.html -->
|
|
453
|
+
{% extends "base.html" %}
|
|
454
|
+
|
|
455
|
+
{% block content %}
|
|
456
|
+
<h1>Contact Us</h1>
|
|
457
|
+
|
|
458
|
+
<form method="post">
|
|
459
|
+
{% for error in form.non_field_errors %}
|
|
460
|
+
<div class="error">{{ error }}</div>
|
|
461
|
+
{% endfor %}
|
|
462
|
+
|
|
463
|
+
<div>
|
|
464
|
+
<label for="{{ form.name.html_id }}">Name</label>
|
|
465
|
+
<input
|
|
466
|
+
type="text"
|
|
467
|
+
name="{{ form.name.html_name }}"
|
|
468
|
+
id="{{ form.name.html_id }}"
|
|
469
|
+
value="{{ form.name.value }}"
|
|
470
|
+
required>
|
|
471
|
+
{% for error in form.name.errors %}
|
|
472
|
+
<div class="field-error">{{ error }}</div>
|
|
473
|
+
{% endfor %}
|
|
474
|
+
</div>
|
|
475
|
+
|
|
476
|
+
<div>
|
|
477
|
+
<label for="{{ form.email.html_id }}">Email</label>
|
|
478
|
+
<input
|
|
479
|
+
type="email"
|
|
480
|
+
name="{{ form.email.html_name }}"
|
|
481
|
+
id="{{ form.email.html_id }}"
|
|
482
|
+
value="{{ form.email.value }}"
|
|
483
|
+
required>
|
|
484
|
+
{% for error in form.email.errors %}
|
|
485
|
+
<div class="field-error">{{ error }}</div>
|
|
486
|
+
{% endfor %}
|
|
487
|
+
</div>
|
|
488
|
+
|
|
489
|
+
<div>
|
|
490
|
+
<label for="{{ form.message.html_id }}">Message</label>
|
|
491
|
+
<textarea
|
|
492
|
+
name="{{ form.message.html_name }}"
|
|
493
|
+
id="{{ form.message.html_id }}"
|
|
494
|
+
required>{{ form.message.value }}</textarea>
|
|
495
|
+
{% for error in form.message.errors %}
|
|
496
|
+
<div class="field-error">{{ error }}</div>
|
|
497
|
+
{% endfor %}
|
|
498
|
+
</div>
|
|
499
|
+
|
|
500
|
+
<button type="submit">Send Message</button>
|
|
501
|
+
</form>
|
|
502
|
+
{% endblock %}
|
|
503
|
+
```
|