plain 0.68.0__py3-none-any.whl → 0.103.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. plain/CHANGELOG.md +684 -1
  2. plain/README.md +1 -1
  3. plain/agents/.claude/rules/plain.md +88 -0
  4. plain/agents/.claude/skills/plain-install/SKILL.md +26 -0
  5. plain/agents/.claude/skills/plain-upgrade/SKILL.md +35 -0
  6. plain/assets/compile.py +25 -12
  7. plain/assets/finders.py +24 -17
  8. plain/assets/fingerprints.py +10 -7
  9. plain/assets/urls.py +1 -1
  10. plain/assets/views.py +47 -33
  11. plain/chores/README.md +25 -23
  12. plain/chores/__init__.py +2 -1
  13. plain/chores/core.py +27 -0
  14. plain/chores/registry.py +23 -36
  15. plain/cli/README.md +185 -16
  16. plain/cli/__init__.py +2 -1
  17. plain/cli/agent.py +234 -0
  18. plain/cli/build.py +7 -8
  19. plain/cli/changelog.py +11 -5
  20. plain/cli/chores.py +32 -34
  21. plain/cli/core.py +110 -26
  22. plain/cli/docs.py +98 -21
  23. plain/cli/formatting.py +40 -17
  24. plain/cli/install.py +10 -54
  25. plain/cli/{agent/llmdocs.py → llmdocs.py} +45 -26
  26. plain/cli/output.py +6 -2
  27. plain/cli/preflight.py +27 -75
  28. plain/cli/print.py +4 -4
  29. plain/cli/registry.py +96 -10
  30. plain/cli/{agent/request.py → request.py} +67 -33
  31. plain/cli/runtime.py +45 -0
  32. plain/cli/scaffold.py +2 -7
  33. plain/cli/server.py +153 -0
  34. plain/cli/settings.py +53 -49
  35. plain/cli/shell.py +15 -12
  36. plain/cli/startup.py +9 -8
  37. plain/cli/upgrade.py +17 -104
  38. plain/cli/urls.py +12 -7
  39. plain/cli/utils.py +3 -3
  40. plain/csrf/README.md +65 -40
  41. plain/csrf/middleware.py +53 -43
  42. plain/debug.py +5 -2
  43. plain/exceptions.py +22 -114
  44. plain/forms/README.md +453 -24
  45. plain/forms/__init__.py +55 -4
  46. plain/forms/boundfield.py +15 -8
  47. plain/forms/exceptions.py +1 -1
  48. plain/forms/fields.py +346 -143
  49. plain/forms/forms.py +75 -45
  50. plain/http/README.md +356 -9
  51. plain/http/__init__.py +41 -26
  52. plain/http/cookie.py +15 -7
  53. plain/http/exceptions.py +65 -0
  54. plain/http/middleware.py +32 -0
  55. plain/http/multipartparser.py +99 -88
  56. plain/http/request.py +362 -250
  57. plain/http/response.py +99 -197
  58. plain/internal/__init__.py +8 -1
  59. plain/internal/files/base.py +35 -19
  60. plain/internal/files/locks.py +19 -11
  61. plain/internal/files/move.py +8 -3
  62. plain/internal/files/temp.py +25 -6
  63. plain/internal/files/uploadedfile.py +47 -28
  64. plain/internal/files/uploadhandler.py +64 -58
  65. plain/internal/files/utils.py +24 -10
  66. plain/internal/handlers/base.py +34 -23
  67. plain/internal/handlers/exception.py +68 -65
  68. plain/internal/handlers/wsgi.py +65 -54
  69. plain/internal/middleware/headers.py +37 -11
  70. plain/internal/middleware/hosts.py +11 -8
  71. plain/internal/middleware/https.py +17 -7
  72. plain/internal/middleware/slash.py +14 -9
  73. plain/internal/reloader.py +77 -0
  74. plain/json.py +2 -1
  75. plain/logs/README.md +161 -62
  76. plain/logs/__init__.py +1 -1
  77. plain/logs/{loggers.py → app.py} +71 -67
  78. plain/logs/configure.py +63 -14
  79. plain/logs/debug.py +17 -6
  80. plain/logs/filters.py +15 -0
  81. plain/logs/formatters.py +7 -4
  82. plain/packages/README.md +105 -23
  83. plain/packages/config.py +15 -7
  84. plain/packages/registry.py +27 -16
  85. plain/paginator.py +31 -21
  86. plain/preflight/README.md +209 -24
  87. plain/preflight/__init__.py +1 -0
  88. plain/preflight/checks.py +3 -1
  89. plain/preflight/files.py +3 -1
  90. plain/preflight/registry.py +26 -11
  91. plain/preflight/results.py +15 -7
  92. plain/preflight/security.py +15 -13
  93. plain/preflight/settings.py +54 -0
  94. plain/preflight/urls.py +4 -1
  95. plain/runtime/README.md +115 -47
  96. plain/runtime/__init__.py +10 -6
  97. plain/runtime/global_settings.py +34 -25
  98. plain/runtime/secret.py +20 -0
  99. plain/runtime/user_settings.py +110 -38
  100. plain/runtime/utils.py +1 -1
  101. plain/server/LICENSE +35 -0
  102. plain/server/README.md +155 -0
  103. plain/server/__init__.py +9 -0
  104. plain/server/app.py +52 -0
  105. plain/server/arbiter.py +555 -0
  106. plain/server/config.py +118 -0
  107. plain/server/errors.py +31 -0
  108. plain/server/glogging.py +292 -0
  109. plain/server/http/__init__.py +12 -0
  110. plain/server/http/body.py +283 -0
  111. plain/server/http/errors.py +155 -0
  112. plain/server/http/message.py +400 -0
  113. plain/server/http/parser.py +70 -0
  114. plain/server/http/unreader.py +88 -0
  115. plain/server/http/wsgi.py +421 -0
  116. plain/server/pidfile.py +92 -0
  117. plain/server/sock.py +240 -0
  118. plain/server/util.py +317 -0
  119. plain/server/workers/__init__.py +6 -0
  120. plain/server/workers/base.py +304 -0
  121. plain/server/workers/sync.py +212 -0
  122. plain/server/workers/thread.py +399 -0
  123. plain/server/workers/workertmp.py +50 -0
  124. plain/signals/README.md +170 -1
  125. plain/signals/__init__.py +0 -1
  126. plain/signals/dispatch/dispatcher.py +49 -27
  127. plain/signing.py +131 -35
  128. plain/templates/README.md +211 -20
  129. plain/templates/jinja/__init__.py +13 -5
  130. plain/templates/jinja/environments.py +5 -4
  131. plain/templates/jinja/extensions.py +12 -5
  132. plain/templates/jinja/filters.py +7 -2
  133. plain/templates/jinja/globals.py +2 -2
  134. plain/test/README.md +184 -22
  135. plain/test/client.py +340 -222
  136. plain/test/encoding.py +9 -6
  137. plain/test/exceptions.py +7 -2
  138. plain/urls/README.md +157 -73
  139. plain/urls/converters.py +18 -15
  140. plain/urls/exceptions.py +2 -2
  141. plain/urls/patterns.py +38 -22
  142. plain/urls/resolvers.py +35 -25
  143. plain/urls/utils.py +5 -1
  144. plain/utils/README.md +250 -3
  145. plain/utils/cache.py +17 -11
  146. plain/utils/crypto.py +21 -5
  147. plain/utils/datastructures.py +89 -56
  148. plain/utils/dateparse.py +9 -6
  149. plain/utils/deconstruct.py +15 -7
  150. plain/utils/decorators.py +5 -1
  151. plain/utils/dotenv.py +373 -0
  152. plain/utils/duration.py +8 -4
  153. plain/utils/encoding.py +14 -7
  154. plain/utils/functional.py +66 -49
  155. plain/utils/hashable.py +5 -1
  156. plain/utils/html.py +36 -22
  157. plain/utils/http.py +16 -9
  158. plain/utils/inspect.py +14 -6
  159. plain/utils/ipv6.py +7 -3
  160. plain/utils/itercompat.py +6 -1
  161. plain/utils/module_loading.py +7 -3
  162. plain/utils/regex_helper.py +37 -23
  163. plain/utils/safestring.py +14 -6
  164. plain/utils/text.py +41 -23
  165. plain/utils/timezone.py +33 -22
  166. plain/utils/tree.py +35 -19
  167. plain/validators.py +94 -52
  168. plain/views/README.md +156 -79
  169. plain/views/__init__.py +0 -1
  170. plain/views/base.py +25 -18
  171. plain/views/errors.py +13 -5
  172. plain/views/exceptions.py +4 -1
  173. plain/views/forms.py +6 -6
  174. plain/views/objects.py +52 -49
  175. plain/views/redirect.py +18 -15
  176. plain/views/templates.py +5 -3
  177. plain/wsgi.py +3 -1
  178. {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/METADATA +4 -2
  179. plain-0.103.0.dist-info/RECORD +198 -0
  180. {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/WHEEL +1 -1
  181. plain-0.103.0.dist-info/entry_points.txt +2 -0
  182. plain/AGENTS.md +0 -18
  183. plain/cli/agent/__init__.py +0 -20
  184. plain/cli/agent/docs.py +0 -80
  185. plain/cli/agent/md.py +0 -87
  186. plain/cli/agent/prompt.py +0 -45
  187. plain/csrf/views.py +0 -31
  188. plain/logs/utils.py +0 -46
  189. plain/templates/AGENTS.md +0 -3
  190. plain-0.68.0.dist-info/RECORD +0 -169
  191. plain-0.68.0.dist-info/entry_points.txt +0 -5
  192. {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/licenses/LICENSE +0 -0
plain/http/request.py CHANGED
@@ -1,33 +1,40 @@
1
- import codecs
1
+ from __future__ import annotations
2
+
2
3
  import copy
3
4
  import json
5
+ import secrets
4
6
  import uuid
7
+ from collections.abc import Iterator
5
8
  from functools import cached_property
6
9
  from io import BytesIO
7
10
  from itertools import chain
11
+ from typing import TYPE_CHECKING, Any, TypeVar, overload
8
12
  from urllib.parse import parse_qsl, quote, urlencode, urljoin, urlsplit
9
13
 
10
- from plain.exceptions import (
11
- ImproperlyConfigured,
12
- RequestDataTooBig,
13
- TooManyFieldsSent,
14
- )
14
+ if TYPE_CHECKING:
15
+ from plain.urls import ResolverMatch
16
+
17
+ from plain.exceptions import ImproperlyConfigured
15
18
  from plain.http.cookie import unsign_cookie_value
16
19
  from plain.http.multipartparser import (
17
20
  MultiPartParser,
18
- MultiPartParserError,
19
- TooManyFilesSent,
20
21
  )
21
- from plain.internal.files import uploadhandler
22
22
  from plain.runtime import settings
23
23
  from plain.utils.datastructures import (
24
24
  CaseInsensitiveMapping,
25
- ImmutableList,
26
25
  MultiValueDict,
27
26
  )
28
27
  from plain.utils.encoding import iri_to_uri
29
28
  from plain.utils.http import parse_header_parameters
30
29
 
30
+ from .exceptions import (
31
+ BadRequestError400,
32
+ RequestDataTooBigError400,
33
+ TooManyFieldsSentError400,
34
+ )
35
+
36
+ _T = TypeVar("_T")
37
+
31
38
 
32
39
  class UnreadablePostError(OSError):
33
40
  pass
@@ -43,49 +50,43 @@ class RawPostDataException(Exception):
43
50
  pass
44
51
 
45
52
 
46
- class HttpRequest:
53
+ class Request:
47
54
  """A basic HTTP request."""
48
55
 
49
56
  # The encoding used in GET/POST dicts. None means use default setting.
50
- _encoding = None
51
- _upload_handlers = []
57
+ encoding: str | None = None
52
58
 
53
59
  non_picklable_attrs = frozenset(["resolver_match", "_stream"])
54
60
 
55
- def __init__(self):
56
- # WARNING: The `WSGIRequest` subclass doesn't call `super`.
57
- # Any variable assignment made here should also happen in
58
- # `WSGIRequest.__init__()`.
61
+ method: str | None
62
+ resolver_match: ResolverMatch | None
63
+ content_type: str | None
64
+ content_params: dict[str, str] | None
65
+ query_params: QueryDict
66
+ cookies: dict[str, str]
67
+ environ: dict[str, Any]
68
+ path: str
69
+ path_info: str
70
+ unique_id: str
59
71
 
72
+ def __init__(self):
60
73
  # A unique ID we can use to trace this request
61
74
  self.unique_id = str(uuid.uuid4())
62
-
63
- self.query_params = QueryDict(mutable=True)
64
- self.data = QueryDict(mutable=True)
65
- self.cookies = {}
66
- self.meta = {}
67
- self.files = MultiValueDict()
68
-
69
- self.path = ""
70
- self.path_info = ""
71
- self.method = None
72
75
  self.resolver_match = None
73
- self.content_type = None
74
- self.content_params = None
75
76
 
76
- def __repr__(self):
77
+ def __repr__(self) -> str:
77
78
  if self.method is None or not self.get_full_path():
78
79
  return f"<{self.__class__.__name__}>"
79
80
  return f"<{self.__class__.__name__}: {self.method} {self.get_full_path()!r}>"
80
81
 
81
- def __getstate__(self):
82
+ def __getstate__(self) -> dict[str, Any]:
82
83
  obj_dict = self.__dict__.copy()
83
84
  for attr in self.non_picklable_attrs:
84
85
  if attr in obj_dict:
85
86
  del obj_dict[attr]
86
87
  return obj_dict
87
88
 
88
- def __deepcopy__(self, memo):
89
+ def __deepcopy__(self, memo: dict[int, Any]) -> Request:
89
90
  obj = copy.copy(self)
90
91
  for attr in self.non_picklable_attrs:
91
92
  if hasattr(self, attr):
@@ -94,34 +95,54 @@ class HttpRequest:
94
95
  return obj
95
96
 
96
97
  @cached_property
97
- def headers(self):
98
- return HttpHeaders(self.meta)
98
+ def headers(self) -> RequestHeaders:
99
+ return RequestHeaders(self.environ)
99
100
 
100
101
  @cached_property
101
- def accepted_types(self):
102
- """Return a list of MediaType instances."""
103
- return parse_accept_header(self.headers.get("Accept", "*/*"))
102
+ def csp_nonce(self) -> str:
103
+ """Generate a cryptographically secure nonce for Content Security Policy.
104
104
 
105
- def accepts(self, media_type):
106
- return any(
107
- accepted_type.match(media_type) for accepted_type in self.accepted_types
108
- )
105
+ The nonce is generated once per request and cached. It can be used in
106
+ CSP headers and templates to allow specific inline scripts/styles while
107
+ blocking others.
108
+ """
109
+ return secrets.token_urlsafe(16)
109
110
 
110
- def _set_content_type_params(self, meta):
111
- """Set content_type, content_params, and encoding."""
112
- self.content_type, self.content_params = parse_header_parameters(
113
- meta.get("CONTENT_TYPE", "")
114
- )
115
- if "charset" in self.content_params:
116
- try:
117
- codecs.lookup(self.content_params["charset"])
118
- except LookupError:
119
- pass
120
- else:
121
- self.encoding = self.content_params["charset"]
111
+ @cached_property
112
+ def accepted_types(self) -> list[MediaType]:
113
+ """Return accepted media types sorted by quality value (highest first).
114
+
115
+ When quality values are equal, the original order from the Accept header
116
+ is preserved (as per HTTP spec).
117
+ """
118
+ header = self.headers.get("Accept", "*/*")
119
+ types = [MediaType(token) for token in header.split(",") if token.strip()]
120
+ return sorted(types, key=lambda t: t.quality, reverse=True)
121
+
122
+ def get_preferred_type(self, *media_types: str) -> str | None:
123
+ """Return the most preferred media type from the given options.
124
+
125
+ Checks the Accept header in priority order (by quality value) and returns
126
+ the first matching media type from the provided options.
127
+
128
+ Returns None if none of the options are accepted.
129
+
130
+ Example:
131
+ # Accept: text/html;q=1.0, application/json;q=0.5
132
+ request.get_preferred_type("application/json", "text/html") # Returns "text/html"
133
+ """
134
+ for accepted in self.accepted_types:
135
+ for option in media_types:
136
+ if accepted.match(option):
137
+ return option
138
+ return None
139
+
140
+ def accepts(self, media_type: str) -> bool:
141
+ """Check if the given media type is accepted."""
142
+ return self.get_preferred_type(media_type) is not None
122
143
 
123
144
  @cached_property
124
- def host(self):
145
+ def host(self) -> str:
125
146
  """
126
147
  Return the HTTP host using the environment or request headers.
127
148
 
@@ -129,28 +150,60 @@ class HttpRequest:
129
150
  property can safely return the host without any validation.
130
151
  """
131
152
  # We try three options, in order of decreasing preference.
132
- if settings.USE_X_FORWARDED_HOST and ("HTTP_X_FORWARDED_HOST" in self.meta):
133
- host = self.meta["HTTP_X_FORWARDED_HOST"]
134
- elif "HTTP_HOST" in self.meta:
135
- host = self.meta["HTTP_HOST"]
153
+ if settings.HTTP_X_FORWARDED_HOST and (
154
+ xff_host := self.headers.get("X-Forwarded-Host")
155
+ ):
156
+ host = xff_host
157
+ elif http_host := self.headers.get("Host"):
158
+ host = http_host
136
159
  else:
137
160
  # Reconstruct the host using the algorithm from PEP 333.
138
- host = self.meta["SERVER_NAME"]
161
+ host = self.environ["SERVER_NAME"]
139
162
  server_port = self.port
140
163
  if server_port != ("443" if self.is_https() else "80"):
141
164
  host = f"{host}:{server_port}"
142
165
  return host
143
166
 
144
167
  @cached_property
145
- def port(self):
168
+ def port(self) -> str:
146
169
  """Return the port number for the request as a string."""
147
- if settings.USE_X_FORWARDED_PORT and "HTTP_X_FORWARDED_PORT" in self.meta:
148
- port = self.meta["HTTP_X_FORWARDED_PORT"]
170
+ if settings.HTTP_X_FORWARDED_PORT and (
171
+ xff_port := self.headers.get("X-Forwarded-Port")
172
+ ):
173
+ port = xff_port
149
174
  else:
150
- port = self.meta["SERVER_PORT"]
175
+ port = self.environ["SERVER_PORT"]
151
176
  return str(port)
152
177
 
153
- def get_full_path(self, force_append_slash=False):
178
+ @cached_property
179
+ def client_ip(self) -> str:
180
+ """Return the client's IP address.
181
+
182
+ If HTTP_X_FORWARDED_FOR is True, checks the X-Forwarded-For header first
183
+ (using the first/leftmost IP). Otherwise returns REMOTE_ADDR directly.
184
+
185
+ Only enable HTTP_X_FORWARDED_FOR when behind a trusted proxy that
186
+ overwrites the X-Forwarded-For header.
187
+ """
188
+ if settings.HTTP_X_FORWARDED_FOR:
189
+ if xff := self.headers.get("X-Forwarded-For"):
190
+ return xff.split(",")[0].strip()
191
+ return self.environ["REMOTE_ADDR"]
192
+
193
+ @property
194
+ def query_string(self) -> str:
195
+ """Return the raw query string from the request URL."""
196
+ return self.environ.get("QUERY_STRING", "")
197
+
198
+ @property
199
+ def content_length(self) -> int:
200
+ """Return the Content-Length header value, or 0 if not provided."""
201
+ try:
202
+ return int(self.environ.get("CONTENT_LENGTH") or 0)
203
+ except (ValueError, TypeError):
204
+ return 0
205
+
206
+ def get_full_path(self, force_append_slash: bool = False) -> str:
154
207
  """
155
208
  Return the full path for the request, including query string.
156
209
 
@@ -160,7 +213,7 @@ class HttpRequest:
160
213
  # RFC 3986 requires query string arguments to be in the ASCII range.
161
214
  # Rather than crash if this doesn't happen, we encode defensively.
162
215
 
163
- def escape_uri_path(path):
216
+ def escape_uri_path(path: str) -> str:
164
217
  """
165
218
  Escape the unsafe characters from the path portion of a Uniform Resource
166
219
  Identifier (URI).
@@ -179,12 +232,10 @@ class HttpRequest:
179
232
  return "{}{}{}".format(
180
233
  escape_uri_path(self.path),
181
234
  "/" if force_append_slash and not self.path.endswith("/") else "",
182
- ("?" + iri_to_uri(self.meta.get("QUERY_STRING", "")))
183
- if self.meta.get("QUERY_STRING", "")
184
- else "",
235
+ ("?" + (iri_to_uri(self.query_string) or "")) if self.query_string else "",
185
236
  )
186
237
 
187
- def build_absolute_uri(self, location=None):
238
+ def build_absolute_uri(self, location: str | None = None) -> str:
188
239
  """
189
240
  Build an absolute URI from the location and the variables available in
190
241
  this request. If no ``location`` is specified, build the absolute URI
@@ -224,9 +275,9 @@ class HttpRequest:
224
275
  # base path.
225
276
  location = urljoin(current_scheme_host + self.path, location)
226
277
 
227
- return iri_to_uri(location)
278
+ return iri_to_uri(location) or ""
228
279
 
229
- def _get_scheme(self):
280
+ def _get_scheme(self) -> str:
230
281
  """
231
282
  Hook for subclasses like WSGIRequest to implement. Return 'http' by
232
283
  default.
@@ -234,76 +285,27 @@ class HttpRequest:
234
285
  return "http"
235
286
 
236
287
  @property
237
- def scheme(self):
288
+ def scheme(self) -> str:
238
289
  if settings.HTTPS_PROXY_HEADER:
239
- try:
240
- header, secure_value = settings.HTTPS_PROXY_HEADER
241
- except ValueError:
290
+ if ":" not in settings.HTTPS_PROXY_HEADER:
242
291
  raise ImproperlyConfigured(
243
- "The HTTPS_PROXY_HEADER setting must be a tuple containing "
244
- "two values."
292
+ "The HTTPS_PROXY_HEADER setting must be a string in the format "
293
+ "'Header-Name: value' (e.g., 'X-Forwarded-Proto: https')."
245
294
  )
246
- header_value = self.meta.get(header)
295
+ header, secure_value = settings.HTTPS_PROXY_HEADER.split(":", 1)
296
+ header = header.strip()
297
+ secure_value = secure_value.strip()
298
+ header_value = self.headers.get(header)
247
299
  if header_value is not None:
248
300
  header_value, *_ = header_value.split(",", 1)
249
301
  return "https" if header_value.strip() == secure_value else "http"
250
302
  return self._get_scheme()
251
303
 
252
- def is_https(self):
304
+ def is_https(self) -> bool:
253
305
  return self.scheme == "https"
254
306
 
255
307
  @property
256
- def encoding(self):
257
- return self._encoding
258
-
259
- @encoding.setter
260
- def encoding(self, val):
261
- """
262
- Set the encoding used for query_params/data accesses. If the query_params or data
263
- dictionary has already been created, remove and recreate it on the
264
- next access (so that it is decoded correctly).
265
- """
266
- self._encoding = val
267
- if hasattr(self, "query_params"):
268
- del self.query_params
269
- if hasattr(self, "_data"):
270
- del self._data
271
-
272
- def _initialize_handlers(self):
273
- self._upload_handlers = [
274
- uploadhandler.load_handler(handler, self)
275
- for handler in settings.FILE_UPLOAD_HANDLERS
276
- ]
277
-
278
- @property
279
- def upload_handlers(self):
280
- if not self._upload_handlers:
281
- # If there are no upload handlers defined, initialize them from settings.
282
- self._initialize_handlers()
283
- return self._upload_handlers
284
-
285
- @upload_handlers.setter
286
- def upload_handlers(self, upload_handlers):
287
- if hasattr(self, "_files"):
288
- raise AttributeError(
289
- "You cannot set the upload handlers after the upload has been "
290
- "processed."
291
- )
292
- self._upload_handlers = upload_handlers
293
-
294
- def parse_file_upload(self, meta, post_data):
295
- """Return a tuple of (data QueryDict, files MultiValueDict)."""
296
- self.upload_handlers = ImmutableList(
297
- self.upload_handlers,
298
- warning=(
299
- "You cannot alter upload handlers after the upload has been processed."
300
- ),
301
- )
302
- parser = MultiPartParser(meta, post_data, self.upload_handlers, self.encoding)
303
- return parser.parse()
304
-
305
- @property
306
- def body(self):
308
+ def body(self) -> bytes:
307
309
  if not hasattr(self, "_body"):
308
310
  if self._read_started:
309
311
  raise RawPostDataException(
@@ -313,10 +315,9 @@ class HttpRequest:
313
315
  # Limit the maximum request data size that will be handled in-memory.
314
316
  if (
315
317
  settings.DATA_UPLOAD_MAX_MEMORY_SIZE is not None
316
- and int(self.meta.get("CONTENT_LENGTH") or 0)
317
- > settings.DATA_UPLOAD_MAX_MEMORY_SIZE
318
+ and self.content_length > settings.DATA_UPLOAD_MAX_MEMORY_SIZE
318
319
  ):
319
- raise RequestDataTooBig(
320
+ raise RequestDataTooBigError400(
320
321
  "Request body exceeded settings.DATA_UPLOAD_MAX_MEMORY_SIZE."
321
322
  )
322
323
 
@@ -329,84 +330,123 @@ class HttpRequest:
329
330
  self._stream = BytesIO(self._body)
330
331
  return self._body
331
332
 
332
- def _mark_post_parse_error(self):
333
- self._data = QueryDict()
334
- self._files = MultiValueDict()
333
+ @cached_property
334
+ def _multipart_data(self) -> tuple[QueryDict, MultiValueDict]:
335
+ """Parse multipart/form-data. Used internally by form_data and files properties.
335
336
 
336
- def _load_data_and_files(self):
337
- """Populate self._data and self._files"""
337
+ Raises MultiPartParserError or TooManyFilesSentError400 for malformed uploads,
338
+ which are handled by response_for_exception() as 400 errors.
339
+ """
340
+ return MultiPartParser(self).parse()
338
341
 
339
- if self._read_started and not hasattr(self, "_body"):
340
- self._mark_post_parse_error()
341
- return
342
+ @cached_property
343
+ def json_data(self) -> dict[str, Any]:
344
+ """
345
+ Parsed JSON object from request body.
342
346
 
343
- if self.content_type.startswith("application/json"):
344
- try:
345
- self._data = json.loads(self.body)
346
- self._files = MultiValueDict()
347
- except json.JSONDecodeError:
348
- self._mark_post_parse_error()
349
- raise
350
- elif self.content_type == "multipart/form-data":
351
- if hasattr(self, "_body"):
352
- # Use already read data
353
- data = BytesIO(self._body)
354
- else:
355
- data = self
356
- try:
357
- self._data, self._files = self.parse_file_upload(self.meta, data)
358
- except (MultiPartParserError, TooManyFilesSent):
359
- # An error occurred while parsing POST data. Since when
360
- # formatting the error the request handler might access
361
- # self.POST, set self._post and self._file to prevent
362
- # attempts to parse POST data again.
363
- self._mark_post_parse_error()
364
- raise
365
- elif self.content_type == "application/x-www-form-urlencoded":
366
- self._data, self._files = (
367
- QueryDict(self.body, encoding=self._encoding),
368
- MultiValueDict(),
347
+ Returns dict for JSON objects.
348
+ Raises BadRequestError400 if JSON is invalid or not an object.
349
+ Raises ValueError if request content-type is not JSON.
350
+
351
+ Use this when you expect JSON object data and want type-safe dict access.
352
+ """
353
+ if not self.content_type or not self.content_type.startswith(
354
+ "application/json"
355
+ ):
356
+ raise ValueError(
357
+ f"Request content-type is not JSON (got: {self.content_type})"
358
+ )
359
+ try:
360
+ parsed = json.loads(self.body)
361
+ except json.JSONDecodeError as e:
362
+ raise BadRequestError400(f"Invalid JSON in request body: {e}") from e
363
+
364
+ if not isinstance(parsed, dict):
365
+ raise BadRequestError400(
366
+ f"Expected JSON object, got {type(parsed).__name__}"
369
367
  )
368
+ return parsed
369
+
370
+ @cached_property
371
+ def form_data(self) -> QueryDict:
372
+ """
373
+ Form data from POST body.
374
+
375
+ Returns QueryDict for application/x-www-form-urlencoded or
376
+ multipart/form-data content types.
377
+ Returns empty QueryDict if Content-Type is missing (e.g., GET requests).
378
+ Raises ValueError if request has a different content-type with a body.
379
+
380
+ Use this when you expect form data and want type-safe QueryDict access.
381
+ """
382
+ if self.content_type == "application/x-www-form-urlencoded":
383
+ return QueryDict(self.body, encoding=self.encoding)
384
+ elif self.content_type == "multipart/form-data":
385
+ return self._multipart_data[0]
386
+ elif not self.content_type:
387
+ # No Content-Type (e.g., GET requests) - return empty QueryDict
388
+ return QueryDict(b"", encoding=self.encoding)
370
389
  else:
371
- self._data, self._files = (
372
- QueryDict(encoding=self._encoding),
373
- MultiValueDict(),
390
+ raise ValueError(
391
+ f"Request content-type is not form data (got: {self.content_type})"
374
392
  )
375
393
 
376
- def close(self):
377
- if hasattr(self, "_files"):
378
- for f in chain.from_iterable(list_[1] for list_ in self._files.lists()):
394
+ @cached_property
395
+ def files(self) -> MultiValueDict:
396
+ """
397
+ File uploads from multipart/form-data requests.
398
+
399
+ Returns MultiValueDict of uploaded files for multipart requests,
400
+ or empty MultiValueDict for other content types.
401
+ """
402
+ if self.content_type == "multipart/form-data":
403
+ return self._multipart_data[1]
404
+ return MultiValueDict()
405
+
406
+ def close(self) -> None:
407
+ # Close any uploaded files if they were accessed
408
+ if self.content_type == "multipart/form-data" and hasattr(
409
+ self, "_multipart_data"
410
+ ):
411
+ _, files = self._multipart_data
412
+ for f in chain.from_iterable(list_[1] for list_ in files.lists()):
379
413
  f.close()
380
414
 
381
415
  # File-like and iterator interface.
382
416
  #
383
417
  # Expects self._stream to be set to an appropriate source of bytes by
384
418
  # a corresponding request subclass (e.g. WSGIRequest).
385
- # Also when request data has already been read by request.data or
386
- # request.body, self._stream points to a BytesIO instance
387
- # containing that data.
419
+ # Also when request data has already been read by request.json_data,
420
+ # request.form_data, or request.body, self._stream points to a BytesIO
421
+ # instance containing that data.
388
422
 
389
- def read(self, *args, **kwargs):
423
+ def read(self, *args: Any, **kwargs: Any) -> bytes:
390
424
  self._read_started = True
391
425
  try:
392
426
  return self._stream.read(*args, **kwargs)
393
427
  except OSError as e:
394
428
  raise UnreadablePostError(*e.args) from e
395
429
 
396
- def readline(self, *args, **kwargs):
430
+ def readline(self, *args: Any, **kwargs: Any) -> bytes:
397
431
  self._read_started = True
398
432
  try:
399
433
  return self._stream.readline(*args, **kwargs)
400
434
  except OSError as e:
401
435
  raise UnreadablePostError(*e.args) from e
402
436
 
403
- def __iter__(self):
437
+ def __iter__(self) -> Iterator[bytes]:
404
438
  return iter(self.readline, b"")
405
439
 
406
- def readlines(self):
440
+ def readlines(self) -> list[bytes]:
407
441
  return list(self)
408
442
 
409
- def get_signed_cookie(self, key, default=None, salt="", max_age=None):
443
+ def get_signed_cookie(
444
+ self,
445
+ key: str,
446
+ default: str | None = None,
447
+ salt: str = "",
448
+ max_age: int | None = None,
449
+ ) -> str | None:
410
450
  """
411
451
  Retrieve a cookie value signed with the SECRET_KEY.
412
452
 
@@ -421,12 +461,12 @@ class HttpRequest:
421
461
  return unsign_cookie_value(key, cookie_value, salt, max_age, default)
422
462
 
423
463
 
424
- class HttpHeaders(CaseInsensitiveMapping):
464
+ class RequestHeaders(CaseInsensitiveMapping):
425
465
  HTTP_PREFIX = "HTTP_"
426
466
  # PEP 333 gives two headers which aren't prepended with HTTP_.
427
467
  UNPREFIXED_HEADERS = {"CONTENT_TYPE", "CONTENT_LENGTH"}
428
468
 
429
- def __init__(self, environ):
469
+ def __init__(self, environ: dict[str, Any]):
430
470
  headers = {}
431
471
  for header, value in environ.items():
432
472
  name = self.parse_header_name(header)
@@ -434,12 +474,12 @@ class HttpHeaders(CaseInsensitiveMapping):
434
474
  headers[name] = value
435
475
  super().__init__(headers)
436
476
 
437
- def __getitem__(self, key):
477
+ def __getitem__(self, key: str) -> str:
438
478
  """Allow header lookup using underscores in place of hyphens."""
439
479
  return super().__getitem__(key.replace("_", "-"))
440
480
 
441
481
  @classmethod
442
- def parse_header_name(cls, header):
482
+ def parse_header_name(cls, header: str) -> str | None:
443
483
  if header.startswith(cls.HTTP_PREFIX):
444
484
  header = header.removeprefix(cls.HTTP_PREFIX)
445
485
  elif header not in cls.UNPREFIXED_HEADERS:
@@ -447,14 +487,14 @@ class HttpHeaders(CaseInsensitiveMapping):
447
487
  return header.replace("_", "-").title()
448
488
 
449
489
  @classmethod
450
- def to_wsgi_name(cls, header):
490
+ def to_wsgi_name(cls, header: str) -> str:
451
491
  header = header.replace("-", "_").upper()
452
492
  if header in cls.UNPREFIXED_HEADERS:
453
493
  return header
454
494
  return f"{cls.HTTP_PREFIX}{header}"
455
495
 
456
496
  @classmethod
457
- def to_wsgi_names(cls, headers):
497
+ def to_wsgi_names(cls, headers: dict[str, Any]) -> dict[str, Any]:
458
498
  return {
459
499
  cls.to_wsgi_name(header_name): value
460
500
  for header_name, value in headers.items()
@@ -481,22 +521,28 @@ class QueryDict(MultiValueDict):
481
521
  _mutable = True
482
522
  _encoding = None
483
523
 
484
- def __init__(self, query_string=None, mutable=False, encoding=None):
524
+ def __init__(
525
+ self,
526
+ query_string: str | bytes | None = None,
527
+ mutable: bool = False,
528
+ encoding: str | None = None,
529
+ ):
485
530
  super().__init__()
486
531
  self.encoding = encoding or settings.DEFAULT_CHARSET
487
532
  query_string = query_string or ""
488
- parse_qsl_kwargs = {
533
+ parse_qsl_kwargs: dict[str, Any] = {
489
534
  "keep_blank_values": True,
490
535
  "encoding": self.encoding,
491
536
  "max_num_fields": settings.DATA_UPLOAD_MAX_NUMBER_FIELDS,
492
537
  }
493
538
  if isinstance(query_string, bytes):
494
539
  # query_string normally contains URL-encoded data, a subset of ASCII.
540
+ query_bytes = query_string
495
541
  try:
496
- query_string = query_string.decode(self.encoding)
542
+ query_string = query_bytes.decode(self.encoding)
497
543
  except UnicodeDecodeError:
498
544
  # ... but some user agents are misbehaving :-(
499
- query_string = query_string.decode("iso-8859-1")
545
+ query_string = query_bytes.decode("iso-8859-1")
500
546
  try:
501
547
  for key, value in parse_qsl(query_string, **parse_qsl_kwargs):
502
548
  self.appendlist(key, value)
@@ -505,14 +551,20 @@ class QueryDict(MultiValueDict):
505
551
  # parse_qsl() is True. As that is not used by Plain, assume that
506
552
  # the exception was raised by exceeding the value of max_num_fields
507
553
  # instead of fragile checks of exception message strings.
508
- raise TooManyFieldsSent(
554
+ raise TooManyFieldsSentError400(
509
555
  "The number of GET/POST parameters exceeded "
510
556
  "settings.DATA_UPLOAD_MAX_NUMBER_FIELDS."
511
557
  ) from e
512
558
  self._mutable = mutable
513
559
 
514
560
  @classmethod
515
- def fromkeys(cls, iterable, value="", mutable=False, encoding=None):
561
+ def fromkeys( # type: ignore[override]
562
+ cls,
563
+ iterable: Any,
564
+ value: str = "",
565
+ mutable: bool = False,
566
+ encoding: str | None = None,
567
+ ) -> QueryDict:
516
568
  """
517
569
  Return a new QueryDict with keys (may be repeated) from an iterable and
518
570
  values from value.
@@ -525,81 +577,141 @@ class QueryDict(MultiValueDict):
525
577
  return q
526
578
 
527
579
  @property
528
- def encoding(self):
580
+ def encoding(self) -> str:
529
581
  if self._encoding is None:
530
582
  self._encoding = settings.DEFAULT_CHARSET
531
583
  return self._encoding
532
584
 
533
585
  @encoding.setter
534
- def encoding(self, value):
586
+ def encoding(self, value: str) -> None:
535
587
  self._encoding = value
536
588
 
537
- def _assert_mutable(self):
589
+ def _assert_mutable(self) -> None:
538
590
  if not self._mutable:
539
591
  raise AttributeError("This QueryDict instance is immutable")
540
592
 
541
- def __setitem__(self, key, value):
593
+ def __setitem__(self, key: str, value: Any) -> None:
542
594
  self._assert_mutable()
543
- key = bytes_to_text(key, self.encoding)
544
- value = bytes_to_text(value, self.encoding)
595
+ key = self.bytes_to_text(key, self.encoding)
596
+ value = self.bytes_to_text(value, self.encoding)
545
597
  super().__setitem__(key, value)
546
598
 
547
- def __delitem__(self, key):
599
+ def __delitem__(self, key: str) -> None:
548
600
  self._assert_mutable()
549
601
  super().__delitem__(key)
550
602
 
551
- def __copy__(self):
603
+ def __getitem__(self, key: str) -> str: # type: ignore[override]
604
+ """
605
+ Return the last data value for this key as a string.
606
+ QueryDict values are always strings.
607
+ """
608
+ return super().__getitem__(key)
609
+
610
+ def __copy__(self) -> QueryDict:
552
611
  result = self.__class__("", mutable=True, encoding=self.encoding)
553
612
  for key, value in self.lists():
554
613
  result.setlist(key, value)
555
614
  return result
556
615
 
557
- def __deepcopy__(self, memo):
616
+ def __deepcopy__(self, memo: dict[int, Any]) -> QueryDict:
558
617
  result = self.__class__("", mutable=True, encoding=self.encoding)
559
618
  memo[id(self)] = result
560
619
  for key, value in self.lists():
561
620
  result.setlist(copy.deepcopy(key, memo), copy.deepcopy(value, memo))
562
621
  return result
563
622
 
564
- def setlist(self, key, list_):
623
+ def setlist(self, key: str, list_: list[Any]) -> None:
565
624
  self._assert_mutable()
566
- key = bytes_to_text(key, self.encoding)
567
- list_ = [bytes_to_text(elt, self.encoding) for elt in list_]
625
+ key = self.bytes_to_text(key, self.encoding)
626
+ list_ = [self.bytes_to_text(elt, self.encoding) for elt in list_]
568
627
  super().setlist(key, list_)
569
628
 
570
- def setlistdefault(self, key, default_list=None):
629
+ def setlistdefault(
630
+ self, key: str, default_list: list[Any] | None = None
631
+ ) -> list[Any]:
571
632
  self._assert_mutable()
572
633
  return super().setlistdefault(key, default_list)
573
634
 
574
- def appendlist(self, key, value):
635
+ def appendlist(self, key: str, value: Any) -> None:
575
636
  self._assert_mutable()
576
- key = bytes_to_text(key, self.encoding)
577
- value = bytes_to_text(value, self.encoding)
637
+ key = self.bytes_to_text(key, self.encoding)
638
+ value = self.bytes_to_text(value, self.encoding)
578
639
  super().appendlist(key, value)
579
640
 
580
- def pop(self, key, *args):
641
+ def getlist(self, key: str, default: list[str] | None = None) -> list[str]:
642
+ """
643
+ Return the list of values for the key as strings.
644
+ QueryDict values are always strings.
645
+ """
646
+ return super().getlist(key, default)
647
+
648
+ @overload
649
+ def get(self, key: str) -> str | None: ...
650
+
651
+ @overload
652
+ def get(self, key: str, default: str) -> str: ...
653
+
654
+ @overload
655
+ def get(self, key: str, default: _T) -> str | _T: ...
656
+
657
+ def get(self, key: str, default: Any = None) -> str | Any: # type: ignore[override]
658
+ """
659
+ Return the last data value for the passed key. If key doesn't exist
660
+ or value is an empty list, return `default`.
661
+
662
+ QueryDict values are always strings (from URL parsing), but the
663
+ return type preserves the type of the default parameter for type safety.
664
+
665
+ Examples:
666
+ get("page") -> str | None
667
+ get("page", "1") -> str
668
+ get("page", 1) -> str | int
669
+ """
670
+ return super().get(key, default)
671
+
672
+ @overload
673
+ def pop(self, key: str) -> str: ...
674
+
675
+ @overload
676
+ def pop(self, key: str, default: str) -> str: ...
677
+
678
+ @overload
679
+ def pop(self, key: str, default: _T) -> str | _T: ...
680
+
681
+ def pop(self, key: str, *args: Any) -> str | Any: # type: ignore[override]
682
+ """
683
+ Remove and return a value for the key.
684
+
685
+ QueryDict values are always strings, but the return type preserves
686
+ the type of the default parameter for type safety.
687
+
688
+ Examples:
689
+ pop("page") -> str (or raises KeyError)
690
+ pop("page", "1") -> str
691
+ pop("page", 1) -> str | int
692
+ """
581
693
  self._assert_mutable()
582
694
  return super().pop(key, *args)
583
695
 
584
- def popitem(self):
696
+ def popitem(self) -> tuple[str, Any]:
585
697
  self._assert_mutable()
586
698
  return super().popitem()
587
699
 
588
- def clear(self):
700
+ def clear(self) -> None:
589
701
  self._assert_mutable()
590
702
  super().clear()
591
703
 
592
- def setdefault(self, key, default=None):
704
+ def setdefault(self, key: str, default: Any = None) -> Any:
593
705
  self._assert_mutable()
594
- key = bytes_to_text(key, self.encoding)
595
- default = bytes_to_text(default, self.encoding)
706
+ key = self.bytes_to_text(key, self.encoding)
707
+ default = self.bytes_to_text(default, self.encoding)
596
708
  return super().setdefault(key, default)
597
709
 
598
- def copy(self):
710
+ def copy(self) -> QueryDict:
599
711
  """Return a mutable copy of this object."""
600
712
  return self.__deepcopy__({})
601
713
 
602
- def urlencode(self, safe=None):
714
+ def urlencode(self, safe: str | None = None) -> str:
603
715
  """
604
716
  Return an encoded string of all query string arguments.
605
717
 
@@ -614,14 +726,14 @@ class QueryDict(MultiValueDict):
614
726
  """
615
727
  output = []
616
728
  if safe:
617
- safe = safe.encode(self.encoding)
729
+ safe_bytes: bytes = safe.encode(self.encoding)
618
730
 
619
- def encode(k, v):
620
- return f"{quote(k, safe)}={quote(v, safe)}"
731
+ def encode(k: bytes, v: bytes) -> str:
732
+ return f"{quote(k, safe_bytes)}={quote(v, safe_bytes)}"
621
733
 
622
734
  else:
623
735
 
624
- def encode(k, v):
736
+ def encode(k: bytes, v: bytes) -> str:
625
737
  return urlencode({k: v})
626
738
 
627
739
  for k, list_ in self.lists():
@@ -631,15 +743,31 @@ class QueryDict(MultiValueDict):
631
743
  )
632
744
  return "&".join(output)
633
745
 
746
+ # It's neither necessary nor appropriate to use
747
+ # plain.utils.encoding.force_str() for parsing URLs and form inputs. Thus,
748
+ # this slightly more restricted function, used by QueryDict.
749
+ @staticmethod
750
+ def bytes_to_text(s: Any, encoding: str) -> str:
751
+ """
752
+ Convert bytes objects to strings, using the given encoding. Illegally
753
+ encoded input characters are replaced with Unicode "unknown" codepoint
754
+ (\ufffd).
755
+
756
+ Return any non-bytes objects without change.
757
+ """
758
+ if isinstance(s, bytes):
759
+ return str(s, encoding, "replace")
760
+ else:
761
+ return s
762
+
634
763
 
635
764
  class MediaType:
636
- def __init__(self, media_type_raw_line):
637
- full_type, self.params = parse_header_parameters(
638
- media_type_raw_line if media_type_raw_line else ""
639
- )
765
+ def __init__(self, media_type_raw_line: str | MediaType):
766
+ line = str(media_type_raw_line) if media_type_raw_line else ""
767
+ full_type, self.params = parse_header_parameters(line)
640
768
  self.main_type, _, self.sub_type = full_type.partition("/")
641
769
 
642
- def __str__(self):
770
+ def __str__(self) -> str:
643
771
  params_str = "".join(f"; {k}={v}" for k, v in self.params.items())
644
772
  return "{}{}{}".format(
645
773
  self.main_type,
@@ -647,38 +775,22 @@ class MediaType:
647
775
  params_str,
648
776
  )
649
777
 
650
- def __repr__(self):
778
+ def __repr__(self) -> str:
651
779
  return f"<{self.__class__.__qualname__}: {self}>"
652
780
 
653
781
  @property
654
- def is_all_types(self):
782
+ def is_all_types(self) -> bool:
655
783
  return self.main_type == "*" and self.sub_type == "*"
656
784
 
657
- def match(self, other):
785
+ @property
786
+ def quality(self) -> float:
787
+ """Return the quality value from the Accept header (default 1.0)."""
788
+ return float(self.params.get("q", 1.0))
789
+
790
+ def match(self, other: str | MediaType) -> bool:
658
791
  if self.is_all_types:
659
792
  return True
660
793
  other = MediaType(other)
661
794
  if self.main_type == other.main_type and self.sub_type in {"*", other.sub_type}:
662
795
  return True
663
796
  return False
664
-
665
-
666
- # It's neither necessary nor appropriate to use
667
- # plain.utils.encoding.force_str() for parsing URLs and form inputs. Thus,
668
- # this slightly more restricted function, used by QueryDict.
669
- def bytes_to_text(s, encoding):
670
- """
671
- Convert bytes objects to strings, using the given encoding. Illegally
672
- encoded input characters are replaced with Unicode "unknown" codepoint
673
- (\ufffd).
674
-
675
- Return any non-bytes objects without change.
676
- """
677
- if isinstance(s, bytes):
678
- return str(s, encoding, "replace")
679
- else:
680
- return s
681
-
682
-
683
- def parse_accept_header(header):
684
- return [MediaType(token) for token in header.split(",") if token.strip()]