plain 0.69.0__py3-none-any.whl → 0.71.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 (128) hide show
  1. plain/AGENTS.md +1 -1
  2. plain/CHANGELOG.md +28 -0
  3. plain/assets/compile.py +20 -7
  4. plain/assets/finders.py +15 -11
  5. plain/assets/fingerprints.py +6 -5
  6. plain/assets/urls.py +1 -1
  7. plain/assets/views.py +23 -17
  8. plain/chores/registry.py +14 -9
  9. plain/cli/agent/__init__.py +1 -1
  10. plain/cli/agent/docs.py +7 -6
  11. plain/cli/agent/llmdocs.py +18 -8
  12. plain/cli/agent/md.py +19 -14
  13. plain/cli/agent/prompt.py +1 -1
  14. plain/cli/agent/request.py +37 -17
  15. plain/cli/build.py +2 -2
  16. plain/cli/changelog.py +8 -4
  17. plain/cli/chores.py +4 -4
  18. plain/cli/core.py +8 -5
  19. plain/cli/docs.py +2 -2
  20. plain/cli/formatting.py +10 -7
  21. plain/cli/output.py +6 -2
  22. plain/cli/preflight.py +3 -3
  23. plain/cli/print.py +1 -1
  24. plain/cli/registry.py +10 -6
  25. plain/cli/scaffold.py +1 -1
  26. plain/cli/settings.py +1 -1
  27. plain/cli/shell.py +10 -7
  28. plain/cli/startup.py +3 -3
  29. plain/cli/urls.py +10 -4
  30. plain/cli/utils.py +2 -2
  31. plain/csrf/middleware.py +15 -5
  32. plain/csrf/views.py +11 -8
  33. plain/debug.py +5 -2
  34. plain/exceptions.py +19 -8
  35. plain/forms/__init__.py +1 -1
  36. plain/forms/boundfield.py +14 -7
  37. plain/forms/exceptions.py +1 -1
  38. plain/forms/fields.py +139 -97
  39. plain/forms/forms.py +55 -39
  40. plain/http/README.md +1 -1
  41. plain/http/__init__.py +4 -4
  42. plain/http/cookie.py +15 -7
  43. plain/http/multipartparser.py +50 -30
  44. plain/http/request.py +156 -108
  45. plain/http/response.py +99 -80
  46. plain/internal/__init__.py +8 -1
  47. plain/internal/files/base.py +34 -18
  48. plain/internal/files/locks.py +19 -11
  49. plain/internal/files/move.py +8 -3
  50. plain/internal/files/temp.py +23 -5
  51. plain/internal/files/uploadedfile.py +42 -26
  52. plain/internal/files/uploadhandler.py +50 -29
  53. plain/internal/files/utils.py +13 -6
  54. plain/internal/handlers/base.py +21 -7
  55. plain/internal/handlers/exception.py +19 -5
  56. plain/internal/handlers/wsgi.py +33 -21
  57. plain/internal/middleware/headers.py +11 -2
  58. plain/internal/middleware/hosts.py +12 -4
  59. plain/internal/middleware/https.py +13 -3
  60. plain/internal/middleware/slash.py +15 -5
  61. plain/json.py +2 -1
  62. plain/logs/configure.py +3 -1
  63. plain/logs/debug.py +16 -5
  64. plain/logs/formatters.py +6 -3
  65. plain/logs/loggers.py +56 -52
  66. plain/logs/utils.py +19 -9
  67. plain/packages/config.py +14 -6
  68. plain/packages/registry.py +27 -12
  69. plain/paginator.py +31 -21
  70. plain/preflight/checks.py +3 -1
  71. plain/preflight/files.py +3 -1
  72. plain/preflight/registry.py +25 -10
  73. plain/preflight/results.py +10 -4
  74. plain/preflight/security.py +7 -5
  75. plain/preflight/urls.py +4 -1
  76. plain/runtime/__init__.py +7 -6
  77. plain/runtime/global_settings.py +6 -9
  78. plain/runtime/user_settings.py +26 -17
  79. plain/runtime/utils.py +1 -1
  80. plain/signals/dispatch/dispatcher.py +39 -17
  81. plain/signing.py +49 -30
  82. plain/templates/jinja/__init__.py +13 -5
  83. plain/templates/jinja/environments.py +4 -3
  84. plain/templates/jinja/extensions.py +9 -3
  85. plain/templates/jinja/filters.py +7 -2
  86. plain/templates/jinja/globals.py +1 -1
  87. plain/test/client.py +249 -177
  88. plain/test/encoding.py +9 -6
  89. plain/test/exceptions.py +10 -2
  90. plain/urls/converters.py +13 -10
  91. plain/urls/patterns.py +32 -20
  92. plain/urls/resolvers.py +32 -22
  93. plain/urls/utils.py +5 -1
  94. plain/utils/cache.py +14 -8
  95. plain/utils/crypto.py +21 -5
  96. plain/utils/datastructures.py +84 -54
  97. plain/utils/dateparse.py +10 -7
  98. plain/utils/deconstruct.py +12 -4
  99. plain/utils/decorators.py +5 -1
  100. plain/utils/duration.py +8 -4
  101. plain/utils/encoding.py +14 -7
  102. plain/utils/functional.py +62 -47
  103. plain/utils/hashable.py +5 -1
  104. plain/utils/html.py +21 -14
  105. plain/utils/http.py +16 -9
  106. plain/utils/inspect.py +14 -6
  107. plain/utils/ipv6.py +7 -3
  108. plain/utils/itercompat.py +6 -1
  109. plain/utils/module_loading.py +7 -3
  110. plain/utils/regex_helper.py +23 -13
  111. plain/utils/safestring.py +14 -6
  112. plain/utils/text.py +34 -18
  113. plain/utils/timezone.py +30 -19
  114. plain/utils/tree.py +31 -18
  115. plain/validators.py +71 -44
  116. plain/views/base.py +16 -8
  117. plain/views/errors.py +11 -4
  118. plain/views/exceptions.py +4 -1
  119. plain/views/objects.py +15 -15
  120. plain/views/redirect.py +14 -10
  121. plain/views/templates.py +1 -1
  122. plain/wsgi.py +3 -1
  123. {plain-0.69.0.dist-info → plain-0.71.0.dist-info}/METADATA +1 -1
  124. plain-0.71.0.dist-info/RECORD +169 -0
  125. plain-0.69.0.dist-info/RECORD +0 -169
  126. {plain-0.69.0.dist-info → plain-0.71.0.dist-info}/WHEEL +0 -0
  127. {plain-0.69.0.dist-info → plain-0.71.0.dist-info}/entry_points.txt +0 -0
  128. {plain-0.69.0.dist-info → plain-0.71.0.dist-info}/licenses/LICENSE +0 -0
plain/http/request.py CHANGED
@@ -1,10 +1,14 @@
1
+ from __future__ import annotations
2
+
1
3
  import codecs
2
4
  import copy
3
5
  import json
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 IO, Any
8
12
  from urllib.parse import parse_qsl, quote, urlencode, urljoin, urlsplit
9
13
 
10
14
  from plain.exceptions import (
@@ -43,7 +47,7 @@ class RawPostDataException(Exception):
43
47
  pass
44
48
 
45
49
 
46
- class HttpRequest:
50
+ class Request:
47
51
  """A basic HTTP request."""
48
52
 
49
53
  # The encoding used in GET/POST dicts. None means use default setting.
@@ -73,19 +77,19 @@ class HttpRequest:
73
77
  self.content_type = None
74
78
  self.content_params = None
75
79
 
76
- def __repr__(self):
80
+ def __repr__(self) -> str:
77
81
  if self.method is None or not self.get_full_path():
78
82
  return f"<{self.__class__.__name__}>"
79
83
  return f"<{self.__class__.__name__}: {self.method} {self.get_full_path()!r}>"
80
84
 
81
- def __getstate__(self):
85
+ def __getstate__(self) -> dict[str, Any]:
82
86
  obj_dict = self.__dict__.copy()
83
87
  for attr in self.non_picklable_attrs:
84
88
  if attr in obj_dict:
85
89
  del obj_dict[attr]
86
90
  return obj_dict
87
91
 
88
- def __deepcopy__(self, memo):
92
+ def __deepcopy__(self, memo: dict[int, Any]) -> Request:
89
93
  obj = copy.copy(self)
90
94
  for attr in self.non_picklable_attrs:
91
95
  if hasattr(self, attr):
@@ -94,20 +98,43 @@ class HttpRequest:
94
98
  return obj
95
99
 
96
100
  @cached_property
97
- def headers(self):
98
- return HttpHeaders(self.meta)
101
+ def headers(self) -> RequestHeaders:
102
+ return RequestHeaders(self.meta)
99
103
 
100
104
  @cached_property
101
- def accepted_types(self):
102
- """Return a list of MediaType instances."""
103
- return parse_accept_header(self.headers.get("Accept", "*/*"))
105
+ def accepted_types(self) -> list[MediaType]:
106
+ """Return accepted media types sorted by quality value (highest first).
104
107
 
105
- def accepts(self, media_type):
106
- return any(
107
- accepted_type.match(media_type) for accepted_type in self.accepted_types
108
- )
108
+ When quality values are equal, the original order from the Accept header
109
+ is preserved (as per HTTP spec).
110
+ """
111
+ header = self.headers.get("Accept", "*/*")
112
+ types = [MediaType(token) for token in header.split(",") if token.strip()]
113
+ return sorted(types, key=lambda t: t.quality, reverse=True)
114
+
115
+ def get_preferred_type(self, *media_types: str) -> str | None:
116
+ """Return the most preferred media type from the given options.
117
+
118
+ Checks the Accept header in priority order (by quality value) and returns
119
+ the first matching media type from the provided options.
120
+
121
+ Returns None if none of the options are accepted.
109
122
 
110
- def _set_content_type_params(self, meta):
123
+ Example:
124
+ # Accept: text/html;q=1.0, application/json;q=0.5
125
+ request.get_preferred_type("application/json", "text/html") # Returns "text/html"
126
+ """
127
+ for accepted in self.accepted_types:
128
+ for option in media_types:
129
+ if accepted.match(option):
130
+ return option
131
+ return None
132
+
133
+ def accepts(self, media_type: str) -> bool:
134
+ """Check if the given media type is accepted."""
135
+ return self.get_preferred_type(media_type) is not None
136
+
137
+ def _set_content_type_params(self, meta: dict[str, Any]) -> None:
111
138
  """Set content_type, content_params, and encoding."""
112
139
  self.content_type, self.content_params = parse_header_parameters(
113
140
  meta.get("CONTENT_TYPE", "")
@@ -121,7 +148,7 @@ class HttpRequest:
121
148
  self.encoding = self.content_params["charset"]
122
149
 
123
150
  @cached_property
124
- def host(self):
151
+ def host(self) -> str:
125
152
  """
126
153
  Return the HTTP host using the environment or request headers.
127
154
 
@@ -142,7 +169,7 @@ class HttpRequest:
142
169
  return host
143
170
 
144
171
  @cached_property
145
- def port(self):
172
+ def port(self) -> str:
146
173
  """Return the port number for the request as a string."""
147
174
  if settings.USE_X_FORWARDED_PORT and "HTTP_X_FORWARDED_PORT" in self.meta:
148
175
  port = self.meta["HTTP_X_FORWARDED_PORT"]
@@ -150,7 +177,7 @@ class HttpRequest:
150
177
  port = self.meta["SERVER_PORT"]
151
178
  return str(port)
152
179
 
153
- def get_full_path(self, force_append_slash=False):
180
+ def get_full_path(self, force_append_slash: bool = False) -> str:
154
181
  """
155
182
  Return the full path for the request, including query string.
156
183
 
@@ -160,7 +187,7 @@ class HttpRequest:
160
187
  # RFC 3986 requires query string arguments to be in the ASCII range.
161
188
  # Rather than crash if this doesn't happen, we encode defensively.
162
189
 
163
- def escape_uri_path(path):
190
+ def escape_uri_path(path: str) -> str:
164
191
  """
165
192
  Escape the unsafe characters from the path portion of a Uniform Resource
166
193
  Identifier (URI).
@@ -176,15 +203,14 @@ class HttpRequest:
176
203
  # the entire path, not a path segment.
177
204
  return quote(path, safe="/:@&+$,-_.!~*'()")
178
205
 
206
+ query_string = self.meta.get("QUERY_STRING", "")
179
207
  return "{}{}{}".format(
180
208
  escape_uri_path(self.path),
181
209
  "/" if force_append_slash and not self.path.endswith("/") else "",
182
- ("?" + iri_to_uri(self.meta.get("QUERY_STRING", "")))
183
- if self.meta.get("QUERY_STRING", "")
184
- else "",
210
+ ("?" + (iri_to_uri(query_string) or "")) if query_string else "",
185
211
  )
186
212
 
187
- def build_absolute_uri(self, location=None):
213
+ def build_absolute_uri(self, location: str | None = None) -> str:
188
214
  """
189
215
  Build an absolute URI from the location and the variables available in
190
216
  this request. If no ``location`` is specified, build the absolute URI
@@ -224,9 +250,9 @@ class HttpRequest:
224
250
  # base path.
225
251
  location = urljoin(current_scheme_host + self.path, location)
226
252
 
227
- return iri_to_uri(location)
253
+ return iri_to_uri(location) or ""
228
254
 
229
- def _get_scheme(self):
255
+ def _get_scheme(self) -> str:
230
256
  """
231
257
  Hook for subclasses like WSGIRequest to implement. Return 'http' by
232
258
  default.
@@ -234,7 +260,7 @@ class HttpRequest:
234
260
  return "http"
235
261
 
236
262
  @property
237
- def scheme(self):
263
+ def scheme(self) -> str:
238
264
  if settings.HTTPS_PROXY_HEADER:
239
265
  try:
240
266
  header, secure_value = settings.HTTPS_PROXY_HEADER
@@ -249,15 +275,15 @@ class HttpRequest:
249
275
  return "https" if header_value.strip() == secure_value else "http"
250
276
  return self._get_scheme()
251
277
 
252
- def is_https(self):
278
+ def is_https(self) -> bool:
253
279
  return self.scheme == "https"
254
280
 
255
281
  @property
256
- def encoding(self):
282
+ def encoding(self) -> str | None:
257
283
  return self._encoding
258
284
 
259
285
  @encoding.setter
260
- def encoding(self, val):
286
+ def encoding(self, val: str) -> None:
261
287
  """
262
288
  Set the encoding used for query_params/data accesses. If the query_params or data
263
289
  dictionary has already been created, remove and recreate it on the
@@ -269,21 +295,21 @@ class HttpRequest:
269
295
  if hasattr(self, "_data"):
270
296
  del self._data
271
297
 
272
- def _initialize_handlers(self):
298
+ def _initialize_handlers(self) -> None:
273
299
  self._upload_handlers = [
274
300
  uploadhandler.load_handler(handler, self)
275
301
  for handler in settings.FILE_UPLOAD_HANDLERS
276
302
  ]
277
303
 
278
304
  @property
279
- def upload_handlers(self):
305
+ def upload_handlers(self) -> list[Any]:
280
306
  if not self._upload_handlers:
281
307
  # If there are no upload handlers defined, initialize them from settings.
282
308
  self._initialize_handlers()
283
309
  return self._upload_handlers
284
310
 
285
311
  @upload_handlers.setter
286
- def upload_handlers(self, upload_handlers):
312
+ def upload_handlers(self, upload_handlers: list[Any]) -> None:
287
313
  if hasattr(self, "_files"):
288
314
  raise AttributeError(
289
315
  "You cannot set the upload handlers after the upload has been "
@@ -291,7 +317,9 @@ class HttpRequest:
291
317
  )
292
318
  self._upload_handlers = upload_handlers
293
319
 
294
- def parse_file_upload(self, meta, post_data):
320
+ def parse_file_upload(
321
+ self, meta: dict[str, Any], post_data: IO[bytes]
322
+ ) -> tuple[Any, MultiValueDict]:
295
323
  """Return a tuple of (data QueryDict, files MultiValueDict)."""
296
324
  self.upload_handlers = ImmutableList(
297
325
  self.upload_handlers,
@@ -303,7 +331,7 @@ class HttpRequest:
303
331
  return parser.parse()
304
332
 
305
333
  @property
306
- def body(self):
334
+ def body(self) -> bytes:
307
335
  if not hasattr(self, "_body"):
308
336
  if self._read_started:
309
337
  raise RawPostDataException(
@@ -329,11 +357,11 @@ class HttpRequest:
329
357
  self._stream = BytesIO(self._body)
330
358
  return self._body
331
359
 
332
- def _mark_post_parse_error(self):
360
+ def _mark_post_parse_error(self) -> None:
333
361
  self._data = QueryDict()
334
362
  self._files = MultiValueDict()
335
363
 
336
- def _load_data_and_files(self):
364
+ def _load_data_and_files(self) -> None:
337
365
  """Populate self._data and self._files"""
338
366
 
339
367
  if self._read_started and not hasattr(self, "_body"):
@@ -373,7 +401,7 @@ class HttpRequest:
373
401
  MultiValueDict(),
374
402
  )
375
403
 
376
- def close(self):
404
+ def close(self) -> None:
377
405
  if hasattr(self, "_files"):
378
406
  for f in chain.from_iterable(list_[1] for list_ in self._files.lists()):
379
407
  f.close()
@@ -386,27 +414,33 @@ class HttpRequest:
386
414
  # request.body, self._stream points to a BytesIO instance
387
415
  # containing that data.
388
416
 
389
- def read(self, *args, **kwargs):
417
+ def read(self, *args: Any, **kwargs: Any) -> bytes:
390
418
  self._read_started = True
391
419
  try:
392
420
  return self._stream.read(*args, **kwargs)
393
421
  except OSError as e:
394
422
  raise UnreadablePostError(*e.args) from e
395
423
 
396
- def readline(self, *args, **kwargs):
424
+ def readline(self, *args: Any, **kwargs: Any) -> bytes:
397
425
  self._read_started = True
398
426
  try:
399
427
  return self._stream.readline(*args, **kwargs)
400
428
  except OSError as e:
401
429
  raise UnreadablePostError(*e.args) from e
402
430
 
403
- def __iter__(self):
431
+ def __iter__(self) -> Iterator[bytes]:
404
432
  return iter(self.readline, b"")
405
433
 
406
- def readlines(self):
434
+ def readlines(self) -> list[bytes]:
407
435
  return list(self)
408
436
 
409
- def get_signed_cookie(self, key, default=None, salt="", max_age=None):
437
+ def get_signed_cookie(
438
+ self,
439
+ key: str,
440
+ default: str | None = None,
441
+ salt: str = "",
442
+ max_age: int | None = None,
443
+ ) -> str | None:
410
444
  """
411
445
  Retrieve a cookie value signed with the SECRET_KEY.
412
446
 
@@ -421,12 +455,12 @@ class HttpRequest:
421
455
  return unsign_cookie_value(key, cookie_value, salt, max_age, default)
422
456
 
423
457
 
424
- class HttpHeaders(CaseInsensitiveMapping):
458
+ class RequestHeaders(CaseInsensitiveMapping):
425
459
  HTTP_PREFIX = "HTTP_"
426
460
  # PEP 333 gives two headers which aren't prepended with HTTP_.
427
461
  UNPREFIXED_HEADERS = {"CONTENT_TYPE", "CONTENT_LENGTH"}
428
462
 
429
- def __init__(self, environ):
463
+ def __init__(self, environ: dict[str, Any]):
430
464
  headers = {}
431
465
  for header, value in environ.items():
432
466
  name = self.parse_header_name(header)
@@ -434,12 +468,12 @@ class HttpHeaders(CaseInsensitiveMapping):
434
468
  headers[name] = value
435
469
  super().__init__(headers)
436
470
 
437
- def __getitem__(self, key):
471
+ def __getitem__(self, key: str) -> str:
438
472
  """Allow header lookup using underscores in place of hyphens."""
439
473
  return super().__getitem__(key.replace("_", "-"))
440
474
 
441
475
  @classmethod
442
- def parse_header_name(cls, header):
476
+ def parse_header_name(cls, header: str) -> str | None:
443
477
  if header.startswith(cls.HTTP_PREFIX):
444
478
  header = header.removeprefix(cls.HTTP_PREFIX)
445
479
  elif header not in cls.UNPREFIXED_HEADERS:
@@ -447,14 +481,14 @@ class HttpHeaders(CaseInsensitiveMapping):
447
481
  return header.replace("_", "-").title()
448
482
 
449
483
  @classmethod
450
- def to_wsgi_name(cls, header):
484
+ def to_wsgi_name(cls, header: str) -> str:
451
485
  header = header.replace("-", "_").upper()
452
486
  if header in cls.UNPREFIXED_HEADERS:
453
487
  return header
454
488
  return f"{cls.HTTP_PREFIX}{header}"
455
489
 
456
490
  @classmethod
457
- def to_wsgi_names(cls, headers):
491
+ def to_wsgi_names(cls, headers: dict[str, Any]) -> dict[str, Any]:
458
492
  return {
459
493
  cls.to_wsgi_name(header_name): value
460
494
  for header_name, value in headers.items()
@@ -481,7 +515,12 @@ class QueryDict(MultiValueDict):
481
515
  _mutable = True
482
516
  _encoding = None
483
517
 
484
- def __init__(self, query_string=None, mutable=False, encoding=None):
518
+ def __init__(
519
+ self,
520
+ query_string: str | bytes | None = None,
521
+ mutable: bool = False,
522
+ encoding: str | None = None,
523
+ ):
485
524
  super().__init__()
486
525
  self.encoding = encoding or settings.DEFAULT_CHARSET
487
526
  query_string = query_string or ""
@@ -492,11 +531,12 @@ class QueryDict(MultiValueDict):
492
531
  }
493
532
  if isinstance(query_string, bytes):
494
533
  # query_string normally contains URL-encoded data, a subset of ASCII.
534
+ query_bytes = query_string
495
535
  try:
496
- query_string = query_string.decode(self.encoding)
536
+ query_string = query_bytes.decode(self.encoding)
497
537
  except UnicodeDecodeError:
498
538
  # ... but some user agents are misbehaving :-(
499
- query_string = query_string.decode("iso-8859-1")
539
+ query_string = query_bytes.decode("iso-8859-1")
500
540
  try:
501
541
  for key, value in parse_qsl(query_string, **parse_qsl_kwargs):
502
542
  self.appendlist(key, value)
@@ -512,7 +552,13 @@ class QueryDict(MultiValueDict):
512
552
  self._mutable = mutable
513
553
 
514
554
  @classmethod
515
- def fromkeys(cls, iterable, value="", mutable=False, encoding=None):
555
+ def fromkeys(
556
+ cls,
557
+ iterable: Any,
558
+ value: str = "",
559
+ mutable: bool = False,
560
+ encoding: str | None = None,
561
+ ) -> QueryDict:
516
562
  """
517
563
  Return a new QueryDict with keys (may be repeated) from an iterable and
518
564
  values from value.
@@ -525,81 +571,83 @@ class QueryDict(MultiValueDict):
525
571
  return q
526
572
 
527
573
  @property
528
- def encoding(self):
574
+ def encoding(self) -> str:
529
575
  if self._encoding is None:
530
576
  self._encoding = settings.DEFAULT_CHARSET
531
577
  return self._encoding
532
578
 
533
579
  @encoding.setter
534
- def encoding(self, value):
580
+ def encoding(self, value: str) -> None:
535
581
  self._encoding = value
536
582
 
537
- def _assert_mutable(self):
583
+ def _assert_mutable(self) -> None:
538
584
  if not self._mutable:
539
585
  raise AttributeError("This QueryDict instance is immutable")
540
586
 
541
- def __setitem__(self, key, value):
587
+ def __setitem__(self, key: str, value: Any) -> None:
542
588
  self._assert_mutable()
543
- key = bytes_to_text(key, self.encoding)
544
- value = bytes_to_text(value, self.encoding)
589
+ key = self.bytes_to_text(key, self.encoding)
590
+ value = self.bytes_to_text(value, self.encoding)
545
591
  super().__setitem__(key, value)
546
592
 
547
- def __delitem__(self, key):
593
+ def __delitem__(self, key: str) -> None:
548
594
  self._assert_mutable()
549
595
  super().__delitem__(key)
550
596
 
551
- def __copy__(self):
597
+ def __copy__(self) -> QueryDict:
552
598
  result = self.__class__("", mutable=True, encoding=self.encoding)
553
599
  for key, value in self.lists():
554
600
  result.setlist(key, value)
555
601
  return result
556
602
 
557
- def __deepcopy__(self, memo):
603
+ def __deepcopy__(self, memo: dict[int, Any]) -> QueryDict:
558
604
  result = self.__class__("", mutable=True, encoding=self.encoding)
559
605
  memo[id(self)] = result
560
606
  for key, value in self.lists():
561
607
  result.setlist(copy.deepcopy(key, memo), copy.deepcopy(value, memo))
562
608
  return result
563
609
 
564
- def setlist(self, key, list_):
610
+ def setlist(self, key: str, list_: list[Any]) -> None:
565
611
  self._assert_mutable()
566
- key = bytes_to_text(key, self.encoding)
567
- list_ = [bytes_to_text(elt, self.encoding) for elt in list_]
612
+ key = self.bytes_to_text(key, self.encoding)
613
+ list_ = [self.bytes_to_text(elt, self.encoding) for elt in list_]
568
614
  super().setlist(key, list_)
569
615
 
570
- def setlistdefault(self, key, default_list=None):
616
+ def setlistdefault(
617
+ self, key: str, default_list: list[Any] | None = None
618
+ ) -> list[Any]:
571
619
  self._assert_mutable()
572
620
  return super().setlistdefault(key, default_list)
573
621
 
574
- def appendlist(self, key, value):
622
+ def appendlist(self, key: str, value: Any) -> None:
575
623
  self._assert_mutable()
576
- key = bytes_to_text(key, self.encoding)
577
- value = bytes_to_text(value, self.encoding)
624
+ key = self.bytes_to_text(key, self.encoding)
625
+ value = self.bytes_to_text(value, self.encoding)
578
626
  super().appendlist(key, value)
579
627
 
580
- def pop(self, key, *args):
628
+ def pop(self, key: str, *args: Any) -> Any:
581
629
  self._assert_mutable()
582
630
  return super().pop(key, *args)
583
631
 
584
- def popitem(self):
632
+ def popitem(self) -> tuple[str, Any]:
585
633
  self._assert_mutable()
586
634
  return super().popitem()
587
635
 
588
- def clear(self):
636
+ def clear(self) -> None:
589
637
  self._assert_mutable()
590
638
  super().clear()
591
639
 
592
- def setdefault(self, key, default=None):
640
+ def setdefault(self, key: str, default: Any = None) -> Any:
593
641
  self._assert_mutable()
594
- key = bytes_to_text(key, self.encoding)
595
- default = bytes_to_text(default, self.encoding)
642
+ key = self.bytes_to_text(key, self.encoding)
643
+ default = self.bytes_to_text(default, self.encoding)
596
644
  return super().setdefault(key, default)
597
645
 
598
- def copy(self):
646
+ def copy(self) -> QueryDict:
599
647
  """Return a mutable copy of this object."""
600
648
  return self.__deepcopy__({})
601
649
 
602
- def urlencode(self, safe=None):
650
+ def urlencode(self, safe: str | None = None) -> str:
603
651
  """
604
652
  Return an encoded string of all query string arguments.
605
653
 
@@ -614,14 +662,14 @@ class QueryDict(MultiValueDict):
614
662
  """
615
663
  output = []
616
664
  if safe:
617
- safe = safe.encode(self.encoding)
665
+ safe_bytes: bytes = safe.encode(self.encoding)
618
666
 
619
- def encode(k, v):
620
- return f"{quote(k, safe)}={quote(v, safe)}"
667
+ def encode(k: bytes, v: bytes) -> str:
668
+ return f"{quote(k, safe_bytes)}={quote(v, safe_bytes)}"
621
669
 
622
670
  else:
623
671
 
624
- def encode(k, v):
672
+ def encode(k: bytes, v: bytes) -> str:
625
673
  return urlencode({k: v})
626
674
 
627
675
  for k, list_ in self.lists():
@@ -631,15 +679,31 @@ class QueryDict(MultiValueDict):
631
679
  )
632
680
  return "&".join(output)
633
681
 
682
+ # It's neither necessary nor appropriate to use
683
+ # plain.utils.encoding.force_str() for parsing URLs and form inputs. Thus,
684
+ # this slightly more restricted function, used by QueryDict.
685
+ @staticmethod
686
+ def bytes_to_text(s: Any, encoding: str) -> str:
687
+ """
688
+ Convert bytes objects to strings, using the given encoding. Illegally
689
+ encoded input characters are replaced with Unicode "unknown" codepoint
690
+ (\ufffd).
691
+
692
+ Return any non-bytes objects without change.
693
+ """
694
+ if isinstance(s, bytes):
695
+ return str(s, encoding, "replace")
696
+ else:
697
+ return s
698
+
634
699
 
635
700
  class MediaType:
636
- def __init__(self, media_type_raw_line):
637
- full_type, self.params = parse_header_parameters(
638
- media_type_raw_line if media_type_raw_line else ""
639
- )
701
+ def __init__(self, media_type_raw_line: str | MediaType):
702
+ line = str(media_type_raw_line) if media_type_raw_line else ""
703
+ full_type, self.params = parse_header_parameters(line)
640
704
  self.main_type, _, self.sub_type = full_type.partition("/")
641
705
 
642
- def __str__(self):
706
+ def __str__(self) -> str:
643
707
  params_str = "".join(f"; {k}={v}" for k, v in self.params.items())
644
708
  return "{}{}{}".format(
645
709
  self.main_type,
@@ -647,38 +711,22 @@ class MediaType:
647
711
  params_str,
648
712
  )
649
713
 
650
- def __repr__(self):
714
+ def __repr__(self) -> str:
651
715
  return f"<{self.__class__.__qualname__}: {self}>"
652
716
 
653
717
  @property
654
- def is_all_types(self):
718
+ def is_all_types(self) -> bool:
655
719
  return self.main_type == "*" and self.sub_type == "*"
656
720
 
657
- def match(self, other):
721
+ @property
722
+ def quality(self) -> float:
723
+ """Return the quality value from the Accept header (default 1.0)."""
724
+ return float(self.params.get("q", 1.0))
725
+
726
+ def match(self, other: str | MediaType) -> bool:
658
727
  if self.is_all_types:
659
728
  return True
660
729
  other = MediaType(other)
661
730
  if self.main_type == other.main_type and self.sub_type in {"*", other.sub_type}:
662
731
  return True
663
732
  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()]