plain 0.69.0__py3-none-any.whl → 0.71.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. plain/AGENTS.md +1 -1
  2. plain/CHANGELOG.md +28 -0
  3. plain/assets/compile.py +20 -7
  4. plain/assets/finders.py +15 -11
  5. plain/assets/fingerprints.py +6 -5
  6. plain/assets/urls.py +1 -1
  7. plain/assets/views.py +23 -17
  8. plain/chores/registry.py +14 -9
  9. plain/cli/agent/__init__.py +1 -1
  10. plain/cli/agent/docs.py +7 -6
  11. plain/cli/agent/llmdocs.py +18 -8
  12. plain/cli/agent/md.py +19 -14
  13. plain/cli/agent/prompt.py +1 -1
  14. plain/cli/agent/request.py +37 -17
  15. plain/cli/build.py +2 -2
  16. plain/cli/changelog.py +8 -4
  17. plain/cli/chores.py +4 -4
  18. plain/cli/core.py +8 -5
  19. plain/cli/docs.py +2 -2
  20. plain/cli/formatting.py +10 -7
  21. plain/cli/output.py +6 -2
  22. plain/cli/preflight.py +3 -3
  23. plain/cli/print.py +1 -1
  24. plain/cli/registry.py +10 -6
  25. plain/cli/scaffold.py +1 -1
  26. plain/cli/settings.py +1 -1
  27. plain/cli/shell.py +10 -7
  28. plain/cli/startup.py +3 -3
  29. plain/cli/urls.py +10 -4
  30. plain/cli/utils.py +2 -2
  31. plain/csrf/middleware.py +15 -5
  32. plain/csrf/views.py +11 -8
  33. plain/debug.py +5 -2
  34. plain/exceptions.py +19 -8
  35. plain/forms/__init__.py +1 -1
  36. plain/forms/boundfield.py +14 -7
  37. plain/forms/exceptions.py +1 -1
  38. plain/forms/fields.py +139 -97
  39. plain/forms/forms.py +55 -39
  40. plain/http/README.md +1 -1
  41. plain/http/__init__.py +4 -4
  42. plain/http/cookie.py +15 -7
  43. plain/http/multipartparser.py +50 -30
  44. plain/http/request.py +156 -108
  45. plain/http/response.py +99 -80
  46. plain/internal/__init__.py +8 -1
  47. plain/internal/files/base.py +34 -18
  48. plain/internal/files/locks.py +19 -11
  49. plain/internal/files/move.py +8 -3
  50. plain/internal/files/temp.py +23 -5
  51. plain/internal/files/uploadedfile.py +42 -26
  52. plain/internal/files/uploadhandler.py +50 -29
  53. plain/internal/files/utils.py +13 -6
  54. plain/internal/handlers/base.py +21 -7
  55. plain/internal/handlers/exception.py +19 -5
  56. plain/internal/handlers/wsgi.py +33 -21
  57. plain/internal/middleware/headers.py +11 -2
  58. plain/internal/middleware/hosts.py +12 -4
  59. plain/internal/middleware/https.py +13 -3
  60. plain/internal/middleware/slash.py +15 -5
  61. plain/json.py +2 -1
  62. plain/logs/configure.py +3 -1
  63. plain/logs/debug.py +16 -5
  64. plain/logs/formatters.py +6 -3
  65. plain/logs/loggers.py +56 -52
  66. plain/logs/utils.py +19 -9
  67. plain/packages/config.py +14 -6
  68. plain/packages/registry.py +27 -12
  69. plain/paginator.py +31 -21
  70. plain/preflight/checks.py +3 -1
  71. plain/preflight/files.py +3 -1
  72. plain/preflight/registry.py +25 -10
  73. plain/preflight/results.py +10 -4
  74. plain/preflight/security.py +7 -5
  75. plain/preflight/urls.py +4 -1
  76. plain/runtime/__init__.py +7 -6
  77. plain/runtime/global_settings.py +6 -9
  78. plain/runtime/user_settings.py +26 -17
  79. plain/runtime/utils.py +1 -1
  80. plain/signals/dispatch/dispatcher.py +39 -17
  81. plain/signing.py +49 -30
  82. plain/templates/jinja/__init__.py +13 -5
  83. plain/templates/jinja/environments.py +4 -3
  84. plain/templates/jinja/extensions.py +9 -3
  85. plain/templates/jinja/filters.py +7 -2
  86. plain/templates/jinja/globals.py +1 -1
  87. plain/test/client.py +249 -177
  88. plain/test/encoding.py +9 -6
  89. plain/test/exceptions.py +10 -2
  90. plain/urls/converters.py +13 -10
  91. plain/urls/patterns.py +32 -20
  92. plain/urls/resolvers.py +32 -22
  93. plain/urls/utils.py +5 -1
  94. plain/utils/cache.py +14 -8
  95. plain/utils/crypto.py +21 -5
  96. plain/utils/datastructures.py +84 -54
  97. plain/utils/dateparse.py +10 -7
  98. plain/utils/deconstruct.py +12 -4
  99. plain/utils/decorators.py +5 -1
  100. plain/utils/duration.py +8 -4
  101. plain/utils/encoding.py +14 -7
  102. plain/utils/functional.py +62 -47
  103. plain/utils/hashable.py +5 -1
  104. plain/utils/html.py +21 -14
  105. plain/utils/http.py +16 -9
  106. plain/utils/inspect.py +14 -6
  107. plain/utils/ipv6.py +7 -3
  108. plain/utils/itercompat.py +6 -1
  109. plain/utils/module_loading.py +7 -3
  110. plain/utils/regex_helper.py +23 -13
  111. plain/utils/safestring.py +14 -6
  112. plain/utils/text.py +34 -18
  113. plain/utils/timezone.py +30 -19
  114. plain/utils/tree.py +31 -18
  115. plain/validators.py +71 -44
  116. plain/views/base.py +16 -8
  117. plain/views/errors.py +11 -4
  118. plain/views/exceptions.py +4 -1
  119. plain/views/objects.py +15 -15
  120. plain/views/redirect.py +14 -10
  121. plain/views/templates.py +1 -1
  122. plain/wsgi.py +3 -1
  123. {plain-0.69.0.dist-info → plain-0.71.0.dist-info}/METADATA +1 -1
  124. plain-0.71.0.dist-info/RECORD +169 -0
  125. plain-0.69.0.dist-info/RECORD +0 -169
  126. {plain-0.69.0.dist-info → plain-0.71.0.dist-info}/WHEEL +0 -0
  127. {plain-0.69.0.dist-info → plain-0.71.0.dist-info}/entry_points.txt +0 -0
  128. {plain-0.69.0.dist-info → plain-0.71.0.dist-info}/licenses/LICENSE +0 -0
plain/http/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(self, value, charset, mime_encode=False):
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.reason += f", HTTP response headers must be in {charset} format"
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(self, key, value, salt="", **kwargs):
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(self, key, path="/", domain=None, samesite=None):
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__(self, *args, as_attachment=False, filename="", **kwargs):
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(
@@ -1,4 +1,11 @@
1
- def internalcode(obj):
1
+ from __future__ import annotations
2
+
3
+ from typing import TypeVar
4
+
5
+ T = TypeVar("T")
6
+
7
+
8
+ def internalcode(obj: T) -> T:
2
9
  """
3
10
  A decorator that simply marks a class or function as internal.
4
11
 
@@ -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__(self, exc_type, exc_value, tb):
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
- return line.endswith("\r" if isinstance(line, str) else b"\r")
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
- return line.endswith("\n" if isinstance(line, str) else b"\n")
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")