plain 0.66.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.
- plain/CHANGELOG.md +684 -0
- plain/README.md +1 -1
- 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 -53
- plain/cli/README.md +185 -16
- plain/cli/__init__.py +2 -1
- plain/cli/agent.py +236 -0
- plain/cli/build.py +7 -8
- plain/cli/changelog.py +11 -5
- plain/cli/chores.py +32 -34
- plain/cli/core.py +112 -28
- plain/cli/docs.py +52 -11
- plain/cli/formatting.py +40 -17
- plain/cli/install.py +10 -54
- plain/cli/{agent/llmdocs.py → llmdocs.py} +21 -9
- plain/cli/output.py +6 -2
- plain/cli/preflight.py +175 -102
- plain/cli/print.py +4 -4
- plain/cli/registry.py +95 -26
- plain/cli/request.py +206 -0
- 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 -13
- 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 +40 -15
- plain/paginator.py +31 -21
- plain/preflight/README.md +208 -23
- plain/preflight/__init__.py +5 -24
- plain/preflight/checks.py +12 -0
- plain/preflight/files.py +19 -13
- plain/preflight/registry.py +80 -58
- plain/preflight/results.py +37 -0
- plain/preflight/security.py +65 -71
- plain/preflight/settings.py +54 -0
- plain/preflight/urls.py +10 -48
- plain/runtime/README.md +115 -47
- plain/runtime/__init__.py +10 -6
- plain/runtime/global_settings.py +43 -33
- 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/skills/README.md +36 -0
- plain/skills/plain-docs/SKILL.md +25 -0
- plain/skills/plain-install/SKILL.md +26 -0
- plain/skills/plain-request/SKILL.md +39 -0
- plain/skills/plain-shell/SKILL.md +24 -0
- plain/skills/plain-upgrade/SKILL.md +35 -0
- plain/templates/README.md +211 -20
- plain/templates/jinja/__init__.py +14 -27
- 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 +56 -40
- plain/urls/resolvers.py +38 -28
- 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.66.0.dist-info → plain-0.101.2.dist-info}/METADATA +4 -2
- plain-0.101.2.dist-info/RECORD +201 -0
- {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/WHEEL +1 -1
- plain-0.101.2.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/cli/agent/request.py +0 -181
- plain/csrf/views.py +0 -31
- plain/logs/utils.py +0 -46
- plain/preflight/messages.py +0 -81
- plain/templates/AGENTS.md +0 -3
- plain-0.66.0.dist-info/RECORD +0 -168
- plain-0.66.0.dist-info/entry_points.txt +0 -4
- {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/licenses/LICENSE +0 -0
plain/preflight/security.py
CHANGED
|
@@ -1,89 +1,83 @@
|
|
|
1
|
-
from
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
from plain.runtime import settings
|
|
3
4
|
|
|
4
|
-
from .
|
|
5
|
+
from .checks import PreflightCheck
|
|
5
6
|
from .registry import register_check
|
|
7
|
+
from .results import PreflightResult
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
SECRET_KEY_WARNING_MSG = (
|
|
11
|
-
f"Your %s has less than {SECRET_KEY_MIN_LENGTH} characters or less than "
|
|
12
|
-
f"{SECRET_KEY_MIN_UNIQUE_CHARACTERS} unique characters. Please generate "
|
|
13
|
-
f"a long and random value, otherwise many of Plain's security-critical "
|
|
14
|
-
f"features will be vulnerable to attack."
|
|
15
|
-
)
|
|
9
|
+
_SECRET_KEY_MIN_LENGTH = 50
|
|
10
|
+
_SECRET_KEY_MIN_UNIQUE_CHARACTERS = 5
|
|
16
11
|
|
|
17
|
-
W025 = Warning(SECRET_KEY_WARNING_MSG, id="security.W025")
|
|
18
12
|
|
|
19
|
-
|
|
20
|
-
def _check_secret_key(secret_key):
|
|
13
|
+
def _check_secret_key(secret_key: str) -> bool:
|
|
21
14
|
return (
|
|
22
|
-
len(set(secret_key)) >=
|
|
23
|
-
and len(secret_key) >=
|
|
15
|
+
len(set(secret_key)) >= _SECRET_KEY_MIN_UNIQUE_CHARACTERS
|
|
16
|
+
and len(secret_key) >= _SECRET_KEY_MIN_LENGTH
|
|
24
17
|
)
|
|
25
18
|
|
|
26
19
|
|
|
27
|
-
@register_check(deploy=True)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
]
|
|
44
|
-
)
|
|
20
|
+
@register_check(name="security.secret_key", deploy=True)
|
|
21
|
+
class CheckSecretKey(PreflightCheck):
|
|
22
|
+
"""Validates that SECRET_KEY is long and random enough for security."""
|
|
23
|
+
|
|
24
|
+
def run(self) -> list[PreflightResult]:
|
|
25
|
+
if not _check_secret_key(settings.SECRET_KEY):
|
|
26
|
+
return [
|
|
27
|
+
PreflightResult(
|
|
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 "
|
|
30
|
+
f"Plain's security features will be vulnerable to attack.",
|
|
31
|
+
id="security.secret_key_weak",
|
|
32
|
+
)
|
|
33
|
+
]
|
|
34
|
+
return []
|
|
35
|
+
|
|
45
36
|
|
|
37
|
+
@register_check(name="security.secret_key_fallbacks", deploy=True)
|
|
38
|
+
class CheckSecretKeyFallbacks(PreflightCheck):
|
|
39
|
+
"""Validates that SECRET_KEY_FALLBACKS are long and random enough for security."""
|
|
46
40
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
try:
|
|
51
|
-
fallbacks = settings.SECRET_KEY_FALLBACKS
|
|
52
|
-
except (ImproperlyConfigured, AttributeError):
|
|
53
|
-
warnings.append(Warning(W025.msg % "SECRET_KEY_FALLBACKS", id=W025.id))
|
|
54
|
-
else:
|
|
55
|
-
for index, key in enumerate(fallbacks):
|
|
41
|
+
def run(self) -> list[PreflightResult]:
|
|
42
|
+
errors = []
|
|
43
|
+
for index, key in enumerate(settings.SECRET_KEY_FALLBACKS):
|
|
56
44
|
if not _check_secret_key(key):
|
|
57
|
-
|
|
58
|
-
|
|
45
|
+
errors.append(
|
|
46
|
+
PreflightResult(
|
|
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 "
|
|
49
|
+
f"Plain's security features will be vulnerable to attack.",
|
|
50
|
+
id="security.secret_key_fallback_weak",
|
|
51
|
+
)
|
|
59
52
|
)
|
|
60
|
-
|
|
53
|
+
return errors
|
|
61
54
|
|
|
62
55
|
|
|
63
|
-
@register_check(deploy=True)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return (
|
|
67
|
-
[]
|
|
68
|
-
if passed_check
|
|
69
|
-
else [
|
|
70
|
-
Warning(
|
|
71
|
-
"You should not have DEBUG set to True in deployment.",
|
|
72
|
-
id="security.W018",
|
|
73
|
-
)
|
|
74
|
-
]
|
|
75
|
-
)
|
|
56
|
+
@register_check(name="security.debug", deploy=True)
|
|
57
|
+
class CheckDebug(PreflightCheck):
|
|
58
|
+
"""Ensures DEBUG is False in production deployment."""
|
|
76
59
|
|
|
60
|
+
def run(self) -> list[PreflightResult]:
|
|
61
|
+
if settings.DEBUG:
|
|
62
|
+
return [
|
|
63
|
+
PreflightResult(
|
|
64
|
+
fix="DEBUG is True in deployment. Set DEBUG=False to prevent exposing sensitive information.",
|
|
65
|
+
id="security.debug_enabled_in_production",
|
|
66
|
+
)
|
|
67
|
+
]
|
|
68
|
+
return []
|
|
77
69
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
70
|
+
|
|
71
|
+
@register_check(name="security.allowed_hosts", deploy=True)
|
|
72
|
+
class CheckAllowedHosts(PreflightCheck):
|
|
73
|
+
"""Ensures ALLOWED_HOSTS is not empty in production deployment."""
|
|
74
|
+
|
|
75
|
+
def run(self) -> list[PreflightResult]:
|
|
76
|
+
if not settings.ALLOWED_HOSTS:
|
|
77
|
+
return [
|
|
78
|
+
PreflightResult(
|
|
79
|
+
fix="ALLOWED_HOSTS is empty in deployment. Add your domain(s) to prevent host header attacks.",
|
|
80
|
+
id="security.allowed_hosts_empty",
|
|
81
|
+
)
|
|
82
|
+
]
|
|
83
|
+
return []
|
|
@@ -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,54 +1,16 @@
|
|
|
1
|
-
from
|
|
1
|
+
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from . import
|
|
3
|
+
from .checks import PreflightCheck
|
|
4
|
+
from .registry import register_check
|
|
5
|
+
from .results import PreflightResult
|
|
4
6
|
|
|
5
7
|
|
|
6
|
-
@register_check
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
@register_check("urls.config")
|
|
9
|
+
class CheckUrlConfig(PreflightCheck):
|
|
10
|
+
"""Validates the URL configuration for common issues."""
|
|
11
|
+
|
|
12
|
+
def run(self) -> list[PreflightResult]:
|
|
9
13
|
from plain.urls import get_resolver
|
|
10
14
|
|
|
11
15
|
resolver = get_resolver()
|
|
12
|
-
return
|
|
13
|
-
|
|
14
|
-
return []
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def check_resolver(resolver):
|
|
18
|
-
"""
|
|
19
|
-
Recursively check the resolver.
|
|
20
|
-
"""
|
|
21
|
-
check_method = getattr(resolver, "check", None)
|
|
22
|
-
if check_method is not None:
|
|
23
|
-
return check_method()
|
|
24
|
-
elif not hasattr(resolver, "resolve"):
|
|
25
|
-
return get_warning_for_invalid_pattern(resolver)
|
|
26
|
-
else:
|
|
27
|
-
return []
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def get_warning_for_invalid_pattern(pattern):
|
|
31
|
-
"""
|
|
32
|
-
Return a list containing a warning that the pattern is invalid.
|
|
33
|
-
|
|
34
|
-
describe_pattern() cannot be used here, because we cannot rely on the
|
|
35
|
-
urlpattern having regex or name attributes.
|
|
36
|
-
"""
|
|
37
|
-
if isinstance(pattern, str):
|
|
38
|
-
hint = (
|
|
39
|
-
f"Try removing the string '{pattern}'. The list of urlpatterns should not "
|
|
40
|
-
"have a prefix string as the first element."
|
|
41
|
-
)
|
|
42
|
-
elif isinstance(pattern, tuple):
|
|
43
|
-
hint = "Try using path() instead of a tuple."
|
|
44
|
-
else:
|
|
45
|
-
hint = None
|
|
46
|
-
|
|
47
|
-
return [
|
|
48
|
-
Error(
|
|
49
|
-
f"Your URL pattern {pattern!r} is invalid. Ensure that urlpatterns is a list "
|
|
50
|
-
"of path() and/or re_path() instances.",
|
|
51
|
-
hint=hint,
|
|
52
|
-
id="urls.E004",
|
|
53
|
-
)
|
|
54
|
-
]
|
|
16
|
+
return resolver.preflight()
|
plain/runtime/README.md
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
# Runtime
|
|
2
2
|
|
|
3
|
-
**Access
|
|
3
|
+
**Access and configure settings for your Plain application.**
|
|
4
4
|
|
|
5
5
|
- [Overview](#overview)
|
|
6
6
|
- [Environment variables](#environment-variables)
|
|
7
7
|
- [.env files](#env-files)
|
|
8
|
+
- [Custom prefixes](#custom-prefixes)
|
|
8
9
|
- [Package settings](#package-settings)
|
|
9
|
-
- [Custom app
|
|
10
|
-
- [
|
|
10
|
+
- [Custom app settings](#custom-app-settings)
|
|
11
|
+
- [Secret values](#secret-values)
|
|
12
|
+
- [Using Plain outside of an app](#using-plain-outside-of-an-app)
|
|
13
|
+
- [FAQs](#faqs)
|
|
14
|
+
- [Installation](#installation)
|
|
11
15
|
|
|
12
16
|
## Overview
|
|
13
17
|
|
|
14
|
-
|
|
18
|
+
You configure Plain through settings, which are Python variables defined in your `app/settings.py` file.
|
|
15
19
|
|
|
16
20
|
```python
|
|
17
21
|
# app/settings.py
|
|
22
|
+
from plain.runtime import Secret
|
|
23
|
+
|
|
24
|
+
SECRET_KEY: Secret[str]
|
|
25
|
+
|
|
18
26
|
URLS_ROUTER = "app.urls.AppRouter"
|
|
19
27
|
|
|
20
28
|
TIME_ZONE = "America/Chicago"
|
|
@@ -23,11 +31,9 @@ INSTALLED_PACKAGES = [
|
|
|
23
31
|
"plain.models",
|
|
24
32
|
"plain.tailwind",
|
|
25
33
|
"plain.auth",
|
|
26
|
-
"plain.passwords",
|
|
27
34
|
"plain.sessions",
|
|
28
35
|
"plain.htmx",
|
|
29
36
|
"plain.admin",
|
|
30
|
-
"plain.elements",
|
|
31
37
|
# Local packages
|
|
32
38
|
"app.users",
|
|
33
39
|
]
|
|
@@ -37,107 +43,99 @@ AUTH_LOGIN_URL = "login"
|
|
|
37
43
|
|
|
38
44
|
MIDDLEWARE = [
|
|
39
45
|
"plain.sessions.middleware.SessionMiddleware",
|
|
40
|
-
"plain.auth.middleware.AuthenticationMiddleware",
|
|
41
46
|
"plain.admin.AdminMiddleware",
|
|
42
47
|
]
|
|
43
48
|
```
|
|
44
49
|
|
|
45
|
-
|
|
50
|
+
You can access settings anywhere in your application via `plain.runtime.settings`.
|
|
46
51
|
|
|
47
52
|
```python
|
|
48
53
|
from plain.runtime import settings
|
|
49
54
|
|
|
50
|
-
print(settings.
|
|
55
|
+
print(settings.TIME_ZONE)
|
|
56
|
+
print(settings.DEBUG)
|
|
51
57
|
```
|
|
52
58
|
|
|
53
|
-
|
|
59
|
+
Plain's built-in settings are defined in [`global_settings.py`](./global_settings.py). Each installed package can also define its own settings in a `default_settings.py` file.
|
|
54
60
|
|
|
55
61
|
## Environment variables
|
|
56
62
|
|
|
57
|
-
|
|
63
|
+
Type-annotated settings can be loaded from environment variables using a `PLAIN_` prefix.
|
|
58
64
|
|
|
59
|
-
For example,
|
|
65
|
+
For example, if you define a setting with a type annotation:
|
|
60
66
|
|
|
61
67
|
```python
|
|
62
68
|
SECRET_KEY: str
|
|
63
69
|
```
|
|
64
70
|
|
|
65
|
-
|
|
71
|
+
You can set it via an environment variable:
|
|
66
72
|
|
|
67
73
|
```bash
|
|
68
74
|
PLAIN_SECRET_KEY=supersecret
|
|
69
75
|
```
|
|
70
76
|
|
|
71
|
-
For
|
|
77
|
+
For lists, dicts, and other complex types, use JSON-encoded values:
|
|
72
78
|
|
|
73
79
|
```python
|
|
74
|
-
|
|
80
|
+
ALLOWED_HOSTS: list[str]
|
|
75
81
|
```
|
|
76
82
|
|
|
77
|
-
|
|
83
|
+
```bash
|
|
84
|
+
PLAIN_ALLOWED_HOSTS='["example.com", "www.example.com"]'
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Boolean settings accept `true`, `1`, `yes` (case-insensitive) as truthy values:
|
|
78
88
|
|
|
79
89
|
```bash
|
|
80
|
-
|
|
90
|
+
PLAIN_DEBUG=true
|
|
81
91
|
```
|
|
82
92
|
|
|
83
|
-
|
|
93
|
+
### .env files
|
|
84
94
|
|
|
85
|
-
|
|
86
|
-
# plain/models/default_settings.py
|
|
87
|
-
from os import environ
|
|
95
|
+
Plain does not load `.env` files automatically. If you use [`plain.dev`](/plain-dev/README.md), it loads `.env` files for you during development. For production, you need to load them yourself or rely on your deployment platform to inject environment variables.
|
|
88
96
|
|
|
89
|
-
|
|
97
|
+
### Custom prefixes
|
|
90
98
|
|
|
91
|
-
|
|
92
|
-
DATABASE: dict
|
|
99
|
+
You can configure additional environment variable prefixes using `ENV_SETTINGS_PREFIXES`:
|
|
93
100
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
environ["DATABASE_URL"],
|
|
98
|
-
# Enable persistent connections by default
|
|
99
|
-
conn_max_age=int(environ.get("DATABASE_CONN_MAX_AGE", 600)),
|
|
100
|
-
conn_health_checks=environ.get("DATABASE_CONN_HEALTH_CHECKS", "true").lower()
|
|
101
|
-
in [
|
|
102
|
-
"true",
|
|
103
|
-
"1",
|
|
104
|
-
],
|
|
105
|
-
)
|
|
101
|
+
```python
|
|
102
|
+
# app/settings.py
|
|
103
|
+
ENV_SETTINGS_PREFIXES = ["PLAIN_", "MYAPP_"]
|
|
106
104
|
```
|
|
107
105
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
Plain itself does not load `.env` files automatically, except in development if you use [`plain.dev`](/plain-dev/README.md). If you use `.env` files in production then you will need to load them yourself.
|
|
106
|
+
Now both `PLAIN_DEBUG=true` and `MYAPP_DEBUG=true` would set the `DEBUG` setting. The first matching prefix wins if the same setting appears with multiple prefixes.
|
|
111
107
|
|
|
112
108
|
## Package settings
|
|
113
109
|
|
|
114
|
-
|
|
110
|
+
Installed packages can provide default settings via a `default_settings.py` file. It's best practice to prefix package settings with the package name to avoid conflicts.
|
|
115
111
|
|
|
116
112
|
```python
|
|
117
113
|
# app/users/default_settings.py
|
|
118
114
|
USERS_DEFAULT_ROLE = "user"
|
|
119
115
|
```
|
|
120
116
|
|
|
121
|
-
|
|
117
|
+
To make a setting required (no default value), define it with only a type annotation:
|
|
122
118
|
|
|
123
119
|
```python
|
|
124
120
|
# app/users/default_settings.py
|
|
125
121
|
USERS_DEFAULT_ROLE: str
|
|
126
122
|
```
|
|
127
123
|
|
|
128
|
-
Type annotations
|
|
124
|
+
Type annotations provide basic runtime validation. If a setting is defined as `str` but someone sets it to an `int`, Plain raises an error.
|
|
129
125
|
|
|
130
126
|
```python
|
|
131
127
|
# app/users/default_settings.py
|
|
132
|
-
USERS_DEFAULT_ROLE: str = "user"
|
|
128
|
+
USERS_DEFAULT_ROLE: str = "user" # Optional with a default
|
|
133
129
|
```
|
|
134
130
|
|
|
135
|
-
## Custom app
|
|
131
|
+
## Custom app settings
|
|
136
132
|
|
|
137
|
-
|
|
133
|
+
You can create your own app-wide settings by prefixing them with `APP_`:
|
|
138
134
|
|
|
139
135
|
```python
|
|
140
136
|
# app/settings.py
|
|
137
|
+
import os
|
|
138
|
+
|
|
141
139
|
# A required env setting
|
|
142
140
|
APP_STRIPE_SECRET_KEY = os.environ["STRIPE_SECRET_KEY"]
|
|
143
141
|
|
|
@@ -149,12 +147,82 @@ with open("app/secret_key.txt") as f:
|
|
|
149
147
|
APP_EXAMPLE_KEY = f.read().strip()
|
|
150
148
|
```
|
|
151
149
|
|
|
152
|
-
|
|
150
|
+
Settings without the `APP_` prefix that aren't recognized by Plain or installed packages will raise an error.
|
|
151
|
+
|
|
152
|
+
## Secret values
|
|
153
|
+
|
|
154
|
+
You can mark sensitive settings using the [`Secret`](./secret.py#Secret) type. Secret values are masked when displayed in logs or debugging output.
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from plain.runtime import Secret
|
|
158
|
+
|
|
159
|
+
SECRET_KEY: Secret[str]
|
|
160
|
+
DATABASE_PASSWORD: Secret[str]
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
At runtime, the value is still a plain string. The `Secret` type is purely a marker that tells Plain to mask the value when displaying settings.
|
|
164
|
+
|
|
165
|
+
## Using Plain outside of an app
|
|
153
166
|
|
|
154
|
-
|
|
167
|
+
If you need to use Plain in a standalone script, call `plain.runtime.setup()` first:
|
|
155
168
|
|
|
156
169
|
```python
|
|
157
170
|
import plain.runtime
|
|
158
171
|
|
|
159
172
|
plain.runtime.setup()
|
|
173
|
+
|
|
174
|
+
# Now you can use Plain normally
|
|
175
|
+
from plain.runtime import settings
|
|
176
|
+
print(settings.DEBUG)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The `setup()` function configures settings, logging, and populates the package registry. You can only call it once.
|
|
180
|
+
|
|
181
|
+
## FAQs
|
|
182
|
+
|
|
183
|
+
#### Where are the default settings defined?
|
|
184
|
+
|
|
185
|
+
Plain's core settings are in [`global_settings.py`](./global_settings.py). Each installed package can also have a `default_settings.py` file with package-specific defaults.
|
|
186
|
+
|
|
187
|
+
#### How do I see what settings are available?
|
|
188
|
+
|
|
189
|
+
Check [`global_settings.py`](./global_settings.py) for core settings. For package-specific settings, look at the `default_settings.py` file in each package.
|
|
190
|
+
|
|
191
|
+
#### What's the difference between required and optional settings?
|
|
192
|
+
|
|
193
|
+
A setting with only a type annotation (no value) is required:
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
SECRET_KEY: str # Required - must be set
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
A setting with a value is optional (has a default):
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
DEBUG: bool = False # Optional - defaults to False
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
#### Can I modify settings at runtime?
|
|
206
|
+
|
|
207
|
+
Yes, you can assign new values to settings after setup:
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
from plain.runtime import settings
|
|
211
|
+
|
|
212
|
+
settings.DEBUG = True
|
|
160
213
|
```
|
|
214
|
+
|
|
215
|
+
#### What paths are available without setup?
|
|
216
|
+
|
|
217
|
+
`APP_PATH` and `PLAIN_TEMP_PATH` are available immediately without calling `setup()`:
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
from plain.runtime import APP_PATH, PLAIN_TEMP_PATH
|
|
221
|
+
|
|
222
|
+
print(APP_PATH) # /path/to/project/app
|
|
223
|
+
print(PLAIN_TEMP_PATH) # /path/to/project/.plain
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Installation
|
|
227
|
+
|
|
228
|
+
The runtime module is included with Plain by default. No additional installation is required.
|
plain/runtime/__init__.py
CHANGED
|
@@ -2,10 +2,12 @@ import importlib.metadata
|
|
|
2
2
|
import sys
|
|
3
3
|
from importlib.metadata import entry_points
|
|
4
4
|
from pathlib import Path
|
|
5
|
+
from typing import Self
|
|
5
6
|
|
|
6
7
|
from plain.logs.configure import configure_logging
|
|
7
8
|
from plain.packages import packages_registry
|
|
8
9
|
|
|
10
|
+
from .secret import Secret
|
|
9
11
|
from .user_settings import Settings
|
|
10
12
|
|
|
11
13
|
try:
|
|
@@ -32,7 +34,7 @@ class SetupError(RuntimeError):
|
|
|
32
34
|
pass
|
|
33
35
|
|
|
34
36
|
|
|
35
|
-
def setup():
|
|
37
|
+
def setup() -> None:
|
|
36
38
|
"""
|
|
37
39
|
Configure the settings (this happens as a side effect of accessing the
|
|
38
40
|
first setting), configure logging and populate the app registry.
|
|
@@ -63,9 +65,10 @@ def setup():
|
|
|
63
65
|
sys.path.insert(0, APP_PATH.parent.as_posix())
|
|
64
66
|
|
|
65
67
|
configure_logging(
|
|
66
|
-
plain_log_level=settings.
|
|
67
|
-
app_log_level=settings.
|
|
68
|
-
app_log_format=settings.
|
|
68
|
+
plain_log_level=settings.FRAMEWORK_LOG_LEVEL,
|
|
69
|
+
app_log_level=settings.LOG_LEVEL,
|
|
70
|
+
app_log_format=settings.LOG_FORMAT,
|
|
71
|
+
log_stream=settings.LOG_STREAM,
|
|
69
72
|
)
|
|
70
73
|
|
|
71
74
|
packages_registry.populate(settings.INSTALLED_PACKAGES)
|
|
@@ -77,17 +80,18 @@ class SettingsReference(str):
|
|
|
77
80
|
the value in memory but serializes to a settings.NAME attribute reference.
|
|
78
81
|
"""
|
|
79
82
|
|
|
80
|
-
def __new__(self, setting_name):
|
|
83
|
+
def __new__(self, setting_name: str) -> Self:
|
|
81
84
|
value = getattr(settings, setting_name)
|
|
82
85
|
return str.__new__(self, value)
|
|
83
86
|
|
|
84
|
-
def __init__(self, setting_name):
|
|
87
|
+
def __init__(self, setting_name: str):
|
|
85
88
|
self.setting_name = setting_name
|
|
86
89
|
|
|
87
90
|
|
|
88
91
|
__all__ = [
|
|
89
92
|
"setup",
|
|
90
93
|
"settings",
|
|
94
|
+
"Secret",
|
|
91
95
|
"SettingsReference",
|
|
92
96
|
"APP_PATH",
|
|
93
97
|
"__version__",
|