plain 0.68.0__py3-none-any.whl → 0.103.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plain/CHANGELOG.md +684 -1
- plain/README.md +1 -1
- plain/agents/.claude/rules/plain.md +88 -0
- plain/agents/.claude/skills/plain-install/SKILL.md +26 -0
- plain/agents/.claude/skills/plain-upgrade/SKILL.md +35 -0
- plain/assets/compile.py +25 -12
- plain/assets/finders.py +24 -17
- plain/assets/fingerprints.py +10 -7
- plain/assets/urls.py +1 -1
- plain/assets/views.py +47 -33
- plain/chores/README.md +25 -23
- plain/chores/__init__.py +2 -1
- plain/chores/core.py +27 -0
- plain/chores/registry.py +23 -36
- plain/cli/README.md +185 -16
- plain/cli/__init__.py +2 -1
- plain/cli/agent.py +234 -0
- plain/cli/build.py +7 -8
- plain/cli/changelog.py +11 -5
- plain/cli/chores.py +32 -34
- plain/cli/core.py +110 -26
- plain/cli/docs.py +98 -21
- plain/cli/formatting.py +40 -17
- plain/cli/install.py +10 -54
- plain/cli/{agent/llmdocs.py → llmdocs.py} +45 -26
- plain/cli/output.py +6 -2
- plain/cli/preflight.py +27 -75
- plain/cli/print.py +4 -4
- plain/cli/registry.py +96 -10
- plain/cli/{agent/request.py → request.py} +67 -33
- plain/cli/runtime.py +45 -0
- plain/cli/scaffold.py +2 -7
- plain/cli/server.py +153 -0
- plain/cli/settings.py +53 -49
- plain/cli/shell.py +15 -12
- plain/cli/startup.py +9 -8
- plain/cli/upgrade.py +17 -104
- plain/cli/urls.py +12 -7
- plain/cli/utils.py +3 -3
- plain/csrf/README.md +65 -40
- plain/csrf/middleware.py +53 -43
- plain/debug.py +5 -2
- plain/exceptions.py +22 -114
- plain/forms/README.md +453 -24
- plain/forms/__init__.py +55 -4
- plain/forms/boundfield.py +15 -8
- plain/forms/exceptions.py +1 -1
- plain/forms/fields.py +346 -143
- plain/forms/forms.py +75 -45
- plain/http/README.md +356 -9
- plain/http/__init__.py +41 -26
- plain/http/cookie.py +15 -7
- plain/http/exceptions.py +65 -0
- plain/http/middleware.py +32 -0
- plain/http/multipartparser.py +99 -88
- plain/http/request.py +362 -250
- plain/http/response.py +99 -197
- plain/internal/__init__.py +8 -1
- plain/internal/files/base.py +35 -19
- plain/internal/files/locks.py +19 -11
- plain/internal/files/move.py +8 -3
- plain/internal/files/temp.py +25 -6
- plain/internal/files/uploadedfile.py +47 -28
- plain/internal/files/uploadhandler.py +64 -58
- plain/internal/files/utils.py +24 -10
- plain/internal/handlers/base.py +34 -23
- plain/internal/handlers/exception.py +68 -65
- plain/internal/handlers/wsgi.py +65 -54
- plain/internal/middleware/headers.py +37 -11
- plain/internal/middleware/hosts.py +11 -8
- plain/internal/middleware/https.py +17 -7
- plain/internal/middleware/slash.py +14 -9
- plain/internal/reloader.py +77 -0
- plain/json.py +2 -1
- plain/logs/README.md +161 -62
- plain/logs/__init__.py +1 -1
- plain/logs/{loggers.py → app.py} +71 -67
- plain/logs/configure.py +63 -14
- plain/logs/debug.py +17 -6
- plain/logs/filters.py +15 -0
- plain/logs/formatters.py +7 -4
- plain/packages/README.md +105 -23
- plain/packages/config.py +15 -7
- plain/packages/registry.py +27 -16
- plain/paginator.py +31 -21
- plain/preflight/README.md +209 -24
- plain/preflight/__init__.py +1 -0
- plain/preflight/checks.py +3 -1
- plain/preflight/files.py +3 -1
- plain/preflight/registry.py +26 -11
- plain/preflight/results.py +15 -7
- plain/preflight/security.py +15 -13
- plain/preflight/settings.py +54 -0
- plain/preflight/urls.py +4 -1
- plain/runtime/README.md +115 -47
- plain/runtime/__init__.py +10 -6
- plain/runtime/global_settings.py +34 -25
- plain/runtime/secret.py +20 -0
- plain/runtime/user_settings.py +110 -38
- plain/runtime/utils.py +1 -1
- plain/server/LICENSE +35 -0
- plain/server/README.md +155 -0
- plain/server/__init__.py +9 -0
- plain/server/app.py +52 -0
- plain/server/arbiter.py +555 -0
- plain/server/config.py +118 -0
- plain/server/errors.py +31 -0
- plain/server/glogging.py +292 -0
- plain/server/http/__init__.py +12 -0
- plain/server/http/body.py +283 -0
- plain/server/http/errors.py +155 -0
- plain/server/http/message.py +400 -0
- plain/server/http/parser.py +70 -0
- plain/server/http/unreader.py +88 -0
- plain/server/http/wsgi.py +421 -0
- plain/server/pidfile.py +92 -0
- plain/server/sock.py +240 -0
- plain/server/util.py +317 -0
- plain/server/workers/__init__.py +6 -0
- plain/server/workers/base.py +304 -0
- plain/server/workers/sync.py +212 -0
- plain/server/workers/thread.py +399 -0
- plain/server/workers/workertmp.py +50 -0
- plain/signals/README.md +170 -1
- plain/signals/__init__.py +0 -1
- plain/signals/dispatch/dispatcher.py +49 -27
- plain/signing.py +131 -35
- plain/templates/README.md +211 -20
- plain/templates/jinja/__init__.py +13 -5
- plain/templates/jinja/environments.py +5 -4
- plain/templates/jinja/extensions.py +12 -5
- plain/templates/jinja/filters.py +7 -2
- plain/templates/jinja/globals.py +2 -2
- plain/test/README.md +184 -22
- plain/test/client.py +340 -222
- plain/test/encoding.py +9 -6
- plain/test/exceptions.py +7 -2
- plain/urls/README.md +157 -73
- plain/urls/converters.py +18 -15
- plain/urls/exceptions.py +2 -2
- plain/urls/patterns.py +38 -22
- plain/urls/resolvers.py +35 -25
- plain/urls/utils.py +5 -1
- plain/utils/README.md +250 -3
- plain/utils/cache.py +17 -11
- plain/utils/crypto.py +21 -5
- plain/utils/datastructures.py +89 -56
- plain/utils/dateparse.py +9 -6
- plain/utils/deconstruct.py +15 -7
- plain/utils/decorators.py +5 -1
- plain/utils/dotenv.py +373 -0
- plain/utils/duration.py +8 -4
- plain/utils/encoding.py +14 -7
- plain/utils/functional.py +66 -49
- plain/utils/hashable.py +5 -1
- plain/utils/html.py +36 -22
- plain/utils/http.py +16 -9
- plain/utils/inspect.py +14 -6
- plain/utils/ipv6.py +7 -3
- plain/utils/itercompat.py +6 -1
- plain/utils/module_loading.py +7 -3
- plain/utils/regex_helper.py +37 -23
- plain/utils/safestring.py +14 -6
- plain/utils/text.py +41 -23
- plain/utils/timezone.py +33 -22
- plain/utils/tree.py +35 -19
- plain/validators.py +94 -52
- plain/views/README.md +156 -79
- plain/views/__init__.py +0 -1
- plain/views/base.py +25 -18
- plain/views/errors.py +13 -5
- plain/views/exceptions.py +4 -1
- plain/views/forms.py +6 -6
- plain/views/objects.py +52 -49
- plain/views/redirect.py +18 -15
- plain/views/templates.py +5 -3
- plain/wsgi.py +3 -1
- {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/METADATA +4 -2
- plain-0.103.0.dist-info/RECORD +198 -0
- {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/WHEEL +1 -1
- plain-0.103.0.dist-info/entry_points.txt +2 -0
- plain/AGENTS.md +0 -18
- plain/cli/agent/__init__.py +0 -20
- plain/cli/agent/docs.py +0 -80
- plain/cli/agent/md.py +0 -87
- plain/cli/agent/prompt.py +0 -45
- plain/csrf/views.py +0 -31
- plain/logs/utils.py +0 -46
- plain/templates/AGENTS.md +0 -3
- plain-0.68.0.dist-info/RECORD +0 -169
- plain-0.68.0.dist-info/entry_points.txt +0 -5
- {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/licenses/LICENSE +0 -0
plain/preflight/README.md
CHANGED
|
@@ -1,61 +1,246 @@
|
|
|
1
|
-
#
|
|
1
|
+
# plain.preflight
|
|
2
2
|
|
|
3
|
-
**System checks
|
|
3
|
+
**System checks that validate your settings and environment before running your application.**
|
|
4
4
|
|
|
5
5
|
- [Overview](#overview)
|
|
6
|
-
- [
|
|
7
|
-
- [
|
|
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
|
-
- [
|
|
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
|
|
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
|
|
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
|
-
##
|
|
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,
|
|
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
|
-
|
|
73
|
+
### Deployment
|
|
24
74
|
|
|
25
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
184
|
+
Warnings display with a yellow indicator but don't cause the command to exit with an error code.
|
|
53
185
|
|
|
54
|
-
|
|
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.
|
|
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.
|
plain/preflight/__init__.py
CHANGED
|
@@ -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 [
|
plain/preflight/registry.py
CHANGED
|
@@ -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
|
-
|
|
5
|
-
def __init__(self):
|
|
6
|
-
self.checks = {} # name -> (check_class, deploy)
|
|
10
|
+
T = TypeVar("T")
|
|
7
11
|
|
|
8
|
-
|
|
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(
|
|
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
|
|
plain/preflight/results.py
CHANGED
|
@@ -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__(
|
|
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, "
|
|
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.
|
|
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)
|
plain/preflight/security.py
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
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)) >=
|
|
14
|
-
and len(secret_key) >=
|
|
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 {
|
|
27
|
-
f"{
|
|
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 {
|
|
46
|
-
f"{
|
|
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()
|