plain 0.67.0__tar.gz → 0.68.1__tar.gz
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-0.67.0 → plain-0.68.1}/PKG-INFO +1 -1
- {plain-0.67.0 → plain-0.68.1}/plain/CHANGELOG.md +28 -0
- {plain-0.67.0 → plain-0.68.1}/plain/chores/registry.py +1 -18
- {plain-0.67.0 → plain-0.68.1}/plain/cli/core.py +2 -2
- plain-0.68.1/plain/cli/preflight.py +247 -0
- {plain-0.67.0 → plain-0.68.1}/plain/cli/registry.py +1 -18
- {plain-0.67.0 → plain-0.68.1}/plain/packages/registry.py +14 -0
- {plain-0.67.0 → plain-0.68.1}/plain/preflight/README.md +3 -3
- plain-0.68.1/plain/preflight/__init__.py +16 -0
- plain-0.68.1/plain/preflight/checks.py +10 -0
- plain-0.68.1/plain/preflight/files.py +23 -0
- plain-0.68.1/plain/preflight/registry.py +79 -0
- plain-0.68.1/plain/preflight/results.py +29 -0
- plain-0.68.1/plain/preflight/security.py +81 -0
- plain-0.68.1/plain/preflight/urls.py +13 -0
- {plain-0.67.0 → plain-0.68.1}/plain/runtime/global_settings.py +5 -5
- {plain-0.67.0 → plain-0.68.1}/plain/templates/jinja/__init__.py +2 -23
- {plain-0.67.0 → plain-0.68.1}/plain/urls/patterns.py +21 -21
- {plain-0.67.0 → plain-0.68.1}/plain/urls/resolvers.py +4 -4
- {plain-0.67.0 → plain-0.68.1}/pyproject.toml +2 -1
- plain-0.67.0/plain/cli/preflight.py +0 -126
- plain-0.67.0/plain/preflight/__init__.py +0 -36
- plain-0.67.0/plain/preflight/files.py +0 -19
- plain-0.67.0/plain/preflight/messages.py +0 -81
- plain-0.67.0/plain/preflight/registry.py +0 -72
- plain-0.67.0/plain/preflight/security.py +0 -89
- plain-0.67.0/plain/preflight/urls.py +0 -54
- {plain-0.67.0 → plain-0.68.1}/.gitignore +0 -0
- {plain-0.67.0 → plain-0.68.1}/LICENSE +0 -0
- {plain-0.67.0 → plain-0.68.1}/README.md +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/AGENTS.md +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/README.md +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/__main__.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/assets/README.md +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/assets/__init__.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/assets/compile.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/assets/finders.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/assets/fingerprints.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/assets/urls.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/assets/views.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/chores/README.md +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/chores/__init__.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/cli/README.md +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/cli/__init__.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/cli/agent/__init__.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/cli/agent/docs.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/cli/agent/llmdocs.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/cli/agent/md.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/cli/agent/prompt.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/cli/agent/request.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/cli/build.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/cli/changelog.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/cli/chores.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/cli/docs.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/cli/formatting.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/cli/install.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/cli/output.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/cli/print.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/cli/scaffold.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/cli/settings.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/cli/shell.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/cli/startup.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/cli/upgrade.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/cli/urls.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/cli/utils.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/csrf/README.md +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/csrf/middleware.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/csrf/views.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/debug.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/exceptions.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/forms/README.md +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/forms/__init__.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/forms/boundfield.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/forms/exceptions.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/forms/fields.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/forms/forms.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/http/README.md +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/http/__init__.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/http/cookie.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/http/multipartparser.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/http/request.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/http/response.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/internal/__init__.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/internal/files/__init__.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/internal/files/base.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/internal/files/locks.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/internal/files/move.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/internal/files/temp.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/internal/files/uploadedfile.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/internal/files/uploadhandler.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/internal/files/utils.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/internal/handlers/__init__.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/internal/handlers/base.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/internal/handlers/exception.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/internal/handlers/wsgi.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/internal/middleware/__init__.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/internal/middleware/headers.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/internal/middleware/hosts.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/internal/middleware/https.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/internal/middleware/slash.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/json.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/logs/README.md +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/logs/__init__.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/logs/configure.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/logs/debug.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/logs/formatters.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/logs/loggers.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/logs/utils.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/packages/README.md +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/packages/__init__.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/packages/config.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/paginator.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/runtime/README.md +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/runtime/__init__.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/runtime/user_settings.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/runtime/utils.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/signals/README.md +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/signals/__init__.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/signals/dispatch/__init__.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/signals/dispatch/dispatcher.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/signals/dispatch/license.txt +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/signing.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/templates/AGENTS.md +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/templates/README.md +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/templates/__init__.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/templates/core.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/templates/jinja/environments.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/templates/jinja/extensions.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/templates/jinja/filters.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/templates/jinja/globals.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/test/README.md +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/test/__init__.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/test/client.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/test/encoding.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/test/exceptions.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/urls/README.md +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/urls/__init__.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/urls/converters.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/urls/exceptions.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/urls/routers.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/urls/utils.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/utils/README.md +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/utils/__init__.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/utils/cache.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/utils/crypto.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/utils/datastructures.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/utils/dateparse.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/utils/deconstruct.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/utils/decorators.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/utils/duration.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/utils/encoding.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/utils/functional.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/utils/hashable.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/utils/html.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/utils/http.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/utils/inspect.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/utils/ipv6.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/utils/itercompat.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/utils/module_loading.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/utils/regex_helper.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/utils/safestring.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/utils/text.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/utils/timesince.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/utils/timezone.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/utils/tree.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/validators.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/views/README.md +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/views/__init__.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/views/base.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/views/errors.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/views/exceptions.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/views/forms.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/views/objects.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/views/redirect.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/views/templates.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/plain/wsgi.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/tests/.gitignore +0 -0
- {plain-0.67.0 → plain-0.68.1}/tests/app/.gitignore +0 -0
- {plain-0.67.0 → plain-0.68.1}/tests/app/settings.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/tests/app/test/__init__.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/tests/app/test/default_settings.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/tests/app/urls.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/tests/conftest.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/tests/test_cli.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/tests/test_csrf.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/tests/test_http_hosts.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/tests/test_logs.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/tests/test_runtime.py +0 -0
- {plain-0.67.0 → plain-0.68.1}/tests/test_wsgi.py +0 -0
@@ -1,5 +1,33 @@
|
|
1
1
|
# plain changelog
|
2
2
|
|
3
|
+
## [0.68.1](https://github.com/dropseed/plain/releases/plain@0.68.1) (2025-09-25)
|
4
|
+
|
5
|
+
### What's changed
|
6
|
+
|
7
|
+
- Preflight checks are now sorted by name for consistent ordering ([cb8e160](https://github.com/dropseed/plain/commit/cb8e160934))
|
8
|
+
|
9
|
+
### Upgrade instructions
|
10
|
+
|
11
|
+
- No changes required
|
12
|
+
|
13
|
+
## [0.68.0](https://github.com/dropseed/plain/releases/plain@0.68.0) (2025-09-25)
|
14
|
+
|
15
|
+
### What's changed
|
16
|
+
|
17
|
+
- Major refactor of the preflight check system with new CLI commands and improved output ([b0b610d461](https://github.com/dropseed/plain/commit/b0b610d461))
|
18
|
+
- Preflight checks now use descriptive IDs instead of numeric codes ([cd96c97b25](https://github.com/dropseed/plain/commit/cd96c97b25))
|
19
|
+
- Unified preflight error messages and hints into a single `fix` field ([c7cde12149](https://github.com/dropseed/plain/commit/c7cde12149))
|
20
|
+
- Added `plain-upgrade` as a standalone command for upgrading Plain packages ([42f2eed80c](https://github.com/dropseed/plain/commit/42f2eed80c))
|
21
|
+
|
22
|
+
### Upgrade instructions
|
23
|
+
|
24
|
+
- Update any uses of the `plain preflight` command to `plain preflight check`, and remove the `--database` and `--fail-level` options which no longer exist
|
25
|
+
- Custom preflight checks should be class based, extending `PreflightCheck` and implementing the `run()` method
|
26
|
+
- Preflight checks need to be registered with a custom name (ex. `@register_check("app.my_custom_check")`) and optionally with `deploy=True` if it should run in only in deploy mode
|
27
|
+
- Preflight results should use `PreflightResult` (optionally with `warning=True`) instead of `preflight.Warning` or `preflight.Error`
|
28
|
+
- Preflight result IDs should be descriptive strings (e.g., `models.lazy_reference_resolution_failed`) instead of numeric codes
|
29
|
+
- `PREFLIGHT_SILENCED_CHECKS` setting has been replaced with `PREFLIGHT_SILENCED_RESULTS` which should contain a list of result IDs to silence. `PREFLIGHT_SILENCED_CHECKS` now silences entire checks by name.
|
30
|
+
|
3
31
|
## [0.67.0](https://github.com/dropseed/plain/releases/plain@0.67.0) (2025-09-22)
|
4
32
|
|
5
33
|
### What's changed
|
@@ -1,6 +1,3 @@
|
|
1
|
-
from importlib import import_module
|
2
|
-
from importlib.util import find_spec
|
3
|
-
|
4
1
|
from plain.packages import packages_registry
|
5
2
|
|
6
3
|
|
@@ -35,21 +32,7 @@ class ChoresRegistry:
|
|
35
32
|
"""
|
36
33
|
Import modules from installed packages and app to trigger registration.
|
37
34
|
"""
|
38
|
-
|
39
|
-
for package_config in packages_registry.get_package_configs():
|
40
|
-
import_name = f"{package_config.name}.chores"
|
41
|
-
try:
|
42
|
-
import_module(import_name)
|
43
|
-
except ModuleNotFoundError:
|
44
|
-
pass
|
45
|
-
|
46
|
-
# Import from app
|
47
|
-
import_name = "app.chores"
|
48
|
-
if find_spec(import_name):
|
49
|
-
try:
|
50
|
-
import_module(import_name)
|
51
|
-
except ModuleNotFoundError:
|
52
|
-
pass
|
35
|
+
packages_registry.autodiscover_modules("chores", include_app=True)
|
53
36
|
|
54
37
|
def get_chores(self):
|
55
38
|
"""
|
@@ -13,7 +13,7 @@ from .chores import chores
|
|
13
13
|
from .docs import docs
|
14
14
|
from .formatting import PlainContext
|
15
15
|
from .install import install
|
16
|
-
from .preflight import
|
16
|
+
from .preflight import preflight_cli
|
17
17
|
from .registry import cli_registry
|
18
18
|
from .scaffold import create
|
19
19
|
from .settings import setting
|
@@ -30,7 +30,7 @@ def plain_cli():
|
|
30
30
|
|
31
31
|
plain_cli.add_command(agent)
|
32
32
|
plain_cli.add_command(docs)
|
33
|
-
plain_cli.add_command(
|
33
|
+
plain_cli.add_command(preflight_cli)
|
34
34
|
plain_cli.add_command(create)
|
35
35
|
plain_cli.add_command(chores)
|
36
36
|
plain_cli.add_command(build)
|
@@ -0,0 +1,247 @@
|
|
1
|
+
import json
|
2
|
+
import sys
|
3
|
+
|
4
|
+
import click
|
5
|
+
|
6
|
+
from plain import preflight
|
7
|
+
from plain.packages import packages_registry
|
8
|
+
from plain.preflight.registry import checks_registry
|
9
|
+
from plain.runtime import settings
|
10
|
+
|
11
|
+
|
12
|
+
@click.group("preflight")
|
13
|
+
def preflight_cli():
|
14
|
+
"""Run or manage preflight checks."""
|
15
|
+
pass
|
16
|
+
|
17
|
+
|
18
|
+
@preflight_cli.command("check")
|
19
|
+
@click.option(
|
20
|
+
"--deploy",
|
21
|
+
is_flag=True,
|
22
|
+
help="Check deployment settings.",
|
23
|
+
)
|
24
|
+
@click.option(
|
25
|
+
"--format",
|
26
|
+
default="text",
|
27
|
+
type=click.Choice(["text", "json"]),
|
28
|
+
help="Output format (default: text)",
|
29
|
+
)
|
30
|
+
@click.option(
|
31
|
+
"--quiet",
|
32
|
+
is_flag=True,
|
33
|
+
help="Hide progress output and warnings, only show errors.",
|
34
|
+
)
|
35
|
+
def check_command(deploy, format, quiet):
|
36
|
+
"""
|
37
|
+
Use the system check framework to validate entire Plain project.
|
38
|
+
Exit with error code if any errors are found. Warnings do not cause failure.
|
39
|
+
"""
|
40
|
+
# Auto-discover and load preflight checks
|
41
|
+
packages_registry.autodiscover_modules("preflight", include_app=True)
|
42
|
+
|
43
|
+
if not quiet:
|
44
|
+
click.secho("Running preflight checks...", dim=True, italic=True, err=True)
|
45
|
+
|
46
|
+
total_checks = 0
|
47
|
+
passed_checks = 0
|
48
|
+
check_results = []
|
49
|
+
|
50
|
+
# Run checks and collect results
|
51
|
+
for check_class, check_name, issues in preflight.run_checks(
|
52
|
+
include_deploy_checks=deploy,
|
53
|
+
):
|
54
|
+
total_checks += 1
|
55
|
+
|
56
|
+
# Filter out silenced issues
|
57
|
+
visible_issues = [issue for issue in issues if not issue.is_silenced()]
|
58
|
+
|
59
|
+
# For text format, show real-time progress
|
60
|
+
if format == "text":
|
61
|
+
if not quiet:
|
62
|
+
# Print check name without newline
|
63
|
+
click.echo("Check:", nl=False, err=True)
|
64
|
+
click.secho(f"{check_name} ", bold=True, nl=False, err=True)
|
65
|
+
|
66
|
+
# Determine status icon based on issue severity
|
67
|
+
if not visible_issues:
|
68
|
+
# No issues - passed
|
69
|
+
if not quiet:
|
70
|
+
click.secho("✔", fg="green", err=True)
|
71
|
+
passed_checks += 1
|
72
|
+
else:
|
73
|
+
# Has issues - determine icon based on highest severity
|
74
|
+
has_errors = any(not issue.warning for issue in visible_issues)
|
75
|
+
if not quiet:
|
76
|
+
if has_errors:
|
77
|
+
click.secho("✗", fg="red", err=True)
|
78
|
+
else:
|
79
|
+
click.secho("⚠", fg="yellow", err=True)
|
80
|
+
|
81
|
+
# Print issues with simple indentation
|
82
|
+
issues_to_show = (
|
83
|
+
visible_issues
|
84
|
+
if not quiet
|
85
|
+
else [issue for issue in visible_issues if not issue.warning]
|
86
|
+
)
|
87
|
+
for i, issue in enumerate(issues_to_show):
|
88
|
+
issue_color = "red" if not issue.warning else "yellow"
|
89
|
+
issue_type = "ERROR" if not issue.warning else "WARNING"
|
90
|
+
|
91
|
+
if quiet:
|
92
|
+
# In quiet mode, show check name once, then issues
|
93
|
+
if i == 0:
|
94
|
+
click.secho(f"{check_name}:", err=True)
|
95
|
+
# Show ID and fix on separate lines with same indentation
|
96
|
+
click.secho(
|
97
|
+
f" [{issue_type}] {issue.id}:",
|
98
|
+
fg=issue_color,
|
99
|
+
bold=True,
|
100
|
+
err=True,
|
101
|
+
nl=False,
|
102
|
+
)
|
103
|
+
click.secho(f" {issue.fix}", err=True, dim=True)
|
104
|
+
else:
|
105
|
+
# Show ID and fix on separate lines with same indentation
|
106
|
+
click.secho(
|
107
|
+
f" [{issue_type}] {issue.id}: ",
|
108
|
+
fg=issue_color,
|
109
|
+
bold=True,
|
110
|
+
err=True,
|
111
|
+
nl=False,
|
112
|
+
)
|
113
|
+
click.secho(f"{issue.fix}", err=True, dim=True)
|
114
|
+
else:
|
115
|
+
# For JSON format, just count passed checks
|
116
|
+
if not visible_issues:
|
117
|
+
passed_checks += 1
|
118
|
+
|
119
|
+
check_results.append((check_class, check_name, issues))
|
120
|
+
|
121
|
+
# Output results based on format
|
122
|
+
|
123
|
+
# Get all issues from check_results instead of maintaining separate list
|
124
|
+
all_issues = [issue for _, _, issues in check_results for issue in issues]
|
125
|
+
# Errors (non-warnings) cause preflight to fail
|
126
|
+
has_errors = any(
|
127
|
+
not issue.warning and not issue.is_silenced() for issue in all_issues
|
128
|
+
)
|
129
|
+
|
130
|
+
if format == "json":
|
131
|
+
# Build JSON output
|
132
|
+
results = {"passed": not has_errors, "checks": []}
|
133
|
+
|
134
|
+
for check_class, check_name, issues in check_results:
|
135
|
+
visible_issues = [issue for issue in issues if not issue.is_silenced()]
|
136
|
+
|
137
|
+
check_result = {
|
138
|
+
"name": check_name,
|
139
|
+
"passed": len(visible_issues) == 0,
|
140
|
+
"issues": [],
|
141
|
+
}
|
142
|
+
|
143
|
+
for issue in visible_issues:
|
144
|
+
issue_data = {
|
145
|
+
"id": issue.id,
|
146
|
+
"warning": issue.warning,
|
147
|
+
"fix": issue.fix,
|
148
|
+
"obj": str(issue.obj) if issue.obj is not None else None,
|
149
|
+
}
|
150
|
+
check_result["issues"].append(issue_data)
|
151
|
+
|
152
|
+
results["checks"].append(check_result)
|
153
|
+
|
154
|
+
click.echo(json.dumps(results, indent=2))
|
155
|
+
else:
|
156
|
+
# Text format summary
|
157
|
+
if not quiet:
|
158
|
+
click.echo()
|
159
|
+
|
160
|
+
# Calculate warning and error counts
|
161
|
+
warning_count = sum(
|
162
|
+
1
|
163
|
+
for _, _, issues in check_results
|
164
|
+
if issues
|
165
|
+
and not any(
|
166
|
+
not issue.warning for issue in issues if not issue.is_silenced()
|
167
|
+
)
|
168
|
+
)
|
169
|
+
error_count = sum(
|
170
|
+
1
|
171
|
+
for _, _, issues in check_results
|
172
|
+
if issues
|
173
|
+
and any(not issue.warning for issue in issues if not issue.is_silenced())
|
174
|
+
)
|
175
|
+
|
176
|
+
# Build colored summary parts
|
177
|
+
summary_parts = []
|
178
|
+
|
179
|
+
if passed_checks > 0:
|
180
|
+
summary_parts.append(click.style(f"{passed_checks} passed", fg="green"))
|
181
|
+
|
182
|
+
if warning_count > 0:
|
183
|
+
summary_parts.append(click.style(f"{warning_count} warnings", fg="yellow"))
|
184
|
+
|
185
|
+
if error_count > 0:
|
186
|
+
summary_parts.append(click.style(f"{error_count} errors", fg="red"))
|
187
|
+
|
188
|
+
# Show checkmark if successful (no errors)
|
189
|
+
if not has_errors:
|
190
|
+
icon = click.style("✔ ", fg="green")
|
191
|
+
summary_color = "green"
|
192
|
+
else:
|
193
|
+
icon = ""
|
194
|
+
summary_color = None
|
195
|
+
|
196
|
+
summary_text = ", ".join(summary_parts) if summary_parts else "no issues"
|
197
|
+
|
198
|
+
click.secho(f"{icon}{summary_text}", fg=summary_color, err=True)
|
199
|
+
|
200
|
+
# Exit with error if there are any errors (not warnings)
|
201
|
+
if has_errors:
|
202
|
+
sys.exit(1)
|
203
|
+
|
204
|
+
|
205
|
+
@preflight_cli.command("list")
|
206
|
+
def list_checks():
|
207
|
+
"""List all available preflight checks."""
|
208
|
+
packages_registry.autodiscover_modules("preflight", include_app=True)
|
209
|
+
|
210
|
+
regular = []
|
211
|
+
deployment = []
|
212
|
+
silenced_checks = settings.PREFLIGHT_SILENCED_CHECKS
|
213
|
+
|
214
|
+
for name, (check_class, deploy) in sorted(checks_registry.checks.items()):
|
215
|
+
# Use class docstring as description
|
216
|
+
description = check_class.__doc__ or "No description"
|
217
|
+
# Get first line of docstring
|
218
|
+
description = description.strip().split("\n")[0]
|
219
|
+
|
220
|
+
is_silenced = name in silenced_checks
|
221
|
+
if deploy:
|
222
|
+
deployment.append((name, description, is_silenced))
|
223
|
+
else:
|
224
|
+
regular.append((name, description, is_silenced))
|
225
|
+
|
226
|
+
if regular:
|
227
|
+
click.echo("Regular checks:")
|
228
|
+
for name, description, is_silenced in regular:
|
229
|
+
silenced_text = (
|
230
|
+
click.style(" (silenced)", fg="red", dim=True) if is_silenced else ""
|
231
|
+
)
|
232
|
+
click.echo(
|
233
|
+
f" {click.style(name)}: {click.style(description, dim=True)}{silenced_text}"
|
234
|
+
)
|
235
|
+
|
236
|
+
if deployment:
|
237
|
+
click.echo("\nDeployment checks:")
|
238
|
+
for name, description, is_silenced in deployment:
|
239
|
+
silenced_text = (
|
240
|
+
click.style(" (silenced)", fg="red", dim=True) if is_silenced else ""
|
241
|
+
)
|
242
|
+
click.echo(
|
243
|
+
f" {click.style(name)}: {click.style(description, dim=True)}{silenced_text}"
|
244
|
+
)
|
245
|
+
|
246
|
+
if not regular and not deployment:
|
247
|
+
click.echo("No preflight checks found.")
|
@@ -1,6 +1,3 @@
|
|
1
|
-
from importlib import import_module
|
2
|
-
from importlib.util import find_spec
|
3
|
-
|
4
1
|
from plain.packages import packages_registry
|
5
2
|
|
6
3
|
|
@@ -18,21 +15,7 @@ class CLIRegistry:
|
|
18
15
|
"""
|
19
16
|
Import modules from installed packages and app to trigger registration.
|
20
17
|
"""
|
21
|
-
|
22
|
-
for package_config in packages_registry.get_package_configs():
|
23
|
-
import_name = f"{package_config.name}.cli"
|
24
|
-
try:
|
25
|
-
import_module(import_name)
|
26
|
-
except ModuleNotFoundError:
|
27
|
-
pass
|
28
|
-
|
29
|
-
# Import from app
|
30
|
-
import_name = "app.cli"
|
31
|
-
if find_spec(import_name):
|
32
|
-
try:
|
33
|
-
import_module(import_name)
|
34
|
-
except ModuleNotFoundError:
|
35
|
-
pass
|
18
|
+
packages_registry.autodiscover_modules("cli", include_app=True)
|
36
19
|
|
37
20
|
def get_commands(self):
|
38
21
|
"""
|
@@ -2,6 +2,7 @@ import sys
|
|
2
2
|
import threading
|
3
3
|
from collections import Counter
|
4
4
|
from importlib import import_module
|
5
|
+
from importlib.util import find_spec
|
5
6
|
|
6
7
|
from plain.exceptions import ImproperlyConfigured, PackageRegistryNotReady
|
7
8
|
|
@@ -188,6 +189,19 @@ class PackagesRegistry:
|
|
188
189
|
|
189
190
|
return package_config
|
190
191
|
|
192
|
+
def autodiscover_modules(self, module_name: str, *, include_app: bool) -> None:
|
193
|
+
def _import_if_exists(name):
|
194
|
+
if find_spec(name):
|
195
|
+
import_module(name)
|
196
|
+
|
197
|
+
# Load from all packages
|
198
|
+
for package_config in self.get_package_configs():
|
199
|
+
_import_if_exists(f"{package_config.name}.{module_name}")
|
200
|
+
|
201
|
+
# Load from app if requested
|
202
|
+
if include_app:
|
203
|
+
_import_if_exists(f"app.{module_name}")
|
204
|
+
|
191
205
|
|
192
206
|
packages_registry = PackagesRegistry(installed_packages=None)
|
193
207
|
|
@@ -13,7 +13,7 @@
|
|
13
13
|
Preflight checks help identify issues with your settings or environment before running your application.
|
14
14
|
|
15
15
|
```bash
|
16
|
-
plain preflight
|
16
|
+
plain preflight check
|
17
17
|
```
|
18
18
|
|
19
19
|
## Development
|
@@ -22,10 +22,10 @@ If you use [`plain.dev`](/plain-dev/README.md) for local development, the Plain
|
|
22
22
|
|
23
23
|
## Deployment
|
24
24
|
|
25
|
-
The `plain preflight` 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.
|
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.
|
26
26
|
|
27
27
|
```bash
|
28
|
-
plain preflight --deploy
|
28
|
+
plain preflight check --deploy
|
29
29
|
```
|
30
30
|
|
31
31
|
## Custom preflight checks
|
@@ -0,0 +1,16 @@
|
|
1
|
+
from .checks import PreflightCheck
|
2
|
+
from .registry import register_check, run_checks
|
3
|
+
from .results import PreflightResult
|
4
|
+
|
5
|
+
# Import these to force registration of checks
|
6
|
+
import plain.preflight.files # NOQA isort:skip
|
7
|
+
import plain.preflight.security # NOQA isort:skip
|
8
|
+
import plain.preflight.urls # NOQA isort:skip
|
9
|
+
|
10
|
+
|
11
|
+
__all__ = [
|
12
|
+
"PreflightCheck",
|
13
|
+
"PreflightResult",
|
14
|
+
"register_check",
|
15
|
+
"run_checks",
|
16
|
+
]
|
@@ -0,0 +1,23 @@
|
|
1
|
+
from pathlib import Path
|
2
|
+
|
3
|
+
from plain.runtime import settings
|
4
|
+
|
5
|
+
from .checks import PreflightCheck
|
6
|
+
from .registry import register_check
|
7
|
+
from .results import PreflightResult
|
8
|
+
|
9
|
+
|
10
|
+
@register_check("files.upload_temp_dir")
|
11
|
+
class CheckSettingFileUploadTempDir(PreflightCheck):
|
12
|
+
"""Validates that the FILE_UPLOAD_TEMP_DIR setting points to an existing directory."""
|
13
|
+
|
14
|
+
def run(self):
|
15
|
+
setting = settings.FILE_UPLOAD_TEMP_DIR
|
16
|
+
if setting and not Path(setting).is_dir():
|
17
|
+
return [
|
18
|
+
PreflightResult(
|
19
|
+
fix=f"FILE_UPLOAD_TEMP_DIR points to nonexistent directory '{setting}'. Create the directory or update the setting.",
|
20
|
+
id="files.upload_temp_dir_nonexistent",
|
21
|
+
)
|
22
|
+
]
|
23
|
+
return []
|
@@ -0,0 +1,79 @@
|
|
1
|
+
from plain.runtime import settings
|
2
|
+
|
3
|
+
|
4
|
+
class CheckRegistry:
|
5
|
+
def __init__(self):
|
6
|
+
self.checks = {} # name -> (check_class, deploy)
|
7
|
+
|
8
|
+
def register_check(self, check_class, name, deploy=False):
|
9
|
+
"""Register a check class with a unique name."""
|
10
|
+
if name in self.checks:
|
11
|
+
raise ValueError(f"Check {name} already registered")
|
12
|
+
self.checks[name] = (check_class, deploy)
|
13
|
+
|
14
|
+
def run_checks(
|
15
|
+
self,
|
16
|
+
include_deploy_checks=False,
|
17
|
+
):
|
18
|
+
"""
|
19
|
+
Run all registered checks and yield (check_class, name, results) tuples.
|
20
|
+
"""
|
21
|
+
# Validate silenced check names
|
22
|
+
silenced_checks = settings.PREFLIGHT_SILENCED_CHECKS
|
23
|
+
unknown_silenced = set(silenced_checks) - set(self.checks.keys())
|
24
|
+
if unknown_silenced:
|
25
|
+
unknown_names = ", ".join(sorted(unknown_silenced))
|
26
|
+
raise ValueError(
|
27
|
+
f"Unknown check names in PREFLIGHT_SILENCED_CHECKS: {unknown_names}. "
|
28
|
+
"Check for typos or remove outdated check names."
|
29
|
+
)
|
30
|
+
|
31
|
+
for name, (check_class, deploy) in sorted(self.checks.items()):
|
32
|
+
# Skip silenced checks
|
33
|
+
if name in silenced_checks:
|
34
|
+
continue
|
35
|
+
|
36
|
+
# Skip deployment checks if not requested
|
37
|
+
if deploy and not include_deploy_checks:
|
38
|
+
continue
|
39
|
+
|
40
|
+
# Instantiate and run check
|
41
|
+
check = check_class()
|
42
|
+
results = check.run()
|
43
|
+
yield check_class, name, results
|
44
|
+
|
45
|
+
def get_checks(self, include_deploy_checks=False):
|
46
|
+
"""Get list of (check_class, name) tuples."""
|
47
|
+
result = []
|
48
|
+
for name, (check_class, deploy) in self.checks.items():
|
49
|
+
if deploy and not include_deploy_checks:
|
50
|
+
continue
|
51
|
+
result.append((check_class, name))
|
52
|
+
return result
|
53
|
+
|
54
|
+
|
55
|
+
checks_registry = CheckRegistry()
|
56
|
+
|
57
|
+
|
58
|
+
def register_check(name: str, *, deploy: bool = False):
|
59
|
+
"""
|
60
|
+
Decorator to register a check class.
|
61
|
+
|
62
|
+
Usage:
|
63
|
+
@register_check("security.secret_key", deploy=True)
|
64
|
+
class CheckSecretKey(PreflightCheck):
|
65
|
+
pass
|
66
|
+
|
67
|
+
@register_check("files.upload_temp_dir")
|
68
|
+
class CheckUploadTempDir(PreflightCheck):
|
69
|
+
pass
|
70
|
+
"""
|
71
|
+
|
72
|
+
def wrapper(cls):
|
73
|
+
checks_registry.register_check(cls, name=name, deploy=deploy)
|
74
|
+
return cls
|
75
|
+
|
76
|
+
return wrapper
|
77
|
+
|
78
|
+
|
79
|
+
run_checks = checks_registry.run_checks
|
@@ -0,0 +1,29 @@
|
|
1
|
+
from plain.runtime import settings
|
2
|
+
|
3
|
+
|
4
|
+
class PreflightResult:
|
5
|
+
def __init__(self, *, fix: str, id: str, obj=None, warning: bool = False):
|
6
|
+
self.fix = fix
|
7
|
+
self.obj = obj
|
8
|
+
self.id = id
|
9
|
+
self.warning = warning
|
10
|
+
|
11
|
+
def __eq__(self, other):
|
12
|
+
return isinstance(other, self.__class__) and all(
|
13
|
+
getattr(self, attr) == getattr(other, attr)
|
14
|
+
for attr in ["fix", "obj", "id", "warning"]
|
15
|
+
)
|
16
|
+
|
17
|
+
def __str__(self):
|
18
|
+
if self.obj is None:
|
19
|
+
obj = ""
|
20
|
+
elif hasattr(self.obj, "_meta") and hasattr(self.obj._meta, "label"):
|
21
|
+
# Duck type for model objects - use their meta label
|
22
|
+
obj = self.obj._meta.label
|
23
|
+
else:
|
24
|
+
obj = str(self.obj)
|
25
|
+
id_part = f"({self.id}) " if self.id else ""
|
26
|
+
return f"{obj}: {id_part}{self.fix}"
|
27
|
+
|
28
|
+
def is_silenced(self):
|
29
|
+
return self.id and self.id in settings.PREFLIGHT_SILENCED_RESULTS
|
@@ -0,0 +1,81 @@
|
|
1
|
+
from plain.runtime import settings
|
2
|
+
|
3
|
+
from .checks import PreflightCheck
|
4
|
+
from .registry import register_check
|
5
|
+
from .results import PreflightResult
|
6
|
+
|
7
|
+
SECRET_KEY_MIN_LENGTH = 50
|
8
|
+
SECRET_KEY_MIN_UNIQUE_CHARACTERS = 5
|
9
|
+
|
10
|
+
|
11
|
+
def _check_secret_key(secret_key):
|
12
|
+
return (
|
13
|
+
len(set(secret_key)) >= SECRET_KEY_MIN_UNIQUE_CHARACTERS
|
14
|
+
and len(secret_key) >= SECRET_KEY_MIN_LENGTH
|
15
|
+
)
|
16
|
+
|
17
|
+
|
18
|
+
@register_check(name="security.secret_key", deploy=True)
|
19
|
+
class CheckSecretKey(PreflightCheck):
|
20
|
+
"""Validates that SECRET_KEY is long and random enough for security."""
|
21
|
+
|
22
|
+
def run(self):
|
23
|
+
if not _check_secret_key(settings.SECRET_KEY):
|
24
|
+
return [
|
25
|
+
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
|
+
f"Plain's security features will be vulnerable to attack.",
|
29
|
+
id="security.secret_key_weak",
|
30
|
+
)
|
31
|
+
]
|
32
|
+
return []
|
33
|
+
|
34
|
+
|
35
|
+
@register_check(name="security.secret_key_fallbacks", deploy=True)
|
36
|
+
class CheckSecretKeyFallbacks(PreflightCheck):
|
37
|
+
"""Validates that SECRET_KEY_FALLBACKS are long and random enough for security."""
|
38
|
+
|
39
|
+
def run(self):
|
40
|
+
errors = []
|
41
|
+
for index, key in enumerate(settings.SECRET_KEY_FALLBACKS):
|
42
|
+
if not _check_secret_key(key):
|
43
|
+
errors.append(
|
44
|
+
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
|
+
f"Plain's security features will be vulnerable to attack.",
|
48
|
+
id="security.secret_key_fallback_weak",
|
49
|
+
)
|
50
|
+
)
|
51
|
+
return errors
|
52
|
+
|
53
|
+
|
54
|
+
@register_check(name="security.debug", deploy=True)
|
55
|
+
class CheckDebug(PreflightCheck):
|
56
|
+
"""Ensures DEBUG is False in production deployment."""
|
57
|
+
|
58
|
+
def run(self):
|
59
|
+
if settings.DEBUG:
|
60
|
+
return [
|
61
|
+
PreflightResult(
|
62
|
+
fix="DEBUG is True in deployment. Set DEBUG=False to prevent exposing sensitive information.",
|
63
|
+
id="security.debug_enabled_in_production",
|
64
|
+
)
|
65
|
+
]
|
66
|
+
return []
|
67
|
+
|
68
|
+
|
69
|
+
@register_check(name="security.allowed_hosts", deploy=True)
|
70
|
+
class CheckAllowedHosts(PreflightCheck):
|
71
|
+
"""Ensures ALLOWED_HOSTS is not empty in production deployment."""
|
72
|
+
|
73
|
+
def run(self):
|
74
|
+
if not settings.ALLOWED_HOSTS:
|
75
|
+
return [
|
76
|
+
PreflightResult(
|
77
|
+
fix="ALLOWED_HOSTS is empty in deployment. Add your domain(s) to prevent host header attacks.",
|
78
|
+
id="security.allowed_hosts_empty",
|
79
|
+
)
|
80
|
+
]
|
81
|
+
return []
|
@@ -0,0 +1,13 @@
|
|
1
|
+
from .checks import PreflightCheck
|
2
|
+
from .registry import register_check
|
3
|
+
|
4
|
+
|
5
|
+
@register_check("urls.config")
|
6
|
+
class CheckUrlConfig(PreflightCheck):
|
7
|
+
"""Validates the URL configuration for common issues."""
|
8
|
+
|
9
|
+
def run(self):
|
10
|
+
from plain.urls import get_resolver
|
11
|
+
|
12
|
+
resolver = get_resolver()
|
13
|
+
return resolver.preflight()
|