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