plain 0.66.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 (197) hide show
  1. plain/CHANGELOG.md +684 -0
  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 -53
  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 +112 -28
  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 +175 -102
  25. plain/cli/print.py +4 -4
  26. plain/cli/registry.py +95 -26
  27. plain/cli/request.py +206 -0
  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 -13
  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 +40 -15
  82. plain/paginator.py +31 -21
  83. plain/preflight/README.md +208 -23
  84. plain/preflight/__init__.py +5 -24
  85. plain/preflight/checks.py +12 -0
  86. plain/preflight/files.py +19 -13
  87. plain/preflight/registry.py +80 -58
  88. plain/preflight/results.py +37 -0
  89. plain/preflight/security.py +65 -71
  90. plain/preflight/settings.py +54 -0
  91. plain/preflight/urls.py +10 -48
  92. plain/runtime/README.md +115 -47
  93. plain/runtime/__init__.py +10 -6
  94. plain/runtime/global_settings.py +43 -33
  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 +14 -27
  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 +56 -40
  145. plain/urls/resolvers.py +38 -28
  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.66.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.66.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/cli/agent/request.py +0 -181
  191. plain/csrf/views.py +0 -31
  192. plain/logs/utils.py +0 -46
  193. plain/preflight/messages.py +0 -81
  194. plain/templates/AGENTS.md +0 -3
  195. plain-0.66.0.dist-info/RECORD +0 -168
  196. plain-0.66.0.dist-info/entry_points.txt +0 -4
  197. {plain-0.66.0.dist-info → plain-0.101.2.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__(self, message, code=None, params=None):
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(self, 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 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
+ ```