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/preflight/README.md CHANGED
@@ -1,61 +1,246 @@
1
- # Preflight
1
+ # plain.preflight
2
2
 
3
- **System checks for Plain applications.**
3
+ **System checks that validate your settings and environment before running your application.**
4
4
 
5
5
  - [Overview](#overview)
6
- - [Development](#development)
7
- - [Deployment](#deployment)
6
+ - [Running preflight checks](#running-preflight-checks)
7
+ - [Development](#development)
8
+ - [Deployment](#deployment)
9
+ - [JSON output](#json-output)
10
+ - [Built-in checks](#built-in-checks)
8
11
  - [Custom preflight checks](#custom-preflight-checks)
9
- - [Silencing preflight checks](#silencing-preflight-checks)
12
+ - [Basic checks](#basic-checks)
13
+ - [Deployment-only checks](#deployment-only-checks)
14
+ - [Warnings vs errors](#warnings-vs-errors)
15
+ - [Silencing checks](#silencing-checks)
16
+ - [Silencing entire checks](#silencing-entire-checks)
17
+ - [Silencing specific results](#silencing-specific-results)
18
+ - [FAQs](#faqs)
19
+ - [Installation](#installation)
10
20
 
11
21
  ## Overview
12
22
 
13
- Preflight checks help identify issues with your settings or environment before running your application.
23
+ Preflight checks help you catch configuration problems early. You can run checks to verify that settings are valid, directories exist, URL patterns are correct, and security requirements are met before your application starts.
24
+
25
+ ```python
26
+ from plain.preflight import PreflightCheck, PreflightResult, register_check
27
+
28
+
29
+ @register_check("custom.database_connection")
30
+ class CheckDatabaseConnection(PreflightCheck):
31
+ """Verify database is reachable."""
32
+
33
+ def run(self) -> list[PreflightResult]:
34
+ from plain.models import connection
35
+
36
+ try:
37
+ connection.ensure_connection()
38
+ except Exception as e:
39
+ return [
40
+ PreflightResult(
41
+ fix=f"Database connection failed: {e}. Check your DATABASE_URL.",
42
+ id="custom.database_unreachable",
43
+ )
44
+ ]
45
+ return []
46
+ ```
47
+
48
+ When you run `plain preflight`, your check runs alongside the built-in checks:
14
49
 
15
50
  ```bash
16
- plain preflight check
51
+ $ plain preflight
52
+ Running preflight checks...
53
+ Check: custom.database_connection ✔
54
+ Check: files.upload_temp_dir ✔
55
+ Check: settings.unused_env_vars ✔
56
+ Check: urls.config ✔
57
+
58
+ 4 passed
17
59
  ```
18
60
 
19
- ## Development
61
+ ## Running preflight checks
62
+
63
+ ### Development
64
+
65
+ Run preflight checks at any time:
66
+
67
+ ```bash
68
+ plain preflight
69
+ ```
20
70
 
21
- If you use [`plain.dev`](/plain-dev/README.md) for local development, the Plain preflight command is run automatically when you run `plain dev`.
71
+ If you use [`plain.dev`](/plain-dev/README.md) for local development, preflight checks run automatically when you start `plain dev`.
22
72
 
23
- ## Deployment
73
+ ### Deployment
24
74
 
25
- The `plain preflight check` command should often be part of your deployment process. Make sure to add the `--deploy` flag to the command to run checks that are only relevant in a production environment.
75
+ Add `--deploy` to include deployment-specific checks like `SECRET_KEY` strength, `DEBUG` mode, and `ALLOWED_HOSTS`:
26
76
 
27
77
  ```bash
28
- plain preflight check --deploy
78
+ plain preflight --deploy
29
79
  ```
30
80
 
81
+ This should be part of your deployment process. If any check fails (returns errors, not warnings), the command exits with code 1.
82
+
83
+ ### JSON output
84
+
85
+ For CI/CD pipelines or programmatic access, use JSON output:
86
+
87
+ ```bash
88
+ plain preflight --format json
89
+ ```
90
+
91
+ ```json
92
+ {
93
+ "passed": true,
94
+ "checks": [
95
+ {
96
+ "name": "files.upload_temp_dir",
97
+ "passed": true,
98
+ "issues": []
99
+ }
100
+ ]
101
+ }
102
+ ```
103
+
104
+ Use `--quiet` to suppress progress output and only show errors.
105
+
106
+ ## Built-in checks
107
+
108
+ Plain includes these checks out of the box:
109
+
110
+ | Check | Description | Deploy only |
111
+ | ------------------------------- | -------------------------------------------------------- | ----------- |
112
+ | `files.upload_temp_dir` | Validates `FILE_UPLOAD_TEMP_DIR` exists | No |
113
+ | `settings.unused_env_vars` | Detects env vars that look like settings but aren't used | No |
114
+ | `urls.config` | Validates URL patterns for common issues | No |
115
+ | `security.secret_key` | Validates `SECRET_KEY` strength | Yes |
116
+ | `security.secret_key_fallbacks` | Validates `SECRET_KEY_FALLBACKS` strength | Yes |
117
+ | `security.debug` | Ensures `DEBUG` is False | Yes |
118
+ | `security.allowed_hosts` | Ensures `ALLOWED_HOSTS` is not empty | Yes |
119
+
31
120
  ## Custom preflight checks
32
121
 
33
- Use the `@register_check` decorator to add your own preflight check to the system. Just make sure that particular Python module is somehow imported so the check registration runs.
122
+ ### Basic checks
123
+
124
+ Create a check by subclassing [`PreflightCheck`](./checks.py#PreflightCheck) and using the [`@register_check`](./registry.py#register_check) decorator:
34
125
 
35
126
  ```python
36
- from plain.preflight import register_check, Error
127
+ from plain.preflight import PreflightCheck, PreflightResult, register_check
128
+
129
+
130
+ @register_check("custom.redis_connection")
131
+ class CheckRedisConnection(PreflightCheck):
132
+ """Verify Redis cache is reachable."""
133
+
134
+ def run(self) -> list[PreflightResult]:
135
+ from plain.cache import cache
136
+
137
+ try:
138
+ cache.set("preflight_test", "ok", timeout=1)
139
+ except Exception as e:
140
+ return [
141
+ PreflightResult(
142
+ fix=f"Redis connection failed: {e}",
143
+ id="custom.redis_unreachable",
144
+ )
145
+ ]
146
+ return []
147
+ ```
148
+
149
+ Place this in a `preflight.py` file in your app directory. Plain autodiscovers `preflight.py` modules when running checks.
37
150
 
151
+ ### Deployment-only checks
38
152
 
39
- @register_check
40
- def custom_check(package_configs, **kwargs):
41
- return Error("This is a custom error message.", id="custom.C001")
153
+ For checks that only matter in production, add `deploy=True`:
154
+
155
+ ```python
156
+ @register_check("custom.ssl_certificate", deploy=True)
157
+ class CheckSSLCertificate(PreflightCheck):
158
+ """Verify SSL certificate is valid and not expiring soon."""
159
+
160
+ def run(self) -> list[PreflightResult]:
161
+ # Check certificate expiration...
162
+ if days_until_expiry < 30:
163
+ return [
164
+ PreflightResult(
165
+ fix=f"SSL certificate expires in {days_until_expiry} days.",
166
+ id="custom.ssl_expiring_soon",
167
+ )
168
+ ]
169
+ return []
42
170
  ```
43
171
 
44
- For deployment-specific checks, add the `deploy` argument to the decorator.
172
+ ### Warnings vs errors
173
+
174
+ By default, [`PreflightResult`](./results.py#PreflightResult) represents an error that fails the preflight. For non-critical issues, use `warning=True`:
45
175
 
46
176
  ```python
47
- @register_check(deploy=True)
48
- def custom_deploy_check(package_configs, **kwargs):
49
- return Error("This is a custom error message for deployment.", id="custom.D001")
177
+ PreflightResult(
178
+ fix="Consider enabling gzip compression for better performance.",
179
+ id="custom.gzip_disabled",
180
+ warning=True, # Won't cause preflight to fail
181
+ )
50
182
  ```
51
183
 
52
- ## Silencing preflight checks
184
+ Warnings display with a yellow indicator but don't cause the command to exit with an error code.
53
185
 
54
- The `settings.PREFLIGHT_SILENCED_CHECKS` setting can be used to silence individual checks by their ID (ex. `security.E020`).
186
+ ## Silencing checks
187
+
188
+ ### Silencing entire checks
189
+
190
+ To skip a check entirely, add its name to `PREFLIGHT_SILENCED_CHECKS`:
55
191
 
56
192
  ```python
57
193
  # app/settings.py
58
194
  PREFLIGHT_SILENCED_CHECKS = [
59
- "security.E020", # Allow empty ALLOWED_HOSTS in deployment
195
+ "security.debug", # We intentionally run with DEBUG=True in staging
196
+ ]
197
+ ```
198
+
199
+ ### Silencing specific results
200
+
201
+ To silence individual result IDs (not the whole check), use `PREFLIGHT_SILENCED_RESULTS`:
202
+
203
+ ```python
204
+ # app/settings.py
205
+ PREFLIGHT_SILENCED_RESULTS = [
206
+ "security.secret_key_weak", # Using a known weak key in testing
60
207
  ]
61
208
  ```
209
+
210
+ ## FAQs
211
+
212
+ #### What's the difference between a check name and a result ID?
213
+
214
+ The check name (like `security.secret_key`) identifies the check class. The result ID (like `security.secret_key_weak`) identifies a specific issue that check can report. A single check can return multiple different result IDs.
215
+
216
+ #### Where should I put custom preflight checks?
217
+
218
+ Create a `preflight.py` file in your app directory. Plain autodiscovers these modules when running `plain preflight`.
219
+
220
+ #### How do I run checks programmatically?
221
+
222
+ Use the [`run_checks`](./registry.py#run_checks) function:
223
+
224
+ ```python
225
+ from plain.preflight import run_checks
226
+
227
+ for check_class, name, results in run_checks(include_deploy_checks=True):
228
+ for result in results:
229
+ print(f"{name}: {result.fix}")
230
+ ```
231
+
232
+ #### Can I attach additional context to a result?
233
+
234
+ Use the `obj` parameter to attach a related object:
235
+
236
+ ```python
237
+ PreflightResult(
238
+ fix="Invalid URL pattern",
239
+ id="urls.invalid_pattern",
240
+ obj=some_url_pattern, # Will be included in output
241
+ )
242
+ ```
243
+
244
+ ## Installation
245
+
246
+ `plain.preflight` is included with the `plain` package. No additional installation is required.
@@ -5,6 +5,7 @@ from .results import PreflightResult
5
5
  # Import these to force registration of checks
6
6
  import plain.preflight.files # NOQA isort:skip
7
7
  import plain.preflight.security # NOQA isort:skip
8
+ import plain.preflight.settings # NOQA isort:skip
8
9
  import plain.preflight.urls # NOQA isort:skip
9
10
 
10
11
 
plain/preflight/checks.py CHANGED
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  from abc import ABC, abstractmethod
2
4
 
3
5
 
@@ -5,6 +7,6 @@ class PreflightCheck(ABC):
5
7
  """Base class for all preflight checks."""
6
8
 
7
9
  @abstractmethod
8
- def run(self):
10
+ def run(self) -> list:
9
11
  """Must return a list of Warning/Error results."""
10
12
  raise NotImplementedError
plain/preflight/files.py CHANGED
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  from pathlib import Path
2
4
 
3
5
  from plain.runtime import settings
@@ -11,7 +13,7 @@ from .results import PreflightResult
11
13
  class CheckSettingFileUploadTempDir(PreflightCheck):
12
14
  """Validates that the FILE_UPLOAD_TEMP_DIR setting points to an existing directory."""
13
15
 
14
- def run(self):
16
+ def run(self) -> list[PreflightResult]:
15
17
  setting = settings.FILE_UPLOAD_TEMP_DIR
16
18
  if setting and not Path(setting).is_dir():
17
19
  return [
@@ -1,11 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Generator
4
+ from typing import Any, TypeVar
5
+
1
6
  from plain.runtime import settings
2
7
 
8
+ from .results import PreflightResult
3
9
 
4
- class CheckRegistry:
5
- def __init__(self):
6
- self.checks = {} # name -> (check_class, deploy)
10
+ T = TypeVar("T")
7
11
 
8
- def register_check(self, check_class, name, deploy=False):
12
+
13
+ class CheckRegistry:
14
+ def __init__(self) -> None:
15
+ self.checks: dict[
16
+ str, tuple[type[Any], bool]
17
+ ] = {} # name -> (check_class, deploy)
18
+
19
+ def register_check(
20
+ self, check_class: type[Any], name: str, deploy: bool = False
21
+ ) -> None:
9
22
  """Register a check class with a unique name."""
10
23
  if name in self.checks:
11
24
  raise ValueError(f"Check {name} already registered")
@@ -13,8 +26,8 @@ class CheckRegistry:
13
26
 
14
27
  def run_checks(
15
28
  self,
16
- include_deploy_checks=False,
17
- ):
29
+ include_deploy_checks: bool = False,
30
+ ) -> Generator[tuple[type[Any], str, list[PreflightResult]]]:
18
31
  """
19
32
  Run all registered checks and yield (check_class, name, results) tuples.
20
33
  """
@@ -28,7 +41,7 @@ class CheckRegistry:
28
41
  "Check for typos or remove outdated check names."
29
42
  )
30
43
 
31
- for name, (check_class, deploy) in self.checks.items():
44
+ for name, (check_class, deploy) in sorted(self.checks.items()):
32
45
  # Skip silenced checks
33
46
  if name in silenced_checks:
34
47
  continue
@@ -42,9 +55,11 @@ class CheckRegistry:
42
55
  results = check.run()
43
56
  yield check_class, name, results
44
57
 
45
- def get_checks(self, include_deploy_checks=False):
58
+ def get_checks(
59
+ self, include_deploy_checks: bool = False
60
+ ) -> list[tuple[type[Any], str]]:
46
61
  """Get list of (check_class, name) tuples."""
47
- result = []
62
+ result: list[tuple[type[Any], str]] = []
48
63
  for name, (check_class, deploy) in self.checks.items():
49
64
  if deploy and not include_deploy_checks:
50
65
  continue
@@ -55,7 +70,7 @@ class CheckRegistry:
55
70
  checks_registry = CheckRegistry()
56
71
 
57
72
 
58
- def register_check(name: str, *, deploy: bool = False):
73
+ def register_check(name: str, *, deploy: bool = False) -> Callable[[type[T]], type[T]]:
59
74
  """
60
75
  Decorator to register a check class.
61
76
 
@@ -69,7 +84,7 @@ def register_check(name: str, *, deploy: bool = False):
69
84
  pass
70
85
  """
71
86
 
72
- def wrapper(cls):
87
+ def wrapper(cls: type[T]) -> type[T]:
73
88
  checks_registry.register_check(cls, name=name, deploy=deploy)
74
89
  return cls
75
90
 
@@ -1,29 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
1
5
  from plain.runtime import settings
2
6
 
3
7
 
4
8
  class PreflightResult:
5
- def __init__(self, *, fix: str, id: str, obj=None, warning: bool = False):
9
+ def __init__(
10
+ self, *, fix: str, id: str, obj: Any = None, warning: bool = False
11
+ ) -> None:
6
12
  self.fix = fix
7
13
  self.obj = obj
8
14
  self.id = id
9
15
  self.warning = warning
10
16
 
11
- def __eq__(self, other):
17
+ def __eq__(self, other: object) -> bool:
12
18
  return isinstance(other, self.__class__) and all(
13
19
  getattr(self, attr) == getattr(other, attr)
14
20
  for attr in ["fix", "obj", "id", "warning"]
15
21
  )
16
22
 
17
- def __str__(self):
23
+ def __str__(self) -> str:
18
24
  if self.obj is None:
19
25
  obj = ""
20
- elif hasattr(self.obj, "_meta") and hasattr(self.obj._meta, "label"):
26
+ elif hasattr(self.obj, "model_options") and hasattr(
27
+ self.obj.model_options, "label"
28
+ ):
21
29
  # Duck type for model objects - use their meta label
22
- obj = self.obj._meta.label
30
+ obj = self.obj.model_options.label
23
31
  else:
24
32
  obj = str(self.obj)
25
33
  id_part = f"({self.id}) " if self.id else ""
26
34
  return f"{obj}: {id_part}{self.fix}"
27
35
 
28
- def is_silenced(self):
29
- return self.id and self.id in settings.PREFLIGHT_SILENCED_RESULTS
36
+ def is_silenced(self) -> bool:
37
+ return bool(self.id and self.id in settings.PREFLIGHT_SILENCED_RESULTS)
@@ -1,17 +1,19 @@
1
+ from __future__ import annotations
2
+
1
3
  from plain.runtime import settings
2
4
 
3
5
  from .checks import PreflightCheck
4
6
  from .registry import register_check
5
7
  from .results import PreflightResult
6
8
 
7
- SECRET_KEY_MIN_LENGTH = 50
8
- SECRET_KEY_MIN_UNIQUE_CHARACTERS = 5
9
+ _SECRET_KEY_MIN_LENGTH = 50
10
+ _SECRET_KEY_MIN_UNIQUE_CHARACTERS = 5
9
11
 
10
12
 
11
- def _check_secret_key(secret_key):
13
+ def _check_secret_key(secret_key: str) -> bool:
12
14
  return (
13
- len(set(secret_key)) >= SECRET_KEY_MIN_UNIQUE_CHARACTERS
14
- and len(secret_key) >= SECRET_KEY_MIN_LENGTH
15
+ len(set(secret_key)) >= _SECRET_KEY_MIN_UNIQUE_CHARACTERS
16
+ and len(secret_key) >= _SECRET_KEY_MIN_LENGTH
15
17
  )
16
18
 
17
19
 
@@ -19,12 +21,12 @@ def _check_secret_key(secret_key):
19
21
  class CheckSecretKey(PreflightCheck):
20
22
  """Validates that SECRET_KEY is long and random enough for security."""
21
23
 
22
- def run(self):
24
+ def run(self) -> list[PreflightResult]:
23
25
  if not _check_secret_key(settings.SECRET_KEY):
24
26
  return [
25
27
  PreflightResult(
26
- fix=f"SECRET_KEY is too weak (needs {SECRET_KEY_MIN_LENGTH}+ characters, "
27
- f"{SECRET_KEY_MIN_UNIQUE_CHARACTERS}+ unique). Generate a new long random value or "
28
+ fix=f"SECRET_KEY is too weak (needs {_SECRET_KEY_MIN_LENGTH}+ characters, "
29
+ f"{_SECRET_KEY_MIN_UNIQUE_CHARACTERS}+ unique). Generate a new long random value or "
28
30
  f"Plain's security features will be vulnerable to attack.",
29
31
  id="security.secret_key_weak",
30
32
  )
@@ -36,14 +38,14 @@ class CheckSecretKey(PreflightCheck):
36
38
  class CheckSecretKeyFallbacks(PreflightCheck):
37
39
  """Validates that SECRET_KEY_FALLBACKS are long and random enough for security."""
38
40
 
39
- def run(self):
41
+ def run(self) -> list[PreflightResult]:
40
42
  errors = []
41
43
  for index, key in enumerate(settings.SECRET_KEY_FALLBACKS):
42
44
  if not _check_secret_key(key):
43
45
  errors.append(
44
46
  PreflightResult(
45
- fix=f"SECRET_KEY_FALLBACKS[{index}] is too weak (needs {SECRET_KEY_MIN_LENGTH}+ characters, "
46
- f"{SECRET_KEY_MIN_UNIQUE_CHARACTERS}+ unique). Generate a new long random value or "
47
+ fix=f"SECRET_KEY_FALLBACKS[{index}] is too weak (needs {_SECRET_KEY_MIN_LENGTH}+ characters, "
48
+ f"{_SECRET_KEY_MIN_UNIQUE_CHARACTERS}+ unique). Generate a new long random value or "
47
49
  f"Plain's security features will be vulnerable to attack.",
48
50
  id="security.secret_key_fallback_weak",
49
51
  )
@@ -55,7 +57,7 @@ class CheckSecretKeyFallbacks(PreflightCheck):
55
57
  class CheckDebug(PreflightCheck):
56
58
  """Ensures DEBUG is False in production deployment."""
57
59
 
58
- def run(self):
60
+ def run(self) -> list[PreflightResult]:
59
61
  if settings.DEBUG:
60
62
  return [
61
63
  PreflightResult(
@@ -70,7 +72,7 @@ class CheckDebug(PreflightCheck):
70
72
  class CheckAllowedHosts(PreflightCheck):
71
73
  """Ensures ALLOWED_HOSTS is not empty in production deployment."""
72
74
 
73
- def run(self):
75
+ def run(self) -> list[PreflightResult]:
74
76
  if not settings.ALLOWED_HOSTS:
75
77
  return [
76
78
  PreflightResult(
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ from plain.runtime import settings
6
+
7
+ from .checks import PreflightCheck
8
+ from .registry import register_check
9
+ from .results import PreflightResult
10
+
11
+
12
+ @register_check(name="settings.unused_env_vars")
13
+ class CheckUnusedEnvVars(PreflightCheck):
14
+ """Detect environment variables that look like settings but aren't used."""
15
+
16
+ def run(self) -> list[PreflightResult]:
17
+ results: list[PreflightResult] = []
18
+
19
+ # Get all env vars matching any configured prefix
20
+ for prefix in settings._env_prefixes:
21
+ for key in os.environ:
22
+ if key.startswith(prefix) and key.isupper():
23
+ setting_name = key[len(prefix) :]
24
+ # Skip empty setting names (just the prefix itself)
25
+ if setting_name and setting_name not in settings._settings:
26
+ results.append(
27
+ PreflightResult(
28
+ fix=f"Environment variable '{key}' looks like a setting but "
29
+ f"'{setting_name}' is not a recognized setting.",
30
+ id="settings.unused_env_var",
31
+ warning=True,
32
+ )
33
+ )
34
+
35
+ # Warn if PLAIN_ env vars exist but PLAIN_ not in prefixes
36
+ if "PLAIN_" not in settings._env_prefixes:
37
+ plain_vars = [
38
+ k
39
+ for k in os.environ
40
+ if k.startswith("PLAIN_")
41
+ and k.isupper()
42
+ and k != "PLAIN_SETTINGS_MODULE" # This one is always valid
43
+ ]
44
+ if plain_vars:
45
+ results.append(
46
+ PreflightResult(
47
+ fix=f"Found PLAIN_ environment variables but 'PLAIN_' is not in "
48
+ f"ENV_SETTINGS_PREFIXES: {', '.join(sorted(plain_vars))}",
49
+ id="settings.plain_prefix_disabled",
50
+ warning=True,
51
+ )
52
+ )
53
+
54
+ return results
plain/preflight/urls.py CHANGED
@@ -1,12 +1,15 @@
1
+ from __future__ import annotations
2
+
1
3
  from .checks import PreflightCheck
2
4
  from .registry import register_check
5
+ from .results import PreflightResult
3
6
 
4
7
 
5
8
  @register_check("urls.config")
6
9
  class CheckUrlConfig(PreflightCheck):
7
10
  """Validates the URL configuration for common issues."""
8
11
 
9
- def run(self):
12
+ def run(self) -> list[PreflightResult]:
10
13
  from plain.urls import get_resolver
11
14
 
12
15
  resolver = get_resolver()