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.
Files changed (197) hide show
  1. plain/CHANGELOG.md +684 -0
  2. plain/README.md +1 -1
  3. plain/assets/compile.py +25 -12
  4. plain/assets/finders.py +24 -17
  5. plain/assets/fingerprints.py +10 -7
  6. plain/assets/urls.py +1 -1
  7. plain/assets/views.py +47 -33
  8. plain/chores/README.md +25 -23
  9. plain/chores/__init__.py +2 -1
  10. plain/chores/core.py +27 -0
  11. plain/chores/registry.py +23 -53
  12. plain/cli/README.md +185 -16
  13. plain/cli/__init__.py +2 -1
  14. plain/cli/agent.py +236 -0
  15. plain/cli/build.py +7 -8
  16. plain/cli/changelog.py +11 -5
  17. plain/cli/chores.py +32 -34
  18. plain/cli/core.py +112 -28
  19. plain/cli/docs.py +52 -11
  20. plain/cli/formatting.py +40 -17
  21. plain/cli/install.py +10 -54
  22. plain/cli/{agent/llmdocs.py → llmdocs.py} +21 -9
  23. plain/cli/output.py +6 -2
  24. plain/cli/preflight.py +175 -102
  25. plain/cli/print.py +4 -4
  26. plain/cli/registry.py +95 -26
  27. plain/cli/request.py +206 -0
  28. plain/cli/runtime.py +45 -0
  29. plain/cli/scaffold.py +2 -7
  30. plain/cli/server.py +153 -0
  31. plain/cli/settings.py +53 -49
  32. plain/cli/shell.py +15 -12
  33. plain/cli/startup.py +9 -8
  34. plain/cli/upgrade.py +17 -104
  35. plain/cli/urls.py +12 -7
  36. plain/cli/utils.py +3 -3
  37. plain/csrf/README.md +65 -40
  38. plain/csrf/middleware.py +53 -43
  39. plain/debug.py +5 -2
  40. plain/exceptions.py +22 -114
  41. plain/forms/README.md +453 -24
  42. plain/forms/__init__.py +55 -4
  43. plain/forms/boundfield.py +15 -8
  44. plain/forms/exceptions.py +1 -1
  45. plain/forms/fields.py +346 -143
  46. plain/forms/forms.py +75 -45
  47. plain/http/README.md +356 -9
  48. plain/http/__init__.py +41 -26
  49. plain/http/cookie.py +15 -7
  50. plain/http/exceptions.py +65 -0
  51. plain/http/middleware.py +32 -0
  52. plain/http/multipartparser.py +99 -88
  53. plain/http/request.py +362 -250
  54. plain/http/response.py +99 -197
  55. plain/internal/__init__.py +8 -1
  56. plain/internal/files/base.py +35 -19
  57. plain/internal/files/locks.py +19 -11
  58. plain/internal/files/move.py +8 -3
  59. plain/internal/files/temp.py +25 -6
  60. plain/internal/files/uploadedfile.py +47 -28
  61. plain/internal/files/uploadhandler.py +64 -58
  62. plain/internal/files/utils.py +24 -10
  63. plain/internal/handlers/base.py +34 -23
  64. plain/internal/handlers/exception.py +68 -65
  65. plain/internal/handlers/wsgi.py +65 -54
  66. plain/internal/middleware/headers.py +37 -11
  67. plain/internal/middleware/hosts.py +11 -13
  68. plain/internal/middleware/https.py +17 -7
  69. plain/internal/middleware/slash.py +14 -9
  70. plain/internal/reloader.py +77 -0
  71. plain/json.py +2 -1
  72. plain/logs/README.md +161 -62
  73. plain/logs/__init__.py +1 -1
  74. plain/logs/{loggers.py → app.py} +71 -67
  75. plain/logs/configure.py +63 -14
  76. plain/logs/debug.py +17 -6
  77. plain/logs/filters.py +15 -0
  78. plain/logs/formatters.py +7 -4
  79. plain/packages/README.md +105 -23
  80. plain/packages/config.py +15 -7
  81. plain/packages/registry.py +40 -15
  82. plain/paginator.py +31 -21
  83. plain/preflight/README.md +208 -23
  84. plain/preflight/__init__.py +5 -24
  85. plain/preflight/checks.py +12 -0
  86. plain/preflight/files.py +19 -13
  87. plain/preflight/registry.py +80 -58
  88. plain/preflight/results.py +37 -0
  89. plain/preflight/security.py +65 -71
  90. plain/preflight/settings.py +54 -0
  91. plain/preflight/urls.py +10 -48
  92. plain/runtime/README.md +115 -47
  93. plain/runtime/__init__.py +10 -6
  94. plain/runtime/global_settings.py +43 -33
  95. plain/runtime/secret.py +20 -0
  96. plain/runtime/user_settings.py +110 -38
  97. plain/runtime/utils.py +1 -1
  98. plain/server/LICENSE +35 -0
  99. plain/server/README.md +155 -0
  100. plain/server/__init__.py +9 -0
  101. plain/server/app.py +52 -0
  102. plain/server/arbiter.py +555 -0
  103. plain/server/config.py +118 -0
  104. plain/server/errors.py +31 -0
  105. plain/server/glogging.py +292 -0
  106. plain/server/http/__init__.py +12 -0
  107. plain/server/http/body.py +283 -0
  108. plain/server/http/errors.py +155 -0
  109. plain/server/http/message.py +400 -0
  110. plain/server/http/parser.py +70 -0
  111. plain/server/http/unreader.py +88 -0
  112. plain/server/http/wsgi.py +421 -0
  113. plain/server/pidfile.py +92 -0
  114. plain/server/sock.py +240 -0
  115. plain/server/util.py +317 -0
  116. plain/server/workers/__init__.py +6 -0
  117. plain/server/workers/base.py +304 -0
  118. plain/server/workers/sync.py +212 -0
  119. plain/server/workers/thread.py +399 -0
  120. plain/server/workers/workertmp.py +50 -0
  121. plain/signals/README.md +170 -1
  122. plain/signals/__init__.py +0 -1
  123. plain/signals/dispatch/dispatcher.py +49 -27
  124. plain/signing.py +131 -35
  125. plain/skills/README.md +36 -0
  126. plain/skills/plain-docs/SKILL.md +25 -0
  127. plain/skills/plain-install/SKILL.md +26 -0
  128. plain/skills/plain-request/SKILL.md +39 -0
  129. plain/skills/plain-shell/SKILL.md +24 -0
  130. plain/skills/plain-upgrade/SKILL.md +35 -0
  131. plain/templates/README.md +211 -20
  132. plain/templates/jinja/__init__.py +14 -27
  133. plain/templates/jinja/environments.py +5 -4
  134. plain/templates/jinja/extensions.py +12 -5
  135. plain/templates/jinja/filters.py +7 -2
  136. plain/templates/jinja/globals.py +2 -2
  137. plain/test/README.md +184 -22
  138. plain/test/client.py +340 -222
  139. plain/test/encoding.py +9 -6
  140. plain/test/exceptions.py +7 -2
  141. plain/urls/README.md +157 -73
  142. plain/urls/converters.py +18 -15
  143. plain/urls/exceptions.py +2 -2
  144. plain/urls/patterns.py +56 -40
  145. plain/urls/resolvers.py +38 -28
  146. plain/urls/utils.py +5 -1
  147. plain/utils/README.md +250 -3
  148. plain/utils/cache.py +17 -11
  149. plain/utils/crypto.py +21 -5
  150. plain/utils/datastructures.py +89 -56
  151. plain/utils/dateparse.py +9 -6
  152. plain/utils/deconstruct.py +15 -7
  153. plain/utils/decorators.py +5 -1
  154. plain/utils/dotenv.py +373 -0
  155. plain/utils/duration.py +8 -4
  156. plain/utils/encoding.py +14 -7
  157. plain/utils/functional.py +66 -49
  158. plain/utils/hashable.py +5 -1
  159. plain/utils/html.py +36 -22
  160. plain/utils/http.py +16 -9
  161. plain/utils/inspect.py +14 -6
  162. plain/utils/ipv6.py +7 -3
  163. plain/utils/itercompat.py +6 -1
  164. plain/utils/module_loading.py +7 -3
  165. plain/utils/regex_helper.py +37 -23
  166. plain/utils/safestring.py +14 -6
  167. plain/utils/text.py +41 -23
  168. plain/utils/timezone.py +33 -22
  169. plain/utils/tree.py +35 -19
  170. plain/validators.py +94 -52
  171. plain/views/README.md +156 -79
  172. plain/views/__init__.py +0 -1
  173. plain/views/base.py +25 -18
  174. plain/views/errors.py +13 -5
  175. plain/views/exceptions.py +4 -1
  176. plain/views/forms.py +6 -6
  177. plain/views/objects.py +52 -49
  178. plain/views/redirect.py +18 -15
  179. plain/views/templates.py +5 -3
  180. plain/wsgi.py +3 -1
  181. {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/METADATA +4 -2
  182. plain-0.101.2.dist-info/RECORD +201 -0
  183. {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/WHEEL +1 -1
  184. plain-0.101.2.dist-info/entry_points.txt +2 -0
  185. plain/AGENTS.md +0 -18
  186. plain/cli/agent/__init__.py +0 -20
  187. plain/cli/agent/docs.py +0 -80
  188. plain/cli/agent/md.py +0 -87
  189. plain/cli/agent/prompt.py +0 -45
  190. plain/cli/agent/request.py +0 -181
  191. plain/csrf/views.py +0 -31
  192. plain/logs/utils.py +0 -46
  193. plain/preflight/messages.py +0 -81
  194. plain/templates/AGENTS.md +0 -3
  195. plain-0.66.0.dist-info/RECORD +0 -168
  196. plain-0.66.0.dist-info/entry_points.txt +0 -4
  197. {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/licenses/LICENSE +0 -0
@@ -1,89 +1,83 @@
1
- from plain.exceptions import ImproperlyConfigured
1
+ from __future__ import annotations
2
+
2
3
  from plain.runtime import settings
3
4
 
4
- from .messages import Warning
5
+ from .checks import PreflightCheck
5
6
  from .registry import register_check
7
+ from .results import PreflightResult
6
8
 
7
- SECRET_KEY_MIN_LENGTH = 50
8
- SECRET_KEY_MIN_UNIQUE_CHARACTERS = 5
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)) >= SECRET_KEY_MIN_UNIQUE_CHARACTERS
23
- and len(secret_key) >= SECRET_KEY_MIN_LENGTH
15
+ len(set(secret_key)) >= _SECRET_KEY_MIN_UNIQUE_CHARACTERS
16
+ and len(secret_key) >= _SECRET_KEY_MIN_LENGTH
24
17
  )
25
18
 
26
19
 
27
- @register_check(deploy=True)
28
- def check_secret_key(package_configs, **kwargs):
29
- try:
30
- secret_key = settings.SECRET_KEY
31
- except (ImproperlyConfigured, AttributeError):
32
- passed_check = False
33
- else:
34
- passed_check = _check_secret_key(secret_key)
35
- return (
36
- []
37
- if passed_check
38
- else [
39
- Warning(
40
- SECRET_KEY_WARNING_MSG % "SECRET_KEY",
41
- id="security.W009",
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
- @register_check(deploy=True)
48
- def check_secret_key_fallbacks(package_configs, **kwargs):
49
- warnings = []
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
- warnings.append(
58
- Warning(W025.msg % f"SECRET_KEY_FALLBACKS[{index}]", id=W025.id)
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
- return warnings
53
+ return errors
61
54
 
62
55
 
63
- @register_check(deploy=True)
64
- def check_debug(package_configs, **kwargs):
65
- passed_check = not settings.DEBUG
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
- @register_check(deploy=True)
79
- def check_allowed_hosts(package_configs, **kwargs):
80
- return (
81
- []
82
- if settings.ALLOWED_HOSTS
83
- else [
84
- Warning(
85
- "ALLOWED_HOSTS must not be empty in deployment.",
86
- id="security.W020",
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 plain.runtime import settings
1
+ from __future__ import annotations
2
2
 
3
- from . import Error, register_check
3
+ from .checks import PreflightCheck
4
+ from .registry import register_check
5
+ from .results import PreflightResult
4
6
 
5
7
 
6
- @register_check
7
- def check_url_config(package_configs, **kwargs):
8
- if getattr(settings, "URLS_ROUTER", None):
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 check_resolver(resolver)
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 app and package settings at runtime.**
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-wide settings](#custom-app-wide-settings)
10
- - [Using Plain in other environments](#using-plain-in-other-environments)
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
- Plain is configured by "settings", which are ultimately just Python variables. Most settings have default values which can be overidden either by your `app/settings.py` file or by environment variables.
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
- While working inside a Plain application or package, you can access settings at runtime via `plain.runtime.settings`.
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.AN_EXAMPLE_SETTING)
55
+ print(settings.TIME_ZONE)
56
+ print(settings.DEBUG)
51
57
  ```
52
58
 
53
- The Plain core settings are defined in [`plain/runtime/global_settings.py`](global_settings.py) and you should look at that for reference. Each installed package can also define its own settings in a `default_settings.py` file.
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
- It's common in both development and production to use environment variables to manage settings. To handle this, any type-annotated setting can be loaded from the env with a `PLAIN_` prefix.
63
+ Type-annotated settings can be loaded from environment variables using a `PLAIN_` prefix.
58
64
 
59
- For example, to set the `SECRET_KEY` setting is defined with a type annotation.
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
- And can be set by an environment variable.
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 more complex types like lists or dictionaries, just use the `list` or `dict` type annotation and JSON-compatible types.
77
+ For lists, dicts, and other complex types, use JSON-encoded values:
72
78
 
73
79
  ```python
74
- LIST_EXAMPLE: list[str]
80
+ ALLOWED_HOSTS: list[str]
75
81
  ```
76
82
 
77
- And set the environment variable with a JSON-encoded string.
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
- PLAIN_LIST_EXAMPLE='["one", "two", "three"]'
90
+ PLAIN_DEBUG=true
81
91
  ```
82
92
 
83
- Custom behavior can always be supported by checking the environment directly.
93
+ ### .env files
84
94
 
85
- ```python
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
- from . import database_url
97
+ ### Custom prefixes
90
98
 
91
- # Make DATABASE a required setting
92
- DATABASE: dict
99
+ You can configure additional environment variable prefixes using `ENV_SETTINGS_PREFIXES`:
93
100
 
94
- # Automatically configure DATABASE if a DATABASE_URL was given in the environment
95
- if "DATABASE_URL" in environ:
96
- DATABASE = database_url.parse_database_url(
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
- ### .env files
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
- An installed package can provide a `default_settings.py` file. It is strongly recommended to prefix any defined settings with the package name to avoid conflicts.
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
- The way you define these settings can impact the runtime behavior. For example, a required setting should be defined with a type annotation but no default value.
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 are only required for settings that don't provide a default value (to enable the environment variable loading). But generally type annotations are recommended as they also provide basic validation at runtime if a setting is defined as a `str` but the user sets it to an `int`, an error will be raised.
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-wide settings
131
+ ## Custom app settings
136
132
 
137
- At times it can be useful to create your own settings that are used across your application. When you define these in `app/settings.py`, you simply prefix them with `APP_` which marks them as a custom setting.
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
- ## Using Plain in other environments
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
- There may be some situations where you want to manually invoke Plain, like in a Python script. To get everything set up, you can call the `plain.runtime.setup()` function.
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.PLAIN_LOG_LEVEL,
67
- app_log_level=settings.APP_LOG_LEVEL,
68
- app_log_format=settings.APP_LOG_FORMAT,
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__",