plain 0.68.1__py3-none-any.whl → 0.70.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 (126) hide show
  1. plain/AGENTS.md +1 -1
  2. plain/CHANGELOG.md +23 -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 +20 -51
  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/cookie.py +15 -7
  41. plain/http/multipartparser.py +50 -30
  42. plain/http/request.py +97 -73
  43. plain/http/response.py +99 -80
  44. plain/internal/__init__.py +8 -1
  45. plain/internal/files/base.py +34 -18
  46. plain/internal/files/locks.py +19 -11
  47. plain/internal/files/move.py +8 -3
  48. plain/internal/files/temp.py +23 -5
  49. plain/internal/files/uploadedfile.py +42 -26
  50. plain/internal/files/uploadhandler.py +48 -27
  51. plain/internal/files/utils.py +13 -6
  52. plain/internal/handlers/base.py +20 -6
  53. plain/internal/handlers/exception.py +19 -5
  54. plain/internal/handlers/wsgi.py +30 -18
  55. plain/internal/middleware/headers.py +11 -2
  56. plain/internal/middleware/hosts.py +10 -2
  57. plain/internal/middleware/https.py +13 -3
  58. plain/internal/middleware/slash.py +15 -5
  59. plain/json.py +2 -1
  60. plain/logs/configure.py +3 -1
  61. plain/logs/debug.py +16 -5
  62. plain/logs/formatters.py +6 -3
  63. plain/logs/loggers.py +56 -52
  64. plain/logs/utils.py +19 -9
  65. plain/packages/config.py +14 -6
  66. plain/packages/registry.py +27 -12
  67. plain/paginator.py +31 -21
  68. plain/preflight/checks.py +3 -1
  69. plain/preflight/files.py +3 -1
  70. plain/preflight/registry.py +25 -10
  71. plain/preflight/results.py +10 -4
  72. plain/preflight/security.py +7 -5
  73. plain/preflight/urls.py +4 -1
  74. plain/runtime/__init__.py +4 -3
  75. plain/runtime/global_settings.py +1 -1
  76. plain/runtime/user_settings.py +26 -17
  77. plain/runtime/utils.py +1 -1
  78. plain/signals/dispatch/dispatcher.py +39 -17
  79. plain/signing.py +49 -30
  80. plain/templates/jinja/__init__.py +13 -5
  81. plain/templates/jinja/environments.py +4 -3
  82. plain/templates/jinja/extensions.py +9 -3
  83. plain/templates/jinja/filters.py +7 -2
  84. plain/templates/jinja/globals.py +1 -1
  85. plain/test/client.py +246 -174
  86. plain/test/encoding.py +9 -6
  87. plain/test/exceptions.py +10 -2
  88. plain/urls/converters.py +13 -10
  89. plain/urls/patterns.py +32 -20
  90. plain/urls/resolvers.py +32 -22
  91. plain/urls/utils.py +5 -1
  92. plain/utils/cache.py +14 -8
  93. plain/utils/crypto.py +21 -5
  94. plain/utils/datastructures.py +84 -54
  95. plain/utils/dateparse.py +10 -7
  96. plain/utils/deconstruct.py +12 -4
  97. plain/utils/decorators.py +5 -1
  98. plain/utils/duration.py +8 -4
  99. plain/utils/encoding.py +14 -7
  100. plain/utils/functional.py +62 -47
  101. plain/utils/hashable.py +5 -1
  102. plain/utils/html.py +21 -14
  103. plain/utils/http.py +16 -9
  104. plain/utils/inspect.py +14 -6
  105. plain/utils/ipv6.py +7 -3
  106. plain/utils/itercompat.py +6 -1
  107. plain/utils/module_loading.py +7 -3
  108. plain/utils/regex_helper.py +23 -13
  109. plain/utils/safestring.py +14 -6
  110. plain/utils/text.py +34 -18
  111. plain/utils/timezone.py +30 -19
  112. plain/utils/tree.py +31 -18
  113. plain/validators.py +71 -44
  114. plain/views/base.py +16 -6
  115. plain/views/errors.py +11 -4
  116. plain/views/exceptions.py +4 -1
  117. plain/views/objects.py +27 -17
  118. plain/views/redirect.py +14 -10
  119. plain/views/templates.py +1 -1
  120. plain/wsgi.py +3 -1
  121. {plain-0.68.1.dist-info → plain-0.70.0.dist-info}/METADATA +1 -1
  122. plain-0.70.0.dist-info/RECORD +169 -0
  123. plain-0.68.1.dist-info/RECORD +0 -169
  124. {plain-0.68.1.dist-info → plain-0.70.0.dist-info}/WHEEL +0 -0
  125. {plain-0.68.1.dist-info → plain-0.70.0.dist-info}/entry_points.txt +0 -0
  126. {plain-0.68.1.dist-info → plain-0.70.0.dist-info}/licenses/LICENSE +0 -0
plain/test/client.py CHANGED
@@ -1,9 +1,12 @@
1
+ from __future__ import annotations
2
+
1
3
  import json
2
4
  import sys
3
5
  from functools import partial
4
6
  from http import HTTPStatus
5
7
  from http.cookies import SimpleCookie
6
8
  from io import BytesIO, IOBase
9
+ from typing import TYPE_CHECKING, Any
7
10
  from urllib.parse import unquote_to_bytes, urljoin, urlparse, urlsplit
8
11
 
9
12
  from plain.http import HttpHeaders, QueryDict
@@ -22,6 +25,9 @@ from plain.utils.regex_helper import _lazy_re_compile
22
25
  from .encoding import encode_multipart
23
26
  from .exceptions import RedirectCycleError
24
27
 
28
+ if TYPE_CHECKING:
29
+ from plain.http import Response
30
+
25
31
  __all__ = (
26
32
  "Client",
27
33
  "RequestFactory",
@@ -44,17 +50,17 @@ class FakePayload(IOBase):
44
50
  that wouldn't work in real life.
45
51
  """
46
52
 
47
- def __init__(self, initial_bytes=None):
53
+ def __init__(self, initial_bytes: bytes | None = None) -> None:
48
54
  self.__content = BytesIO()
49
55
  self.__len = 0
50
56
  self.read_started = False
51
57
  if initial_bytes is not None:
52
58
  self.write(initial_bytes)
53
59
 
54
- def __len__(self):
60
+ def __len__(self) -> int:
55
61
  return self.__len
56
62
 
57
- def read(self, size=-1, /):
63
+ def read(self, size: int = -1, /) -> bytes:
58
64
  if not self.read_started:
59
65
  self.__content.seek(0)
60
66
  self.read_started = True
@@ -67,7 +73,7 @@ class FakePayload(IOBase):
67
73
  self.__len -= len(content)
68
74
  return content
69
75
 
70
- def readline(self, size=-1, /):
76
+ def readline(self, size: int = -1, /) -> bytes:
71
77
  if not self.read_started:
72
78
  self.__content.seek(0)
73
79
  self.read_started = True
@@ -80,7 +86,7 @@ class FakePayload(IOBase):
80
86
  self.__len -= len(content)
81
87
  return content
82
88
 
83
- def write(self, b, /):
89
+ def write(self, b: bytes | str, /) -> None:
84
90
  if self.read_started:
85
91
  raise ValueError("Unable to write a payload after it's been read")
86
92
  content = force_bytes(b)
@@ -88,20 +94,20 @@ class FakePayload(IOBase):
88
94
  self.__len += len(content)
89
95
 
90
96
 
91
- def _conditional_content_removal(request, response):
97
+ def _conditional_content_removal(request: WSGIRequest, response: Response) -> Response:
92
98
  """
93
99
  Simulate the behavior of most web servers by removing the content of
94
100
  responses for HEAD requests, 1xx, 204, and 304 responses. Ensure
95
101
  compliance with RFC 9112 Section 6.3.
96
102
  """
97
103
  if 100 <= response.status_code < 200 or response.status_code in (204, 304):
98
- if response.streaming:
99
- response.streaming_content = []
104
+ if response.streaming: # type: ignore[attr-defined]
105
+ response.streaming_content = [] # type: ignore[attr-defined]
100
106
  else:
101
107
  response.content = b""
102
108
  if request.method == "HEAD":
103
- if response.streaming:
104
- response.streaming_content = []
109
+ if response.streaming: # type: ignore[attr-defined]
110
+ response.streaming_content = [] # type: ignore[attr-defined]
105
111
  else:
106
112
  response.content = b""
107
113
  return response
@@ -114,10 +120,10 @@ class ClientHandler(BaseHandler):
114
120
  the originating WSGIRequest attached to its ``wsgi_request`` attribute.
115
121
  """
116
122
 
117
- def __init__(self, *args, **kwargs):
123
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
118
124
  super().__init__(*args, **kwargs)
119
125
 
120
- def __call__(self, environ):
126
+ def __call__(self, environ: dict[str, Any]) -> Response:
121
127
  # Set up middleware if needed. We couldn't do this earlier, because
122
128
  # settings weren't available.
123
129
  if self._middleware_chain is None:
@@ -156,15 +162,21 @@ class RequestFactory:
156
162
  just as if that view had been hooked up using a urlrouter.
157
163
  """
158
164
 
159
- def __init__(self, *, json_encoder=PlainJSONEncoder, headers=None, **defaults):
165
+ def __init__(
166
+ self,
167
+ *,
168
+ json_encoder: type[json.JSONEncoder] = PlainJSONEncoder,
169
+ headers: dict[str, str] | None = None,
170
+ **defaults: Any,
171
+ ) -> None:
160
172
  self.json_encoder = json_encoder
161
- self.defaults = defaults
162
- self.cookies = SimpleCookie()
173
+ self.defaults: dict[str, Any] = defaults
174
+ self.cookies: SimpleCookie[str] = SimpleCookie()
163
175
  self.errors = BytesIO()
164
176
  if headers:
165
177
  self.defaults.update(HttpHeaders.to_wsgi_names(headers))
166
178
 
167
- def _base_environ(self, **request):
179
+ def _base_environ(self, **request: Any) -> dict[str, Any]:
168
180
  """
169
181
  The base environment for a request.
170
182
  """
@@ -197,13 +209,13 @@ class RequestFactory:
197
209
  **request,
198
210
  }
199
211
 
200
- def request(self, **request):
212
+ def request(self, **request: Any) -> WSGIRequest:
201
213
  "Construct a generic request object."
202
214
  return WSGIRequest(self._base_environ(**request))
203
215
 
204
- def _encode_data(self, data, content_type):
216
+ def _encode_data(self, data: dict[str, Any] | str, content_type: str) -> bytes:
205
217
  if content_type is _MULTIPART_CONTENT:
206
- return encode_multipart(_BOUNDARY, data)
218
+ return encode_multipart(_BOUNDARY, data) # type: ignore[arg-type]
207
219
  else:
208
220
  # Encode the content so that the byte representation is correct.
209
221
  match = _CONTENT_TYPE_RE.match(content_type)
@@ -213,7 +225,7 @@ class RequestFactory:
213
225
  charset = settings.DEFAULT_CHARSET
214
226
  return force_bytes(data, encoding=charset)
215
227
 
216
- def _encode_json(self, data, content_type):
228
+ def _encode_json(self, data: Any, content_type: str) -> Any:
217
229
  """
218
230
  Return encoded JSON if data is a dict, list, or tuple and content_type
219
231
  is application/json.
@@ -223,7 +235,7 @@ class RequestFactory:
223
235
  )
224
236
  return json.dumps(data, cls=self.json_encoder) if should_encode else data
225
237
 
226
- def _get_path(self, parsed):
238
+ def _get_path(self, parsed: Any) -> str:
227
239
  path = parsed.path
228
240
  # If there are parameters, add them
229
241
  if parsed.params:
@@ -234,7 +246,15 @@ class RequestFactory:
234
246
  # Refs comment in `get_bytes_from_wsgi()`.
235
247
  return path.decode("iso-8859-1")
236
248
 
237
- def get(self, path, data=None, secure=True, *, headers=None, **extra):
249
+ def get(
250
+ self,
251
+ path: str,
252
+ data: dict[str, Any] | None = None,
253
+ secure: bool = True,
254
+ *,
255
+ headers: dict[str, str] | None = None,
256
+ **extra: Any,
257
+ ) -> WSGIRequest:
238
258
  """Construct a GET request."""
239
259
  data = {} if data is None else data
240
260
  return self.generic(
@@ -250,14 +270,14 @@ class RequestFactory:
250
270
 
251
271
  def post(
252
272
  self,
253
- path,
254
- data=None,
255
- content_type=_MULTIPART_CONTENT,
256
- secure=True,
273
+ path: str,
274
+ data: dict[str, Any] | None = None,
275
+ content_type: str = _MULTIPART_CONTENT,
276
+ secure: bool = True,
257
277
  *,
258
- headers=None,
259
- **extra,
260
- ):
278
+ headers: dict[str, str] | None = None,
279
+ **extra: Any,
280
+ ) -> WSGIRequest:
261
281
  """Construct a POST request."""
262
282
  data = self._encode_json({} if data is None else data, content_type)
263
283
  post_data = self._encode_data(data, content_type)
@@ -272,7 +292,15 @@ class RequestFactory:
272
292
  **extra,
273
293
  )
274
294
 
275
- def head(self, path, data=None, secure=True, *, headers=None, **extra):
295
+ def head(
296
+ self,
297
+ path: str,
298
+ data: dict[str, Any] | None = None,
299
+ secure: bool = True,
300
+ *,
301
+ headers: dict[str, str] | None = None,
302
+ **extra: Any,
303
+ ) -> WSGIRequest:
276
304
  """Construct a HEAD request."""
277
305
  data = {} if data is None else data
278
306
  return self.generic(
@@ -286,20 +314,27 @@ class RequestFactory:
286
314
  },
287
315
  )
288
316
 
289
- def trace(self, path, secure=True, *, headers=None, **extra):
317
+ def trace(
318
+ self,
319
+ path: str,
320
+ secure: bool = True,
321
+ *,
322
+ headers: dict[str, str] | None = None,
323
+ **extra: Any,
324
+ ) -> WSGIRequest:
290
325
  """Construct a TRACE request."""
291
326
  return self.generic("TRACE", path, secure=secure, headers=headers, **extra)
292
327
 
293
328
  def options(
294
329
  self,
295
- path,
296
- data="",
297
- content_type="application/octet-stream",
298
- secure=True,
330
+ path: str,
331
+ data: Any = "",
332
+ content_type: str = "application/octet-stream",
333
+ secure: bool = True,
299
334
  *,
300
- headers=None,
301
- **extra,
302
- ):
335
+ headers: dict[str, str] | None = None,
336
+ **extra: Any,
337
+ ) -> WSGIRequest:
303
338
  "Construct an OPTIONS request."
304
339
  return self.generic(
305
340
  "OPTIONS", path, data, content_type, secure=secure, headers=headers, **extra
@@ -307,14 +342,14 @@ class RequestFactory:
307
342
 
308
343
  def put(
309
344
  self,
310
- path,
311
- data="",
312
- content_type="application/octet-stream",
313
- secure=True,
345
+ path: str,
346
+ data: Any = "",
347
+ content_type: str = "application/octet-stream",
348
+ secure: bool = True,
314
349
  *,
315
- headers=None,
316
- **extra,
317
- ):
350
+ headers: dict[str, str] | None = None,
351
+ **extra: Any,
352
+ ) -> WSGIRequest:
318
353
  """Construct a PUT request."""
319
354
  data = self._encode_json(data, content_type)
320
355
  return self.generic(
@@ -323,14 +358,14 @@ class RequestFactory:
323
358
 
324
359
  def patch(
325
360
  self,
326
- path,
327
- data="",
328
- content_type="application/octet-stream",
329
- secure=True,
361
+ path: str,
362
+ data: Any = "",
363
+ content_type: str = "application/octet-stream",
364
+ secure: bool = True,
330
365
  *,
331
- headers=None,
332
- **extra,
333
- ):
366
+ headers: dict[str, str] | None = None,
367
+ **extra: Any,
368
+ ) -> WSGIRequest:
334
369
  """Construct a PATCH request."""
335
370
  data = self._encode_json(data, content_type)
336
371
  return self.generic(
@@ -339,14 +374,14 @@ class RequestFactory:
339
374
 
340
375
  def delete(
341
376
  self,
342
- path,
343
- data="",
344
- content_type="application/octet-stream",
345
- secure=True,
377
+ path: str,
378
+ data: Any = "",
379
+ content_type: str = "application/octet-stream",
380
+ secure: bool = True,
346
381
  *,
347
- headers=None,
348
- **extra,
349
- ):
382
+ headers: dict[str, str] | None = None,
383
+ **extra: Any,
384
+ ) -> WSGIRequest:
350
385
  """Construct a DELETE request."""
351
386
  data = self._encode_json(data, content_type)
352
387
  return self.generic(
@@ -355,19 +390,19 @@ class RequestFactory:
355
390
 
356
391
  def generic(
357
392
  self,
358
- method,
359
- path,
360
- data="",
361
- content_type="application/octet-stream",
362
- secure=True,
393
+ method: str,
394
+ path: str,
395
+ data: Any = "",
396
+ content_type: str = "application/octet-stream",
397
+ secure: bool = True,
363
398
  *,
364
- headers=None,
365
- **extra,
366
- ):
399
+ headers: dict[str, str] | None = None,
400
+ **extra: Any,
401
+ ) -> WSGIRequest:
367
402
  """Construct an arbitrary HTTP request."""
368
403
  parsed = urlparse(str(path)) # path can be lazy
369
404
  data = force_bytes(data, settings.DEFAULT_CHARSET)
370
- r = {
405
+ r: dict[str, Any] = {
371
406
  "PATH_INFO": self._get_path(parsed),
372
407
  "REQUEST_METHOD": method,
373
408
  "SERVER_PORT": "443" if secure else "80",
@@ -392,7 +427,7 @@ class RequestFactory:
392
427
  return self.request(**r)
393
428
 
394
429
 
395
- class Client(RequestFactory):
430
+ class Client:
396
431
  """
397
432
  A class that can act as a client for testing purposes.
398
433
 
@@ -413,26 +448,36 @@ class Client(RequestFactory):
413
448
 
414
449
  def __init__(
415
450
  self,
416
- raise_request_exception=True,
451
+ raise_request_exception: bool = True,
417
452
  *,
418
- headers=None,
419
- **defaults,
420
- ):
421
- super().__init__(headers=headers, **defaults)
453
+ headers: dict[str, str] | None = None,
454
+ **defaults: Any,
455
+ ) -> None:
456
+ self._request_factory = RequestFactory(headers=headers, **defaults)
422
457
  self.handler = ClientHandler()
423
458
  self.raise_request_exception = raise_request_exception
424
- self.exc_info = None
425
- self.extra = None
426
- self.headers = None
459
+ self.exc_info: tuple[Any, Any, Any] | None = None
460
+ self.extra: dict[str, Any] | None = None
461
+ self.headers: dict[str, str] | None = None
427
462
 
428
- def request(self, **request):
463
+ @property
464
+ def cookies(self) -> SimpleCookie[str]:
465
+ """Access the cookies from the request factory."""
466
+ return self._request_factory.cookies
467
+
468
+ @cookies.setter
469
+ def cookies(self, value: SimpleCookie[str]) -> None:
470
+ """Set the cookies on the request factory."""
471
+ self._request_factory.cookies = value
472
+
473
+ def request(self, **request: Any) -> Response:
429
474
  """
430
475
  Make a generic request. Compose the environment dictionary and pass
431
476
  to the handler, return the result of the handler. Assume defaults for
432
477
  the query environment, which can be overridden using the arguments to
433
478
  the request.
434
479
  """
435
- environ = self._base_environ(**request)
480
+ environ = self._request_factory._base_environ(**request)
436
481
 
437
482
  # Capture exceptions created by the handler.
438
483
  exception_uid = f"request-exception-{id(request)}"
@@ -466,18 +511,23 @@ class Client(RequestFactory):
466
511
 
467
512
  def get(
468
513
  self,
469
- path,
470
- data=None,
471
- follow=False,
472
- secure=True,
514
+ path: str,
515
+ data: dict[str, Any] | None = None,
516
+ follow: bool = False,
517
+ secure: bool = True,
473
518
  *,
474
- headers=None,
475
- **extra,
476
- ):
519
+ headers: dict[str, str] | None = None,
520
+ **extra: Any,
521
+ ) -> Response:
477
522
  """Request a response from the server using GET."""
478
523
  self.extra = extra
479
524
  self.headers = headers
480
- response = super().get(path, data=data, secure=secure, headers=headers, **extra)
525
+ # Build the request using the factory
526
+ wsgi_request = self._request_factory.get(
527
+ path, data=data, secure=secure, headers=headers, **extra
528
+ )
529
+ # Execute and get response
530
+ response = self.request(**wsgi_request.environ)
481
531
  if follow:
482
532
  response = self._handle_redirects(
483
533
  response, data=data, headers=headers, **extra
@@ -486,19 +536,20 @@ class Client(RequestFactory):
486
536
 
487
537
  def post(
488
538
  self,
489
- path,
490
- data=None,
491
- content_type=_MULTIPART_CONTENT,
492
- follow=False,
493
- secure=True,
539
+ path: str,
540
+ data: dict[str, Any] | None = None,
541
+ content_type: str = _MULTIPART_CONTENT,
542
+ follow: bool = False,
543
+ secure: bool = True,
494
544
  *,
495
- headers=None,
496
- **extra,
497
- ):
545
+ headers: dict[str, str] | None = None,
546
+ **extra: Any,
547
+ ) -> Response:
498
548
  """Request a response from the server using POST."""
499
549
  self.extra = extra
500
550
  self.headers = headers
501
- response = super().post(
551
+ # Build the request using the factory
552
+ wsgi_request = self._request_factory.post(
502
553
  path,
503
554
  data=data,
504
555
  content_type=content_type,
@@ -506,6 +557,8 @@ class Client(RequestFactory):
506
557
  headers=headers,
507
558
  **extra,
508
559
  )
560
+ # Execute and get response
561
+ response = self.request(**wsgi_request.environ)
509
562
  if follow:
510
563
  response = self._handle_redirects(
511
564
  response, data=data, content_type=content_type, headers=headers, **extra
@@ -514,20 +567,23 @@ class Client(RequestFactory):
514
567
 
515
568
  def head(
516
569
  self,
517
- path,
518
- data=None,
519
- follow=False,
520
- secure=True,
570
+ path: str,
571
+ data: dict[str, Any] | None = None,
572
+ follow: bool = False,
573
+ secure: bool = True,
521
574
  *,
522
- headers=None,
523
- **extra,
524
- ):
575
+ headers: dict[str, str] | None = None,
576
+ **extra: Any,
577
+ ) -> Response:
525
578
  """Request a response from the server using HEAD."""
526
579
  self.extra = extra
527
580
  self.headers = headers
528
- response = super().head(
581
+ # Build the request using the factory
582
+ wsgi_request = self._request_factory.head(
529
583
  path, data=data, secure=secure, headers=headers, **extra
530
584
  )
585
+ # Execute and get response
586
+ response = self.request(**wsgi_request.environ)
531
587
  if follow:
532
588
  response = self._handle_redirects(
533
589
  response, data=data, headers=headers, **extra
@@ -536,19 +592,20 @@ class Client(RequestFactory):
536
592
 
537
593
  def options(
538
594
  self,
539
- path,
540
- data="",
541
- content_type="application/octet-stream",
542
- follow=False,
543
- secure=True,
595
+ path: str,
596
+ data: Any = "",
597
+ content_type: str = "application/octet-stream",
598
+ follow: bool = False,
599
+ secure: bool = True,
544
600
  *,
545
- headers=None,
546
- **extra,
547
- ):
601
+ headers: dict[str, str] | None = None,
602
+ **extra: Any,
603
+ ) -> Response:
548
604
  """Request a response from the server using OPTIONS."""
549
605
  self.extra = extra
550
606
  self.headers = headers
551
- response = super().options(
607
+ # Build the request using the factory
608
+ wsgi_request = self._request_factory.options(
552
609
  path,
553
610
  data=data,
554
611
  content_type=content_type,
@@ -556,6 +613,8 @@ class Client(RequestFactory):
556
613
  headers=headers,
557
614
  **extra,
558
615
  )
616
+ # Execute and get response
617
+ response = self.request(**wsgi_request.environ)
559
618
  if follow:
560
619
  response = self._handle_redirects(
561
620
  response, data=data, content_type=content_type, headers=headers, **extra
@@ -564,19 +623,20 @@ class Client(RequestFactory):
564
623
 
565
624
  def put(
566
625
  self,
567
- path,
568
- data="",
569
- content_type="application/octet-stream",
570
- follow=False,
571
- secure=True,
626
+ path: str,
627
+ data: Any = "",
628
+ content_type: str = "application/octet-stream",
629
+ follow: bool = False,
630
+ secure: bool = True,
572
631
  *,
573
- headers=None,
574
- **extra,
575
- ):
632
+ headers: dict[str, str] | None = None,
633
+ **extra: Any,
634
+ ) -> Response:
576
635
  """Send a resource to the server using PUT."""
577
636
  self.extra = extra
578
637
  self.headers = headers
579
- response = super().put(
638
+ # Build the request using the factory
639
+ wsgi_request = self._request_factory.put(
580
640
  path,
581
641
  data=data,
582
642
  content_type=content_type,
@@ -584,6 +644,8 @@ class Client(RequestFactory):
584
644
  headers=headers,
585
645
  **extra,
586
646
  )
647
+ # Execute and get response
648
+ response = self.request(**wsgi_request.environ)
587
649
  if follow:
588
650
  response = self._handle_redirects(
589
651
  response, data=data, content_type=content_type, headers=headers, **extra
@@ -592,19 +654,20 @@ class Client(RequestFactory):
592
654
 
593
655
  def patch(
594
656
  self,
595
- path,
596
- data="",
597
- content_type="application/octet-stream",
598
- follow=False,
599
- secure=True,
657
+ path: str,
658
+ data: Any = "",
659
+ content_type: str = "application/octet-stream",
660
+ follow: bool = False,
661
+ secure: bool = True,
600
662
  *,
601
- headers=None,
602
- **extra,
603
- ):
663
+ headers: dict[str, str] | None = None,
664
+ **extra: Any,
665
+ ) -> Response:
604
666
  """Send a resource to the server using PATCH."""
605
667
  self.extra = extra
606
668
  self.headers = headers
607
- response = super().patch(
669
+ # Build the request using the factory
670
+ wsgi_request = self._request_factory.patch(
608
671
  path,
609
672
  data=data,
610
673
  content_type=content_type,
@@ -612,6 +675,8 @@ class Client(RequestFactory):
612
675
  headers=headers,
613
676
  **extra,
614
677
  )
678
+ # Execute and get response
679
+ response = self.request(**wsgi_request.environ)
615
680
  if follow:
616
681
  response = self._handle_redirects(
617
682
  response, data=data, content_type=content_type, headers=headers, **extra
@@ -620,19 +685,20 @@ class Client(RequestFactory):
620
685
 
621
686
  def delete(
622
687
  self,
623
- path,
624
- data="",
625
- content_type="application/octet-stream",
626
- follow=False,
627
- secure=True,
688
+ path: str,
689
+ data: Any = "",
690
+ content_type: str = "application/octet-stream",
691
+ follow: bool = False,
692
+ secure: bool = True,
628
693
  *,
629
- headers=None,
630
- **extra,
631
- ):
694
+ headers: dict[str, str] | None = None,
695
+ **extra: Any,
696
+ ) -> Response:
632
697
  """Send a DELETE request to the server."""
633
698
  self.extra = extra
634
699
  self.headers = headers
635
- response = super().delete(
700
+ # Build the request using the factory
701
+ wsgi_request = self._request_factory.delete(
636
702
  path,
637
703
  data=data,
638
704
  content_type=content_type,
@@ -640,6 +706,8 @@ class Client(RequestFactory):
640
706
  headers=headers,
641
707
  **extra,
642
708
  )
709
+ # Execute and get response
710
+ response = self.request(**wsgi_request.environ)
643
711
  if follow:
644
712
  response = self._handle_redirects(
645
713
  response, data=data, content_type=content_type, headers=headers, **extra
@@ -648,20 +716,23 @@ class Client(RequestFactory):
648
716
 
649
717
  def trace(
650
718
  self,
651
- path,
652
- data="",
653
- follow=False,
654
- secure=True,
719
+ path: str,
720
+ data: Any = "",
721
+ follow: bool = False,
722
+ secure: bool = True,
655
723
  *,
656
- headers=None,
657
- **extra,
658
- ):
724
+ headers: dict[str, str] | None = None,
725
+ **extra: Any,
726
+ ) -> Response:
659
727
  """Send a TRACE request to the server."""
660
728
  self.extra = extra
661
729
  self.headers = headers
662
- response = super().trace(
730
+ # Build the request using the factory
731
+ wsgi_request = self._request_factory.trace(
663
732
  path, data=data, secure=secure, headers=headers, **extra
664
733
  )
734
+ # Execute and get response
735
+ response = self.request(**wsgi_request.environ)
665
736
  if follow:
666
737
  response = self._handle_redirects(
667
738
  response, data=data, headers=headers, **extra
@@ -670,16 +741,16 @@ class Client(RequestFactory):
670
741
 
671
742
  def _handle_redirects(
672
743
  self,
673
- response,
674
- data="",
675
- content_type="",
676
- headers=None,
677
- **extra,
678
- ):
744
+ response: Response,
745
+ data: Any = "",
746
+ content_type: str = "",
747
+ headers: dict[str, str] | None = None,
748
+ **extra: Any,
749
+ ) -> Response:
679
750
  """
680
751
  Follow any redirects by requesting responses from the server using GET.
681
752
  """
682
- response.redirect_chain = []
753
+ response.redirect_chain = [] # type: ignore[attr-defined]
683
754
  redirect_status_codes = (
684
755
  HTTPStatus.MOVED_PERMANENTLY,
685
756
  HTTPStatus.FOUND,
@@ -688,8 +759,8 @@ class Client(RequestFactory):
688
759
  HTTPStatus.PERMANENT_REDIRECT,
689
760
  )
690
761
  while response.status_code in redirect_status_codes:
691
- response_url = response.url
692
- redirect_chain = response.redirect_chain
762
+ response_url = response.url # type: ignore[attr-defined]
763
+ redirect_chain = response.redirect_chain # type: ignore[attr-defined]
693
764
  redirect_chain.append((response_url, response.status_code))
694
765
 
695
766
  url = urlsplit(response_url)
@@ -706,7 +777,7 @@ class Client(RequestFactory):
706
777
  path = "/"
707
778
  # Prepend the request path to handle relative path redirects
708
779
  if not path.startswith("/"):
709
- path = urljoin(response.request["PATH_INFO"], path)
780
+ path = urljoin(response.request["PATH_INFO"], path) # type: ignore[attr-defined]
710
781
 
711
782
  if response.status_code in (
712
783
  HTTPStatus.TEMPORARY_REDIRECT,
@@ -714,24 +785,24 @@ class Client(RequestFactory):
714
785
  ):
715
786
  # Preserve request method and query string (if needed)
716
787
  # post-redirect for 307/308 responses.
717
- request_method = response.request["REQUEST_METHOD"].lower()
718
- if request_method not in ("get", "head"):
788
+ request_method_name = response.request["REQUEST_METHOD"].lower() # type: ignore[attr-defined]
789
+ if request_method_name not in ("get", "head"):
719
790
  extra["QUERY_STRING"] = url.query
720
- request_method = getattr(self, request_method)
791
+ request_method = getattr(self, request_method_name)
721
792
  else:
722
793
  request_method = self.get
723
794
  data = QueryDict(url.query)
724
- content_type = None
795
+ content_type = "" # type: ignore[assignment]
725
796
 
726
797
  response = request_method(
727
798
  path,
728
799
  data=data,
729
- content_type=content_type,
800
+ content_type=content_type, # type: ignore[arg-type]
730
801
  follow=False,
731
802
  headers=headers,
732
803
  **extra,
733
804
  )
734
- response.redirect_chain = redirect_chain
805
+ response.redirect_chain = redirect_chain # type: ignore[attr-defined]
735
806
 
736
807
  if redirect_chain[-1] in redirect_chain[:-1]:
737
808
  # Check that we're not redirecting to somewhere we've already
@@ -747,17 +818,17 @@ class Client(RequestFactory):
747
818
 
748
819
  return response
749
820
 
750
- def store_exc_info(self, **kwargs):
821
+ def store_exc_info(self, **kwargs: Any) -> None:
751
822
  """Store exceptions when they are generated by a view."""
752
823
  self.exc_info = sys.exc_info()
753
824
 
754
- def check_exception(self, response):
825
+ def check_exception(self, response: Response) -> None:
755
826
  """
756
827
  Look for a signaled exception, clear the current context exception
757
828
  data, re-raise the signaled exception, and clear the signaled exception
758
829
  from the local cache.
759
830
  """
760
- response.exc_info = self.exc_info
831
+ response.exc_info = self.exc_info # type: ignore[attr-defined]
761
832
  if self.exc_info:
762
833
  _, exc_value, _ = self.exc_info
763
834
  self.exc_info = None
@@ -765,24 +836,24 @@ class Client(RequestFactory):
765
836
  raise exc_value
766
837
 
767
838
  @property
768
- def session(self):
839
+ def session(self) -> Any:
769
840
  """Return the current session variables."""
770
841
  from plain.sessions.test import get_client_session
771
842
 
772
843
  return get_client_session(self)
773
844
 
774
- def force_login(self, user):
845
+ def force_login(self, user: Any) -> None:
775
846
  from plain.auth.test import login_client
776
847
 
777
848
  login_client(self, user)
778
849
 
779
- def logout(self):
850
+ def logout(self) -> None:
780
851
  """Log out the user by removing the cookies and session object."""
781
852
  from plain.auth.test import logout_client
782
853
 
783
854
  logout_client(self)
784
855
 
785
- def _parse_json(self, response, **extra):
856
+ def _parse_json(self, response: Response, **extra: Any) -> Any:
786
857
  if not hasattr(response, "_json"):
787
858
  if not _JSON_CONTENT_TYPE_RE.match(response.headers.get("Content-Type")):
788
859
  raise ValueError(
@@ -790,7 +861,8 @@ class Client(RequestFactory):
790
861
  response.headers.get("Content-Type")
791
862
  )
792
863
  )
793
- response._json = json.loads(
794
- response.content.decode(response.charset), **extra
864
+ response._json = json.loads( # type: ignore[attr-defined]
865
+ response.content.decode(response.charset),
866
+ **extra, # type: ignore[arg-type]
795
867
  )
796
- return response._json
868
+ return response._json # type: ignore[attr-defined]