plain 0.68.0__py3-none-any.whl → 0.103.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. plain/CHANGELOG.md +684 -1
  2. plain/README.md +1 -1
  3. plain/agents/.claude/rules/plain.md +88 -0
  4. plain/agents/.claude/skills/plain-install/SKILL.md +26 -0
  5. plain/agents/.claude/skills/plain-upgrade/SKILL.md +35 -0
  6. plain/assets/compile.py +25 -12
  7. plain/assets/finders.py +24 -17
  8. plain/assets/fingerprints.py +10 -7
  9. plain/assets/urls.py +1 -1
  10. plain/assets/views.py +47 -33
  11. plain/chores/README.md +25 -23
  12. plain/chores/__init__.py +2 -1
  13. plain/chores/core.py +27 -0
  14. plain/chores/registry.py +23 -36
  15. plain/cli/README.md +185 -16
  16. plain/cli/__init__.py +2 -1
  17. plain/cli/agent.py +234 -0
  18. plain/cli/build.py +7 -8
  19. plain/cli/changelog.py +11 -5
  20. plain/cli/chores.py +32 -34
  21. plain/cli/core.py +110 -26
  22. plain/cli/docs.py +98 -21
  23. plain/cli/formatting.py +40 -17
  24. plain/cli/install.py +10 -54
  25. plain/cli/{agent/llmdocs.py → llmdocs.py} +45 -26
  26. plain/cli/output.py +6 -2
  27. plain/cli/preflight.py +27 -75
  28. plain/cli/print.py +4 -4
  29. plain/cli/registry.py +96 -10
  30. plain/cli/{agent/request.py → request.py} +67 -33
  31. plain/cli/runtime.py +45 -0
  32. plain/cli/scaffold.py +2 -7
  33. plain/cli/server.py +153 -0
  34. plain/cli/settings.py +53 -49
  35. plain/cli/shell.py +15 -12
  36. plain/cli/startup.py +9 -8
  37. plain/cli/upgrade.py +17 -104
  38. plain/cli/urls.py +12 -7
  39. plain/cli/utils.py +3 -3
  40. plain/csrf/README.md +65 -40
  41. plain/csrf/middleware.py +53 -43
  42. plain/debug.py +5 -2
  43. plain/exceptions.py +22 -114
  44. plain/forms/README.md +453 -24
  45. plain/forms/__init__.py +55 -4
  46. plain/forms/boundfield.py +15 -8
  47. plain/forms/exceptions.py +1 -1
  48. plain/forms/fields.py +346 -143
  49. plain/forms/forms.py +75 -45
  50. plain/http/README.md +356 -9
  51. plain/http/__init__.py +41 -26
  52. plain/http/cookie.py +15 -7
  53. plain/http/exceptions.py +65 -0
  54. plain/http/middleware.py +32 -0
  55. plain/http/multipartparser.py +99 -88
  56. plain/http/request.py +362 -250
  57. plain/http/response.py +99 -197
  58. plain/internal/__init__.py +8 -1
  59. plain/internal/files/base.py +35 -19
  60. plain/internal/files/locks.py +19 -11
  61. plain/internal/files/move.py +8 -3
  62. plain/internal/files/temp.py +25 -6
  63. plain/internal/files/uploadedfile.py +47 -28
  64. plain/internal/files/uploadhandler.py +64 -58
  65. plain/internal/files/utils.py +24 -10
  66. plain/internal/handlers/base.py +34 -23
  67. plain/internal/handlers/exception.py +68 -65
  68. plain/internal/handlers/wsgi.py +65 -54
  69. plain/internal/middleware/headers.py +37 -11
  70. plain/internal/middleware/hosts.py +11 -8
  71. plain/internal/middleware/https.py +17 -7
  72. plain/internal/middleware/slash.py +14 -9
  73. plain/internal/reloader.py +77 -0
  74. plain/json.py +2 -1
  75. plain/logs/README.md +161 -62
  76. plain/logs/__init__.py +1 -1
  77. plain/logs/{loggers.py → app.py} +71 -67
  78. plain/logs/configure.py +63 -14
  79. plain/logs/debug.py +17 -6
  80. plain/logs/filters.py +15 -0
  81. plain/logs/formatters.py +7 -4
  82. plain/packages/README.md +105 -23
  83. plain/packages/config.py +15 -7
  84. plain/packages/registry.py +27 -16
  85. plain/paginator.py +31 -21
  86. plain/preflight/README.md +209 -24
  87. plain/preflight/__init__.py +1 -0
  88. plain/preflight/checks.py +3 -1
  89. plain/preflight/files.py +3 -1
  90. plain/preflight/registry.py +26 -11
  91. plain/preflight/results.py +15 -7
  92. plain/preflight/security.py +15 -13
  93. plain/preflight/settings.py +54 -0
  94. plain/preflight/urls.py +4 -1
  95. plain/runtime/README.md +115 -47
  96. plain/runtime/__init__.py +10 -6
  97. plain/runtime/global_settings.py +34 -25
  98. plain/runtime/secret.py +20 -0
  99. plain/runtime/user_settings.py +110 -38
  100. plain/runtime/utils.py +1 -1
  101. plain/server/LICENSE +35 -0
  102. plain/server/README.md +155 -0
  103. plain/server/__init__.py +9 -0
  104. plain/server/app.py +52 -0
  105. plain/server/arbiter.py +555 -0
  106. plain/server/config.py +118 -0
  107. plain/server/errors.py +31 -0
  108. plain/server/glogging.py +292 -0
  109. plain/server/http/__init__.py +12 -0
  110. plain/server/http/body.py +283 -0
  111. plain/server/http/errors.py +155 -0
  112. plain/server/http/message.py +400 -0
  113. plain/server/http/parser.py +70 -0
  114. plain/server/http/unreader.py +88 -0
  115. plain/server/http/wsgi.py +421 -0
  116. plain/server/pidfile.py +92 -0
  117. plain/server/sock.py +240 -0
  118. plain/server/util.py +317 -0
  119. plain/server/workers/__init__.py +6 -0
  120. plain/server/workers/base.py +304 -0
  121. plain/server/workers/sync.py +212 -0
  122. plain/server/workers/thread.py +399 -0
  123. plain/server/workers/workertmp.py +50 -0
  124. plain/signals/README.md +170 -1
  125. plain/signals/__init__.py +0 -1
  126. plain/signals/dispatch/dispatcher.py +49 -27
  127. plain/signing.py +131 -35
  128. plain/templates/README.md +211 -20
  129. plain/templates/jinja/__init__.py +13 -5
  130. plain/templates/jinja/environments.py +5 -4
  131. plain/templates/jinja/extensions.py +12 -5
  132. plain/templates/jinja/filters.py +7 -2
  133. plain/templates/jinja/globals.py +2 -2
  134. plain/test/README.md +184 -22
  135. plain/test/client.py +340 -222
  136. plain/test/encoding.py +9 -6
  137. plain/test/exceptions.py +7 -2
  138. plain/urls/README.md +157 -73
  139. plain/urls/converters.py +18 -15
  140. plain/urls/exceptions.py +2 -2
  141. plain/urls/patterns.py +38 -22
  142. plain/urls/resolvers.py +35 -25
  143. plain/urls/utils.py +5 -1
  144. plain/utils/README.md +250 -3
  145. plain/utils/cache.py +17 -11
  146. plain/utils/crypto.py +21 -5
  147. plain/utils/datastructures.py +89 -56
  148. plain/utils/dateparse.py +9 -6
  149. plain/utils/deconstruct.py +15 -7
  150. plain/utils/decorators.py +5 -1
  151. plain/utils/dotenv.py +373 -0
  152. plain/utils/duration.py +8 -4
  153. plain/utils/encoding.py +14 -7
  154. plain/utils/functional.py +66 -49
  155. plain/utils/hashable.py +5 -1
  156. plain/utils/html.py +36 -22
  157. plain/utils/http.py +16 -9
  158. plain/utils/inspect.py +14 -6
  159. plain/utils/ipv6.py +7 -3
  160. plain/utils/itercompat.py +6 -1
  161. plain/utils/module_loading.py +7 -3
  162. plain/utils/regex_helper.py +37 -23
  163. plain/utils/safestring.py +14 -6
  164. plain/utils/text.py +41 -23
  165. plain/utils/timezone.py +33 -22
  166. plain/utils/tree.py +35 -19
  167. plain/validators.py +94 -52
  168. plain/views/README.md +156 -79
  169. plain/views/__init__.py +0 -1
  170. plain/views/base.py +25 -18
  171. plain/views/errors.py +13 -5
  172. plain/views/exceptions.py +4 -1
  173. plain/views/forms.py +6 -6
  174. plain/views/objects.py +52 -49
  175. plain/views/redirect.py +18 -15
  176. plain/views/templates.py +5 -3
  177. plain/wsgi.py +3 -1
  178. {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/METADATA +4 -2
  179. plain-0.103.0.dist-info/RECORD +198 -0
  180. {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/WHEEL +1 -1
  181. plain-0.103.0.dist-info/entry_points.txt +2 -0
  182. plain/AGENTS.md +0 -18
  183. plain/cli/agent/__init__.py +0 -20
  184. plain/cli/agent/docs.py +0 -80
  185. plain/cli/agent/md.py +0 -87
  186. plain/cli/agent/prompt.py +0 -45
  187. plain/csrf/views.py +0 -31
  188. plain/logs/utils.py +0 -46
  189. plain/templates/AGENTS.md +0 -3
  190. plain-0.68.0.dist-info/RECORD +0 -169
  191. plain-0.68.0.dist-info/entry_points.txt +0 -5
  192. {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/licenses/LICENSE +0 -0
plain/test/client.py CHANGED
@@ -1,18 +1,19 @@
1
+ from __future__ import annotations
2
+
1
3
  import json
2
- import sys
3
- from functools import partial
4
4
  from http import HTTPStatus
5
5
  from http.cookies import SimpleCookie
6
6
  from io import BytesIO, IOBase
7
+ from typing import TYPE_CHECKING, Any
7
8
  from urllib.parse import unquote_to_bytes, urljoin, urlparse, urlsplit
8
9
 
9
- from plain.http import HttpHeaders, QueryDict
10
+ from plain.http import QueryDict, RequestHeaders
10
11
  from plain.internal import internalcode
11
12
  from plain.internal.handlers.base import BaseHandler
12
13
  from plain.internal.handlers.wsgi import WSGIRequest
13
14
  from plain.json import PlainJSONEncoder
14
15
  from plain.runtime import settings
15
- from plain.signals import got_request_exception, request_started
16
+ from plain.signals import request_started
16
17
  from plain.urls import get_resolver
17
18
  from plain.utils.encoding import force_bytes
18
19
  from plain.utils.functional import SimpleLazyObject
@@ -22,8 +23,13 @@ from plain.utils.regex_helper import _lazy_re_compile
22
23
  from .encoding import encode_multipart
23
24
  from .exceptions import RedirectCycleError
24
25
 
26
+ if TYPE_CHECKING:
27
+ from plain.http import ResponseBase
28
+ from plain.urls import ResolverMatch
29
+
25
30
  __all__ = (
26
31
  "Client",
32
+ "ClientResponse",
27
33
  "RequestFactory",
28
34
  )
29
35
 
@@ -35,6 +41,77 @@ _CONTENT_TYPE_RE = _lazy_re_compile(r".*; charset=([\w-]+);?")
35
41
  _JSON_CONTENT_TYPE_RE = _lazy_re_compile(r"^application\/(.+\+)?json")
36
42
 
37
43
 
44
+ class ClientResponse:
45
+ """
46
+ Response wrapper returned by test Client with test-specific attributes.
47
+
48
+ Wraps any ResponseBase subclass and adds attributes useful for testing,
49
+ while delegating all other attribute access to the wrapped response.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ response: ResponseBase,
55
+ client: Client,
56
+ request: dict[str, Any],
57
+ ):
58
+ # Store wrapped response in __dict__ directly to avoid __setattr__ recursion
59
+ object.__setattr__(self, "_response", response)
60
+ object.__setattr__(self, "_json_cache", None)
61
+ # Test-specific attributes
62
+ self.client = client
63
+ self.request = request
64
+ self.wsgi_request: WSGIRequest
65
+ self.redirect_chain: list[tuple[str, int]]
66
+ self.resolver_match: SimpleLazyObject | ResolverMatch
67
+ # Optional: set by plain.auth if available
68
+ # self.user: Model
69
+
70
+ def json(self, **extra: Any) -> Any:
71
+ """Parse response content as JSON."""
72
+ _json_cache = object.__getattribute__(self, "_json_cache")
73
+ if _json_cache is None:
74
+ response = object.__getattribute__(self, "_response")
75
+ content_type = response.headers.get("Content-Type", "")
76
+ if not _JSON_CONTENT_TYPE_RE.match(content_type):
77
+ raise ValueError(
78
+ f'Content-Type header is "{content_type}", not "application/json"'
79
+ )
80
+ _json_cache = json.loads(
81
+ response.content.decode(response.charset),
82
+ **extra,
83
+ )
84
+ object.__setattr__(self, "_json_cache", _json_cache)
85
+ return _json_cache
86
+
87
+ @property
88
+ def url(self) -> str:
89
+ """
90
+ Return redirect URL if this is a redirect response.
91
+
92
+ This property exists on RedirectResponse and is added for redirects.
93
+ """
94
+ response = object.__getattribute__(self, "_response")
95
+ if hasattr(response, "url"):
96
+ return response.url
97
+ # For non-redirect responses, try to get Location header
98
+ if "Location" in response.headers:
99
+ return response.headers["Location"]
100
+ raise AttributeError(f"{response.__class__.__name__} has no attribute 'url'")
101
+
102
+ def __getattr__(self, name: str) -> Any:
103
+ """Delegate attribute access to the wrapped response."""
104
+ return getattr(object.__getattribute__(self, "_response"), name)
105
+
106
+ def __setattr__(self, name: str, value: Any) -> None:
107
+ """Set attributes on the wrapper itself."""
108
+ object.__setattr__(self, name, value)
109
+
110
+ def __repr__(self) -> str:
111
+ """Return repr of wrapped response."""
112
+ return repr(object.__getattribute__(self, "_response"))
113
+
114
+
38
115
  @internalcode
39
116
  class FakePayload(IOBase):
40
117
  """
@@ -44,17 +121,17 @@ class FakePayload(IOBase):
44
121
  that wouldn't work in real life.
45
122
  """
46
123
 
47
- def __init__(self, initial_bytes=None):
124
+ def __init__(self, initial_bytes: bytes | None = None) -> None:
48
125
  self.__content = BytesIO()
49
126
  self.__len = 0
50
127
  self.read_started = False
51
128
  if initial_bytes is not None:
52
129
  self.write(initial_bytes)
53
130
 
54
- def __len__(self):
131
+ def __len__(self) -> int:
55
132
  return self.__len
56
133
 
57
- def read(self, size=-1, /):
134
+ def read(self, size: int = -1, /) -> bytes:
58
135
  if not self.read_started:
59
136
  self.__content.seek(0)
60
137
  self.read_started = True
@@ -67,11 +144,11 @@ class FakePayload(IOBase):
67
144
  self.__len -= len(content)
68
145
  return content
69
146
 
70
- def readline(self, size=-1, /):
147
+ def readline(self, size: int | None = -1, /) -> bytes:
71
148
  if not self.read_started:
72
149
  self.__content.seek(0)
73
150
  self.read_started = True
74
- if size == -1 or size is None:
151
+ if size is None or size == -1:
75
152
  size = self.__len
76
153
  assert self.__len >= size, (
77
154
  "Cannot read more than the available bytes from the HTTP incoming data."
@@ -80,7 +157,7 @@ class FakePayload(IOBase):
80
157
  self.__len -= len(content)
81
158
  return content
82
159
 
83
- def write(self, b, /):
160
+ def write(self, b: bytes | str, /) -> None:
84
161
  if self.read_started:
85
162
  raise ValueError("Unable to write a payload after it's been read")
86
163
  content = force_bytes(b)
@@ -88,25 +165,28 @@ class FakePayload(IOBase):
88
165
  self.__len += len(content)
89
166
 
90
167
 
91
- def _conditional_content_removal(request, response):
168
+ def _conditional_content_removal(
169
+ request: WSGIRequest, response: ResponseBase
170
+ ) -> ResponseBase:
92
171
  """
93
172
  Simulate the behavior of most web servers by removing the content of
94
173
  responses for HEAD requests, 1xx, 204, and 304 responses. Ensure
95
174
  compliance with RFC 9112 Section 6.3.
96
175
  """
97
176
  if 100 <= response.status_code < 200 or response.status_code in (204, 304):
98
- if response.streaming:
99
- response.streaming_content = []
177
+ if response.streaming: # type: ignore[attr-defined]
178
+ response.streaming_content = [] # type: ignore[attr-defined]
100
179
  else:
101
- response.content = b""
180
+ response.content = b"" # type: ignore[attr-defined]
102
181
  if request.method == "HEAD":
103
- if response.streaming:
104
- response.streaming_content = []
182
+ if response.streaming: # type: ignore[attr-defined]
183
+ response.streaming_content = [] # type: ignore[attr-defined]
105
184
  else:
106
- response.content = b""
185
+ response.content = b"" # type: ignore[attr-defined]
107
186
  return response
108
187
 
109
188
 
189
+ @internalcode
110
190
  class ClientHandler(BaseHandler):
111
191
  """
112
192
  An HTTP Handler that can be used for testing purposes. Use the WSGI
@@ -114,10 +194,10 @@ class ClientHandler(BaseHandler):
114
194
  the originating WSGIRequest attached to its ``wsgi_request`` attribute.
115
195
  """
116
196
 
117
- def __init__(self, *args, **kwargs):
197
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
118
198
  super().__init__(*args, **kwargs)
119
199
 
120
- def __call__(self, environ):
200
+ def __call__(self, environ: dict[str, Any]) -> ResponseBase:
121
201
  # Set up middleware if needed. We couldn't do this earlier, because
122
202
  # settings weren't available.
123
203
  if self._middleware_chain is None:
@@ -134,7 +214,7 @@ class ClientHandler(BaseHandler):
134
214
 
135
215
  # Attach the originating request to the response so that it could be
136
216
  # later retrieved.
137
- response.wsgi_request = request
217
+ response.wsgi_request = request # type: ignore[attr-defined]
138
218
 
139
219
  # Emulate a WSGI server by calling the close method on completion.
140
220
  response.close()
@@ -156,15 +236,21 @@ class RequestFactory:
156
236
  just as if that view had been hooked up using a urlrouter.
157
237
  """
158
238
 
159
- def __init__(self, *, json_encoder=PlainJSONEncoder, headers=None, **defaults):
239
+ def __init__(
240
+ self,
241
+ *,
242
+ json_encoder: type[json.JSONEncoder] = PlainJSONEncoder,
243
+ headers: dict[str, str] | None = None,
244
+ **defaults: Any,
245
+ ) -> None:
160
246
  self.json_encoder = json_encoder
161
- self.defaults = defaults
162
- self.cookies = SimpleCookie()
247
+ self.defaults: dict[str, Any] = defaults
248
+ self.cookies: SimpleCookie[str] = SimpleCookie()
163
249
  self.errors = BytesIO()
164
250
  if headers:
165
- self.defaults.update(HttpHeaders.to_wsgi_names(headers))
251
+ self.defaults.update(RequestHeaders.to_wsgi_names(headers))
166
252
 
167
- def _base_environ(self, **request):
253
+ def _base_environ(self, **request: Any) -> dict[str, Any]:
168
254
  """
169
255
  The base environment for a request.
170
256
  """
@@ -197,13 +283,13 @@ class RequestFactory:
197
283
  **request,
198
284
  }
199
285
 
200
- def request(self, **request):
286
+ def request(self, **request: Any) -> WSGIRequest:
201
287
  "Construct a generic request object."
202
288
  return WSGIRequest(self._base_environ(**request))
203
289
 
204
- def _encode_data(self, data, content_type):
290
+ def _encode_data(self, data: dict[str, Any] | str, content_type: str) -> bytes:
205
291
  if content_type is _MULTIPART_CONTENT:
206
- return encode_multipart(_BOUNDARY, data)
292
+ return encode_multipart(_BOUNDARY, data) # type: ignore[arg-type]
207
293
  else:
208
294
  # Encode the content so that the byte representation is correct.
209
295
  match = _CONTENT_TYPE_RE.match(content_type)
@@ -213,7 +299,7 @@ class RequestFactory:
213
299
  charset = settings.DEFAULT_CHARSET
214
300
  return force_bytes(data, encoding=charset)
215
301
 
216
- def _encode_json(self, data, content_type):
302
+ def _encode_json(self, data: Any, content_type: str) -> Any:
217
303
  """
218
304
  Return encoded JSON if data is a dict, list, or tuple and content_type
219
305
  is application/json.
@@ -223,7 +309,7 @@ class RequestFactory:
223
309
  )
224
310
  return json.dumps(data, cls=self.json_encoder) if should_encode else data
225
311
 
226
- def _get_path(self, parsed):
312
+ def _get_path(self, parsed: Any) -> str:
227
313
  path = parsed.path
228
314
  # If there are parameters, add them
229
315
  if parsed.params:
@@ -234,7 +320,15 @@ class RequestFactory:
234
320
  # Refs comment in `get_bytes_from_wsgi()`.
235
321
  return path.decode("iso-8859-1")
236
322
 
237
- def get(self, path, data=None, secure=True, *, headers=None, **extra):
323
+ def get(
324
+ self,
325
+ path: str,
326
+ data: dict[str, Any] | None = None,
327
+ secure: bool = True,
328
+ *,
329
+ headers: dict[str, str] | None = None,
330
+ **extra: Any,
331
+ ) -> WSGIRequest:
238
332
  """Construct a GET request."""
239
333
  data = {} if data is None else data
240
334
  return self.generic(
@@ -250,14 +344,14 @@ class RequestFactory:
250
344
 
251
345
  def post(
252
346
  self,
253
- path,
254
- data=None,
255
- content_type=_MULTIPART_CONTENT,
256
- secure=True,
347
+ path: str,
348
+ data: dict[str, Any] | None = None,
349
+ content_type: str = _MULTIPART_CONTENT,
350
+ secure: bool = True,
257
351
  *,
258
- headers=None,
259
- **extra,
260
- ):
352
+ headers: dict[str, str] | None = None,
353
+ **extra: Any,
354
+ ) -> WSGIRequest:
261
355
  """Construct a POST request."""
262
356
  data = self._encode_json({} if data is None else data, content_type)
263
357
  post_data = self._encode_data(data, content_type)
@@ -272,7 +366,15 @@ class RequestFactory:
272
366
  **extra,
273
367
  )
274
368
 
275
- def head(self, path, data=None, secure=True, *, headers=None, **extra):
369
+ def head(
370
+ self,
371
+ path: str,
372
+ data: dict[str, Any] | None = None,
373
+ secure: bool = True,
374
+ *,
375
+ headers: dict[str, str] | None = None,
376
+ **extra: Any,
377
+ ) -> WSGIRequest:
276
378
  """Construct a HEAD request."""
277
379
  data = {} if data is None else data
278
380
  return self.generic(
@@ -286,20 +388,27 @@ class RequestFactory:
286
388
  },
287
389
  )
288
390
 
289
- def trace(self, path, secure=True, *, headers=None, **extra):
391
+ def trace(
392
+ self,
393
+ path: str,
394
+ secure: bool = True,
395
+ *,
396
+ headers: dict[str, str] | None = None,
397
+ **extra: Any,
398
+ ) -> WSGIRequest:
290
399
  """Construct a TRACE request."""
291
400
  return self.generic("TRACE", path, secure=secure, headers=headers, **extra)
292
401
 
293
402
  def options(
294
403
  self,
295
- path,
296
- data="",
297
- content_type="application/octet-stream",
298
- secure=True,
404
+ path: str,
405
+ data: Any = "",
406
+ content_type: str = "application/octet-stream",
407
+ secure: bool = True,
299
408
  *,
300
- headers=None,
301
- **extra,
302
- ):
409
+ headers: dict[str, str] | None = None,
410
+ **extra: Any,
411
+ ) -> WSGIRequest:
303
412
  "Construct an OPTIONS request."
304
413
  return self.generic(
305
414
  "OPTIONS", path, data, content_type, secure=secure, headers=headers, **extra
@@ -307,14 +416,14 @@ class RequestFactory:
307
416
 
308
417
  def put(
309
418
  self,
310
- path,
311
- data="",
312
- content_type="application/octet-stream",
313
- secure=True,
419
+ path: str,
420
+ data: Any = "",
421
+ content_type: str = "application/octet-stream",
422
+ secure: bool = True,
314
423
  *,
315
- headers=None,
316
- **extra,
317
- ):
424
+ headers: dict[str, str] | None = None,
425
+ **extra: Any,
426
+ ) -> WSGIRequest:
318
427
  """Construct a PUT request."""
319
428
  data = self._encode_json(data, content_type)
320
429
  return self.generic(
@@ -323,14 +432,14 @@ class RequestFactory:
323
432
 
324
433
  def patch(
325
434
  self,
326
- path,
327
- data="",
328
- content_type="application/octet-stream",
329
- secure=True,
435
+ path: str,
436
+ data: Any = "",
437
+ content_type: str = "application/octet-stream",
438
+ secure: bool = True,
330
439
  *,
331
- headers=None,
332
- **extra,
333
- ):
440
+ headers: dict[str, str] | None = None,
441
+ **extra: Any,
442
+ ) -> WSGIRequest:
334
443
  """Construct a PATCH request."""
335
444
  data = self._encode_json(data, content_type)
336
445
  return self.generic(
@@ -339,14 +448,14 @@ class RequestFactory:
339
448
 
340
449
  def delete(
341
450
  self,
342
- path,
343
- data="",
344
- content_type="application/octet-stream",
345
- secure=True,
451
+ path: str,
452
+ data: Any = "",
453
+ content_type: str = "application/octet-stream",
454
+ secure: bool = True,
346
455
  *,
347
- headers=None,
348
- **extra,
349
- ):
456
+ headers: dict[str, str] | None = None,
457
+ **extra: Any,
458
+ ) -> WSGIRequest:
350
459
  """Construct a DELETE request."""
351
460
  data = self._encode_json(data, content_type)
352
461
  return self.generic(
@@ -355,19 +464,19 @@ class RequestFactory:
355
464
 
356
465
  def generic(
357
466
  self,
358
- method,
359
- path,
360
- data="",
361
- content_type="application/octet-stream",
362
- secure=True,
467
+ method: str,
468
+ path: str,
469
+ data: Any = "",
470
+ content_type: str = "application/octet-stream",
471
+ secure: bool = True,
363
472
  *,
364
- headers=None,
365
- **extra,
366
- ):
473
+ headers: dict[str, str] | None = None,
474
+ **extra: Any,
475
+ ) -> WSGIRequest:
367
476
  """Construct an arbitrary HTTP request."""
368
477
  parsed = urlparse(str(path)) # path can be lazy
369
478
  data = force_bytes(data, settings.DEFAULT_CHARSET)
370
- r = {
479
+ r: dict[str, Any] = {
371
480
  "PATH_INFO": self._get_path(parsed),
372
481
  "REQUEST_METHOD": method,
373
482
  "SERVER_PORT": "443" if secure else "80",
@@ -382,7 +491,7 @@ class RequestFactory:
382
491
  }
383
492
  )
384
493
  if headers:
385
- extra.update(HttpHeaders.to_wsgi_names(headers))
494
+ extra.update(RequestHeaders.to_wsgi_names(headers))
386
495
  r.update(extra)
387
496
  # If QUERY_STRING is absent or empty, we want to extract it from the URL.
388
497
  if not r.get("QUERY_STRING"):
@@ -392,7 +501,7 @@ class RequestFactory:
392
501
  return self.request(**r)
393
502
 
394
503
 
395
- class Client(RequestFactory):
504
+ class Client:
396
505
  """
397
506
  A class that can act as a client for testing purposes.
398
507
 
@@ -413,71 +522,89 @@ class Client(RequestFactory):
413
522
 
414
523
  def __init__(
415
524
  self,
416
- raise_request_exception=True,
525
+ raise_request_exception: bool = True,
417
526
  *,
418
- headers=None,
419
- **defaults,
420
- ):
421
- super().__init__(headers=headers, **defaults)
527
+ headers: dict[str, str] | None = None,
528
+ **defaults: Any,
529
+ ) -> None:
530
+ self._request_factory = RequestFactory(headers=headers, **defaults)
422
531
  self.handler = ClientHandler()
423
532
  self.raise_request_exception = raise_request_exception
424
- self.exc_info = None
425
- self.extra = None
426
- self.headers = None
533
+ self.extra: dict[str, Any] | None = None
534
+ self.headers: dict[str, str] | None = None
535
+
536
+ @property
537
+ def cookies(self) -> SimpleCookie[str]:
538
+ """Access the cookies from the request factory."""
539
+ return self._request_factory.cookies
540
+
541
+ @cookies.setter
542
+ def cookies(self, value: SimpleCookie[str]) -> None:
543
+ """Set the cookies on the request factory."""
544
+ self._request_factory.cookies = value
427
545
 
428
- def request(self, **request):
546
+ def request(self, **request: Any) -> ClientResponse:
429
547
  """
430
548
  Make a generic request. Compose the environment dictionary and pass
431
549
  to the handler, return the result of the handler. Assume defaults for
432
550
  the query environment, which can be overridden using the arguments to
433
551
  the request.
434
552
  """
435
- environ = self._base_environ(**request)
553
+ environ = self._request_factory._base_environ(**request)
554
+
555
+ # Make the request
556
+ response = self.handler(environ)
557
+
558
+ # Wrap the response in ClientResponse for test-specific attributes
559
+ client_response = ClientResponse(
560
+ response=response,
561
+ client=self,
562
+ request=request,
563
+ )
436
564
 
437
- # Capture exceptions created by the handler.
438
- exception_uid = f"request-exception-{id(request)}"
439
- got_request_exception.connect(self.store_exc_info, dispatch_uid=exception_uid)
565
+ # Re-raise the exception if configured to do so
566
+ # Only 5xx errors have response.exception set
567
+ if client_response.exception and self.raise_request_exception:
568
+ raise client_response.exception
569
+
570
+ # If the request had a user, make it available on the response.
440
571
  try:
441
- response = self.handler(environ)
442
- finally:
443
- # signals.template_rendered.disconnect(dispatch_uid=signal_uid)
444
- got_request_exception.disconnect(dispatch_uid=exception_uid)
445
- # Check for signaled exceptions.
446
- self.check_exception(response)
447
- # Save the client and request that stimulated the response.
448
- response.client = self
449
- response.request = request
450
- response.json = partial(self._parse_json, response)
451
-
452
- # If the request had a user attached, make it available on the response.
453
- if hasattr(response.wsgi_request, "user"):
454
- response.user = response.wsgi_request.user
572
+ from plain.auth.requests import get_request_user
573
+
574
+ client_response.user = get_request_user(client_response.wsgi_request)
575
+ except ImportError:
576
+ pass
455
577
 
456
578
  # Attach the ResolverMatch instance to the response.
457
579
  resolver = get_resolver()
458
- response.resolver_match = SimpleLazyObject(
580
+ client_response.resolver_match = SimpleLazyObject(
459
581
  lambda: resolver.resolve(request["PATH_INFO"]),
460
582
  )
461
583
 
462
584
  # Update persistent cookie data.
463
- if response.cookies:
464
- self.cookies.update(response.cookies)
465
- return response
585
+ if client_response.cookies:
586
+ self.cookies.update(client_response.cookies)
587
+ return client_response
466
588
 
467
589
  def get(
468
590
  self,
469
- path,
470
- data=None,
471
- follow=False,
472
- secure=True,
591
+ path: str,
592
+ data: dict[str, Any] | None = None,
593
+ follow: bool = False,
594
+ secure: bool = True,
473
595
  *,
474
- headers=None,
475
- **extra,
476
- ):
596
+ headers: dict[str, str] | None = None,
597
+ **extra: Any,
598
+ ) -> ClientResponse:
477
599
  """Request a response from the server using GET."""
478
600
  self.extra = extra
479
601
  self.headers = headers
480
- response = super().get(path, data=data, secure=secure, headers=headers, **extra)
602
+ # Build the request using the factory
603
+ wsgi_request = self._request_factory.get(
604
+ path, data=data, secure=secure, headers=headers, **extra
605
+ )
606
+ # Execute and get response
607
+ response = self.request(**wsgi_request.environ)
481
608
  if follow:
482
609
  response = self._handle_redirects(
483
610
  response, data=data, headers=headers, **extra
@@ -486,19 +613,20 @@ class Client(RequestFactory):
486
613
 
487
614
  def post(
488
615
  self,
489
- path,
490
- data=None,
491
- content_type=_MULTIPART_CONTENT,
492
- follow=False,
493
- secure=True,
616
+ path: str,
617
+ data: dict[str, Any] | None = None,
618
+ content_type: str = _MULTIPART_CONTENT,
619
+ follow: bool = False,
620
+ secure: bool = True,
494
621
  *,
495
- headers=None,
496
- **extra,
497
- ):
622
+ headers: dict[str, str] | None = None,
623
+ **extra: Any,
624
+ ) -> ClientResponse:
498
625
  """Request a response from the server using POST."""
499
626
  self.extra = extra
500
627
  self.headers = headers
501
- response = super().post(
628
+ # Build the request using the factory
629
+ wsgi_request = self._request_factory.post(
502
630
  path,
503
631
  data=data,
504
632
  content_type=content_type,
@@ -506,6 +634,8 @@ class Client(RequestFactory):
506
634
  headers=headers,
507
635
  **extra,
508
636
  )
637
+ # Execute and get response
638
+ response = self.request(**wsgi_request.environ)
509
639
  if follow:
510
640
  response = self._handle_redirects(
511
641
  response, data=data, content_type=content_type, headers=headers, **extra
@@ -514,20 +644,23 @@ class Client(RequestFactory):
514
644
 
515
645
  def head(
516
646
  self,
517
- path,
518
- data=None,
519
- follow=False,
520
- secure=True,
647
+ path: str,
648
+ data: dict[str, Any] | None = None,
649
+ follow: bool = False,
650
+ secure: bool = True,
521
651
  *,
522
- headers=None,
523
- **extra,
524
- ):
652
+ headers: dict[str, str] | None = None,
653
+ **extra: Any,
654
+ ) -> ClientResponse:
525
655
  """Request a response from the server using HEAD."""
526
656
  self.extra = extra
527
657
  self.headers = headers
528
- response = super().head(
658
+ # Build the request using the factory
659
+ wsgi_request = self._request_factory.head(
529
660
  path, data=data, secure=secure, headers=headers, **extra
530
661
  )
662
+ # Execute and get response
663
+ response = self.request(**wsgi_request.environ)
531
664
  if follow:
532
665
  response = self._handle_redirects(
533
666
  response, data=data, headers=headers, **extra
@@ -536,19 +669,20 @@ class Client(RequestFactory):
536
669
 
537
670
  def options(
538
671
  self,
539
- path,
540
- data="",
541
- content_type="application/octet-stream",
542
- follow=False,
543
- secure=True,
672
+ path: str,
673
+ data: Any = "",
674
+ content_type: str = "application/octet-stream",
675
+ follow: bool = False,
676
+ secure: bool = True,
544
677
  *,
545
- headers=None,
546
- **extra,
547
- ):
678
+ headers: dict[str, str] | None = None,
679
+ **extra: Any,
680
+ ) -> ClientResponse:
548
681
  """Request a response from the server using OPTIONS."""
549
682
  self.extra = extra
550
683
  self.headers = headers
551
- response = super().options(
684
+ # Build the request using the factory
685
+ wsgi_request = self._request_factory.options(
552
686
  path,
553
687
  data=data,
554
688
  content_type=content_type,
@@ -556,6 +690,8 @@ class Client(RequestFactory):
556
690
  headers=headers,
557
691
  **extra,
558
692
  )
693
+ # Execute and get response
694
+ response = self.request(**wsgi_request.environ)
559
695
  if follow:
560
696
  response = self._handle_redirects(
561
697
  response, data=data, content_type=content_type, headers=headers, **extra
@@ -564,19 +700,20 @@ class Client(RequestFactory):
564
700
 
565
701
  def put(
566
702
  self,
567
- path,
568
- data="",
569
- content_type="application/octet-stream",
570
- follow=False,
571
- secure=True,
703
+ path: str,
704
+ data: Any = "",
705
+ content_type: str = "application/octet-stream",
706
+ follow: bool = False,
707
+ secure: bool = True,
572
708
  *,
573
- headers=None,
574
- **extra,
575
- ):
709
+ headers: dict[str, str] | None = None,
710
+ **extra: Any,
711
+ ) -> ClientResponse:
576
712
  """Send a resource to the server using PUT."""
577
713
  self.extra = extra
578
714
  self.headers = headers
579
- response = super().put(
715
+ # Build the request using the factory
716
+ wsgi_request = self._request_factory.put(
580
717
  path,
581
718
  data=data,
582
719
  content_type=content_type,
@@ -584,6 +721,8 @@ class Client(RequestFactory):
584
721
  headers=headers,
585
722
  **extra,
586
723
  )
724
+ # Execute and get response
725
+ response = self.request(**wsgi_request.environ)
587
726
  if follow:
588
727
  response = self._handle_redirects(
589
728
  response, data=data, content_type=content_type, headers=headers, **extra
@@ -592,19 +731,20 @@ class Client(RequestFactory):
592
731
 
593
732
  def patch(
594
733
  self,
595
- path,
596
- data="",
597
- content_type="application/octet-stream",
598
- follow=False,
599
- secure=True,
734
+ path: str,
735
+ data: Any = "",
736
+ content_type: str = "application/octet-stream",
737
+ follow: bool = False,
738
+ secure: bool = True,
600
739
  *,
601
- headers=None,
602
- **extra,
603
- ):
740
+ headers: dict[str, str] | None = None,
741
+ **extra: Any,
742
+ ) -> ClientResponse:
604
743
  """Send a resource to the server using PATCH."""
605
744
  self.extra = extra
606
745
  self.headers = headers
607
- response = super().patch(
746
+ # Build the request using the factory
747
+ wsgi_request = self._request_factory.patch(
608
748
  path,
609
749
  data=data,
610
750
  content_type=content_type,
@@ -612,6 +752,8 @@ class Client(RequestFactory):
612
752
  headers=headers,
613
753
  **extra,
614
754
  )
755
+ # Execute and get response
756
+ response = self.request(**wsgi_request.environ)
615
757
  if follow:
616
758
  response = self._handle_redirects(
617
759
  response, data=data, content_type=content_type, headers=headers, **extra
@@ -620,19 +762,20 @@ class Client(RequestFactory):
620
762
 
621
763
  def delete(
622
764
  self,
623
- path,
624
- data="",
625
- content_type="application/octet-stream",
626
- follow=False,
627
- secure=True,
765
+ path: str,
766
+ data: Any = "",
767
+ content_type: str = "application/octet-stream",
768
+ follow: bool = False,
769
+ secure: bool = True,
628
770
  *,
629
- headers=None,
630
- **extra,
631
- ):
771
+ headers: dict[str, str] | None = None,
772
+ **extra: Any,
773
+ ) -> ClientResponse:
632
774
  """Send a DELETE request to the server."""
633
775
  self.extra = extra
634
776
  self.headers = headers
635
- response = super().delete(
777
+ # Build the request using the factory
778
+ wsgi_request = self._request_factory.delete(
636
779
  path,
637
780
  data=data,
638
781
  content_type=content_type,
@@ -640,6 +783,8 @@ class Client(RequestFactory):
640
783
  headers=headers,
641
784
  **extra,
642
785
  )
786
+ # Execute and get response
787
+ response = self.request(**wsgi_request.environ)
643
788
  if follow:
644
789
  response = self._handle_redirects(
645
790
  response, data=data, content_type=content_type, headers=headers, **extra
@@ -648,20 +793,23 @@ class Client(RequestFactory):
648
793
 
649
794
  def trace(
650
795
  self,
651
- path,
652
- data="",
653
- follow=False,
654
- secure=True,
796
+ path: str,
797
+ data: Any = "",
798
+ follow: bool = False,
799
+ secure: bool = True,
655
800
  *,
656
- headers=None,
657
- **extra,
658
- ):
801
+ headers: dict[str, str] | None = None,
802
+ **extra: Any,
803
+ ) -> ClientResponse:
659
804
  """Send a TRACE request to the server."""
660
805
  self.extra = extra
661
806
  self.headers = headers
662
- response = super().trace(
807
+ # Build the request using the factory
808
+ wsgi_request = self._request_factory.trace(
663
809
  path, data=data, secure=secure, headers=headers, **extra
664
810
  )
811
+ # Execute and get response
812
+ response = self.request(**wsgi_request.environ)
665
813
  if follow:
666
814
  response = self._handle_redirects(
667
815
  response, data=data, headers=headers, **extra
@@ -670,12 +818,12 @@ class Client(RequestFactory):
670
818
 
671
819
  def _handle_redirects(
672
820
  self,
673
- response,
674
- data="",
675
- content_type="",
676
- headers=None,
677
- **extra,
678
- ):
821
+ response: ClientResponse,
822
+ data: Any = "",
823
+ content_type: str = "",
824
+ headers: dict[str, str] | None = None,
825
+ **extra: Any,
826
+ ) -> ClientResponse:
679
827
  """
680
828
  Follow any redirects by requesting responses from the server using GET.
681
829
  """
@@ -714,14 +862,14 @@ class Client(RequestFactory):
714
862
  ):
715
863
  # Preserve request method and query string (if needed)
716
864
  # post-redirect for 307/308 responses.
717
- request_method = response.request["REQUEST_METHOD"].lower()
718
- if request_method not in ("get", "head"):
865
+ request_method_name = response.request["REQUEST_METHOD"].lower()
866
+ if request_method_name not in ("get", "head"):
719
867
  extra["QUERY_STRING"] = url.query
720
- request_method = getattr(self, request_method)
868
+ request_method = getattr(self, request_method_name)
721
869
  else:
722
870
  request_method = self.get
723
871
  data = QueryDict(url.query)
724
- content_type = None
872
+ content_type = ""
725
873
 
726
874
  response = request_method(
727
875
  path,
@@ -747,50 +895,20 @@ class Client(RequestFactory):
747
895
 
748
896
  return response
749
897
 
750
- def store_exc_info(self, **kwargs):
751
- """Store exceptions when they are generated by a view."""
752
- self.exc_info = sys.exc_info()
753
-
754
- def check_exception(self, response):
755
- """
756
- Look for a signaled exception, clear the current context exception
757
- data, re-raise the signaled exception, and clear the signaled exception
758
- from the local cache.
759
- """
760
- response.exc_info = self.exc_info
761
- if self.exc_info:
762
- _, exc_value, _ = self.exc_info
763
- self.exc_info = None
764
- if self.raise_request_exception:
765
- raise exc_value
766
-
767
898
  @property
768
- def session(self):
899
+ def session(self) -> Any:
769
900
  """Return the current session variables."""
770
901
  from plain.sessions.test import get_client_session
771
902
 
772
903
  return get_client_session(self)
773
904
 
774
- def force_login(self, user):
905
+ def force_login(self, user: Any) -> None:
775
906
  from plain.auth.test import login_client
776
907
 
777
908
  login_client(self, user)
778
909
 
779
- def logout(self):
910
+ def logout(self) -> None:
780
911
  """Log out the user by removing the cookies and session object."""
781
912
  from plain.auth.test import logout_client
782
913
 
783
914
  logout_client(self)
784
-
785
- def _parse_json(self, response, **extra):
786
- if not hasattr(response, "_json"):
787
- if not _JSON_CONTENT_TYPE_RE.match(response.headers.get("Content-Type")):
788
- raise ValueError(
789
- 'Content-Type header is "{}", not "application/json"'.format(
790
- response.headers.get("Content-Type")
791
- )
792
- )
793
- response._json = json.loads(
794
- response.content.decode(response.charset), **extra
795
- )
796
- return response._json