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.
Files changed (195) hide show
  1. plain/CHANGELOG.md +656 -1
  2. plain/README.md +1 -1
  3. plain/assets/compile.py +25 -12
  4. plain/assets/finders.py +24 -17
  5. plain/assets/fingerprints.py +10 -7
  6. plain/assets/urls.py +1 -1
  7. plain/assets/views.py +47 -33
  8. plain/chores/README.md +25 -23
  9. plain/chores/__init__.py +2 -1
  10. plain/chores/core.py +27 -0
  11. plain/chores/registry.py +23 -36
  12. plain/cli/README.md +185 -16
  13. plain/cli/__init__.py +2 -1
  14. plain/cli/agent.py +236 -0
  15. plain/cli/build.py +7 -8
  16. plain/cli/changelog.py +11 -5
  17. plain/cli/chores.py +32 -34
  18. plain/cli/core.py +110 -26
  19. plain/cli/docs.py +52 -11
  20. plain/cli/formatting.py +40 -17
  21. plain/cli/install.py +10 -54
  22. plain/cli/{agent/llmdocs.py → llmdocs.py} +21 -9
  23. plain/cli/output.py +6 -2
  24. plain/cli/preflight.py +27 -75
  25. plain/cli/print.py +4 -4
  26. plain/cli/registry.py +96 -10
  27. plain/cli/{agent/request.py → request.py} +67 -33
  28. plain/cli/runtime.py +45 -0
  29. plain/cli/scaffold.py +2 -7
  30. plain/cli/server.py +153 -0
  31. plain/cli/settings.py +53 -49
  32. plain/cli/shell.py +15 -12
  33. plain/cli/startup.py +9 -8
  34. plain/cli/upgrade.py +17 -104
  35. plain/cli/urls.py +12 -7
  36. plain/cli/utils.py +3 -3
  37. plain/csrf/README.md +65 -40
  38. plain/csrf/middleware.py +53 -43
  39. plain/debug.py +5 -2
  40. plain/exceptions.py +22 -114
  41. plain/forms/README.md +453 -24
  42. plain/forms/__init__.py +55 -4
  43. plain/forms/boundfield.py +15 -8
  44. plain/forms/exceptions.py +1 -1
  45. plain/forms/fields.py +346 -143
  46. plain/forms/forms.py +75 -45
  47. plain/http/README.md +356 -9
  48. plain/http/__init__.py +41 -26
  49. plain/http/cookie.py +15 -7
  50. plain/http/exceptions.py +65 -0
  51. plain/http/middleware.py +32 -0
  52. plain/http/multipartparser.py +99 -88
  53. plain/http/request.py +362 -250
  54. plain/http/response.py +99 -197
  55. plain/internal/__init__.py +8 -1
  56. plain/internal/files/base.py +35 -19
  57. plain/internal/files/locks.py +19 -11
  58. plain/internal/files/move.py +8 -3
  59. plain/internal/files/temp.py +25 -6
  60. plain/internal/files/uploadedfile.py +47 -28
  61. plain/internal/files/uploadhandler.py +64 -58
  62. plain/internal/files/utils.py +24 -10
  63. plain/internal/handlers/base.py +34 -23
  64. plain/internal/handlers/exception.py +68 -65
  65. plain/internal/handlers/wsgi.py +65 -54
  66. plain/internal/middleware/headers.py +37 -11
  67. plain/internal/middleware/hosts.py +11 -8
  68. plain/internal/middleware/https.py +17 -7
  69. plain/internal/middleware/slash.py +14 -9
  70. plain/internal/reloader.py +77 -0
  71. plain/json.py +2 -1
  72. plain/logs/README.md +161 -62
  73. plain/logs/__init__.py +1 -1
  74. plain/logs/{loggers.py → app.py} +71 -67
  75. plain/logs/configure.py +63 -14
  76. plain/logs/debug.py +17 -6
  77. plain/logs/filters.py +15 -0
  78. plain/logs/formatters.py +7 -4
  79. plain/packages/README.md +105 -23
  80. plain/packages/config.py +15 -7
  81. plain/packages/registry.py +27 -16
  82. plain/paginator.py +31 -21
  83. plain/preflight/README.md +209 -24
  84. plain/preflight/__init__.py +1 -0
  85. plain/preflight/checks.py +3 -1
  86. plain/preflight/files.py +3 -1
  87. plain/preflight/registry.py +26 -11
  88. plain/preflight/results.py +15 -7
  89. plain/preflight/security.py +15 -13
  90. plain/preflight/settings.py +54 -0
  91. plain/preflight/urls.py +4 -1
  92. plain/runtime/README.md +115 -47
  93. plain/runtime/__init__.py +10 -6
  94. plain/runtime/global_settings.py +34 -25
  95. plain/runtime/secret.py +20 -0
  96. plain/runtime/user_settings.py +110 -38
  97. plain/runtime/utils.py +1 -1
  98. plain/server/LICENSE +35 -0
  99. plain/server/README.md +155 -0
  100. plain/server/__init__.py +9 -0
  101. plain/server/app.py +52 -0
  102. plain/server/arbiter.py +555 -0
  103. plain/server/config.py +118 -0
  104. plain/server/errors.py +31 -0
  105. plain/server/glogging.py +292 -0
  106. plain/server/http/__init__.py +12 -0
  107. plain/server/http/body.py +283 -0
  108. plain/server/http/errors.py +155 -0
  109. plain/server/http/message.py +400 -0
  110. plain/server/http/parser.py +70 -0
  111. plain/server/http/unreader.py +88 -0
  112. plain/server/http/wsgi.py +421 -0
  113. plain/server/pidfile.py +92 -0
  114. plain/server/sock.py +240 -0
  115. plain/server/util.py +317 -0
  116. plain/server/workers/__init__.py +6 -0
  117. plain/server/workers/base.py +304 -0
  118. plain/server/workers/sync.py +212 -0
  119. plain/server/workers/thread.py +399 -0
  120. plain/server/workers/workertmp.py +50 -0
  121. plain/signals/README.md +170 -1
  122. plain/signals/__init__.py +0 -1
  123. plain/signals/dispatch/dispatcher.py +49 -27
  124. plain/signing.py +131 -35
  125. plain/skills/README.md +36 -0
  126. plain/skills/plain-docs/SKILL.md +25 -0
  127. plain/skills/plain-install/SKILL.md +26 -0
  128. plain/skills/plain-request/SKILL.md +39 -0
  129. plain/skills/plain-shell/SKILL.md +24 -0
  130. plain/skills/plain-upgrade/SKILL.md +35 -0
  131. plain/templates/README.md +211 -20
  132. plain/templates/jinja/__init__.py +13 -5
  133. plain/templates/jinja/environments.py +5 -4
  134. plain/templates/jinja/extensions.py +12 -5
  135. plain/templates/jinja/filters.py +7 -2
  136. plain/templates/jinja/globals.py +2 -2
  137. plain/test/README.md +184 -22
  138. plain/test/client.py +340 -222
  139. plain/test/encoding.py +9 -6
  140. plain/test/exceptions.py +7 -2
  141. plain/urls/README.md +157 -73
  142. plain/urls/converters.py +18 -15
  143. plain/urls/exceptions.py +2 -2
  144. plain/urls/patterns.py +38 -22
  145. plain/urls/resolvers.py +35 -25
  146. plain/urls/utils.py +5 -1
  147. plain/utils/README.md +250 -3
  148. plain/utils/cache.py +17 -11
  149. plain/utils/crypto.py +21 -5
  150. plain/utils/datastructures.py +89 -56
  151. plain/utils/dateparse.py +9 -6
  152. plain/utils/deconstruct.py +15 -7
  153. plain/utils/decorators.py +5 -1
  154. plain/utils/dotenv.py +373 -0
  155. plain/utils/duration.py +8 -4
  156. plain/utils/encoding.py +14 -7
  157. plain/utils/functional.py +66 -49
  158. plain/utils/hashable.py +5 -1
  159. plain/utils/html.py +36 -22
  160. plain/utils/http.py +16 -9
  161. plain/utils/inspect.py +14 -6
  162. plain/utils/ipv6.py +7 -3
  163. plain/utils/itercompat.py +6 -1
  164. plain/utils/module_loading.py +7 -3
  165. plain/utils/regex_helper.py +37 -23
  166. plain/utils/safestring.py +14 -6
  167. plain/utils/text.py +41 -23
  168. plain/utils/timezone.py +33 -22
  169. plain/utils/tree.py +35 -19
  170. plain/validators.py +94 -52
  171. plain/views/README.md +156 -79
  172. plain/views/__init__.py +0 -1
  173. plain/views/base.py +25 -18
  174. plain/views/errors.py +13 -5
  175. plain/views/exceptions.py +4 -1
  176. plain/views/forms.py +6 -6
  177. plain/views/objects.py +52 -49
  178. plain/views/redirect.py +18 -15
  179. plain/views/templates.py +5 -3
  180. plain/wsgi.py +3 -1
  181. {plain-0.68.0.dist-info → plain-0.101.2.dist-info}/METADATA +4 -2
  182. plain-0.101.2.dist-info/RECORD +201 -0
  183. {plain-0.68.0.dist-info → plain-0.101.2.dist-info}/WHEEL +1 -1
  184. plain-0.101.2.dist-info/entry_points.txt +2 -0
  185. plain/AGENTS.md +0 -18
  186. plain/cli/agent/__init__.py +0 -20
  187. plain/cli/agent/docs.py +0 -80
  188. plain/cli/agent/md.py +0 -87
  189. plain/cli/agent/prompt.py +0 -45
  190. plain/csrf/views.py +0 -31
  191. plain/logs/utils.py +0 -46
  192. plain/templates/AGENTS.md +0 -3
  193. plain-0.68.0.dist-info/RECORD +0 -169
  194. plain-0.68.0.dist-info/entry_points.txt +0 -5
  195. {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 validation.**
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
- The `Form` and `Field` classes help output, parse, and validate form data from an HTTP request. Unlike other frameworks, the HTML inputs are not rendered automatically, though there are some helpers for you to do your own rendering.
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
- Then in your template, you can render the form fields.
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
- ```html
31
- {% extends "base.html" %}
50
+ ## Fields
32
51
 
33
- {% block content %}
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
- <!-- Render general form errors -->
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
- {% if form.email.errors %}
51
- <div>{{ form.email.errors|join(', ') }}</div>
52
- {% endif %}
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 }}">{{ form.message.value }}</textarea>
298
+ id="{{ form.message.html_id }}"
299
+ {% if form.message.field.required %}required{% endif %}>{{ form.message.value }}</textarea>
62
300
 
63
- {% if form.message.errors %}
64
- <div>{{ form.message.errors|join(', ') }}</div>
65
- {% endif %}
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">Submit</button>
306
+ <button type="submit">Send</button>
69
307
  </form>
308
+ ```
70
309
 
71
- {% endblock %}
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
- With manual form rendering, you have full control over the HTML classes, attributes, and JS behavior. But in large applications the form rendering can become repetitive. You will often end up re-using certain patterns in your HTML which can be abstracted away using 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).
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 # NOQA
6
- from .exceptions import FormFieldMissingError, ValidationError # NOQA
7
- from .fields import * # NOQA
8
- from .forms import Form # NOQA
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 str(auto_id):
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