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
@@ -2,15 +2,24 @@
2
2
  Base file upload handler classes, and the built-in concrete subclasses
3
3
  """
4
4
 
5
+ from __future__ import annotations
6
+
5
7
  import os
8
+ from abc import ABC, abstractmethod
6
9
  from io import BytesIO
10
+ from typing import TYPE_CHECKING
7
11
 
8
12
  from plain.internal.files.uploadedfile import (
9
13
  InMemoryUploadedFile,
10
14
  TemporaryUploadedFile,
15
+ UploadedFile,
11
16
  )
12
17
  from plain.runtime import settings
13
- from plain.utils.module_loading import import_string
18
+
19
+ if TYPE_CHECKING:
20
+ from typing import Any
21
+
22
+ from plain.http import Request
14
23
 
15
24
  __all__ = [
16
25
  "UploadFileException",
@@ -19,7 +28,6 @@ __all__ = [
19
28
  "FileUploadHandler",
20
29
  "TemporaryFileUploadHandler",
21
30
  "MemoryFileUploadHandler",
22
- "load_handler",
23
31
  "StopFutureHandlers",
24
32
  ]
25
33
 
@@ -37,7 +45,7 @@ class StopUpload(UploadFileException):
37
45
  This exception is raised when an upload must abort.
38
46
  """
39
47
 
40
- def __init__(self, connection_reset=False):
48
+ def __init__(self, connection_reset: bool = False) -> None:
41
49
  """
42
50
  If ``connection_reset`` is ``True``, Plain knows will halt the upload
43
51
  without consuming the rest of the upload. This will cause the browser to
@@ -45,7 +53,7 @@ class StopUpload(UploadFileException):
45
53
  """
46
54
  self.connection_reset = connection_reset
47
55
 
48
- def __str__(self):
56
+ def __str__(self) -> str:
49
57
  if self.connection_reset:
50
58
  return "StopUpload: Halt current upload."
51
59
  else:
@@ -69,14 +77,14 @@ class StopFutureHandlers(UploadFileException):
69
77
  pass
70
78
 
71
79
 
72
- class FileUploadHandler:
80
+ class FileUploadHandler(ABC):
73
81
  """
74
82
  Base class for streaming upload handlers.
75
83
  """
76
84
 
77
85
  chunk_size = 64 * 2**10 # : The default chunk size is 64 KB.
78
86
 
79
- def __init__(self, request=None):
87
+ def __init__(self, request: Request) -> None:
80
88
  self.file_name = None
81
89
  self.content_type = None
82
90
  self.content_length = None
@@ -85,8 +93,11 @@ class FileUploadHandler:
85
93
  self.request = request
86
94
 
87
95
  def handle_raw_input(
88
- self, input_data, meta, content_length, boundary, encoding=None
89
- ):
96
+ self,
97
+ input_data: Any,
98
+ boundary: bytes,
99
+ encoding: str | None = None,
100
+ ) -> None:
90
101
  """
91
102
  Handle the raw input from the client.
92
103
 
@@ -94,25 +105,25 @@ class FileUploadHandler:
94
105
 
95
106
  :input_data:
96
107
  An object that supports reading via .read().
97
- :meta:
98
- ``request.meta``.
99
- :content_length:
100
- The (integer) value of the Content-Length header from the
101
- client.
102
- :boundary: The boundary from the Content-Type header. Be sure to
108
+ :boundary:
109
+ The boundary from the Content-Type header. Be sure to
103
110
  prepend two '--'.
111
+ :encoding:
112
+ The encoding of the request data.
113
+
114
+ Note: Access self.request for content_length, environ, or other request data.
104
115
  """
105
116
  pass
106
117
 
107
118
  def new_file(
108
119
  self,
109
- field_name,
110
- file_name,
111
- content_type,
112
- content_length,
113
- charset=None,
114
- content_type_extra=None,
115
- ):
120
+ field_name: str,
121
+ file_name: str,
122
+ content_type: str,
123
+ content_length: int | None,
124
+ charset: str | None = None,
125
+ content_type_extra: dict[str, str] | None = None,
126
+ ) -> None:
116
127
  """
117
128
  Signal that a new file has been started.
118
129
 
@@ -126,34 +137,32 @@ class FileUploadHandler:
126
137
  self.charset = charset
127
138
  self.content_type_extra = content_type_extra
128
139
 
129
- def receive_data_chunk(self, raw_data, start):
140
+ @abstractmethod
141
+ def receive_data_chunk(self, raw_data: bytes, start: int) -> bytes | None:
130
142
  """
131
143
  Receive data from the streamed upload parser. ``start`` is the position
132
144
  in the file of the chunk.
133
145
  """
134
- raise NotImplementedError(
135
- "subclasses of FileUploadHandler must provide a receive_data_chunk() method"
136
- )
146
+ ...
137
147
 
138
- def file_complete(self, file_size):
148
+ @abstractmethod
149
+ def file_complete(self, file_size: int) -> UploadedFile | None:
139
150
  """
140
151
  Signal that a file has completed. File size corresponds to the actual
141
152
  size accumulated by all the chunks.
142
153
 
143
154
  Subclasses should return a valid ``UploadedFile`` object.
144
155
  """
145
- raise NotImplementedError(
146
- "subclasses of FileUploadHandler must provide a file_complete() method"
147
- )
156
+ ...
148
157
 
149
- def upload_complete(self):
158
+ def upload_complete(self) -> None:
150
159
  """
151
160
  Signal that the upload is complete. Subclasses should perform cleanup
152
161
  that is necessary for this handler.
153
162
  """
154
163
  pass
155
164
 
156
- def upload_interrupted(self):
165
+ def upload_interrupted(self) -> None:
157
166
  """
158
167
  Signal that the upload was interrupted. Subclasses should perform
159
168
  cleanup that is necessary for this handler.
@@ -166,24 +175,29 @@ class TemporaryFileUploadHandler(FileUploadHandler):
166
175
  Upload handler that streams data into a temporary file.
167
176
  """
168
177
 
169
- def new_file(self, *args, **kwargs):
178
+ def new_file(self, *args: Any, **kwargs: Any) -> None:
170
179
  """
171
180
  Create the file object to append to as data is coming in.
172
181
  """
173
182
  super().new_file(*args, **kwargs)
183
+ assert self.file_name is not None, "file_name should be set by parent new_file"
184
+ assert self.content_type is not None, (
185
+ "content_type should be set by parent new_file"
186
+ )
174
187
  self.file = TemporaryUploadedFile(
175
188
  self.file_name, self.content_type, 0, self.charset, self.content_type_extra
176
189
  )
177
190
 
178
- def receive_data_chunk(self, raw_data, start):
191
+ def receive_data_chunk(self, raw_data: bytes, start: int) -> None:
179
192
  self.file.write(raw_data)
193
+ return None
180
194
 
181
- def file_complete(self, file_size):
195
+ def file_complete(self, file_size: int) -> TemporaryUploadedFile:
182
196
  self.file.seek(0)
183
197
  self.file.size = file_size
184
198
  return self.file
185
199
 
186
- def upload_interrupted(self):
200
+ def upload_interrupted(self) -> None:
187
201
  if hasattr(self, "file"):
188
202
  temp_location = self.file.temporary_file_path()
189
203
  try:
@@ -199,35 +213,43 @@ class MemoryFileUploadHandler(FileUploadHandler):
199
213
  """
200
214
 
201
215
  def handle_raw_input(
202
- self, input_data, meta, content_length, boundary, encoding=None
203
- ):
216
+ self,
217
+ input_data: Any,
218
+ boundary: bytes,
219
+ encoding: str | None = None,
220
+ ) -> None:
204
221
  """
205
222
  Use the content_length to signal whether or not this handler should be
206
223
  used.
207
224
  """
208
225
  # Check the content-length header to see if we should
209
226
  # If the post is too large, we cannot use the Memory handler.
210
- self.activated = content_length <= settings.FILE_UPLOAD_MAX_MEMORY_SIZE
227
+ self.activated = (
228
+ self.request.content_length <= settings.FILE_UPLOAD_MAX_MEMORY_SIZE
229
+ )
211
230
 
212
- def new_file(self, *args, **kwargs):
231
+ def new_file(self, *args: Any, **kwargs: Any) -> None:
213
232
  super().new_file(*args, **kwargs)
214
233
  if self.activated:
215
234
  self.file = BytesIO()
216
235
  raise StopFutureHandlers()
217
236
 
218
- def receive_data_chunk(self, raw_data, start):
237
+ def receive_data_chunk(self, raw_data: bytes, start: int) -> bytes | None:
219
238
  """Add the data to the BytesIO file."""
220
239
  if self.activated:
221
240
  self.file.write(raw_data)
241
+ return None
222
242
  else:
223
243
  return raw_data
224
244
 
225
- def file_complete(self, file_size):
245
+ def file_complete(self, file_size: int) -> InMemoryUploadedFile | None:
226
246
  """Return a file object if this handler is activated."""
227
247
  if not self.activated:
228
- return
248
+ return None
229
249
 
230
250
  self.file.seek(0)
251
+ assert self.file_name is not None, "file_name should be set by new_file"
252
+ assert self.content_type is not None, "content_type should be set by new_file"
231
253
  return InMemoryUploadedFile(
232
254
  file=self.file,
233
255
  field_name=self.field_name,
@@ -237,19 +259,3 @@ class MemoryFileUploadHandler(FileUploadHandler):
237
259
  charset=self.charset,
238
260
  content_type_extra=self.content_type_extra,
239
261
  )
240
-
241
-
242
- def load_handler(path, *args, **kwargs):
243
- """
244
- Given a path to a handler, return an instance of that handler.
245
-
246
- E.g.::
247
- >>> from plain.http import HttpRequest
248
- >>> request = HttpRequest()
249
- >>> load_handler(
250
- ... 'plain.internal.files.uploadhandler.TemporaryFileUploadHandler',
251
- ... request,
252
- ... )
253
- <TemporaryFileUploadHandler object at 0x...>
254
- """
255
- return import_string(path)(*args, **kwargs)
@@ -1,13 +1,22 @@
1
+ from __future__ import annotations
2
+
1
3
  import os
2
4
  import pathlib
5
+ from typing import TYPE_CHECKING
6
+
7
+ from plain.http import SuspiciousFileOperationError400
3
8
 
4
- from plain.exceptions import SuspiciousFileOperation
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Iterator
11
+ from typing import IO, Any
5
12
 
6
13
 
7
- def validate_file_name(name, allow_relative_path=False):
14
+ def validate_file_name(name: str, allow_relative_path: bool = False) -> str:
8
15
  # Remove potentially dangerous names
9
16
  if os.path.basename(name) in {"", ".", ".."}:
10
- raise SuspiciousFileOperation(f"Could not derive file name from '{name}'")
17
+ raise SuspiciousFileOperationError400(
18
+ f"Could not derive file name from '{name}'"
19
+ )
11
20
 
12
21
  if allow_relative_path:
13
22
  # Use PurePosixPath() because this branch is checked only in
@@ -15,11 +24,13 @@ def validate_file_name(name, allow_relative_path=False):
15
24
  # Unix style (with forward slashes).
16
25
  path = pathlib.PurePosixPath(name)
17
26
  if path.is_absolute() or ".." in path.parts:
18
- raise SuspiciousFileOperation(
27
+ raise SuspiciousFileOperationError400(
19
28
  f"Detected path traversal attempt in '{name}'"
20
29
  )
21
30
  elif name != os.path.basename(name):
22
- raise SuspiciousFileOperation(f"File name '{name}' includes path elements")
31
+ raise SuspiciousFileOperationError400(
32
+ f"File name '{name}' includes path elements"
33
+ )
23
34
 
24
35
  return name
25
36
 
@@ -34,6 +45,9 @@ class FileProxyMixin:
34
45
  self.file = file
35
46
  """
36
47
 
48
+ # Expected to be set by subclasses
49
+ file: IO[Any]
50
+
37
51
  encoding = property(lambda self: self.file.encoding)
38
52
  fileno = property(lambda self: self.file.fileno)
39
53
  flush = property(lambda self: self.file.flush)
@@ -50,29 +64,29 @@ class FileProxyMixin:
50
64
  writelines = property(lambda self: self.file.writelines)
51
65
 
52
66
  @property
53
- def closed(self):
67
+ def closed(self) -> bool:
54
68
  return not self.file or self.file.closed
55
69
 
56
- def readable(self):
70
+ def readable(self) -> bool:
57
71
  if self.closed:
58
72
  return False
59
73
  if hasattr(self.file, "readable"):
60
74
  return self.file.readable()
61
75
  return True
62
76
 
63
- def writable(self):
77
+ def writable(self) -> bool:
64
78
  if self.closed:
65
79
  return False
66
80
  if hasattr(self.file, "writable"):
67
81
  return self.file.writable()
68
82
  return "w" in getattr(self.file, "mode", "")
69
83
 
70
- def seekable(self):
84
+ def seekable(self) -> bool:
71
85
  if self.closed:
72
86
  return False
73
87
  if hasattr(self.file, "seekable"):
74
88
  return self.file.seekable()
75
89
  return True
76
90
 
77
- def __iter__(self):
91
+ def __iter__(self) -> Iterator[Any]:
78
92
  return iter(self.file)
@@ -1,18 +1,23 @@
1
- import logging
1
+ from __future__ import annotations
2
+
2
3
  import types
4
+ from typing import TYPE_CHECKING
3
5
 
4
6
  from opentelemetry import baggage, trace
5
7
  from opentelemetry.semconv.attributes import http_attributes, url_attributes
6
8
 
7
9
  from plain.exceptions import ImproperlyConfigured
8
- from plain.logs.utils import log_response
9
10
  from plain.runtime import settings
10
11
  from plain.urls import get_resolver
11
12
  from plain.utils.module_loading import import_string
12
13
 
13
14
  from .exception import convert_exception_to_response
14
15
 
15
- logger = logging.getLogger("plain.request")
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Callable
18
+
19
+ from plain.http import Request, Response, ResponseBase
20
+ from plain.urls import ResolverMatch
16
21
 
17
22
 
18
23
  # These middleware classes are always used by Plain.
@@ -34,9 +39,9 @@ tracer = trace.get_tracer("plain")
34
39
 
35
40
 
36
41
  class BaseHandler:
37
- _middleware_chain = None
42
+ _middleware_chain: Callable[[Request], ResponseBase] | None = None
38
43
 
39
- def load_middleware(self):
44
+ def load_middleware(self) -> None:
40
45
  """
41
46
  Populate middleware lists from settings.MIDDLEWARE.
42
47
 
@@ -57,18 +62,21 @@ class BaseHandler:
57
62
  f"Middleware factory {middleware_path} returned None."
58
63
  )
59
64
 
60
- handler = convert_exception_to_response(mw_instance)
65
+ handler = convert_exception_to_response(mw_instance.process_request)
61
66
 
62
67
  # We only assign to this when initialization is complete as it is used
63
68
  # as a flag for initialization being complete.
64
69
  self._middleware_chain = handler
65
70
 
66
- def get_response(self, request):
67
- """Return a Response object for the given HttpRequest."""
71
+ def get_response(self, request: Request) -> ResponseBase:
72
+ """Return a Response object for the given Request."""
73
+ assert self._middleware_chain is not None, (
74
+ "load_middleware() must be called before get_response()"
75
+ )
68
76
 
69
- span_attributes = {
77
+ span_attributes: dict[str, str] = {
70
78
  "plain.request.id": request.unique_id,
71
- http_attributes.HTTP_REQUEST_METHOD: request.method,
79
+ http_attributes.HTTP_REQUEST_METHOD: request.method or "",
72
80
  url_attributes.URL_PATH: request.path_info,
73
81
  url_attributes.URL_SCHEME: request.scheme,
74
82
  }
@@ -81,10 +89,13 @@ class BaseHandler:
81
89
  pass
82
90
 
83
91
  # Add query string if present
84
- if query_string := request.meta.get("QUERY_STRING"):
85
- span_attributes[url_attributes.URL_QUERY] = query_string
92
+ if request.query_string:
93
+ span_attributes[url_attributes.URL_QUERY] = request.query_string
86
94
 
87
95
  span_context = baggage.set_baggage("http.request.cookies", request.cookies)
96
+ span_context = baggage.set_baggage(
97
+ "http.request.headers", request.headers, span_context
98
+ )
88
99
 
89
100
  with tracer.start_as_current_span(
90
101
  f"{request.method} {request.path_info}",
@@ -105,17 +116,12 @@ class BaseHandler:
105
116
  else trace.StatusCode.ERROR
106
117
  )
107
118
 
108
- if response.status_code >= 400:
109
- log_response(
110
- "%s: %s",
111
- response.reason_phrase,
112
- request.path,
113
- response=response,
114
- request=request,
115
- )
119
+ if response.exception:
120
+ span.record_exception(response.exception)
121
+
116
122
  return response
117
123
 
118
- def _get_response(self, request):
124
+ def _get_response(self, request: Request) -> ResponseBase:
119
125
  """
120
126
  Resolve and call the view, then apply view, exception, and
121
127
  template_response middleware. This method is everything that happens
@@ -132,7 +138,7 @@ class BaseHandler:
132
138
 
133
139
  return response
134
140
 
135
- def resolve_request(self, request):
141
+ def resolve_request(self, request: Request) -> ResolverMatch:
136
142
  """
137
143
  Retrieve/set the urlrouter for the request. Return the view resolved,
138
144
  with its args and kwargs.
@@ -154,7 +160,12 @@ class BaseHandler:
154
160
  request.resolver_match = resolver_match
155
161
  return resolver_match
156
162
 
157
- def check_response(self, response, callback, name=None):
163
+ def check_response(
164
+ self,
165
+ response: Response | None,
166
+ callback: Callable[..., Response],
167
+ name: str | None = None,
168
+ ) -> None:
158
169
  """
159
170
  Raise an error if the view returned None or an uncalled coroutine.
160
171
  """
@@ -1,29 +1,38 @@
1
+ from __future__ import annotations
2
+
1
3
  import logging
2
4
  from functools import wraps
3
-
4
- from plain import signals
5
- from plain.exceptions import (
6
- BadRequest,
7
- PermissionDenied,
8
- RequestDataTooBig,
9
- SuspiciousOperation,
10
- TooManyFieldsSent,
11
- TooManyFilesSent,
5
+ from typing import TYPE_CHECKING
6
+
7
+ from plain.forms.exceptions import FormFieldMissingError
8
+ from plain.http import (
9
+ BadRequestError400,
10
+ ForbiddenError403,
11
+ NotFoundError404,
12
+ Response,
13
+ SuspiciousOperationError400,
12
14
  )
13
- from plain.http import Http404, ResponseServerError
14
15
  from plain.http.multipartparser import MultiPartParserError
15
- from plain.logs.utils import log_response
16
16
  from plain.runtime import settings
17
- from plain.utils.module_loading import import_string
18
17
  from plain.views.errors import ErrorView
19
18
 
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Callable
21
+
22
+ from plain.http import Request, Response, ResponseBase
23
+
20
24
 
21
- def convert_exception_to_response(get_response):
25
+ request_logger = logging.getLogger("plain.request")
26
+
27
+
28
+ def convert_exception_to_response(
29
+ get_response: Callable[[Request], ResponseBase],
30
+ ) -> Callable[[Request], ResponseBase]:
22
31
  """
23
32
  Wrap the given get_response callable in exception-to-response conversion.
24
33
 
25
- All exceptions will be converted. All known 4xx exceptions (Http404,
26
- PermissionDenied, MultiPartParserError, SuspiciousOperation) will be
34
+ All exceptions will be converted. All known 4xx exceptions (NotFoundError404,
35
+ ForbiddenError403, MultiPartParserError, SuspiciousOperationError400) will be
27
36
  converted to the appropriate response, and all other exceptions will be
28
37
  converted to 500 responses.
29
38
 
@@ -33,7 +42,7 @@ def convert_exception_to_response(get_response):
33
42
  """
34
43
 
35
44
  @wraps(get_response)
36
- def inner(request):
45
+ def inner(request: Request) -> ResponseBase:
37
46
  try:
38
47
  response = get_response(request)
39
48
  except Exception as exc:
@@ -43,106 +52,100 @@ def convert_exception_to_response(get_response):
43
52
  return inner
44
53
 
45
54
 
46
- def response_for_exception(request, exc):
47
- if isinstance(exc, Http404):
55
+ def response_for_exception(request: Request, exc: Exception) -> Response:
56
+ if isinstance(exc, NotFoundError404):
48
57
  response = get_exception_response(
49
58
  request=request, status_code=404, exception=None
50
59
  )
51
60
 
52
- elif isinstance(exc, PermissionDenied):
61
+ elif isinstance(exc, ForbiddenError403):
53
62
  response = get_exception_response(
54
63
  request=request, status_code=403, exception=exc
55
64
  )
56
- log_response(
65
+ request_logger.warning(
57
66
  "Forbidden (Permission denied): %s",
58
67
  request.path,
59
- response=response,
60
- request=request,
61
- exception=exc,
68
+ extra={"status_code": response.status_code, "request": request},
69
+ exc_info=exc,
62
70
  )
63
71
 
64
72
  elif isinstance(exc, MultiPartParserError):
65
73
  response = get_exception_response(
66
74
  request=request, status_code=400, exception=None
67
75
  )
68
- log_response(
76
+ request_logger.warning(
69
77
  "Bad request (Unable to parse request body): %s",
70
78
  request.path,
71
- response=response,
72
- request=request,
73
- exception=exc,
79
+ extra={"status_code": response.status_code, "request": request},
80
+ exc_info=exc,
74
81
  )
75
82
 
76
- elif isinstance(exc, BadRequest):
83
+ elif isinstance(exc, BadRequestError400):
77
84
  response = get_exception_response(
78
85
  request=request, status_code=400, exception=exc
79
86
  )
80
- log_response(
87
+ request_logger.warning(
81
88
  "%s: %s",
82
89
  str(exc),
83
90
  request.path,
84
- response=response,
85
- request=request,
86
- exception=exc,
91
+ extra={"status_code": response.status_code, "request": request},
92
+ exc_info=exc,
87
93
  )
88
- elif isinstance(exc, SuspiciousOperation):
89
- if isinstance(exc, RequestDataTooBig | TooManyFieldsSent | TooManyFilesSent):
90
- # POST data can't be accessed again, otherwise the original
91
- # exception would be raised.
92
- request._mark_post_parse_error()
93
-
94
+ elif isinstance(exc, SuspiciousOperationError400):
94
95
  # The request logger receives events for any problematic request
95
- # The security logger receives events for all SuspiciousOperations
96
+ # The security logger receives events for all SuspiciousOperationError400s
96
97
  security_logger = logging.getLogger(f"plain.security.{exc.__class__.__name__}")
97
98
  security_logger.error(
98
99
  str(exc),
99
- exc_info=exc,
100
100
  extra={"status_code": 400, "request": request},
101
+ exc_info=exc,
102
+ )
103
+ response = get_exception_response(
104
+ request=request, status_code=400, exception=None
101
105
  )
106
+
107
+ elif isinstance(exc, FormFieldMissingError):
102
108
  response = get_exception_response(
103
109
  request=request, status_code=400, exception=None
104
110
  )
111
+ request_logger.warning(
112
+ "Bad request (missing form field '%s'): %s",
113
+ exc.field_name,
114
+ request.path,
115
+ extra={"status_code": 400, "request": request},
116
+ )
105
117
 
106
118
  else:
107
- signals.got_request_exception.send(sender=None, request=request)
108
119
  response = get_exception_response(
109
120
  request=request, status_code=500, exception=None
110
121
  )
111
- log_response(
122
+ request_logger.error(
112
123
  "%s: %s",
113
124
  response.reason_phrase,
114
125
  request.path,
115
- response=response,
116
- request=request,
117
- exception=exc,
126
+ extra={"status_code": response.status_code, "request": request},
127
+ exc_info=exc,
118
128
  )
119
129
 
120
130
  return response
121
131
 
122
132
 
123
- def get_exception_response(*, request, status_code, exception):
133
+ def get_exception_response(
134
+ *, request: Request, status_code: int, exception: Exception | None
135
+ ) -> Response:
124
136
  try:
125
- view_class = get_error_view(status_code=status_code, exception=exception)
126
- return view_class(request)
127
- except Exception:
128
- signals.got_request_exception.send(sender=None, request=request)
129
-
137
+ view_class = ErrorView.as_view(status_code=status_code, exception=exception)
138
+ response = view_class(request)
139
+ if response.status_code >= 500 and exception is not None:
140
+ # Attach the exception to the response for logging/observability
141
+ response.exception = exception
142
+ return response
143
+ except Exception as e:
130
144
  # In development mode, re-raise the exception to get a full stack trace
131
145
  if settings.DEBUG:
132
146
  raise
133
147
 
134
148
  # If we can't load the view, return a 500 response
135
- return ResponseServerError()
136
-
137
-
138
- def get_error_view(*, status_code, exception):
139
- views_by_status = settings.HTTP_ERROR_VIEWS
140
- if status_code in views_by_status:
141
- view = views_by_status[status_code]
142
- if isinstance(view, str):
143
- # Import the view if it's a string
144
- view = import_string(view)
145
- return view.as_view()
146
-
147
- # Create a standard view for any other status code
148
- return ErrorView.as_view(status_code=status_code, exception=exception)
149
+ response = Response(status_code=500)
150
+ response.exception = e
151
+ return response