plain 0.68.0__py3-none-any.whl → 0.101.2__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 (195) hide show
  1. plain/CHANGELOG.md +656 -1
  2. plain/README.md +1 -1
  3. plain/assets/compile.py +25 -12
  4. plain/assets/finders.py +24 -17
  5. plain/assets/fingerprints.py +10 -7
  6. plain/assets/urls.py +1 -1
  7. plain/assets/views.py +47 -33
  8. plain/chores/README.md +25 -23
  9. plain/chores/__init__.py +2 -1
  10. plain/chores/core.py +27 -0
  11. plain/chores/registry.py +23 -36
  12. plain/cli/README.md +185 -16
  13. plain/cli/__init__.py +2 -1
  14. plain/cli/agent.py +236 -0
  15. plain/cli/build.py +7 -8
  16. plain/cli/changelog.py +11 -5
  17. plain/cli/chores.py +32 -34
  18. plain/cli/core.py +110 -26
  19. plain/cli/docs.py +52 -11
  20. plain/cli/formatting.py +40 -17
  21. plain/cli/install.py +10 -54
  22. plain/cli/{agent/llmdocs.py → llmdocs.py} +21 -9
  23. plain/cli/output.py +6 -2
  24. plain/cli/preflight.py +27 -75
  25. plain/cli/print.py +4 -4
  26. plain/cli/registry.py +96 -10
  27. plain/cli/{agent/request.py → request.py} +67 -33
  28. plain/cli/runtime.py +45 -0
  29. plain/cli/scaffold.py +2 -7
  30. plain/cli/server.py +153 -0
  31. plain/cli/settings.py +53 -49
  32. plain/cli/shell.py +15 -12
  33. plain/cli/startup.py +9 -8
  34. plain/cli/upgrade.py +17 -104
  35. plain/cli/urls.py +12 -7
  36. plain/cli/utils.py +3 -3
  37. plain/csrf/README.md +65 -40
  38. plain/csrf/middleware.py +53 -43
  39. plain/debug.py +5 -2
  40. plain/exceptions.py +22 -114
  41. plain/forms/README.md +453 -24
  42. plain/forms/__init__.py +55 -4
  43. plain/forms/boundfield.py +15 -8
  44. plain/forms/exceptions.py +1 -1
  45. plain/forms/fields.py +346 -143
  46. plain/forms/forms.py +75 -45
  47. plain/http/README.md +356 -9
  48. plain/http/__init__.py +41 -26
  49. plain/http/cookie.py +15 -7
  50. plain/http/exceptions.py +65 -0
  51. plain/http/middleware.py +32 -0
  52. plain/http/multipartparser.py +99 -88
  53. plain/http/request.py +362 -250
  54. plain/http/response.py +99 -197
  55. plain/internal/__init__.py +8 -1
  56. plain/internal/files/base.py +35 -19
  57. plain/internal/files/locks.py +19 -11
  58. plain/internal/files/move.py +8 -3
  59. plain/internal/files/temp.py +25 -6
  60. plain/internal/files/uploadedfile.py +47 -28
  61. plain/internal/files/uploadhandler.py +64 -58
  62. plain/internal/files/utils.py +24 -10
  63. plain/internal/handlers/base.py +34 -23
  64. plain/internal/handlers/exception.py +68 -65
  65. plain/internal/handlers/wsgi.py +65 -54
  66. plain/internal/middleware/headers.py +37 -11
  67. plain/internal/middleware/hosts.py +11 -8
  68. plain/internal/middleware/https.py +17 -7
  69. plain/internal/middleware/slash.py +14 -9
  70. plain/internal/reloader.py +77 -0
  71. plain/json.py +2 -1
  72. plain/logs/README.md +161 -62
  73. plain/logs/__init__.py +1 -1
  74. plain/logs/{loggers.py → app.py} +71 -67
  75. plain/logs/configure.py +63 -14
  76. plain/logs/debug.py +17 -6
  77. plain/logs/filters.py +15 -0
  78. plain/logs/formatters.py +7 -4
  79. plain/packages/README.md +105 -23
  80. plain/packages/config.py +15 -7
  81. plain/packages/registry.py +27 -16
  82. plain/paginator.py +31 -21
  83. plain/preflight/README.md +209 -24
  84. plain/preflight/__init__.py +1 -0
  85. plain/preflight/checks.py +3 -1
  86. plain/preflight/files.py +3 -1
  87. plain/preflight/registry.py +26 -11
  88. plain/preflight/results.py +15 -7
  89. plain/preflight/security.py +15 -13
  90. plain/preflight/settings.py +54 -0
  91. plain/preflight/urls.py +4 -1
  92. plain/runtime/README.md +115 -47
  93. plain/runtime/__init__.py +10 -6
  94. plain/runtime/global_settings.py +34 -25
  95. plain/runtime/secret.py +20 -0
  96. plain/runtime/user_settings.py +110 -38
  97. plain/runtime/utils.py +1 -1
  98. plain/server/LICENSE +35 -0
  99. plain/server/README.md +155 -0
  100. plain/server/__init__.py +9 -0
  101. plain/server/app.py +52 -0
  102. plain/server/arbiter.py +555 -0
  103. plain/server/config.py +118 -0
  104. plain/server/errors.py +31 -0
  105. plain/server/glogging.py +292 -0
  106. plain/server/http/__init__.py +12 -0
  107. plain/server/http/body.py +283 -0
  108. plain/server/http/errors.py +155 -0
  109. plain/server/http/message.py +400 -0
  110. plain/server/http/parser.py +70 -0
  111. plain/server/http/unreader.py +88 -0
  112. plain/server/http/wsgi.py +421 -0
  113. plain/server/pidfile.py +92 -0
  114. plain/server/sock.py +240 -0
  115. plain/server/util.py +317 -0
  116. plain/server/workers/__init__.py +6 -0
  117. plain/server/workers/base.py +304 -0
  118. plain/server/workers/sync.py +212 -0
  119. plain/server/workers/thread.py +399 -0
  120. plain/server/workers/workertmp.py +50 -0
  121. plain/signals/README.md +170 -1
  122. plain/signals/__init__.py +0 -1
  123. plain/signals/dispatch/dispatcher.py +49 -27
  124. plain/signing.py +131 -35
  125. plain/skills/README.md +36 -0
  126. plain/skills/plain-docs/SKILL.md +25 -0
  127. plain/skills/plain-install/SKILL.md +26 -0
  128. plain/skills/plain-request/SKILL.md +39 -0
  129. plain/skills/plain-shell/SKILL.md +24 -0
  130. plain/skills/plain-upgrade/SKILL.md +35 -0
  131. plain/templates/README.md +211 -20
  132. plain/templates/jinja/__init__.py +13 -5
  133. plain/templates/jinja/environments.py +5 -4
  134. plain/templates/jinja/extensions.py +12 -5
  135. plain/templates/jinja/filters.py +7 -2
  136. plain/templates/jinja/globals.py +2 -2
  137. plain/test/README.md +184 -22
  138. plain/test/client.py +340 -222
  139. plain/test/encoding.py +9 -6
  140. plain/test/exceptions.py +7 -2
  141. plain/urls/README.md +157 -73
  142. plain/urls/converters.py +18 -15
  143. plain/urls/exceptions.py +2 -2
  144. plain/urls/patterns.py +38 -22
  145. plain/urls/resolvers.py +35 -25
  146. plain/urls/utils.py +5 -1
  147. plain/utils/README.md +250 -3
  148. plain/utils/cache.py +17 -11
  149. plain/utils/crypto.py +21 -5
  150. plain/utils/datastructures.py +89 -56
  151. plain/utils/dateparse.py +9 -6
  152. plain/utils/deconstruct.py +15 -7
  153. plain/utils/decorators.py +5 -1
  154. plain/utils/dotenv.py +373 -0
  155. plain/utils/duration.py +8 -4
  156. plain/utils/encoding.py +14 -7
  157. plain/utils/functional.py +66 -49
  158. plain/utils/hashable.py +5 -1
  159. plain/utils/html.py +36 -22
  160. plain/utils/http.py +16 -9
  161. plain/utils/inspect.py +14 -6
  162. plain/utils/ipv6.py +7 -3
  163. plain/utils/itercompat.py +6 -1
  164. plain/utils/module_loading.py +7 -3
  165. plain/utils/regex_helper.py +37 -23
  166. plain/utils/safestring.py +14 -6
  167. plain/utils/text.py +41 -23
  168. plain/utils/timezone.py +33 -22
  169. plain/utils/tree.py +35 -19
  170. plain/validators.py +94 -52
  171. plain/views/README.md +156 -79
  172. plain/views/__init__.py +0 -1
  173. plain/views/base.py +25 -18
  174. plain/views/errors.py +13 -5
  175. plain/views/exceptions.py +4 -1
  176. plain/views/forms.py +6 -6
  177. plain/views/objects.py +52 -49
  178. plain/views/redirect.py +18 -15
  179. plain/views/templates.py +5 -3
  180. plain/wsgi.py +3 -1
  181. {plain-0.68.0.dist-info → plain-0.101.2.dist-info}/METADATA +4 -2
  182. plain-0.101.2.dist-info/RECORD +201 -0
  183. {plain-0.68.0.dist-info → plain-0.101.2.dist-info}/WHEEL +1 -1
  184. plain-0.101.2.dist-info/entry_points.txt +2 -0
  185. plain/AGENTS.md +0 -18
  186. plain/cli/agent/__init__.py +0 -20
  187. plain/cli/agent/docs.py +0 -80
  188. plain/cli/agent/md.py +0 -87
  189. plain/cli/agent/prompt.py +0 -45
  190. plain/csrf/views.py +0 -31
  191. plain/logs/utils.py +0 -46
  192. plain/templates/AGENTS.md +0 -3
  193. plain-0.68.0.dist-info/RECORD +0 -169
  194. plain-0.68.0.dist-info/entry_points.txt +0 -5
  195. {plain-0.68.0.dist-info → plain-0.101.2.dist-info}/licenses/LICENSE +0 -0
plain/csrf/README.md CHANGED
@@ -1,26 +1,44 @@
1
- # CSRF
1
+ # plain.csrf
2
2
 
3
3
  **Cross-Site Request Forgery (CSRF) protection using modern request headers.**
4
4
 
5
5
  - [Overview](#overview)
6
- - [Usage](#usage)
7
- - [CSRF Exempt Paths](#csrf-exempt-paths)
8
- - [Trusted Origins](#trusted-origins)
6
+ - [How it works](#how-it-works)
7
+ - [Exempt paths](#exempt-paths)
8
+ - [Trusted origins](#trusted-origins)
9
+ - [FAQs](#faqs)
10
+ - [Installation](#installation)
9
11
 
10
12
  ## Overview
11
13
 
12
- Plain provides modern CSRF protection based on [Filippo Valsorda's 2025 research](https://words.filippo.io/csrf/) using `Sec-Fetch-Site` headers and origin validation.
14
+ Plain provides modern CSRF protection based on [Filippo Valsorda's 2025 research](https://words.filippo.io/csrf/) using `Sec-Fetch-Site` headers and origin validation. The protection is automatic and requires no changes to your forms or templates.
13
15
 
14
- ## Usage
16
+ The [`CsrfViewMiddleware`](./middleware.py#CsrfViewMiddleware) runs on every request and blocks cross-origin `POST`, `PUT`, `PATCH`, and `DELETE` requests. Safe methods like `GET`, `HEAD`, and `OPTIONS` are always allowed.
15
17
 
16
- The `CsrfViewMiddleware` is [automatically installed](../internal/handlers/base.py#BUILTIN_BEFORE_MIDDLEWARE) and works transparently. **No changes to your forms or templates are needed.**
18
+ ## How it works
17
19
 
18
- ## CSRF Exempt Paths
20
+ The middleware uses a layered approach to validate requests:
19
21
 
20
- In some cases, you may need to disable CSRF protection for specific paths (like API endpoints or webhooks). Configure exempt paths using regex patterns in your settings:
22
+ 1. **Safe methods pass through** - `GET`, `HEAD`, and `OPTIONS` requests are always allowed since they should not modify server state.
23
+
24
+ 2. **Exempt paths skip validation** - Paths matching patterns in `CSRF_EXEMPT_PATHS` bypass all CSRF checks.
25
+
26
+ 3. **Trusted origins are allowed** - Requests from origins in `CSRF_TRUSTED_ORIGINS` pass through.
27
+
28
+ 4. **Sec-Fetch-Site header check** - Modern browsers send this header indicating the request origin:
29
+ - `same-origin` or `none`: Allowed (request came from your site or was user-initiated)
30
+ - `cross-site` or `same-site`: Blocked (request came from another domain or subdomain)
31
+
32
+ 5. **Origin header fallback** - For older browsers without `Sec-Fetch-Site`, the middleware compares the `Origin` header against the request's `Host`.
33
+
34
+ 6. **Non-browser requests pass** - Requests without either header (like curl or API clients) are allowed since they are not subject to browser CSRF attacks.
35
+
36
+ ## Exempt paths
37
+
38
+ You can disable CSRF protection for specific paths using regex patterns. This is useful for API endpoints, webhooks, or health checks that receive requests from external services.
21
39
 
22
40
  ```python
23
- # settings.py
41
+ # app/settings.py
24
42
  CSRF_EXEMPT_PATHS = [
25
43
  r"^/api/", # All API endpoints
26
44
  r"^/api/v\d+/", # Versioned APIs: /api/v1/, /api/v2/, etc.
@@ -30,41 +48,22 @@ CSRF_EXEMPT_PATHS = [
30
48
  ]
31
49
  ```
32
50
 
33
- **Pattern Matching**: Exempt paths use Python regex patterns with `re.search()` against the full URL path including the leading slash.
34
-
35
- **Examples:**
51
+ Patterns use Python regex with `re.search()` against the full URL path including the leading slash.
36
52
 
37
- - `r"^/api/"` - matches `/api/users/`, `/api/posts/`
38
- - `r"/webhooks/.*"` - matches `/webhooks/github/push`, `/webhooks/stripe/payment`
39
- - `r"/health$"` - matches `/health` but not `/health-check`
40
- - `r"^/api/v\d+/"` - matches `/api/v1/users/`, `/api/v2/posts/`
53
+ **Pattern examples:**
41
54
 
42
- **Common Use Cases:**
55
+ | Pattern | Matches | Does not match |
56
+ | ----------------- | ---------------------------- | --------------- |
57
+ | `r"^/api/"` | `/api/users/`, `/api/posts/` | `/v2/api/` |
58
+ | `r"/webhooks/.*"` | `/webhooks/github/push` | `/webhook/` |
59
+ | `r"/health$"` | `/health` | `/health-check` |
43
60
 
44
- ```python
45
- CSRF_EXEMPT_PATHS = [
46
- # API endpoints (often consumed by JavaScript/mobile apps)
47
- r"^/api/",
48
-
49
- # Webhooks (external services posting data)
50
- r"/webhooks/.*",
51
-
52
- # Health checks and monitoring
53
- r"/health$",
54
- r"/status$",
55
- r"/metrics$",
56
-
57
- # File uploads (if using direct POST)
58
- r"/upload/",
59
- ]
60
- ```
61
+ ## Trusted origins
61
62
 
62
- ## Trusted Origins
63
-
64
- In some cases, you may need to allow requests from specific external origins (like API clients or mobile apps). You can configure trusted origins in your settings:
63
+ You can allow requests from specific external origins that you trust completely.
65
64
 
66
65
  ```python
67
- # settings.py
66
+ # app/settings.py
68
67
  CSRF_TRUSTED_ORIGINS = [
69
68
  "https://api.example.com",
70
69
  "https://mobile.example.com:8443",
@@ -72,4 +71,30 @@ CSRF_TRUSTED_ORIGINS = [
72
71
  ]
73
72
  ```
74
73
 
75
- **Important**: Trusted origins bypass **all** CSRF protection. Only add origins you completely trust, as they can make requests that appear to come from your users.
74
+ Each origin should be a full URL with scheme (e.g., `https://example.com`). Include the port if it's non-standard.
75
+
76
+ **Warning**: Trusted origins bypass all CSRF protection. Only add origins you completely control or trust, as they can make requests that appear to come from your users.
77
+
78
+ ## FAQs
79
+
80
+ #### Why does Plain use Sec-Fetch-Site instead of CSRF tokens?
81
+
82
+ Token-based CSRF protection requires embedding tokens in forms and validating them on the server. This adds complexity to your templates and requires careful handling of token rotation. Modern browsers provide the `Sec-Fetch-Site` header which tells the server whether a request is same-origin, making tokens unnecessary. The header approach is simpler, more reliable, and cannot be leaked through XSS vulnerabilities like tokens can.
83
+
84
+ #### What about HTTP sites during development?
85
+
86
+ The `Sec-Fetch-Site` header is only sent by browsers to HTTPS and localhost origins. For development on localhost, CSRF protection works normally. For HTTP origins on other hosts, the middleware falls back to `Origin` header validation.
87
+
88
+ #### Why are same-site requests (like subdomains) blocked?
89
+
90
+ Plain uses same-origin protection rather than same-site protection. Subdomains can have different trust levels than your main domain. For example, `user-content.example.com` should not be able to make authenticated requests to `app.example.com`. If you need to allow requests from a subdomain, add it to `CSRF_TRUSTED_ORIGINS`.
91
+
92
+ #### How do I debug CSRF rejections?
93
+
94
+ When a request is rejected, the middleware raises a `SuspiciousOperationError400` with a detailed message explaining why. Check your server logs for messages like "CSRF rejected: Cross-origin request from Sec-Fetch-Site: cross-site" to understand the cause.
95
+
96
+ ## Installation
97
+
98
+ This module is included with the `plain` package and enabled by default. No additional installation or configuration is required.
99
+
100
+ The middleware is automatically added to the request handling pipeline through [`BUILTIN_BEFORE_MIDDLEWARE`](../internal/handlers/base.py#BUILTIN_BEFORE_MIDDLEWARE).
plain/csrf/middleware.py CHANGED
@@ -1,16 +1,18 @@
1
- import logging
1
+ from __future__ import annotations
2
+
2
3
  import re
4
+ from collections.abc import Callable
5
+ from typing import TYPE_CHECKING
3
6
  from urllib.parse import urlparse
4
7
 
5
- from plain.logs.utils import log_response
8
+ from plain.http import HttpMiddleware, SuspiciousOperationError400
6
9
  from plain.runtime import settings
7
10
 
8
- from .views import CsrfFailureView
9
-
10
- logger = logging.getLogger("plain.security.csrf")
11
+ if TYPE_CHECKING:
12
+ from plain.http import Request, Response
11
13
 
12
14
 
13
- class CsrfViewMiddleware:
15
+ class CsrfViewMiddleware(HttpMiddleware):
14
16
  """
15
17
  Modern CSRF protection middleware using Sec-Fetch-Site headers and origin validation.
16
18
  Based on Filippo Valsorda's 2025 research (https://words.filippo.io/csrf/).
@@ -19,31 +21,33 @@ class CsrfViewMiddleware:
19
21
  like subdomains can have different trust levels and are rejected.
20
22
  """
21
23
 
22
- def __init__(self, get_response):
23
- self.get_response = get_response
24
+ def __init__(self, get_response: Callable[[Request], Response]):
25
+ super().__init__(get_response)
24
26
 
25
27
  # Compile CSRF exempt patterns once for performance
26
- self.csrf_exempt_patterns = [re.compile(r) for r in settings.CSRF_EXEMPT_PATHS]
28
+ self.csrf_exempt_patterns: list[re.Pattern[str]] = [
29
+ re.compile(r) for r in settings.CSRF_EXEMPT_PATHS
30
+ ]
27
31
 
28
- def __call__(self, request):
32
+ def process_request(self, request: Request) -> Response:
29
33
  allowed, reason = self.should_allow_request(request)
30
34
 
31
- if allowed:
32
- return self.get_response(request)
33
- else:
34
- return self.reject(request, reason)
35
+ if not allowed:
36
+ raise SuspiciousOperationError400(reason)
35
37
 
36
- def should_allow_request(self, request) -> tuple[bool, str]:
38
+ return self.get_response(request)
39
+
40
+ def should_allow_request(self, request: Request) -> tuple[bool, str]:
37
41
  # 1. Allow safe methods (GET, HEAD, OPTIONS)
38
42
  if request.method in ("GET", "HEAD", "OPTIONS"):
39
- return True, f"Safe HTTP method: {request.method}"
43
+ return True, f"CSRF allowed: Safe HTTP method: {request.method}"
40
44
 
41
45
  # 2. Path-based exemption (regex patterns)
42
46
  for pattern in self.csrf_exempt_patterns:
43
47
  if pattern.search(request.path_info):
44
48
  return (
45
49
  True,
46
- f"Path {request.path_info} matches exempt pattern {pattern.pattern}",
50
+ f"CSRF allowed: Path {request.path_info} matches exempt pattern {pattern.pattern}",
47
51
  )
48
52
 
49
53
  origin = request.headers.get("Origin")
@@ -52,25 +56,25 @@ class CsrfViewMiddleware:
52
56
  # 3. Check trusted origins allow-list
53
57
 
54
58
  if origin and origin in settings.CSRF_TRUSTED_ORIGINS:
55
- return True, f"Trusted origin: {origin}"
59
+ return True, f"CSRF allowed: Trusted origin: {origin}"
56
60
 
57
61
  # 4. Primary protection: Check Sec-Fetch-Site header
58
62
  if sec_fetch_site in ("same-origin", "none"):
59
63
  return (
60
64
  True,
61
- f"Same-origin request from Sec-Fetch-Site: {sec_fetch_site}",
65
+ f"CSRF allowed: Same-origin request from Sec-Fetch-Site: {sec_fetch_site}",
62
66
  )
63
67
  elif sec_fetch_site in ("cross-site", "same-site"):
64
68
  return (
65
69
  False,
66
- f"Cross-origin request detected from Sec-Fetch-Site: {sec_fetch_site}",
70
+ f"CSRF rejected: Cross-origin request from Sec-Fetch-Site: {sec_fetch_site}",
67
71
  )
68
72
 
69
73
  # 5. No fetch metadata or Origin headers - allow (non-browser requests)
70
74
  if not origin and not sec_fetch_site:
71
75
  return (
72
76
  True,
73
- "No Origin or Sec-Fetch-Site header - likely non-browser or old browser",
77
+ "CSRF allowed: No Origin or Sec-Fetch-Site header - likely non-browser or old browser",
74
78
  )
75
79
 
76
80
  # 6. Fallback: Origin vs Host comparison for older browsers
@@ -78,7 +82,7 @@ class CsrfViewMiddleware:
78
82
  # (Origin shows :443, request sees :80 if TLS terminated upstream).
79
83
  # HSTS helps here; otherwise add external origins to CSRF_TRUSTED_ORIGINS.
80
84
  if origin == "null":
81
- return False, "Cross-origin request detected - null Origin header"
85
+ return False, "CSRF rejected: Null Origin header"
82
86
 
83
87
  if (parsed_origin := urlparse(origin)) and (host := request.host):
84
88
  try:
@@ -101,33 +105,39 @@ class CsrfViewMiddleware:
101
105
  # Compare hostname and port (scheme-agnostic)
102
106
  # Both origin_host and request_host are normalized by urlparse (IPv6 brackets stripped)
103
107
  if origin_host and origin_port and request_host and request_port:
104
- if (
105
- origin_host.lower() == request_host.lower()
106
- and origin_port == int(request_port)
107
- ):
108
+ host_match = origin_host.lower() == request_host.lower()
109
+ port_match = origin_port == int(request_port)
110
+
111
+ if host_match and port_match:
108
112
  return (
109
113
  True,
110
- f"Same-origin request - Origin {origin} matches Host {host}",
114
+ f"CSRF allowed: Same-origin request - Origin {origin} matches Host {host}",
115
+ )
116
+
117
+ # Build detailed error message based on what mismatched
118
+ if host_match:
119
+ # Port mismatch only - show ports since they're relevant
120
+ return (
121
+ False,
122
+ f"CSRF rejected: Origin {origin_host}:{origin_port} does not match Host {request_host}:{request_port} (port mismatch)",
123
+ )
124
+ elif port_match:
125
+ # Host mismatch only - no need to show ports
126
+ return (
127
+ False,
128
+ f"CSRF rejected: Origin {origin_host} does not match Host {request_host}",
129
+ )
130
+ else:
131
+ # Both mismatch - show full details
132
+ return (
133
+ False,
134
+ f"CSRF rejected: Origin {origin_host}:{origin_port} does not match Host {request_host}:{request_port}",
111
135
  )
112
136
  except ValueError:
113
137
  pass
114
138
 
115
- # Origin present but doesn't match host
139
+ # Origin present but couldn't parse/compare properly
116
140
  return (
117
141
  False,
118
- f"Cross-origin request detected - Origin {origin} does not match Host",
119
- )
120
-
121
- def reject(self, request, reason):
122
- """Reject a request with a 403 Forbidden response."""
123
-
124
- response = CsrfFailureView.as_view()(request, reason=reason)
125
- log_response(
126
- "Forbidden (%s): %s",
127
- reason,
128
- request.path,
129
- response=response,
130
- request=request,
131
- logger=logger,
142
+ f"CSRF rejected: Origin {origin} could not be validated against Host {request.host}",
132
143
  )
133
- return response
plain/debug.py CHANGED
@@ -1,4 +1,7 @@
1
+ from __future__ import annotations
2
+
1
3
  from pprint import pformat
4
+ from typing import Any, NoReturn
2
5
 
3
6
  from markupsafe import Markup, escape
4
7
 
@@ -6,7 +9,7 @@ from plain.http import Response
6
9
  from plain.views.exceptions import ResponseException
7
10
 
8
11
 
9
- def dd(*objs):
12
+ def dd(*objs: Any) -> NoReturn:
10
13
  """
11
14
  Dump and die.
12
15
 
@@ -25,5 +28,5 @@ def dd(*objs):
25
28
  response = Response()
26
29
  response.status_code = 500
27
30
  response.content = combined_dump_str
28
- response.content_type = "text/html"
31
+ response.headers["Content-Type"] = "text/html"
29
32
  raise ResponseException(response)
plain/exceptions.py CHANGED
@@ -2,11 +2,15 @@
2
2
  Global Plain exception and warning classes.
3
3
  """
4
4
 
5
+ from __future__ import annotations
6
+
5
7
  import operator
8
+ from collections.abc import Iterator
9
+ from typing import Any
6
10
 
7
11
  from plain.utils.hashable import make_hashable
8
12
 
9
- # MARK: Configuration and Registry
13
+ # MARK: Configuration and Package Registry
10
14
 
11
15
 
12
16
  class PackageRegistryNotReady(Exception):
@@ -21,94 +25,6 @@ class ImproperlyConfigured(Exception):
21
25
  pass
22
26
 
23
27
 
24
- # MARK: Model and Field Errors
25
-
26
-
27
- class FieldDoesNotExist(Exception):
28
- """The requested model field does not exist"""
29
-
30
- pass
31
-
32
-
33
- class FieldError(Exception):
34
- """Some kind of problem with a model field."""
35
-
36
- pass
37
-
38
-
39
- class ObjectDoesNotExist(Exception):
40
- """The requested object does not exist"""
41
-
42
- pass
43
-
44
-
45
- class MultipleObjectsReturned(Exception):
46
- """The query returned multiple objects when only one was expected."""
47
-
48
- pass
49
-
50
-
51
- # MARK: Security and Suspicious Operations
52
-
53
-
54
- class SuspiciousOperation(Exception):
55
- """The user did something suspicious"""
56
-
57
-
58
- class SuspiciousMultipartForm(SuspiciousOperation):
59
- """Suspect MIME request in multipart form data"""
60
-
61
- pass
62
-
63
-
64
- class SuspiciousFileOperation(SuspiciousOperation):
65
- """A Suspicious filesystem operation was attempted"""
66
-
67
- pass
68
-
69
-
70
- class TooManyFieldsSent(SuspiciousOperation):
71
- """
72
- The number of fields in a GET or POST request exceeded
73
- settings.DATA_UPLOAD_MAX_NUMBER_FIELDS.
74
- """
75
-
76
- pass
77
-
78
-
79
- class TooManyFilesSent(SuspiciousOperation):
80
- """
81
- The number of fields in a GET or POST request exceeded
82
- settings.DATA_UPLOAD_MAX_NUMBER_FILES.
83
- """
84
-
85
- pass
86
-
87
-
88
- class RequestDataTooBig(SuspiciousOperation):
89
- """
90
- The size of the request (excluding any file uploads) exceeded
91
- settings.DATA_UPLOAD_MAX_MEMORY_SIZE.
92
- """
93
-
94
- pass
95
-
96
-
97
- # MARK: HTTP and Request Errors
98
-
99
-
100
- class BadRequest(Exception):
101
- """The request is malformed and cannot be processed."""
102
-
103
- pass
104
-
105
-
106
- class PermissionDenied(Exception):
107
- """The user did not have permission to do that"""
108
-
109
- pass
110
-
111
-
112
28
  # MARK: Validation
113
29
 
114
30
  NON_FIELD_ERRORS = "__all__"
@@ -117,7 +33,12 @@ NON_FIELD_ERRORS = "__all__"
117
33
  class ValidationError(Exception):
118
34
  """An error while validating data."""
119
35
 
120
- def __init__(self, message, code=None, params=None):
36
+ def __init__(
37
+ self,
38
+ message: str | list[Any] | dict[str, Any] | ValidationError,
39
+ code: str | None = None,
40
+ params: dict[str, Any] | None = None,
41
+ ):
121
42
  """
122
43
  The `message` argument can be a single error, a list of errors, or a
123
44
  dictionary that maps field names to lists of errors. What we define as
@@ -161,12 +82,14 @@ class ValidationError(Exception):
161
82
  self.error_list = [self]
162
83
 
163
84
  @property
164
- def messages(self):
85
+ def messages(self) -> list[str]:
165
86
  if hasattr(self, "error_dict"):
166
- return sum(dict(self).values(), [])
87
+ return sum(dict(self).values(), []) # type: ignore[arg-type]
167
88
  return list(self)
168
89
 
169
- def update_error_dict(self, error_dict):
90
+ def update_error_dict(
91
+ self, error_dict: dict[str, list[ValidationError]]
92
+ ) -> dict[str, list[ValidationError]]:
170
93
  if hasattr(self, "error_dict"):
171
94
  for field, error_list in self.error_dict.items():
172
95
  error_dict.setdefault(field, []).extend(error_list)
@@ -174,7 +97,7 @@ class ValidationError(Exception):
174
97
  error_dict.setdefault(NON_FIELD_ERRORS, []).extend(self.error_list)
175
98
  return error_dict
176
99
 
177
- def __iter__(self):
100
+ def __iter__(self) -> Iterator[tuple[str, list[str]] | str]:
178
101
  if hasattr(self, "error_dict"):
179
102
  for field, errors in self.error_dict.items():
180
103
  yield field, list(ValidationError(errors))
@@ -185,20 +108,20 @@ class ValidationError(Exception):
185
108
  message %= error.params
186
109
  yield str(message)
187
110
 
188
- def __str__(self):
111
+ def __str__(self) -> str:
189
112
  if hasattr(self, "error_dict"):
190
- return repr(dict(self))
113
+ return repr(dict(self)) # type: ignore[arg-type]
191
114
  return repr(list(self))
192
115
 
193
- def __repr__(self):
116
+ def __repr__(self) -> str:
194
117
  return f"ValidationError({self})"
195
118
 
196
- def __eq__(self, other):
119
+ def __eq__(self, other: object) -> bool:
197
120
  if not isinstance(other, ValidationError):
198
121
  return NotImplemented
199
122
  return hash(self) == hash(other)
200
123
 
201
- def __hash__(self):
124
+ def __hash__(self) -> int:
202
125
  if hasattr(self, "message"):
203
126
  return hash(
204
127
  (
@@ -210,18 +133,3 @@ class ValidationError(Exception):
210
133
  if hasattr(self, "error_dict"):
211
134
  return hash(make_hashable(self.error_dict))
212
135
  return hash(tuple(sorted(self.error_list, key=operator.attrgetter("message"))))
213
-
214
-
215
- # MARK: Database
216
-
217
-
218
- class EmptyResultSet(Exception):
219
- """A database query predicate is impossible."""
220
-
221
- pass
222
-
223
-
224
- class FullResultSet(Exception):
225
- """A database query predicate is matches everything."""
226
-
227
- pass