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.
- plain/CHANGELOG.md +684 -1
- plain/README.md +1 -1
- plain/agents/.claude/rules/plain.md +88 -0
- plain/agents/.claude/skills/plain-install/SKILL.md +26 -0
- plain/agents/.claude/skills/plain-upgrade/SKILL.md +35 -0
- plain/assets/compile.py +25 -12
- plain/assets/finders.py +24 -17
- plain/assets/fingerprints.py +10 -7
- plain/assets/urls.py +1 -1
- plain/assets/views.py +47 -33
- plain/chores/README.md +25 -23
- plain/chores/__init__.py +2 -1
- plain/chores/core.py +27 -0
- plain/chores/registry.py +23 -36
- plain/cli/README.md +185 -16
- plain/cli/__init__.py +2 -1
- plain/cli/agent.py +234 -0
- plain/cli/build.py +7 -8
- plain/cli/changelog.py +11 -5
- plain/cli/chores.py +32 -34
- plain/cli/core.py +110 -26
- plain/cli/docs.py +98 -21
- plain/cli/formatting.py +40 -17
- plain/cli/install.py +10 -54
- plain/cli/{agent/llmdocs.py → llmdocs.py} +45 -26
- plain/cli/output.py +6 -2
- plain/cli/preflight.py +27 -75
- plain/cli/print.py +4 -4
- plain/cli/registry.py +96 -10
- plain/cli/{agent/request.py → request.py} +67 -33
- plain/cli/runtime.py +45 -0
- plain/cli/scaffold.py +2 -7
- plain/cli/server.py +153 -0
- plain/cli/settings.py +53 -49
- plain/cli/shell.py +15 -12
- plain/cli/startup.py +9 -8
- plain/cli/upgrade.py +17 -104
- plain/cli/urls.py +12 -7
- plain/cli/utils.py +3 -3
- plain/csrf/README.md +65 -40
- plain/csrf/middleware.py +53 -43
- plain/debug.py +5 -2
- plain/exceptions.py +22 -114
- plain/forms/README.md +453 -24
- plain/forms/__init__.py +55 -4
- plain/forms/boundfield.py +15 -8
- plain/forms/exceptions.py +1 -1
- plain/forms/fields.py +346 -143
- plain/forms/forms.py +75 -45
- plain/http/README.md +356 -9
- plain/http/__init__.py +41 -26
- plain/http/cookie.py +15 -7
- plain/http/exceptions.py +65 -0
- plain/http/middleware.py +32 -0
- plain/http/multipartparser.py +99 -88
- plain/http/request.py +362 -250
- plain/http/response.py +99 -197
- plain/internal/__init__.py +8 -1
- plain/internal/files/base.py +35 -19
- plain/internal/files/locks.py +19 -11
- plain/internal/files/move.py +8 -3
- plain/internal/files/temp.py +25 -6
- plain/internal/files/uploadedfile.py +47 -28
- plain/internal/files/uploadhandler.py +64 -58
- plain/internal/files/utils.py +24 -10
- plain/internal/handlers/base.py +34 -23
- plain/internal/handlers/exception.py +68 -65
- plain/internal/handlers/wsgi.py +65 -54
- plain/internal/middleware/headers.py +37 -11
- plain/internal/middleware/hosts.py +11 -8
- plain/internal/middleware/https.py +17 -7
- plain/internal/middleware/slash.py +14 -9
- plain/internal/reloader.py +77 -0
- plain/json.py +2 -1
- plain/logs/README.md +161 -62
- plain/logs/__init__.py +1 -1
- plain/logs/{loggers.py → app.py} +71 -67
- plain/logs/configure.py +63 -14
- plain/logs/debug.py +17 -6
- plain/logs/filters.py +15 -0
- plain/logs/formatters.py +7 -4
- plain/packages/README.md +105 -23
- plain/packages/config.py +15 -7
- plain/packages/registry.py +27 -16
- plain/paginator.py +31 -21
- plain/preflight/README.md +209 -24
- plain/preflight/__init__.py +1 -0
- plain/preflight/checks.py +3 -1
- plain/preflight/files.py +3 -1
- plain/preflight/registry.py +26 -11
- plain/preflight/results.py +15 -7
- plain/preflight/security.py +15 -13
- plain/preflight/settings.py +54 -0
- plain/preflight/urls.py +4 -1
- plain/runtime/README.md +115 -47
- plain/runtime/__init__.py +10 -6
- plain/runtime/global_settings.py +34 -25
- plain/runtime/secret.py +20 -0
- plain/runtime/user_settings.py +110 -38
- plain/runtime/utils.py +1 -1
- plain/server/LICENSE +35 -0
- plain/server/README.md +155 -0
- plain/server/__init__.py +9 -0
- plain/server/app.py +52 -0
- plain/server/arbiter.py +555 -0
- plain/server/config.py +118 -0
- plain/server/errors.py +31 -0
- plain/server/glogging.py +292 -0
- plain/server/http/__init__.py +12 -0
- plain/server/http/body.py +283 -0
- plain/server/http/errors.py +155 -0
- plain/server/http/message.py +400 -0
- plain/server/http/parser.py +70 -0
- plain/server/http/unreader.py +88 -0
- plain/server/http/wsgi.py +421 -0
- plain/server/pidfile.py +92 -0
- plain/server/sock.py +240 -0
- plain/server/util.py +317 -0
- plain/server/workers/__init__.py +6 -0
- plain/server/workers/base.py +304 -0
- plain/server/workers/sync.py +212 -0
- plain/server/workers/thread.py +399 -0
- plain/server/workers/workertmp.py +50 -0
- plain/signals/README.md +170 -1
- plain/signals/__init__.py +0 -1
- plain/signals/dispatch/dispatcher.py +49 -27
- plain/signing.py +131 -35
- plain/templates/README.md +211 -20
- plain/templates/jinja/__init__.py +13 -5
- plain/templates/jinja/environments.py +5 -4
- plain/templates/jinja/extensions.py +12 -5
- plain/templates/jinja/filters.py +7 -2
- plain/templates/jinja/globals.py +2 -2
- plain/test/README.md +184 -22
- plain/test/client.py +340 -222
- plain/test/encoding.py +9 -6
- plain/test/exceptions.py +7 -2
- plain/urls/README.md +157 -73
- plain/urls/converters.py +18 -15
- plain/urls/exceptions.py +2 -2
- plain/urls/patterns.py +38 -22
- plain/urls/resolvers.py +35 -25
- plain/urls/utils.py +5 -1
- plain/utils/README.md +250 -3
- plain/utils/cache.py +17 -11
- plain/utils/crypto.py +21 -5
- plain/utils/datastructures.py +89 -56
- plain/utils/dateparse.py +9 -6
- plain/utils/deconstruct.py +15 -7
- plain/utils/decorators.py +5 -1
- plain/utils/dotenv.py +373 -0
- plain/utils/duration.py +8 -4
- plain/utils/encoding.py +14 -7
- plain/utils/functional.py +66 -49
- plain/utils/hashable.py +5 -1
- plain/utils/html.py +36 -22
- plain/utils/http.py +16 -9
- plain/utils/inspect.py +14 -6
- plain/utils/ipv6.py +7 -3
- plain/utils/itercompat.py +6 -1
- plain/utils/module_loading.py +7 -3
- plain/utils/regex_helper.py +37 -23
- plain/utils/safestring.py +14 -6
- plain/utils/text.py +41 -23
- plain/utils/timezone.py +33 -22
- plain/utils/tree.py +35 -19
- plain/validators.py +94 -52
- plain/views/README.md +156 -79
- plain/views/__init__.py +0 -1
- plain/views/base.py +25 -18
- plain/views/errors.py +13 -5
- plain/views/exceptions.py +4 -1
- plain/views/forms.py +6 -6
- plain/views/objects.py +52 -49
- plain/views/redirect.py +18 -15
- plain/views/templates.py +5 -3
- plain/wsgi.py +3 -1
- {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/METADATA +4 -2
- plain-0.103.0.dist-info/RECORD +198 -0
- {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/WHEEL +1 -1
- plain-0.103.0.dist-info/entry_points.txt +2 -0
- plain/AGENTS.md +0 -18
- plain/cli/agent/__init__.py +0 -20
- plain/cli/agent/docs.py +0 -80
- plain/cli/agent/md.py +0 -87
- plain/cli/agent/prompt.py +0 -45
- plain/csrf/views.py +0 -31
- plain/logs/utils.py +0 -46
- plain/templates/AGENTS.md +0 -3
- plain-0.68.0.dist-info/RECORD +0 -169
- plain-0.68.0.dist-info/entry_points.txt +0 -5
- {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
|
-
|
|
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
|
|
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,
|
|
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
|
-
:
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
"subclasses of FileUploadHandler must provide a receive_data_chunk() method"
|
|
136
|
-
)
|
|
146
|
+
...
|
|
137
147
|
|
|
138
|
-
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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)
|
plain/internal/files/utils.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
27
|
+
raise SuspiciousFileOperationError400(
|
|
19
28
|
f"Detected path traversal attempt in '{name}'"
|
|
20
29
|
)
|
|
21
30
|
elif name != os.path.basename(name):
|
|
22
|
-
raise
|
|
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)
|
plain/internal/handlers/base.py
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
109
|
-
|
|
110
|
-
|
|
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(
|
|
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
|
-
|
|
5
|
-
from plain.exceptions import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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 (
|
|
26
|
-
|
|
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,
|
|
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,
|
|
61
|
+
elif isinstance(exc, ForbiddenError403):
|
|
53
62
|
response = get_exception_response(
|
|
54
63
|
request=request, status_code=403, exception=exc
|
|
55
64
|
)
|
|
56
|
-
|
|
65
|
+
request_logger.warning(
|
|
57
66
|
"Forbidden (Permission denied): %s",
|
|
58
67
|
request.path,
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
76
|
+
request_logger.warning(
|
|
69
77
|
"Bad request (Unable to parse request body): %s",
|
|
70
78
|
request.path,
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
exception=exc,
|
|
79
|
+
extra={"status_code": response.status_code, "request": request},
|
|
80
|
+
exc_info=exc,
|
|
74
81
|
)
|
|
75
82
|
|
|
76
|
-
elif isinstance(exc,
|
|
83
|
+
elif isinstance(exc, BadRequestError400):
|
|
77
84
|
response = get_exception_response(
|
|
78
85
|
request=request, status_code=400, exception=exc
|
|
79
86
|
)
|
|
80
|
-
|
|
87
|
+
request_logger.warning(
|
|
81
88
|
"%s: %s",
|
|
82
89
|
str(exc),
|
|
83
90
|
request.path,
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
exception=exc,
|
|
91
|
+
extra={"status_code": response.status_code, "request": request},
|
|
92
|
+
exc_info=exc,
|
|
87
93
|
)
|
|
88
|
-
elif isinstance(exc,
|
|
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
|
|
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
|
-
|
|
122
|
+
request_logger.error(
|
|
112
123
|
"%s: %s",
|
|
113
124
|
response.reason_phrase,
|
|
114
125
|
request.path,
|
|
115
|
-
|
|
116
|
-
|
|
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(
|
|
133
|
+
def get_exception_response(
|
|
134
|
+
*, request: Request, status_code: int, exception: Exception | None
|
|
135
|
+
) -> Response:
|
|
124
136
|
try:
|
|
125
|
-
view_class =
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|