plain 0.68.1__py3-none-any.whl → 0.70.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. plain/AGENTS.md +1 -1
  2. plain/CHANGELOG.md +23 -0
  3. plain/assets/compile.py +20 -7
  4. plain/assets/finders.py +15 -11
  5. plain/assets/fingerprints.py +6 -5
  6. plain/assets/urls.py +1 -1
  7. plain/assets/views.py +23 -17
  8. plain/chores/registry.py +14 -9
  9. plain/cli/agent/__init__.py +1 -1
  10. plain/cli/agent/docs.py +7 -6
  11. plain/cli/agent/llmdocs.py +18 -8
  12. plain/cli/agent/md.py +19 -14
  13. plain/cli/agent/prompt.py +1 -1
  14. plain/cli/agent/request.py +37 -17
  15. plain/cli/build.py +2 -2
  16. plain/cli/changelog.py +8 -4
  17. plain/cli/chores.py +4 -4
  18. plain/cli/core.py +8 -5
  19. plain/cli/docs.py +2 -2
  20. plain/cli/formatting.py +10 -7
  21. plain/cli/output.py +6 -2
  22. plain/cli/preflight.py +3 -3
  23. plain/cli/print.py +1 -1
  24. plain/cli/registry.py +10 -6
  25. plain/cli/scaffold.py +1 -1
  26. plain/cli/settings.py +1 -1
  27. plain/cli/shell.py +10 -7
  28. plain/cli/startup.py +3 -3
  29. plain/cli/urls.py +10 -4
  30. plain/cli/utils.py +2 -2
  31. plain/csrf/middleware.py +15 -5
  32. plain/csrf/views.py +11 -8
  33. plain/debug.py +5 -2
  34. plain/exceptions.py +20 -51
  35. plain/forms/__init__.py +1 -1
  36. plain/forms/boundfield.py +14 -7
  37. plain/forms/exceptions.py +1 -1
  38. plain/forms/fields.py +139 -97
  39. plain/forms/forms.py +55 -39
  40. plain/http/cookie.py +15 -7
  41. plain/http/multipartparser.py +50 -30
  42. plain/http/request.py +97 -73
  43. plain/http/response.py +99 -80
  44. plain/internal/__init__.py +8 -1
  45. plain/internal/files/base.py +34 -18
  46. plain/internal/files/locks.py +19 -11
  47. plain/internal/files/move.py +8 -3
  48. plain/internal/files/temp.py +23 -5
  49. plain/internal/files/uploadedfile.py +42 -26
  50. plain/internal/files/uploadhandler.py +48 -27
  51. plain/internal/files/utils.py +13 -6
  52. plain/internal/handlers/base.py +20 -6
  53. plain/internal/handlers/exception.py +19 -5
  54. plain/internal/handlers/wsgi.py +30 -18
  55. plain/internal/middleware/headers.py +11 -2
  56. plain/internal/middleware/hosts.py +10 -2
  57. plain/internal/middleware/https.py +13 -3
  58. plain/internal/middleware/slash.py +15 -5
  59. plain/json.py +2 -1
  60. plain/logs/configure.py +3 -1
  61. plain/logs/debug.py +16 -5
  62. plain/logs/formatters.py +6 -3
  63. plain/logs/loggers.py +56 -52
  64. plain/logs/utils.py +19 -9
  65. plain/packages/config.py +14 -6
  66. plain/packages/registry.py +27 -12
  67. plain/paginator.py +31 -21
  68. plain/preflight/checks.py +3 -1
  69. plain/preflight/files.py +3 -1
  70. plain/preflight/registry.py +25 -10
  71. plain/preflight/results.py +10 -4
  72. plain/preflight/security.py +7 -5
  73. plain/preflight/urls.py +4 -1
  74. plain/runtime/__init__.py +4 -3
  75. plain/runtime/global_settings.py +1 -1
  76. plain/runtime/user_settings.py +26 -17
  77. plain/runtime/utils.py +1 -1
  78. plain/signals/dispatch/dispatcher.py +39 -17
  79. plain/signing.py +49 -30
  80. plain/templates/jinja/__init__.py +13 -5
  81. plain/templates/jinja/environments.py +4 -3
  82. plain/templates/jinja/extensions.py +9 -3
  83. plain/templates/jinja/filters.py +7 -2
  84. plain/templates/jinja/globals.py +1 -1
  85. plain/test/client.py +246 -174
  86. plain/test/encoding.py +9 -6
  87. plain/test/exceptions.py +10 -2
  88. plain/urls/converters.py +13 -10
  89. plain/urls/patterns.py +32 -20
  90. plain/urls/resolvers.py +32 -22
  91. plain/urls/utils.py +5 -1
  92. plain/utils/cache.py +14 -8
  93. plain/utils/crypto.py +21 -5
  94. plain/utils/datastructures.py +84 -54
  95. plain/utils/dateparse.py +10 -7
  96. plain/utils/deconstruct.py +12 -4
  97. plain/utils/decorators.py +5 -1
  98. plain/utils/duration.py +8 -4
  99. plain/utils/encoding.py +14 -7
  100. plain/utils/functional.py +62 -47
  101. plain/utils/hashable.py +5 -1
  102. plain/utils/html.py +21 -14
  103. plain/utils/http.py +16 -9
  104. plain/utils/inspect.py +14 -6
  105. plain/utils/ipv6.py +7 -3
  106. plain/utils/itercompat.py +6 -1
  107. plain/utils/module_loading.py +7 -3
  108. plain/utils/regex_helper.py +23 -13
  109. plain/utils/safestring.py +14 -6
  110. plain/utils/text.py +34 -18
  111. plain/utils/timezone.py +30 -19
  112. plain/utils/tree.py +31 -18
  113. plain/validators.py +71 -44
  114. plain/views/base.py +16 -6
  115. plain/views/errors.py +11 -4
  116. plain/views/exceptions.py +4 -1
  117. plain/views/objects.py +27 -17
  118. plain/views/redirect.py +14 -10
  119. plain/views/templates.py +1 -1
  120. plain/wsgi.py +3 -1
  121. {plain-0.68.1.dist-info → plain-0.70.0.dist-info}/METADATA +1 -1
  122. plain-0.70.0.dist-info/RECORD +169 -0
  123. plain-0.68.1.dist-info/RECORD +0 -169
  124. {plain-0.68.1.dist-info → plain-0.70.0.dist-info}/WHEEL +0 -0
  125. {plain-0.68.1.dist-info → plain-0.70.0.dist-info}/entry_points.txt +0 -0
  126. {plain-0.68.1.dist-info → plain-0.70.0.dist-info}/licenses/LICENSE +0 -0
plain/validators.py CHANGED
@@ -1,7 +1,11 @@
1
+ from __future__ import annotations
2
+
1
3
  import ipaddress
2
4
  import math
3
5
  import re
6
+ from collections.abc import Callable
4
7
  from pathlib import Path
8
+ from typing import Any
5
9
  from urllib.parse import urlsplit, urlunsplit
6
10
 
7
11
  from plain.exceptions import ValidationError
@@ -24,8 +28,13 @@ class RegexValidator:
24
28
  flags = 0
25
29
 
26
30
  def __init__(
27
- self, regex=None, message=None, code=None, inverse_match=None, flags=None
28
- ):
31
+ self,
32
+ regex: str | re.Pattern[str] | None = None,
33
+ message: str | None = None,
34
+ code: str | None = None,
35
+ inverse_match: bool | None = None,
36
+ flags: int | None = None,
37
+ ) -> None:
29
38
  if regex is not None:
30
39
  self.regex = regex
31
40
  if message is not None:
@@ -43,7 +52,7 @@ class RegexValidator:
43
52
 
44
53
  self.regex = _lazy_re_compile(self.regex, self.flags)
45
54
 
46
- def __call__(self, value):
55
+ def __call__(self, value: Any) -> None:
47
56
  """
48
57
  Validate that the input contains (or does *not* contain, if
49
58
  inverse_match is True) a match for the regular expression.
@@ -53,11 +62,11 @@ class RegexValidator:
53
62
  if invalid_input:
54
63
  raise ValidationError(self.message, code=self.code, params={"value": value})
55
64
 
56
- def __eq__(self, other):
65
+ def __eq__(self, other: object) -> bool:
57
66
  return (
58
67
  isinstance(other, RegexValidator)
59
- and self.regex.pattern == other.regex.pattern
60
- and self.regex.flags == other.regex.flags
68
+ and self.regex.pattern == other.regex.pattern # type: ignore[attr-defined]
69
+ and self.regex.flags == other.regex.flags # type: ignore[attr-defined]
61
70
  and (self.message == other.message)
62
71
  and (self.code == other.code)
63
72
  and (self.inverse_match == other.inverse_match)
@@ -104,12 +113,12 @@ class URLValidator(RegexValidator):
104
113
  schemes = ["http", "https", "ftp", "ftps"]
105
114
  unsafe_chars = frozenset("\t\r\n")
106
115
 
107
- def __init__(self, schemes=None, **kwargs):
116
+ def __init__(self, schemes: list[str] | None = None, **kwargs: Any) -> None:
108
117
  super().__init__(**kwargs)
109
118
  if schemes is not None:
110
119
  self.schemes = schemes
111
120
 
112
- def __call__(self, value):
121
+ def __call__(self, value: Any) -> None:
113
122
  if not isinstance(value, str):
114
123
  raise ValidationError(self.message, code=self.code, params={"value": value})
115
124
  if self.unsafe_chars.intersection(value):
@@ -182,7 +191,12 @@ class EmailValidator:
182
191
  )
183
192
  domain_allowlist = ["localhost"]
184
193
 
185
- def __init__(self, message=None, code=None, allowlist=None):
194
+ def __init__(
195
+ self,
196
+ message: str | None = None,
197
+ code: str | None = None,
198
+ allowlist: list[str] | None = None,
199
+ ) -> None:
186
200
  if message is not None:
187
201
  self.message = message
188
202
  if code is not None:
@@ -190,7 +204,7 @@ class EmailValidator:
190
204
  if allowlist is not None:
191
205
  self.domain_allowlist = allowlist
192
206
 
193
- def __call__(self, value):
207
+ def __call__(self, value: Any) -> None:
194
208
  if not value or "@" not in value:
195
209
  raise ValidationError(self.message, code=self.code, params={"value": value})
196
210
 
@@ -209,10 +223,11 @@ class EmailValidator:
209
223
  pass
210
224
  else:
211
225
  if self.validate_domain_part(domain_part):
212
- return
226
+ return None
213
227
  raise ValidationError(self.message, code=self.code, params={"value": value})
228
+ return None
214
229
 
215
- def validate_domain_part(self, domain_part):
230
+ def validate_domain_part(self, domain_part: str) -> bool:
216
231
  if self.domain_regex.match(domain_part):
217
232
  return True
218
233
 
@@ -226,7 +241,7 @@ class EmailValidator:
226
241
  pass
227
242
  return False
228
243
 
229
- def __eq__(self, other):
244
+ def __eq__(self, other: object) -> bool:
230
245
  return (
231
246
  isinstance(other, EmailValidator)
232
247
  and (self.domain_allowlist == other.domain_allowlist)
@@ -238,7 +253,7 @@ class EmailValidator:
238
253
  validate_email = EmailValidator()
239
254
 
240
255
 
241
- def validate_ipv4_address(value):
256
+ def validate_ipv4_address(value: str) -> None:
242
257
  try:
243
258
  ipaddress.IPv4Address(value)
244
259
  except ValueError:
@@ -247,14 +262,14 @@ def validate_ipv4_address(value):
247
262
  )
248
263
 
249
264
 
250
- def validate_ipv6_address(value):
265
+ def validate_ipv6_address(value: str) -> None:
251
266
  if not is_valid_ipv6_address(value):
252
267
  raise ValidationError(
253
268
  "Enter a valid IPv6 address.", code="invalid", params={"value": value}
254
269
  )
255
270
 
256
271
 
257
- def validate_ipv46_address(value):
272
+ def validate_ipv46_address(value: str) -> None:
258
273
  try:
259
274
  validate_ipv4_address(value)
260
275
  except ValidationError:
@@ -275,7 +290,9 @@ ip_address_validator_map = {
275
290
  }
276
291
 
277
292
 
278
- def ip_address_validators(protocol, unpack_ipv4):
293
+ def ip_address_validators(
294
+ protocol: str, unpack_ipv4: bool
295
+ ) -> tuple[list[Callable[[str], None]], str]:
279
296
  """
280
297
  Depending on the given parameters, return the appropriate validators for
281
298
  the GenericIPAddressField.
@@ -292,14 +309,19 @@ def ip_address_validators(protocol, unpack_ipv4):
292
309
  )
293
310
 
294
311
 
295
- def int_list_validator(sep=",", message=None, code="invalid", allow_negative=False):
312
+ def int_list_validator(
313
+ sep: str = ",",
314
+ message: str | None = None,
315
+ code: str = "invalid",
316
+ allow_negative: bool = False,
317
+ ) -> RegexValidator:
296
318
  regexp = _lazy_re_compile(
297
319
  r"^{neg}\d+(?:{sep}{neg}\d+)*\Z".format(
298
320
  neg="(-)?" if allow_negative else "",
299
321
  sep=re.escape(sep),
300
322
  )
301
323
  )
302
- return RegexValidator(regexp, message=message, code=code)
324
+ return RegexValidator(regexp, message=message, code=code) # type: ignore[arg-type]
303
325
 
304
326
 
305
327
  validate_comma_separated_integer_list = int_list_validator(
@@ -312,12 +334,12 @@ class BaseValidator:
312
334
  message = "Ensure this value is %(limit_value)s (it is %(show_value)s)."
313
335
  code = "limit_value"
314
336
 
315
- def __init__(self, limit_value, message=None):
337
+ def __init__(self, limit_value: Any, message: str | None = None) -> None:
316
338
  self.limit_value = limit_value
317
339
  if message:
318
340
  self.message = message
319
341
 
320
- def __call__(self, value):
342
+ def __call__(self, value: Any) -> None:
321
343
  cleaned = self.clean(value)
322
344
  limit_value = (
323
345
  self.limit_value() if callable(self.limit_value) else self.limit_value
@@ -326,7 +348,7 @@ class BaseValidator:
326
348
  if self.compare(cleaned, limit_value):
327
349
  raise ValidationError(self.message, code=self.code, params=params)
328
350
 
329
- def __eq__(self, other):
351
+ def __eq__(self, other: object) -> bool:
330
352
  if not isinstance(other, self.__class__):
331
353
  return NotImplemented
332
354
  return (
@@ -335,10 +357,10 @@ class BaseValidator:
335
357
  and self.code == other.code
336
358
  )
337
359
 
338
- def compare(self, a, b):
360
+ def compare(self, a: Any, b: Any) -> bool:
339
361
  return a is not b
340
362
 
341
- def clean(self, x):
363
+ def clean(self, x: Any) -> Any:
342
364
  return x
343
365
 
344
366
 
@@ -347,7 +369,7 @@ class MaxValueValidator(BaseValidator):
347
369
  message = "Ensure this value is less than or equal to %(limit_value)s."
348
370
  code = "max_value"
349
371
 
350
- def compare(self, a, b):
372
+ def compare(self, a: Any, b: Any) -> bool:
351
373
  return a > b
352
374
 
353
375
 
@@ -356,7 +378,7 @@ class MinValueValidator(BaseValidator):
356
378
  message = "Ensure this value is greater than or equal to %(limit_value)s."
357
379
  code = "min_value"
358
380
 
359
- def compare(self, a, b):
381
+ def compare(self, a: Any, b: Any) -> bool:
360
382
  return a < b
361
383
 
362
384
 
@@ -365,7 +387,7 @@ class StepValueValidator(BaseValidator):
365
387
  message = "Ensure this value is a multiple of step size %(limit_value)s."
366
388
  code = "step_size"
367
389
 
368
- def compare(self, a, b):
390
+ def compare(self, a: Any, b: Any) -> bool:
369
391
  return not math.isclose(math.remainder(a, b), 0, abs_tol=1e-9)
370
392
 
371
393
 
@@ -380,10 +402,10 @@ class MinLengthValidator(BaseValidator):
380
402
  )
381
403
  code = "min_length"
382
404
 
383
- def compare(self, a, b):
405
+ def compare(self, a: Any, b: Any) -> bool:
384
406
  return a < b
385
407
 
386
- def clean(self, x):
408
+ def clean(self, x: Any) -> int:
387
409
  return len(x)
388
410
 
389
411
 
@@ -398,10 +420,10 @@ class MaxLengthValidator(BaseValidator):
398
420
  )
399
421
  code = "max_length"
400
422
 
401
- def compare(self, a, b):
423
+ def compare(self, a: Any, b: Any) -> bool:
402
424
  return a > b
403
425
 
404
- def clean(self, x):
426
+ def clean(self, x: Any) -> int:
405
427
  return len(x)
406
428
 
407
429
 
@@ -433,11 +455,11 @@ class DecimalValidator:
433
455
  ),
434
456
  }
435
457
 
436
- def __init__(self, max_digits, decimal_places):
458
+ def __init__(self, max_digits: int | None, decimal_places: int | None) -> None:
437
459
  self.max_digits = max_digits
438
460
  self.decimal_places = decimal_places
439
461
 
440
- def __call__(self, value):
462
+ def __call__(self, value: Any) -> None:
441
463
  digit_tuple, exponent = value.as_tuple()[1:]
442
464
  if exponent in {"F", "n", "N"}:
443
465
  raise ValidationError(
@@ -485,7 +507,7 @@ class DecimalValidator:
485
507
  params={"max": (self.max_digits - self.decimal_places), "value": value},
486
508
  )
487
509
 
488
- def __eq__(self, other):
510
+ def __eq__(self, other: object) -> bool:
489
511
  return (
490
512
  isinstance(other, self.__class__)
491
513
  and self.max_digits == other.max_digits
@@ -495,10 +517,15 @@ class DecimalValidator:
495
517
 
496
518
  @deconstructible
497
519
  class FileExtensionValidator:
498
- message = "File extension “%(extension)s is not allowed. Allowed extensions are: %(allowed_extensions)s."
520
+ message = 'File extension "%(extension)s" is not allowed. Allowed extensions are: %(allowed_extensions)s.'
499
521
  code = "invalid_extension"
500
522
 
501
- def __init__(self, allowed_extensions=None, message=None, code=None):
523
+ def __init__(
524
+ self,
525
+ allowed_extensions: list[str] | None = None,
526
+ message: str | None = None,
527
+ code: str | None = None,
528
+ ) -> None:
502
529
  if allowed_extensions is not None:
503
530
  allowed_extensions = [
504
531
  allowed_extension.lower() for allowed_extension in allowed_extensions
@@ -509,7 +536,7 @@ class FileExtensionValidator:
509
536
  if code is not None:
510
537
  self.code = code
511
538
 
512
- def __call__(self, value):
539
+ def __call__(self, value: Any) -> None:
513
540
  extension = Path(value.name).suffix[1:].lower()
514
541
  if (
515
542
  self.allowed_extensions is not None
@@ -525,7 +552,7 @@ class FileExtensionValidator:
525
552
  },
526
553
  )
527
554
 
528
- def __eq__(self, other):
555
+ def __eq__(self, other: object) -> bool:
529
556
  return (
530
557
  isinstance(other, self.__class__)
531
558
  and self.allowed_extensions == other.allowed_extensions
@@ -534,9 +561,9 @@ class FileExtensionValidator:
534
561
  )
535
562
 
536
563
 
537
- def get_available_image_extensions():
564
+ def get_available_image_extensions() -> list[str]:
538
565
  try:
539
- from PIL import Image
566
+ from PIL import Image # type: ignore[import-untyped]
540
567
  except ImportError:
541
568
  return []
542
569
  else:
@@ -544,7 +571,7 @@ def get_available_image_extensions():
544
571
  return [ext.lower()[1:] for ext in Image.EXTENSION]
545
572
 
546
573
 
547
- def validate_image_file_extension(value):
574
+ def validate_image_file_extension(value: Any) -> None:
548
575
  return FileExtensionValidator(allowed_extensions=get_available_image_extensions())(
549
576
  value
550
577
  )
@@ -557,17 +584,17 @@ class ProhibitNullCharactersValidator:
557
584
  message = "Null characters are not allowed."
558
585
  code = "null_characters_not_allowed"
559
586
 
560
- def __init__(self, message=None, code=None):
587
+ def __init__(self, message: str | None = None, code: str | None = None) -> None:
561
588
  if message is not None:
562
589
  self.message = message
563
590
  if code is not None:
564
591
  self.code = code
565
592
 
566
- def __call__(self, value):
593
+ def __call__(self, value: Any) -> None:
567
594
  if "\x00" in str(value):
568
595
  raise ValidationError(self.message, code=self.code, params={"value": value})
569
596
 
570
- def __eq__(self, other):
597
+ def __eq__(self, other: object) -> bool:
571
598
  return (
572
599
  isinstance(other, self.__class__)
573
600
  and self.message == other.message
plain/views/base.py CHANGED
@@ -1,5 +1,9 @@
1
+ from __future__ import annotations
2
+
1
3
  import logging
4
+ from collections.abc import Callable
2
5
  from http import HTTPMethod
6
+ from typing import Any
3
7
 
4
8
  from opentelemetry import trace
5
9
  from opentelemetry.semconv._incubating.attributes.code_attributes import (
@@ -33,7 +37,9 @@ class View:
33
37
  # View.as_view(example="foo") usage can be customized by defining your own __init__ method.
34
38
  # def __init__(self, *args, **kwargs):
35
39
 
36
- def setup(self, request: HttpRequest, *url_args, **url_kwargs) -> None:
40
+ def setup(
41
+ self, request: HttpRequest, *url_args: object, **url_kwargs: object
42
+ ) -> None:
37
43
  if hasattr(self, "get") and not hasattr(self, "head"):
38
44
  self.head = self.get
39
45
 
@@ -42,8 +48,12 @@ class View:
42
48
  self.url_kwargs = url_kwargs
43
49
 
44
50
  @classonlymethod
45
- def as_view(cls, *init_args, **init_kwargs):
46
- def view(request, *url_args, **url_kwargs):
51
+ def as_view(
52
+ cls, *init_args: object, **init_kwargs: object
53
+ ) -> Callable[[HttpRequest, Any, Any], ResponseBase]:
54
+ def view(
55
+ request: HttpRequest, *url_args: object, **url_kwargs: object
56
+ ) -> ResponseBase:
47
57
  with tracer.start_as_current_span(
48
58
  f"{cls.__name__}",
49
59
  kind=trace.SpanKind.INTERNAL,
@@ -62,11 +72,11 @@ class View:
62
72
  )
63
73
  return response
64
74
 
65
- view.view_class = cls
75
+ view.view_class = cls # type: ignore[attr-defined]
66
76
 
67
77
  return view
68
78
 
69
- def get_request_handler(self) -> callable:
79
+ def get_request_handler(self) -> Callable[[], Any] | None:
70
80
  """Return the handler for the current request method."""
71
81
 
72
82
  if not self.request.method:
@@ -93,7 +103,7 @@ class View:
93
103
 
94
104
  return self.convert_value_to_response(result)
95
105
 
96
- def convert_value_to_response(self, value) -> ResponseBase:
106
+ def convert_value_to_response(self, value: object) -> ResponseBase:
97
107
  """Convert a return value to a Response."""
98
108
  if isinstance(value, ResponseBase):
99
109
  return value
plain/views/errors.py CHANGED
@@ -1,3 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
1
6
  from plain.http import ResponseBase
2
7
  from plain.templates import TemplateFileMissing
3
8
 
@@ -7,7 +12,9 @@ from .templates import TemplateView
7
12
  class ErrorView(TemplateView):
8
13
  status_code: int
9
14
 
10
- def __init__(self, *, status_code=None, exception=None) -> None:
15
+ def __init__(
16
+ self, *, status_code: int | None = None, exception: Any | None = None
17
+ ) -> None:
11
18
  # Allow creating an ErrorView with a status code
12
19
  # e.g. ErrorView.as_view(status_code=404)
13
20
  self.status_code = status_code or self.status_code
@@ -15,7 +22,7 @@ class ErrorView(TemplateView):
15
22
  # Allow creating an ErrorView with an exception
16
23
  self.exception = exception
17
24
 
18
- def get_template_context(self):
25
+ def get_template_context(self) -> dict:
19
26
  context = super().get_template_context()
20
27
  context["status_code"] = self.status_code
21
28
  context["exception"] = self.exception
@@ -24,7 +31,7 @@ class ErrorView(TemplateView):
24
31
  def get_template_names(self) -> list[str]:
25
32
  return [f"{self.status_code}.html", "error.html"]
26
33
 
27
- def get_request_handler(self):
34
+ def get_request_handler(self) -> Callable[[], Any]:
28
35
  return self.get # All methods (post, patch, etc.) will use the get()
29
36
 
30
37
  def get_response(self) -> ResponseBase:
@@ -33,7 +40,7 @@ class ErrorView(TemplateView):
33
40
  response.status_code = self.status_code
34
41
  return response
35
42
 
36
- def get(self):
43
+ def get(self) -> ResponseBase:
37
44
  try:
38
45
  return super().get()
39
46
  except TemplateFileMissing:
plain/views/exceptions.py CHANGED
@@ -1,4 +1,7 @@
1
+ from plain.http import ResponseBase
2
+
3
+
1
4
  class ResponseException(Exception):
2
- def __init__(self, response):
5
+ def __init__(self, response: ResponseBase) -> None:
3
6
  self.response = response
4
7
  super().__init__(response)
plain/views/objects.py CHANGED
@@ -1,7 +1,13 @@
1
1
  from functools import cached_property
2
2
  from typing import Any
3
3
 
4
- from plain.exceptions import ImproperlyConfigured, ObjectDoesNotExist
4
+ from plain.exceptions import ImproperlyConfigured
5
+
6
+ try:
7
+ from plain.models.exceptions import ObjectDoesNotExist
8
+ except ImportError:
9
+ ObjectDoesNotExist = None # type: ignore[assignment]
10
+
5
11
  from plain.forms import Form
6
12
  from plain.http import Http404
7
13
 
@@ -15,7 +21,7 @@ class CreateView(FormView):
15
21
  """
16
22
 
17
23
  # TODO? would rather you have to specify this...
18
- def get_success_url(self, form):
24
+ def get_success_url(self, form: Form) -> str:
19
25
  """Return the URL to redirect to after processing a valid form."""
20
26
  if self.success_url:
21
27
  url = self.success_url.format(**self.object.__dict__)
@@ -29,9 +35,9 @@ class CreateView(FormView):
29
35
  )
30
36
  return url
31
37
 
32
- def form_valid(self, form):
38
+ def form_valid(self, form: Form) -> Any:
33
39
  """If the form is valid, save the associated model."""
34
- self.object = form.save()
40
+ self.object = form.save() # type: ignore[attr-defined]
35
41
  return super().form_valid(form)
36
42
 
37
43
 
@@ -42,8 +48,12 @@ class ObjectTemplateViewMixin:
42
48
  def object(self) -> Any:
43
49
  try:
44
50
  obj = self.get_object()
45
- except ObjectDoesNotExist:
46
- raise Http404
51
+ except Exception as e:
52
+ # If ObjectDoesNotExist is available and this is that exception, raise 404
53
+ if ObjectDoesNotExist and isinstance(e, ObjectDoesNotExist):
54
+ raise Http404
55
+ # Otherwise, let other exceptions bubble up
56
+ raise
47
57
 
48
58
  # Also raise 404 if get_object() returns None
49
59
  if not obj:
@@ -81,7 +91,7 @@ class DetailView(ObjectTemplateViewMixin, TemplateView):
81
91
  class UpdateView(ObjectTemplateViewMixin, FormView):
82
92
  """View for updating an object, with a response rendered by a template."""
83
93
 
84
- def get_success_url(self, form):
94
+ def get_success_url(self, form: Form) -> str:
85
95
  """Return the URL to redirect to after processing a valid form."""
86
96
  if self.success_url:
87
97
  url = self.success_url.format(**self.object.__dict__)
@@ -95,12 +105,12 @@ class UpdateView(ObjectTemplateViewMixin, FormView):
95
105
  )
96
106
  return url
97
107
 
98
- def form_valid(self, form):
108
+ def form_valid(self, form: Form) -> Any:
99
109
  """If the form is valid, save the associated model."""
100
- form.save()
110
+ form.save() # type: ignore[attr-defined]
101
111
  return super().form_valid(form)
102
112
 
103
- def get_form_kwargs(self):
113
+ def get_form_kwargs(self) -> dict:
104
114
  """Return the keyword arguments for instantiating the form."""
105
115
  kwargs = super().get_form_kwargs()
106
116
  kwargs.update({"instance": self.object})
@@ -114,24 +124,24 @@ class DeleteView(ObjectTemplateViewMixin, FormView):
114
124
  """
115
125
 
116
126
  class EmptyDeleteForm(Form):
117
- def __init__(self, instance, *args, **kwargs):
127
+ def __init__(self, instance: Any, *args: object, **kwargs: object) -> None:
118
128
  self.instance = instance
119
129
  super().__init__(*args, **kwargs)
120
130
 
121
- def save(self):
131
+ def save(self) -> None:
122
132
  self.instance.delete()
123
133
 
124
134
  form_class = EmptyDeleteForm
125
135
 
126
- def get_form_kwargs(self):
136
+ def get_form_kwargs(self) -> dict:
127
137
  """Return the keyword arguments for instantiating the form."""
128
138
  kwargs = super().get_form_kwargs()
129
139
  kwargs.update({"instance": self.object})
130
140
  return kwargs
131
141
 
132
- def form_valid(self, form):
142
+ def form_valid(self, form: Form) -> Any:
133
143
  """If the form is valid, save the associated model."""
134
- form.save()
144
+ form.save() # type: ignore[attr-defined]
135
145
  return super().form_valid(form)
136
146
 
137
147
 
@@ -144,10 +154,10 @@ class ListView(TemplateView):
144
154
  context_object_name = ""
145
155
 
146
156
  @cached_property
147
- def objects(self):
157
+ def objects(self) -> Any:
148
158
  return self.get_objects()
149
159
 
150
- def get_objects(self):
160
+ def get_objects(self) -> Any:
151
161
  raise NotImplementedError(
152
162
  f"get_objects() is not implemented on {self.__class__.__name__}"
153
163
  )
plain/views/redirect.py CHANGED
@@ -13,8 +13,12 @@ class RedirectView(View):
13
13
  preserve_query_params = False
14
14
 
15
15
  def __init__(
16
- self, url=None, status_code=None, url_name=None, preserve_query_params=None
17
- ):
16
+ self,
17
+ url: str | None = None,
18
+ status_code: int | None = None,
19
+ url_name: str | None = None,
20
+ preserve_query_params: bool | None = None,
21
+ ) -> None:
18
22
  # Allow attributes to be set in RedirectView.as_view(url="...", status_code=301, etc.)
19
23
  self.url = url or self.url
20
24
  self.status_code = status_code if status_code is not None else self.status_code
@@ -25,7 +29,7 @@ class RedirectView(View):
25
29
  else self.preserve_query_params
26
30
  )
27
31
 
28
- def get_redirect_url(self):
32
+ def get_redirect_url(self) -> str:
29
33
  """
30
34
  Return the URL redirect to. Keyword arguments from the URL pattern
31
35
  match generating the redirect request are provided as kwargs to this
@@ -43,24 +47,24 @@ class RedirectView(View):
43
47
  url = f"{url}?{args}"
44
48
  return url
45
49
 
46
- def get(self):
50
+ def get(self) -> ResponseRedirect:
47
51
  url = self.get_redirect_url()
48
52
  return ResponseRedirect(url, status_code=self.status_code)
49
53
 
50
- def head(self):
54
+ def head(self) -> ResponseRedirect:
51
55
  return self.get()
52
56
 
53
- def post(self):
57
+ def post(self) -> ResponseRedirect:
54
58
  return self.get()
55
59
 
56
- def options(self):
60
+ def options(self) -> ResponseRedirect:
57
61
  return self.get()
58
62
 
59
- def delete(self):
63
+ def delete(self) -> ResponseRedirect:
60
64
  return self.get()
61
65
 
62
- def put(self):
66
+ def put(self) -> ResponseRedirect:
63
67
  return self.get()
64
68
 
65
- def patch(self):
69
+ def patch(self) -> ResponseRedirect:
66
70
  return self.get()
plain/views/templates.py CHANGED
@@ -13,7 +13,7 @@ class TemplateView(View):
13
13
 
14
14
  template_name: str | None = None
15
15
 
16
- def __init__(self, template_name=None):
16
+ def __init__(self, template_name: str | None = None) -> None:
17
17
  # Allow template_name to be passed in as_view()
18
18
  self.template_name = template_name or self.template_name
19
19
 
plain/wsgi.py CHANGED
@@ -1,8 +1,10 @@
1
+ from __future__ import annotations
2
+
1
3
  import plain.runtime
2
4
  from plain.internal.handlers.wsgi import WSGIHandler
3
5
 
4
6
 
5
- def _get_wsgi_application():
7
+ def _get_wsgi_application() -> WSGIHandler:
6
8
  plain.runtime.setup()
7
9
  return WSGIHandler()
8
10
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.68.1
3
+ Version: 0.70.0
4
4
  Summary: A web framework for building products with Python.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-File: LICENSE