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
plain/http/request.py
CHANGED
|
@@ -1,33 +1,40 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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
|
-
|
|
51
|
-
_upload_handlers = []
|
|
57
|
+
encoding: str | None = None
|
|
52
58
|
|
|
53
59
|
non_picklable_attrs = frozenset(["resolver_match", "_stream"])
|
|
54
60
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
98
|
+
def headers(self) -> RequestHeaders:
|
|
99
|
+
return RequestHeaders(self.environ)
|
|
99
100
|
|
|
100
101
|
@cached_property
|
|
101
|
-
def
|
|
102
|
-
"""
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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.
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
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.
|
|
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.
|
|
148
|
-
|
|
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.
|
|
175
|
+
port = self.environ["SERVER_PORT"]
|
|
151
176
|
return str(port)
|
|
152
177
|
|
|
153
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
244
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
|
317
|
-
> settings.DATA_UPLOAD_MAX_MEMORY_SIZE
|
|
318
|
+
and self.content_length > settings.DATA_UPLOAD_MAX_MEMORY_SIZE
|
|
318
319
|
):
|
|
319
|
-
raise
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
337
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
+
@cached_property
|
|
343
|
+
def json_data(self) -> dict[str, Any]:
|
|
344
|
+
"""
|
|
345
|
+
Parsed JSON object from request body.
|
|
342
346
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
MultiValueDict(),
|
|
390
|
+
raise ValueError(
|
|
391
|
+
f"Request content-type is not form data (got: {self.content_type})"
|
|
374
392
|
)
|
|
375
393
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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.
|
|
386
|
-
# request.body, self._stream points to a BytesIO
|
|
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(
|
|
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
|
|
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__(
|
|
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 =
|
|
542
|
+
query_string = query_bytes.decode(self.encoding)
|
|
497
543
|
except UnicodeDecodeError:
|
|
498
544
|
# ... but some user agents are misbehaving :-(
|
|
499
|
-
query_string =
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
729
|
+
safe_bytes: bytes = safe.encode(self.encoding)
|
|
618
730
|
|
|
619
|
-
def encode(k, v):
|
|
620
|
-
return f"{quote(k,
|
|
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
|
-
|
|
638
|
-
|
|
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
|
-
|
|
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()]
|