plain 0.68.0__py3-none-any.whl → 0.101.2__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 +656 -1
- plain/README.md +1 -1
- 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 +236 -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 +52 -11
- plain/cli/formatting.py +40 -17
- plain/cli/install.py +10 -54
- plain/cli/{agent/llmdocs.py → llmdocs.py} +21 -9
- 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/skills/README.md +36 -0
- plain/skills/plain-docs/SKILL.md +25 -0
- plain/skills/plain-install/SKILL.md +26 -0
- plain/skills/plain-request/SKILL.md +39 -0
- plain/skills/plain-shell/SKILL.md +24 -0
- plain/skills/plain-upgrade/SKILL.md +35 -0
- 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.101.2.dist-info}/METADATA +4 -2
- plain-0.101.2.dist-info/RECORD +201 -0
- {plain-0.68.0.dist-info → plain-0.101.2.dist-info}/WHEEL +1 -1
- plain-0.101.2.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.101.2.dist-info}/licenses/LICENSE +0 -0
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
|
+
```
|
plain/forms/__init__.py
CHANGED
|
@@ -2,7 +2,58 @@
|
|
|
2
2
|
Plain validation and HTML form handling.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
from .boundfield import BoundField
|
|
6
|
-
from .exceptions import FormFieldMissingError, ValidationError
|
|
7
|
-
from .fields import
|
|
8
|
-
|
|
5
|
+
from .boundfield import BoundField
|
|
6
|
+
from .exceptions import FormFieldMissingError, ValidationError
|
|
7
|
+
from .fields import (
|
|
8
|
+
BooleanField,
|
|
9
|
+
CharField,
|
|
10
|
+
ChoiceField,
|
|
11
|
+
DateField,
|
|
12
|
+
DateTimeField,
|
|
13
|
+
DecimalField,
|
|
14
|
+
DurationField,
|
|
15
|
+
EmailField,
|
|
16
|
+
Field,
|
|
17
|
+
FileField,
|
|
18
|
+
FloatField,
|
|
19
|
+
ImageField,
|
|
20
|
+
IntegerField,
|
|
21
|
+
JSONField,
|
|
22
|
+
MultipleChoiceField,
|
|
23
|
+
NullBooleanField,
|
|
24
|
+
RegexField,
|
|
25
|
+
TimeField,
|
|
26
|
+
TypedChoiceField,
|
|
27
|
+
URLField,
|
|
28
|
+
UUIDField,
|
|
29
|
+
)
|
|
30
|
+
from .forms import BaseForm, Form
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"BoundField",
|
|
34
|
+
"FormFieldMissingError",
|
|
35
|
+
"ValidationError",
|
|
36
|
+
"BooleanField",
|
|
37
|
+
"CharField",
|
|
38
|
+
"ChoiceField",
|
|
39
|
+
"DateField",
|
|
40
|
+
"DateTimeField",
|
|
41
|
+
"DecimalField",
|
|
42
|
+
"DurationField",
|
|
43
|
+
"EmailField",
|
|
44
|
+
"Field",
|
|
45
|
+
"FileField",
|
|
46
|
+
"FloatField",
|
|
47
|
+
"ImageField",
|
|
48
|
+
"IntegerField",
|
|
49
|
+
"JSONField",
|
|
50
|
+
"MultipleChoiceField",
|
|
51
|
+
"NullBooleanField",
|
|
52
|
+
"RegexField",
|
|
53
|
+
"TimeField",
|
|
54
|
+
"TypedChoiceField",
|
|
55
|
+
"URLField",
|
|
56
|
+
"UUIDField",
|
|
57
|
+
"BaseForm",
|
|
58
|
+
"Form",
|
|
59
|
+
]
|
plain/forms/boundfield.py
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
from functools import cached_property
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from .fields import Field
|
|
8
|
+
from .forms import BaseForm
|
|
2
9
|
|
|
3
10
|
__all__ = ("BoundField",)
|
|
4
11
|
|
|
@@ -6,24 +13,24 @@ __all__ = ("BoundField",)
|
|
|
6
13
|
class BoundField:
|
|
7
14
|
"A Field plus data"
|
|
8
15
|
|
|
9
|
-
def __init__(self, form, field, name):
|
|
16
|
+
def __init__(self, form: BaseForm, field: Field, name: str):
|
|
10
17
|
self._form = form
|
|
11
18
|
self.field = field
|
|
12
19
|
self.name = name
|
|
13
20
|
self.html_name = form.add_prefix(name)
|
|
14
21
|
self.html_id = form.add_prefix(self._auto_id)
|
|
15
22
|
|
|
16
|
-
def __repr__(self):
|
|
23
|
+
def __repr__(self) -> str:
|
|
17
24
|
return f'<{self.__class__.__name__} "{self.html_name}">'
|
|
18
25
|
|
|
19
26
|
@property
|
|
20
|
-
def errors(self):
|
|
27
|
+
def errors(self) -> list[str]:
|
|
21
28
|
"""
|
|
22
29
|
Return an error list (empty if there are no errors) for this field.
|
|
23
30
|
"""
|
|
24
31
|
return self._form.errors.get(self.name, [])
|
|
25
32
|
|
|
26
|
-
def value(self):
|
|
33
|
+
def value(self) -> Any:
|
|
27
34
|
"""
|
|
28
35
|
Return the value for this BoundField, using the initial value if
|
|
29
36
|
the form is not bound or the data otherwise.
|
|
@@ -36,22 +43,22 @@ class BoundField:
|
|
|
36
43
|
return self.field.prepare_value(data)
|
|
37
44
|
|
|
38
45
|
@cached_property
|
|
39
|
-
def initial(self):
|
|
46
|
+
def initial(self) -> Any:
|
|
40
47
|
return self._form.get_initial_for_field(self.field, self.name)
|
|
41
48
|
|
|
42
|
-
def _has_changed(self):
|
|
49
|
+
def _has_changed(self) -> bool:
|
|
43
50
|
return self.field.has_changed(
|
|
44
51
|
self.initial, self._form._field_data_value(self.field, self.html_name)
|
|
45
52
|
)
|
|
46
53
|
|
|
47
54
|
@property
|
|
48
|
-
def _auto_id(self):
|
|
55
|
+
def _auto_id(self) -> str:
|
|
49
56
|
"""
|
|
50
57
|
Calculate and return the ID attribute for this BoundField, if the
|
|
51
58
|
associated Form has specified auto_id. Return an empty string otherwise.
|
|
52
59
|
"""
|
|
53
60
|
auto_id = self._form._auto_id # Boolean or string
|
|
54
|
-
if auto_id and "%s" in
|
|
61
|
+
if auto_id and isinstance(auto_id, str) and "%s" in auto_id:
|
|
55
62
|
return auto_id % self.html_name
|
|
56
63
|
elif auto_id:
|
|
57
64
|
return self.html_name
|
plain/forms/exceptions.py
CHANGED
|
@@ -2,7 +2,7 @@ from plain.exceptions import ValidationError
|
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
class FormFieldMissingError(Exception):
|
|
5
|
-
def __init__(self, field_name):
|
|
5
|
+
def __init__(self, field_name: str):
|
|
6
6
|
self.field_name = field_name
|
|
7
7
|
self.message = f'The "{self.field_name}" field is missing from the form data.'
|
|
8
8
|
|