plain 0.69.0__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.
- plain/AGENTS.md +1 -1
- plain/CHANGELOG.md +11 -0
- plain/assets/compile.py +20 -7
- plain/assets/finders.py +15 -11
- plain/assets/fingerprints.py +6 -5
- plain/assets/urls.py +1 -1
- plain/assets/views.py +23 -17
- plain/chores/registry.py +14 -9
- plain/cli/agent/__init__.py +1 -1
- plain/cli/agent/docs.py +7 -6
- plain/cli/agent/llmdocs.py +18 -8
- plain/cli/agent/md.py +19 -14
- plain/cli/agent/prompt.py +1 -1
- plain/cli/agent/request.py +37 -17
- plain/cli/build.py +2 -2
- plain/cli/changelog.py +8 -4
- plain/cli/chores.py +4 -4
- plain/cli/core.py +8 -5
- plain/cli/docs.py +2 -2
- plain/cli/formatting.py +10 -7
- plain/cli/output.py +6 -2
- plain/cli/preflight.py +3 -3
- plain/cli/print.py +1 -1
- plain/cli/registry.py +10 -6
- plain/cli/scaffold.py +1 -1
- plain/cli/settings.py +1 -1
- plain/cli/shell.py +10 -7
- plain/cli/startup.py +3 -3
- plain/cli/urls.py +10 -4
- plain/cli/utils.py +2 -2
- plain/csrf/middleware.py +15 -5
- plain/csrf/views.py +11 -8
- plain/debug.py +5 -2
- plain/exceptions.py +19 -8
- plain/forms/__init__.py +1 -1
- plain/forms/boundfield.py +14 -7
- plain/forms/exceptions.py +1 -1
- plain/forms/fields.py +139 -97
- plain/forms/forms.py +55 -39
- plain/http/cookie.py +15 -7
- plain/http/multipartparser.py +50 -30
- plain/http/request.py +97 -73
- plain/http/response.py +99 -80
- plain/internal/__init__.py +8 -1
- plain/internal/files/base.py +34 -18
- plain/internal/files/locks.py +19 -11
- plain/internal/files/move.py +8 -3
- plain/internal/files/temp.py +23 -5
- plain/internal/files/uploadedfile.py +42 -26
- plain/internal/files/uploadhandler.py +48 -27
- plain/internal/files/utils.py +13 -6
- plain/internal/handlers/base.py +20 -6
- plain/internal/handlers/exception.py +19 -5
- plain/internal/handlers/wsgi.py +30 -18
- plain/internal/middleware/headers.py +11 -2
- plain/internal/middleware/hosts.py +10 -2
- plain/internal/middleware/https.py +13 -3
- plain/internal/middleware/slash.py +15 -5
- plain/json.py +2 -1
- plain/logs/configure.py +3 -1
- plain/logs/debug.py +16 -5
- plain/logs/formatters.py +6 -3
- plain/logs/loggers.py +56 -52
- plain/logs/utils.py +19 -9
- plain/packages/config.py +14 -6
- plain/packages/registry.py +27 -12
- plain/paginator.py +31 -21
- plain/preflight/checks.py +3 -1
- plain/preflight/files.py +3 -1
- plain/preflight/registry.py +25 -10
- plain/preflight/results.py +10 -4
- plain/preflight/security.py +7 -5
- plain/preflight/urls.py +4 -1
- plain/runtime/__init__.py +4 -3
- plain/runtime/global_settings.py +1 -1
- plain/runtime/user_settings.py +26 -17
- plain/runtime/utils.py +1 -1
- plain/signals/dispatch/dispatcher.py +39 -17
- plain/signing.py +49 -30
- plain/templates/jinja/__init__.py +13 -5
- plain/templates/jinja/environments.py +4 -3
- plain/templates/jinja/extensions.py +9 -3
- plain/templates/jinja/filters.py +7 -2
- plain/templates/jinja/globals.py +1 -1
- plain/test/client.py +246 -174
- plain/test/encoding.py +9 -6
- plain/test/exceptions.py +10 -2
- plain/urls/converters.py +13 -10
- plain/urls/patterns.py +32 -20
- plain/urls/resolvers.py +32 -22
- plain/urls/utils.py +5 -1
- plain/utils/cache.py +14 -8
- plain/utils/crypto.py +21 -5
- plain/utils/datastructures.py +84 -54
- plain/utils/dateparse.py +10 -7
- plain/utils/deconstruct.py +12 -4
- plain/utils/decorators.py +5 -1
- plain/utils/duration.py +8 -4
- plain/utils/encoding.py +14 -7
- plain/utils/functional.py +62 -47
- plain/utils/hashable.py +5 -1
- plain/utils/html.py +21 -14
- plain/utils/http.py +16 -9
- plain/utils/inspect.py +14 -6
- plain/utils/ipv6.py +7 -3
- plain/utils/itercompat.py +6 -1
- plain/utils/module_loading.py +7 -3
- plain/utils/regex_helper.py +23 -13
- plain/utils/safestring.py +14 -6
- plain/utils/text.py +34 -18
- plain/utils/timezone.py +30 -19
- plain/utils/tree.py +31 -18
- plain/validators.py +71 -44
- plain/views/base.py +16 -6
- plain/views/errors.py +11 -4
- plain/views/exceptions.py +4 -1
- plain/views/objects.py +15 -15
- plain/views/redirect.py +14 -10
- plain/views/templates.py +1 -1
- plain/wsgi.py +3 -1
- {plain-0.69.0.dist-info → plain-0.70.0.dist-info}/METADATA +1 -1
- plain-0.70.0.dist-info/RECORD +169 -0
- plain-0.69.0.dist-info/RECORD +0 -169
- {plain-0.69.0.dist-info → plain-0.70.0.dist-info}/WHEEL +0 -0
- {plain-0.69.0.dist-info → plain-0.70.0.dist-info}/entry_points.txt +0 -0
- {plain-0.69.0.dist-info → plain-0.70.0.dist-info}/licenses/LICENSE +0 -0
plain/http/response.py
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
1
3
|
import datetime
|
2
4
|
import io
|
3
5
|
import json
|
@@ -6,10 +8,12 @@ import os
|
|
6
8
|
import re
|
7
9
|
import sys
|
8
10
|
import time
|
11
|
+
from collections.abc import Iterator
|
9
12
|
from email.header import Header
|
10
13
|
from functools import cached_property
|
11
14
|
from http.client import responses
|
12
15
|
from http.cookies import SimpleCookie
|
16
|
+
from typing import IO, Any
|
13
17
|
|
14
18
|
from plain import signals
|
15
19
|
from plain.http.cookie import sign_cookie_value
|
@@ -27,7 +31,7 @@ _charset_from_content_type_re = _lazy_re_compile(
|
|
27
31
|
|
28
32
|
|
29
33
|
class ResponseHeaders(CaseInsensitiveMapping):
|
30
|
-
def __init__(self, data):
|
34
|
+
def __init__(self, data: dict[str, Any] | None = None):
|
31
35
|
"""
|
32
36
|
Populate the initial data using __setitem__ to ensure values are
|
33
37
|
correctly encoded.
|
@@ -37,7 +41,9 @@ class ResponseHeaders(CaseInsensitiveMapping):
|
|
37
41
|
for header, value in self._unpack_items(data):
|
38
42
|
self[header] = value
|
39
43
|
|
40
|
-
def _convert_to_charset(
|
44
|
+
def _convert_to_charset(
|
45
|
+
self, value: str | bytes, charset: str, mime_encode: bool = False
|
46
|
+
) -> str:
|
41
47
|
"""
|
42
48
|
Convert headers key/value to ascii/latin-1 native strings.
|
43
49
|
`charset` must be 'ascii' or 'latin-1'. If `mime_encode` is True and
|
@@ -72,22 +78,23 @@ class ResponseHeaders(CaseInsensitiveMapping):
|
|
72
78
|
if mime_encode:
|
73
79
|
value = Header(value, "utf-8", maxlinelen=sys.maxsize).encode()
|
74
80
|
else:
|
75
|
-
e
|
81
|
+
if hasattr(e, "reason") and isinstance(e.reason, str):
|
82
|
+
e.reason += f", HTTP response headers must be in {charset} format"
|
76
83
|
raise
|
77
84
|
return value
|
78
85
|
|
79
|
-
def __delitem__(self, key):
|
86
|
+
def __delitem__(self, key: str) -> None:
|
80
87
|
self.pop(key)
|
81
88
|
|
82
|
-
def __setitem__(self, key, value):
|
89
|
+
def __setitem__(self, key: str, value: str | bytes) -> None:
|
83
90
|
key = self._convert_to_charset(key, "ascii")
|
84
91
|
value = self._convert_to_charset(value, "latin-1", mime_encode=True)
|
85
92
|
self._store[key.lower()] = (key, value)
|
86
93
|
|
87
|
-
def pop(self, key, default=None):
|
94
|
+
def pop(self, key: str, default: Any = None) -> Any:
|
88
95
|
return self._store.pop(key.lower(), default)
|
89
96
|
|
90
|
-
def setdefault(self, key, value):
|
97
|
+
def setdefault(self, key: str, value: str | bytes) -> None:
|
91
98
|
if key not in self:
|
92
99
|
self[key] = value
|
93
100
|
|
@@ -109,11 +116,11 @@ class ResponseBase:
|
|
109
116
|
def __init__(
|
110
117
|
self,
|
111
118
|
*,
|
112
|
-
content_type=None,
|
113
|
-
status_code=None,
|
114
|
-
reason=None,
|
115
|
-
charset=None,
|
116
|
-
headers=None,
|
119
|
+
content_type: str | None = None,
|
120
|
+
status_code: int | None = None,
|
121
|
+
reason: str | None = None,
|
122
|
+
charset: str | None = None,
|
123
|
+
headers: dict[str, Any] | None = None,
|
117
124
|
):
|
118
125
|
self.headers = ResponseHeaders(headers)
|
119
126
|
self._charset = charset
|
@@ -143,7 +150,7 @@ class ResponseBase:
|
|
143
150
|
self._reason_phrase = reason
|
144
151
|
|
145
152
|
@property
|
146
|
-
def reason_phrase(self):
|
153
|
+
def reason_phrase(self) -> str:
|
147
154
|
if self._reason_phrase is not None:
|
148
155
|
return self._reason_phrase
|
149
156
|
# Leave self._reason_phrase unset in order to use the default
|
@@ -151,11 +158,11 @@ class ResponseBase:
|
|
151
158
|
return responses.get(self.status_code, "Unknown Status Code")
|
152
159
|
|
153
160
|
@reason_phrase.setter
|
154
|
-
def reason_phrase(self, value):
|
161
|
+
def reason_phrase(self, value: str) -> None:
|
155
162
|
self._reason_phrase = value
|
156
163
|
|
157
164
|
@property
|
158
|
-
def charset(self):
|
165
|
+
def charset(self) -> str:
|
159
166
|
if self._charset is not None:
|
160
167
|
return self._charset
|
161
168
|
# The Content-Type header may not yet be set, because the charset is
|
@@ -170,10 +177,10 @@ class ResponseBase:
|
|
170
177
|
return settings.DEFAULT_CHARSET
|
171
178
|
|
172
179
|
@charset.setter
|
173
|
-
def charset(self, value):
|
180
|
+
def charset(self, value: str) -> None:
|
174
181
|
self._charset = value
|
175
182
|
|
176
|
-
def serialize_headers(self):
|
183
|
+
def serialize_headers(self) -> bytes:
|
177
184
|
"""HTTP headers as a bytestring."""
|
178
185
|
return b"\r\n".join(
|
179
186
|
[
|
@@ -185,7 +192,7 @@ class ResponseBase:
|
|
185
192
|
__bytes__ = serialize_headers
|
186
193
|
|
187
194
|
@property
|
188
|
-
def _content_type_for_repr(self):
|
195
|
+
def _content_type_for_repr(self) -> str:
|
189
196
|
return (
|
190
197
|
', "{}"'.format(self.headers["Content-Type"])
|
191
198
|
if "Content-Type" in self.headers
|
@@ -194,16 +201,16 @@ class ResponseBase:
|
|
194
201
|
|
195
202
|
def set_cookie(
|
196
203
|
self,
|
197
|
-
key,
|
198
|
-
value="",
|
199
|
-
max_age=None,
|
200
|
-
expires=None,
|
201
|
-
path="/",
|
202
|
-
domain=None,
|
203
|
-
secure=False,
|
204
|
-
httponly=False,
|
205
|
-
samesite=None,
|
206
|
-
):
|
204
|
+
key: str,
|
205
|
+
value: str = "",
|
206
|
+
max_age: int | float | datetime.timedelta | None = None,
|
207
|
+
expires: str | datetime.datetime | None = None,
|
208
|
+
path: str | None = "/",
|
209
|
+
domain: str | None = None,
|
210
|
+
secure: bool = False,
|
211
|
+
httponly: bool = False,
|
212
|
+
samesite: str | None = None,
|
213
|
+
) -> None:
|
207
214
|
"""
|
208
215
|
Set a cookie.
|
209
216
|
|
@@ -256,13 +263,21 @@ class ResponseBase:
|
|
256
263
|
raise ValueError('samesite must be "lax", "none", or "strict".')
|
257
264
|
self.cookies[key]["samesite"] = samesite
|
258
265
|
|
259
|
-
def set_signed_cookie(
|
266
|
+
def set_signed_cookie(
|
267
|
+
self, key: str, value: str, salt: str = "", **kwargs: Any
|
268
|
+
) -> None:
|
260
269
|
"""Set a cookie signed with the SECRET_KEY."""
|
261
270
|
|
262
271
|
signed_value = sign_cookie_value(key, value, salt)
|
263
272
|
return self.set_cookie(key, signed_value, **kwargs)
|
264
273
|
|
265
|
-
def delete_cookie(
|
274
|
+
def delete_cookie(
|
275
|
+
self,
|
276
|
+
key: str,
|
277
|
+
path: str = "/",
|
278
|
+
domain: str | None = None,
|
279
|
+
samesite: str | None = None,
|
280
|
+
) -> None:
|
266
281
|
# Browsers can ignore the Set-Cookie header if the cookie doesn't use
|
267
282
|
# the secure flag and:
|
268
283
|
# - the cookie name starts with "__Host-" or "__Secure-", or
|
@@ -282,7 +297,7 @@ class ResponseBase:
|
|
282
297
|
|
283
298
|
# Common methods used by subclasses
|
284
299
|
|
285
|
-
def make_bytes(self, value):
|
300
|
+
def make_bytes(self, value: str | bytes) -> bytes:
|
286
301
|
"""Turn a value into a bytestring encoded in the output charset."""
|
287
302
|
# Per PEP 3333, this response body must be bytes. To avoid returning
|
288
303
|
# an instance of a subclass, this function returns `bytes(value)`.
|
@@ -303,7 +318,7 @@ class ResponseBase:
|
|
303
318
|
|
304
319
|
# The WSGI server must call this method upon completion of the request.
|
305
320
|
# See http://blog.dscpl.com.au/2012/10/obligations-for-calling-close-on.html
|
306
|
-
def close(self):
|
321
|
+
def close(self) -> None:
|
307
322
|
for closer in self._resource_closers:
|
308
323
|
try:
|
309
324
|
closer()
|
@@ -314,13 +329,13 @@ class ResponseBase:
|
|
314
329
|
self.closed = True
|
315
330
|
signals.request_finished.send(sender=self._handler_class)
|
316
331
|
|
317
|
-
def write(self, content):
|
332
|
+
def write(self, content: bytes) -> None:
|
318
333
|
raise OSError(f"This {self.__class__.__name__} instance is not writable")
|
319
334
|
|
320
|
-
def flush(self):
|
335
|
+
def flush(self) -> None:
|
321
336
|
pass
|
322
337
|
|
323
|
-
def tell(self):
|
338
|
+
def tell(self) -> int:
|
324
339
|
raise OSError(
|
325
340
|
f"This {self.__class__.__name__} instance cannot tell its position"
|
326
341
|
)
|
@@ -328,16 +343,16 @@ class ResponseBase:
|
|
328
343
|
# These methods partially implement a stream-like object interface.
|
329
344
|
# See https://docs.python.org/library/io.html#io.IOBase
|
330
345
|
|
331
|
-
def readable(self):
|
346
|
+
def readable(self) -> bool:
|
332
347
|
return False
|
333
348
|
|
334
|
-
def seekable(self):
|
349
|
+
def seekable(self) -> bool:
|
335
350
|
return False
|
336
351
|
|
337
|
-
def writable(self):
|
352
|
+
def writable(self) -> bool:
|
338
353
|
return False
|
339
354
|
|
340
|
-
def writelines(self, lines):
|
355
|
+
def writelines(self, lines: list[bytes]) -> None:
|
341
356
|
raise OSError(f"This {self.__class__.__name__} instance is not writable")
|
342
357
|
|
343
358
|
|
@@ -360,45 +375,45 @@ class Response(ResponseBase):
|
|
360
375
|
]
|
361
376
|
)
|
362
377
|
|
363
|
-
def __init__(self, content=b"", **kwargs):
|
378
|
+
def __init__(self, content: bytes | str | Iterator[bytes] = b"", **kwargs: Any):
|
364
379
|
super().__init__(**kwargs)
|
365
380
|
# Content is a bytestring. See the `content` property methods.
|
366
381
|
self.content = content
|
367
382
|
|
368
|
-
def __getstate__(self):
|
383
|
+
def __getstate__(self) -> dict[str, Any]:
|
369
384
|
obj_dict = self.__dict__.copy()
|
370
385
|
for attr in self.non_picklable_attrs:
|
371
386
|
if attr in obj_dict:
|
372
387
|
del obj_dict[attr]
|
373
388
|
return obj_dict
|
374
389
|
|
375
|
-
def __repr__(self):
|
390
|
+
def __repr__(self) -> str:
|
376
391
|
return "<%(cls)s status_code=%(status_code)d%(content_type)s>" % { # noqa: UP031
|
377
392
|
"cls": self.__class__.__name__,
|
378
393
|
"status_code": self.status_code,
|
379
394
|
"content_type": self._content_type_for_repr,
|
380
395
|
}
|
381
396
|
|
382
|
-
def serialize(self):
|
397
|
+
def serialize(self) -> bytes:
|
383
398
|
"""Full HTTP message, including headers, as a bytestring."""
|
384
399
|
return self.serialize_headers() + b"\r\n\r\n" + self.content
|
385
400
|
|
386
401
|
__bytes__ = serialize
|
387
402
|
|
388
403
|
@property
|
389
|
-
def content(self):
|
404
|
+
def content(self) -> bytes:
|
390
405
|
return b"".join(self._container)
|
391
406
|
|
392
407
|
@content.setter
|
393
|
-
def content(self, value):
|
408
|
+
def content(self, value: bytes | str | Iterator[bytes]) -> None:
|
394
409
|
# Consume iterators upon assignment to allow repeated iteration.
|
395
410
|
if hasattr(value, "__iter__") and not isinstance(
|
396
411
|
value, bytes | memoryview | str
|
397
412
|
):
|
398
413
|
content = b"".join(self.make_bytes(chunk) for chunk in value)
|
399
|
-
if hasattr(value, "close"):
|
414
|
+
if hasattr(value, "close") and callable(getattr(value, "close")):
|
400
415
|
try:
|
401
|
-
value.close()
|
416
|
+
value.close() # type: ignore
|
402
417
|
except Exception:
|
403
418
|
pass
|
404
419
|
else:
|
@@ -407,25 +422,25 @@ class Response(ResponseBase):
|
|
407
422
|
self._container = [content]
|
408
423
|
|
409
424
|
@cached_property
|
410
|
-
def text(self):
|
425
|
+
def text(self) -> str:
|
411
426
|
return self.content.decode(self.charset or "utf-8")
|
412
427
|
|
413
|
-
def __iter__(self):
|
428
|
+
def __iter__(self) -> Iterator[bytes]:
|
414
429
|
return iter(self._container)
|
415
430
|
|
416
|
-
def write(self, content):
|
431
|
+
def write(self, content: bytes | str) -> None:
|
417
432
|
self._container.append(self.make_bytes(content))
|
418
433
|
|
419
|
-
def tell(self):
|
434
|
+
def tell(self) -> int:
|
420
435
|
return len(self.content)
|
421
436
|
|
422
|
-
def getvalue(self):
|
437
|
+
def getvalue(self) -> bytes:
|
423
438
|
return self.content
|
424
439
|
|
425
|
-
def writable(self):
|
440
|
+
def writable(self) -> bool:
|
426
441
|
return True
|
427
442
|
|
428
|
-
def writelines(self, lines):
|
443
|
+
def writelines(self, lines: list[bytes | str]) -> None:
|
429
444
|
for line in lines:
|
430
445
|
self.write(line)
|
431
446
|
|
@@ -441,13 +456,13 @@ class StreamingResponse(ResponseBase):
|
|
441
456
|
|
442
457
|
streaming = True
|
443
458
|
|
444
|
-
def __init__(self, streaming_content=(), **kwargs):
|
459
|
+
def __init__(self, streaming_content: Any = (), **kwargs: Any):
|
445
460
|
super().__init__(**kwargs)
|
446
461
|
# `streaming_content` should be an iterable of bytestrings.
|
447
462
|
# See the `streaming_content` property methods.
|
448
463
|
self.streaming_content = streaming_content
|
449
464
|
|
450
|
-
def __repr__(self):
|
465
|
+
def __repr__(self) -> str:
|
451
466
|
return "<%(cls)s status_code=%(status_code)d%(content_type)s>" % { # noqa: UP031
|
452
467
|
"cls": self.__class__.__qualname__,
|
453
468
|
"status_code": self.status_code,
|
@@ -455,30 +470,30 @@ class StreamingResponse(ResponseBase):
|
|
455
470
|
}
|
456
471
|
|
457
472
|
@property
|
458
|
-
def content(self):
|
473
|
+
def content(self) -> bytes:
|
459
474
|
raise AttributeError(
|
460
475
|
f"This {self.__class__.__name__} instance has no `content` attribute. Use "
|
461
476
|
"`streaming_content` instead."
|
462
477
|
)
|
463
478
|
|
464
479
|
@property
|
465
|
-
def streaming_content(self):
|
480
|
+
def streaming_content(self) -> Iterator[bytes]:
|
466
481
|
return map(self.make_bytes, self._iterator)
|
467
482
|
|
468
483
|
@streaming_content.setter
|
469
|
-
def streaming_content(self, value):
|
484
|
+
def streaming_content(self, value: Iterator[bytes | str]) -> None:
|
470
485
|
self._set_streaming_content(value)
|
471
486
|
|
472
|
-
def _set_streaming_content(self, value):
|
487
|
+
def _set_streaming_content(self, value: Iterator[bytes | str]) -> None:
|
473
488
|
# Ensure we can never iterate on "value" more than once.
|
474
489
|
self._iterator = iter(value)
|
475
490
|
if hasattr(value, "close"):
|
476
491
|
self._resource_closers.append(value.close)
|
477
492
|
|
478
|
-
def __iter__(self):
|
493
|
+
def __iter__(self) -> Iterator[bytes]:
|
479
494
|
return iter(self.streaming_content)
|
480
495
|
|
481
|
-
def getvalue(self):
|
496
|
+
def getvalue(self) -> bytes:
|
482
497
|
return b"".join(self.streaming_content)
|
483
498
|
|
484
499
|
|
@@ -489,7 +504,9 @@ class FileResponse(StreamingResponse):
|
|
489
504
|
|
490
505
|
block_size = 4096
|
491
506
|
|
492
|
-
def __init__(
|
507
|
+
def __init__(
|
508
|
+
self, *args: Any, as_attachment: bool = False, filename: str = "", **kwargs: Any
|
509
|
+
):
|
493
510
|
self.as_attachment = as_attachment
|
494
511
|
self.filename = filename
|
495
512
|
self._no_explicit_content_type = (
|
@@ -497,7 +514,7 @@ class FileResponse(StreamingResponse):
|
|
497
514
|
)
|
498
515
|
super().__init__(*args, **kwargs)
|
499
516
|
|
500
|
-
def _set_streaming_content(self, value):
|
517
|
+
def _set_streaming_content(self, value: Any) -> None:
|
501
518
|
if not hasattr(value, "read"):
|
502
519
|
self.file_to_stream = None
|
503
520
|
return super()._set_streaming_content(value)
|
@@ -509,7 +526,7 @@ class FileResponse(StreamingResponse):
|
|
509
526
|
self.set_headers(filelike)
|
510
527
|
super()._set_streaming_content(value)
|
511
528
|
|
512
|
-
def set_headers(self, filelike):
|
529
|
+
def set_headers(self, filelike: IO[bytes]) -> None:
|
513
530
|
"""
|
514
531
|
Set some common response headers (Content-Length, Content-Type, and
|
515
532
|
Content-Disposition) based on the `filelike` response content.
|
@@ -525,9 +542,11 @@ class FileResponse(StreamingResponse):
|
|
525
542
|
filelike.seek(0, io.SEEK_END)
|
526
543
|
self.headers["Content-Length"] = filelike.tell() - initial_position
|
527
544
|
filelike.seek(initial_position)
|
528
|
-
elif hasattr(filelike, "getbuffer")
|
545
|
+
elif hasattr(filelike, "getbuffer") and callable(
|
546
|
+
getattr(filelike, "getbuffer")
|
547
|
+
):
|
529
548
|
self.headers["Content-Length"] = (
|
530
|
-
filelike.getbuffer().nbytes - filelike.tell()
|
549
|
+
filelike.getbuffer().nbytes - filelike.tell() # type: ignore
|
531
550
|
)
|
532
551
|
elif os.path.exists(filename):
|
533
552
|
self.headers["Content-Length"] = (
|
@@ -569,15 +588,15 @@ class ResponseRedirect(Response):
|
|
569
588
|
|
570
589
|
status_code = 302
|
571
590
|
|
572
|
-
def __init__(self, redirect_to, **kwargs):
|
591
|
+
def __init__(self, redirect_to: str, **kwargs: Any):
|
573
592
|
super().__init__(**kwargs)
|
574
|
-
self.headers["Location"] = iri_to_uri(redirect_to)
|
593
|
+
self.headers["Location"] = iri_to_uri(redirect_to) or ""
|
575
594
|
|
576
595
|
@property
|
577
|
-
def url(self):
|
596
|
+
def url(self) -> str:
|
578
597
|
return self.headers["Location"]
|
579
598
|
|
580
|
-
def __repr__(self):
|
599
|
+
def __repr__(self) -> str:
|
581
600
|
return (
|
582
601
|
'<%(cls)s status_code=%(status_code)d%(content_type)s, url="%(url)s">' # noqa: UP031
|
583
602
|
% {
|
@@ -594,12 +613,12 @@ class ResponseNotModified(Response):
|
|
594
613
|
|
595
614
|
status_code = 304
|
596
615
|
|
597
|
-
def __init__(self, *args, **kwargs):
|
616
|
+
def __init__(self, *args: Any, **kwargs: Any):
|
598
617
|
super().__init__(*args, **kwargs)
|
599
618
|
del self.headers["content-type"]
|
600
619
|
|
601
620
|
@Response.content.setter
|
602
|
-
def content(self, value):
|
621
|
+
def content(self, value: bytes | str | Iterator[bytes]) -> None:
|
603
622
|
if value:
|
604
623
|
raise AttributeError(
|
605
624
|
"You cannot set content to a 304 (Not Modified) response"
|
@@ -630,11 +649,11 @@ class ResponseNotAllowed(Response):
|
|
630
649
|
|
631
650
|
status_code = 405
|
632
651
|
|
633
|
-
def __init__(self, permitted_methods, *args, **kwargs):
|
652
|
+
def __init__(self, permitted_methods: list[str], *args: Any, **kwargs: Any):
|
634
653
|
super().__init__(*args, **kwargs)
|
635
654
|
self.headers["Allow"] = ", ".join(permitted_methods)
|
636
655
|
|
637
|
-
def __repr__(self):
|
656
|
+
def __repr__(self) -> str:
|
638
657
|
return "<%(cls)s [%(methods)s] status_code=%(status_code)d%(content_type)s>" % { # noqa: UP031
|
639
658
|
"cls": self.__class__.__name__,
|
640
659
|
"status_code": self.status_code,
|
@@ -675,11 +694,11 @@ class JsonResponse(Response):
|
|
675
694
|
|
676
695
|
def __init__(
|
677
696
|
self,
|
678
|
-
data,
|
679
|
-
encoder=PlainJSONEncoder,
|
680
|
-
safe=True,
|
681
|
-
json_dumps_params=None,
|
682
|
-
**kwargs,
|
697
|
+
data: Any,
|
698
|
+
encoder: type[json.JSONEncoder] = PlainJSONEncoder,
|
699
|
+
safe: bool = True,
|
700
|
+
json_dumps_params: dict[str, Any] | None = None,
|
701
|
+
**kwargs: Any,
|
683
702
|
):
|
684
703
|
if safe and not isinstance(data, dict):
|
685
704
|
raise TypeError(
|
plain/internal/__init__.py
CHANGED
plain/internal/files/base.py
CHANGED
@@ -1,14 +1,21 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
1
3
|
import os
|
2
4
|
from functools import cached_property
|
3
5
|
from io import UnsupportedOperation
|
6
|
+
from typing import TYPE_CHECKING
|
4
7
|
|
5
8
|
from plain.internal.files.utils import FileProxyMixin
|
6
9
|
|
10
|
+
if TYPE_CHECKING:
|
11
|
+
from collections.abc import Iterator
|
12
|
+
from typing import IO, Any
|
13
|
+
|
7
14
|
|
8
15
|
class File(FileProxyMixin):
|
9
16
|
DEFAULT_CHUNK_SIZE = 64 * 2**10
|
10
17
|
|
11
|
-
def __init__(self, file, name=None):
|
18
|
+
def __init__(self, file: IO[Any], name: str | None = None) -> None:
|
12
19
|
self.file = file
|
13
20
|
if name is None:
|
14
21
|
name = getattr(file, "name", None)
|
@@ -16,20 +23,20 @@ class File(FileProxyMixin):
|
|
16
23
|
if hasattr(file, "mode"):
|
17
24
|
self.mode = file.mode
|
18
25
|
|
19
|
-
def __str__(self):
|
26
|
+
def __str__(self) -> str:
|
20
27
|
return self.name or ""
|
21
28
|
|
22
|
-
def __repr__(self):
|
29
|
+
def __repr__(self) -> str:
|
23
30
|
return "<{}: {}>".format(self.__class__.__name__, self or "None")
|
24
31
|
|
25
|
-
def __bool__(self):
|
32
|
+
def __bool__(self) -> bool:
|
26
33
|
return bool(self.name)
|
27
34
|
|
28
|
-
def __len__(self):
|
35
|
+
def __len__(self) -> int:
|
29
36
|
return self.size
|
30
37
|
|
31
38
|
@cached_property
|
32
|
-
def size(self):
|
39
|
+
def size(self) -> int:
|
33
40
|
if hasattr(self.file, "size"):
|
34
41
|
return self.file.size
|
35
42
|
if hasattr(self.file, "name"):
|
@@ -45,7 +52,7 @@ class File(FileProxyMixin):
|
|
45
52
|
return size
|
46
53
|
raise AttributeError("Unable to determine the file's size.")
|
47
54
|
|
48
|
-
def chunks(self, chunk_size=None):
|
55
|
+
def chunks(self, chunk_size: int | None = None) -> Iterator[bytes]:
|
49
56
|
"""
|
50
57
|
Read the file and yield chunks of ``chunk_size`` bytes (defaults to
|
51
58
|
``File.DEFAULT_CHUNK_SIZE``).
|
@@ -62,7 +69,7 @@ class File(FileProxyMixin):
|
|
62
69
|
break
|
63
70
|
yield data
|
64
71
|
|
65
|
-
def multiple_chunks(self, chunk_size=None):
|
72
|
+
def multiple_chunks(self, chunk_size: int | None = None) -> bool:
|
66
73
|
"""
|
67
74
|
Return ``True`` if you can expect multiple chunks.
|
68
75
|
|
@@ -72,7 +79,7 @@ class File(FileProxyMixin):
|
|
72
79
|
"""
|
73
80
|
return self.size > (chunk_size or self.DEFAULT_CHUNK_SIZE)
|
74
81
|
|
75
|
-
def __iter__(self):
|
82
|
+
def __iter__(self) -> Iterator[bytes | str]:
|
76
83
|
# Iterate over this file-like object by newlines
|
77
84
|
buffer_ = None
|
78
85
|
for chunk in self.chunks():
|
@@ -99,13 +106,18 @@ class File(FileProxyMixin):
|
|
99
106
|
if buffer_ is not None:
|
100
107
|
yield buffer_
|
101
108
|
|
102
|
-
def __enter__(self):
|
109
|
+
def __enter__(self) -> File:
|
103
110
|
return self
|
104
111
|
|
105
|
-
def __exit__(
|
112
|
+
def __exit__(
|
113
|
+
self,
|
114
|
+
exc_type: type[BaseException] | None,
|
115
|
+
exc_value: BaseException | None,
|
116
|
+
tb: Any,
|
117
|
+
) -> None:
|
106
118
|
self.close()
|
107
119
|
|
108
|
-
def open(self, mode=None):
|
120
|
+
def open(self, mode: str | None = None) -> File:
|
109
121
|
if not self.closed:
|
110
122
|
self.seek(0)
|
111
123
|
elif self.name and os.path.exists(self.name):
|
@@ -114,20 +126,24 @@ class File(FileProxyMixin):
|
|
114
126
|
raise ValueError("The file cannot be reopened.")
|
115
127
|
return self
|
116
128
|
|
117
|
-
def close(self):
|
129
|
+
def close(self) -> None:
|
118
130
|
self.file.close()
|
119
131
|
|
120
132
|
|
121
|
-
def endswith_cr(line):
|
133
|
+
def endswith_cr(line: str | bytes) -> bool:
|
122
134
|
"""Return True if line (a text or bytestring) ends with '\r'."""
|
123
|
-
|
135
|
+
if isinstance(line, str):
|
136
|
+
return line.endswith("\r")
|
137
|
+
return line.endswith(b"\r")
|
124
138
|
|
125
139
|
|
126
|
-
def endswith_lf(line):
|
140
|
+
def endswith_lf(line: str | bytes) -> bool:
|
127
141
|
"""Return True if line (a text or bytestring) ends with '\n'."""
|
128
|
-
|
142
|
+
if isinstance(line, str):
|
143
|
+
return line.endswith("\n")
|
144
|
+
return line.endswith(b"\n")
|
129
145
|
|
130
146
|
|
131
|
-
def equals_lf(line):
|
147
|
+
def equals_lf(line: str | bytes) -> bool:
|
132
148
|
"""Return True if line (a text or bytestring) equals '\n'."""
|
133
149
|
return line == ("\n" if isinstance(line, str) else b"\n")
|