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
@@ -3,8 +3,7 @@ Default Plain settings. Override these with settings in the module pointed to
3
3
  by the PLAIN_SETTINGS_MODULE environment variable.
4
4
  """
5
5
 
6
- from os import environ
7
-
6
+ from .secret import Secret
8
7
  from .utils import get_app_info_from_pyproject
9
8
 
10
9
  # MARK: Core Settings
@@ -12,24 +11,35 @@ from .utils import get_app_info_from_pyproject
12
11
  DEBUG: bool = False
13
12
 
14
13
  name, version = get_app_info_from_pyproject()
15
- APP_NAME: str = name
16
- APP_VERSION: str = version
14
+ NAME: str = name
15
+ VERSION: str = version
17
16
 
18
17
  # List of strings representing installed packages.
19
18
  INSTALLED_PACKAGES: list[str] = []
20
19
 
21
20
  URLS_ROUTER: str
22
21
 
22
+ # List of environment variable prefixes to check for settings.
23
+ # Settings can be configured via environment variables using these prefixes.
24
+ # Example: ENV_SETTINGS_PREFIXES = ["PLAIN_", "MYAPP_"]
25
+ # Then both PLAIN_DEBUG and MYAPP_DEBUG would set the DEBUG setting.
26
+ ENV_SETTINGS_PREFIXES: list[str] = ["PLAIN_"]
27
+
23
28
  # MARK: HTTP and Security
24
29
 
25
30
  # Hosts/domain names that are valid for this site.
26
- # "*" matches anything, ".example.com" matches example.com and all subdomains
27
- # "192.168.1.0/24" matches IP addresses in that CIDR range
28
- ALLOWED_HOSTS: list[str]
31
+ # - An empty list [] allows all hosts (useful for development).
32
+ # - ".example.com" matches example.com and all subdomains
33
+ # - "192.168.1.0/24" matches IP addresses in that CIDR range
34
+ ALLOWED_HOSTS: list[str] = []
29
35
 
30
36
  # Default headers for all responses.
31
- DEFAULT_RESPONSE_HEADERS = {
32
- # "Content-Security-Policy": "default-src 'self'",
37
+ # Header values can include {request.attribute} placeholders for dynamic content.
38
+ # Example: "script-src 'nonce-{request.csp_nonce}'" will use the request's nonce.
39
+ # Views can override, remove, or extend these headers - see plain/http/README.md
40
+ # for customization patterns.
41
+ DEFAULT_RESPONSE_HEADERS: dict = {
42
+ # "Content-Security-Policy": "default-src 'self'; script-src 'self' 'nonce-{request.csp_nonce}'",
33
43
  # https://hstspreload.org/
34
44
  # "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
35
45
  "Cross-Origin-Opener-Policy": "same-origin",
@@ -46,25 +56,28 @@ HTTPS_REDIRECT_ENABLED = True
46
56
  # If your Plain app is behind a proxy that sets a header to specify secure
47
57
  # connections, AND that proxy ensures that user-submitted headers with the
48
58
  # same name are ignored (so that people can't spoof it), set this value to
49
- # a tuple of (header_name, header_value). For any requests that come in with
50
- # that header/value, request.is_https() will return True.
59
+ # a string in the format "Header-Name: value". For any requests that come in
60
+ # with that header/value, request.is_https() will return True.
51
61
  # WARNING! Only set this if you fully understand what you're doing. Otherwise,
52
62
  # you may be opening yourself up to a security risk.
53
- HTTPS_PROXY_HEADER = None
63
+ # Example: HTTPS_PROXY_HEADER = "X-Forwarded-Proto: https"
64
+ HTTPS_PROXY_HEADER: str = ""
54
65
 
55
- # Whether to use the X-Forwarded-Host and X-Forwarded-Port headers
56
- # when determining the host and port for the request.
57
- USE_X_FORWARDED_HOST = False
58
- USE_X_FORWARDED_PORT = False
66
+ # Whether to use the X-Forwarded-Host, X-Forwarded-Port, and X-Forwarded-For
67
+ # headers when determining the host, port, and client IP for the request.
68
+ # Only enable these when behind a trusted proxy that overwrites these headers.
69
+ HTTP_X_FORWARDED_HOST: bool = False
70
+ HTTP_X_FORWARDED_PORT: bool = False
71
+ HTTP_X_FORWARDED_FOR: bool = False
59
72
 
60
73
  # A secret key for this particular Plain installation. Used in secret-key
61
74
  # hashing algorithms. Set this in your settings, or Plain will complain
62
75
  # loudly.
63
- SECRET_KEY: str
76
+ SECRET_KEY: Secret[str]
64
77
 
65
78
  # List of secret keys used to verify the validity of signatures. This allows
66
79
  # secret key rotation.
67
- SECRET_KEY_FALLBACKS: list[str] = []
80
+ SECRET_KEY_FALLBACKS: Secret[list[str]] = [] # type: ignore[assignment]
68
81
 
69
82
  # MARK: Internationalization
70
83
 
@@ -96,15 +109,15 @@ FILE_UPLOAD_HANDLERS = [
96
109
  FILE_UPLOAD_MAX_MEMORY_SIZE = 2621440 # i.e. 2.5 MB
97
110
 
98
111
  # Maximum size in bytes of request data (excluding file uploads) that will be
99
- # read before a SuspiciousOperation (RequestDataTooBig) is raised.
112
+ # read before a SuspiciousOperationError400 (RequestDataTooBigError400) is raised.
100
113
  DATA_UPLOAD_MAX_MEMORY_SIZE = 2621440 # i.e. 2.5 MB
101
114
 
102
115
  # Maximum number of GET/POST parameters that will be read before a
103
- # SuspiciousOperation (TooManyFieldsSent) is raised.
116
+ # SuspiciousOperationError400 (TooManyFieldsSentError400) is raised.
104
117
  DATA_UPLOAD_MAX_NUMBER_FIELDS = 1000
105
118
 
106
119
  # Maximum number of files encoded in a multipart upload that will be read
107
- # before a SuspiciousOperation (TooManyFilesSent) is raised.
120
+ # before a SuspiciousOperationError400 (TooManyFilesSentError400) is raised.
108
121
  DATA_UPLOAD_MAX_NUMBER_FILES = 100
109
122
 
110
123
  # Directory in which upload streamed files will be temporarily saved. A value of
@@ -112,9 +125,6 @@ DATA_UPLOAD_MAX_NUMBER_FILES = 100
112
125
  # (i.e. "/tmp" on *nix systems).
113
126
  FILE_UPLOAD_TEMP_DIR = None
114
127
 
115
- # User-defined overrides for error views by status code
116
- HTTP_ERROR_VIEWS: dict[int] = {}
117
-
118
128
  # MARK: Middleware
119
129
 
120
130
  # List of middleware to use. Order is important; in the request phase, these
@@ -134,11 +144,11 @@ CSRF_TRUSTED_ORIGINS: list[str] = []
134
144
  CSRF_EXEMPT_PATHS: list[str] = []
135
145
 
136
146
  # MARK: Logging
137
- # (Uses some custom env names in addition to PLAIN_ prefixed )
138
147
 
139
- PLAIN_LOG_LEVEL: str = environ.get("PLAIN_LOG_LEVEL", "INFO")
140
- APP_LOG_LEVEL: str = environ.get("APP_LOG_LEVEL", "INFO")
141
- APP_LOG_FORMAT: str = environ.get("APP_LOG_FORMAT", "keyvalue")
148
+ FRAMEWORK_LOG_LEVEL: str = "INFO"
149
+ LOG_LEVEL: str = "INFO"
150
+ LOG_FORMAT: str = "keyvalue"
151
+ LOG_STREAM: str = "split" # "split", "stdout", or "stderr"
142
152
 
143
153
  # MARK: Assets
144
154
 
@@ -151,11 +161,11 @@ ASSETS_BASE_URL: str = ""
151
161
 
152
162
  # MARK: Preflight Checks
153
163
 
154
- # List of all issues generated by system checks that should be silenced. Light
155
- # issues like warnings, infos or debugs will not generate a message. Silencing
156
- # serious issues like errors and criticals does not result in hiding the
157
- # message, but Plain will not stop you from e.g. running server.
158
- PREFLIGHT_SILENCED_CHECKS = []
164
+ # Silence checks by name
165
+ PREFLIGHT_SILENCED_CHECKS: list[str] = []
166
+
167
+ # Silence specific check results by id
168
+ PREFLIGHT_SILENCED_RESULTS: list[str] = []
159
169
 
160
170
  # MARK: Templates
161
171
 
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Generic, TypeVar
4
+
5
+ T = TypeVar("T")
6
+
7
+
8
+ class Secret(Generic[T]):
9
+ """
10
+ Marker type for sensitive settings. Values are masked in output/logs.
11
+
12
+ Usage:
13
+ SECRET_KEY: Secret[str]
14
+ DATABASE_PASSWORD: Secret[str]
15
+
16
+ At runtime, the value is still a plain str - this is purely for
17
+ indicating that the setting should be masked when displayed.
18
+ """
19
+
20
+ pass
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import importlib
2
4
  import json
3
5
  import os
@@ -9,10 +11,11 @@ from pathlib import Path
9
11
 
10
12
  from plain.exceptions import ImproperlyConfigured
11
13
  from plain.packages import PackageConfig
14
+ from plain.runtime.secret import Secret
12
15
 
13
- ENVIRONMENT_VARIABLE = "PLAIN_SETTINGS_MODULE"
14
- ENV_SETTINGS_PREFIX = "PLAIN_"
15
- CUSTOM_SETTINGS_PREFIX = "APP_"
16
+ _ENVIRONMENT_VARIABLE = "PLAIN_SETTINGS_MODULE"
17
+ _DEFAULT_ENV_SETTINGS_PREFIXES = ["PLAIN_"]
18
+ _CUSTOM_SETTINGS_PREFIX = "APP_"
16
19
 
17
20
 
18
21
  class Settings:
@@ -26,13 +29,14 @@ class Settings:
26
29
  Lazy initialization is implemented to defer loading until settings are first accessed.
27
30
  """
28
31
 
29
- def __init__(self, settings_module=None):
32
+ def __init__(self, settings_module: str | None = None):
30
33
  self._settings_module = settings_module
31
- self._settings = {}
32
- self._errors = [] # Collect configuration errors
34
+ self._settings: dict[str, SettingDefinition] = {}
35
+ self._errors: list[str] = [] # Collect configuration errors
36
+ self._env_prefixes: list[str] = [] # Configured env prefixes
33
37
  self.configured = False
34
38
 
35
- def _setup(self):
39
+ def _setup(self) -> None:
36
40
  if self.configured:
37
41
  return
38
42
  else:
@@ -42,7 +46,9 @@ class Settings:
42
46
 
43
47
  # Determine the settings module
44
48
  if self._settings_module is None:
45
- self._settings_module = os.environ.get(ENVIRONMENT_VARIABLE, "app.settings")
49
+ self._settings_module = os.environ.get(
50
+ _ENVIRONMENT_VARIABLE, "app.settings"
51
+ )
46
52
 
47
53
  # First load the global settings from plain
48
54
  self._load_module_settings(
@@ -58,8 +64,14 @@ class Settings:
58
64
  )
59
65
 
60
66
  # Keep a reference to the settings.py module path
67
+ assert mod.__file__ is not None
61
68
  self.path = Path(mod.__file__).resolve()
62
69
 
70
+ # Get env prefixes from settings module (must be configured in settings.py, not env)
71
+ self._env_prefixes = getattr(
72
+ mod, "ENV_SETTINGS_PREFIXES", _DEFAULT_ENV_SETTINGS_PREFIXES
73
+ )
74
+
63
75
  # Load default settings from installed packages
64
76
  self._load_default_settings(mod)
65
77
  # Load environment settings
@@ -71,7 +83,7 @@ class Settings:
71
83
  # Check for any collected errors
72
84
  self._raise_errors_if_any()
73
85
 
74
- def _load_module_settings(self, module):
86
+ def _load_module_settings(self, module: types.ModuleType) -> None:
75
87
  annotations = getattr(module, "__annotations__", {})
76
88
  settings = dir(module)
77
89
 
@@ -100,7 +112,7 @@ class Settings:
100
112
  required=True,
101
113
  )
102
114
 
103
- def _load_default_settings(self, settings_module):
115
+ def _load_default_settings(self, settings_module: types.ModuleType) -> None:
104
116
  for entry in getattr(settings_module, "INSTALLED_PACKAGES", []):
105
117
  if isinstance(entry, PackageConfig):
106
118
  app_settings = entry.module.default_settings
@@ -111,13 +123,20 @@ class Settings:
111
123
 
112
124
  self._load_module_settings(app_settings)
113
125
 
114
- def _load_env_settings(self):
115
- env_settings = {
116
- k[len(ENV_SETTINGS_PREFIX) :]: v
117
- for k, v in os.environ.items()
118
- if k.startswith(ENV_SETTINGS_PREFIX) and k.isupper()
119
- }
120
- for setting, value in env_settings.items():
126
+ def _load_env_settings(self) -> None:
127
+ # Collect env settings from all configured prefixes
128
+ # First prefix wins if same setting appears with multiple prefixes
129
+ env_settings: dict[
130
+ str, tuple[str, str]
131
+ ] = {} # setting_name -> (value, env_var)
132
+ for prefix in self._env_prefixes:
133
+ for key, value in os.environ.items():
134
+ if key.startswith(prefix) and key.isupper():
135
+ setting_name = key[len(prefix) :]
136
+ if setting_name and setting_name not in env_settings:
137
+ env_settings[setting_name] = (value, key)
138
+
139
+ for setting, (value, env_var) in env_settings.items():
121
140
  if setting in self._settings:
122
141
  setting_def = self._settings[setting]
123
142
  try:
@@ -125,10 +144,11 @@ class Settings:
125
144
  value, setting_def.annotation, setting
126
145
  )
127
146
  setting_def.set_value(parsed_value, "env")
147
+ setting_def.env_var_name = env_var
128
148
  except ImproperlyConfigured as e:
129
149
  self._errors.append(str(e))
130
150
 
131
- def _load_explicit_settings(self, settings_module):
151
+ def _load_explicit_settings(self, settings_module: types.ModuleType) -> None:
132
152
  for setting in dir(settings_module):
133
153
  if setting.isupper():
134
154
  setting_value = getattr(settings_module, setting)
@@ -141,8 +161,8 @@ class Settings:
141
161
  self._errors.append(str(e))
142
162
  continue
143
163
 
144
- elif setting.startswith(CUSTOM_SETTINGS_PREFIX):
145
- # Accept custom settings prefixed with '{CUSTOM_SETTINGS_PREFIX}'
164
+ elif setting.startswith(_CUSTOM_SETTINGS_PREFIX):
165
+ # Accept custom settings prefixed with '{_CUSTOM_SETTINGS_PREFIX}'
146
166
  setting_def = SettingDefinition(
147
167
  name=setting,
148
168
  default_value=None,
@@ -158,7 +178,7 @@ class Settings:
158
178
  else:
159
179
  # Collect unrecognized settings individually
160
180
  self._errors.append(
161
- f"Unknown setting '{setting}'. Custom settings must start with '{CUSTOM_SETTINGS_PREFIX}'."
181
+ f"Unknown setting '{setting}'. Custom settings must start with '{_CUSTOM_SETTINGS_PREFIX}'."
162
182
  )
163
183
 
164
184
  if hasattr(time, "tzset") and self.TIME_ZONE:
@@ -172,19 +192,19 @@ class Settings:
172
192
  os.environ["TZ"] = self.TIME_ZONE
173
193
  time.tzset()
174
194
 
175
- def _check_required_settings(self):
195
+ def _check_required_settings(self) -> None:
176
196
  missing = [k for k, v in self._settings.items() if v.required and not v.is_set]
177
197
  if missing:
178
198
  self._errors.append(f"Missing required setting(s): {', '.join(missing)}.")
179
199
 
180
- def _raise_errors_if_any(self):
200
+ def _raise_errors_if_any(self) -> None:
181
201
  if self._errors:
182
202
  errors = ["- " + e for e in self._errors]
183
203
  raise ImproperlyConfigured(
184
204
  "Settings configuration errors:\n" + "\n".join(errors)
185
205
  )
186
206
 
187
- def __getattr__(self, name):
207
+ def __getattr__(self, name: str) -> typing.Any:
188
208
  # Avoid recursion by directly returning internal attributes
189
209
  if not name.isupper():
190
210
  return object.__getattribute__(self, name)
@@ -196,7 +216,7 @@ class Settings:
196
216
  else:
197
217
  raise AttributeError(f"'Settings' object has no attribute '{name}'")
198
218
 
199
- def __setattr__(self, name, value):
219
+ def __setattr__(self, name: str, value: typing.Any) -> None:
200
220
  # Handle internal attributes without recursion
201
221
  if not name.isupper():
202
222
  object.__setattr__(self, name, value)
@@ -207,18 +227,46 @@ class Settings:
207
227
  else:
208
228
  object.__setattr__(self, name, value)
209
229
 
210
- def __repr__(self):
230
+ def __repr__(self) -> str:
211
231
  if not self.configured:
212
232
  return "<Settings [Unevaluated]>"
213
233
  return f'<Settings "{self._settings_module}">'
214
234
 
235
+ def get_settings(
236
+ self, *, source: str | None = None
237
+ ) -> list[tuple[str, SettingDefinition]]:
238
+ """
239
+ Get settings as a sorted list of (name, definition) tuples.
240
+
241
+ Args:
242
+ source: Filter to settings from a specific source ('default', 'env', 'explicit', 'runtime')
243
+ """
244
+ self._setup()
245
+ result = []
246
+ for name, defn in sorted(self._settings.items()):
247
+ if source is not None and defn.source != source:
248
+ continue
249
+ result.append((name, defn))
250
+ return result
251
+
252
+ def get_env_settings(self) -> list[tuple[str, SettingDefinition]]:
253
+ """Get settings that were loaded from environment variables."""
254
+ return self.get_settings(source="env")
215
255
 
216
- def _parse_env_value(value, annotation, setting_name):
256
+
257
+ def _parse_env_value(
258
+ value: str, annotation: type | None, setting_name: str
259
+ ) -> typing.Any:
217
260
  if not annotation:
218
261
  raise ImproperlyConfigured(
219
262
  f"{setting_name}: Type hint required to set from environment."
220
263
  )
221
264
 
265
+ # Unwrap Secret[T] to get the inner type
266
+ if typing.get_origin(annotation) is Secret:
267
+ if args := typing.get_args(annotation):
268
+ annotation = args[0]
269
+
222
270
  if annotation is bool:
223
271
  # Special case for bools
224
272
  return value.lower() in ("true", "1", "yes")
@@ -238,7 +286,12 @@ class SettingDefinition:
238
286
  """Store detailed information about settings."""
239
287
 
240
288
  def __init__(
241
- self, name, default_value=None, annotation=None, module=None, required=False
289
+ self,
290
+ name: str,
291
+ default_value: typing.Any = None,
292
+ annotation: type | None = None,
293
+ module: types.ModuleType | None = None,
294
+ required: bool = False,
242
295
  ):
243
296
  self.name = name
244
297
  self.default_value = default_value
@@ -248,14 +301,27 @@ class SettingDefinition:
248
301
  self.value = default_value
249
302
  self.source = "default" # 'default', 'env', 'explicit', or 'runtime'
250
303
  self.is_set = False # Indicates if the value was set explicitly
304
+ self.env_var_name: str | None = None # Env var name if loaded from env
305
+ self.is_secret = self._check_if_secret(annotation)
306
+
307
+ @staticmethod
308
+ def _check_if_secret(annotation: type | None) -> bool:
309
+ """Check if annotation is Secret[T]."""
310
+ return annotation is not None and typing.get_origin(annotation) is Secret
251
311
 
252
- def set_value(self, value, source):
312
+ def display_value(self) -> str:
313
+ """Return value for display, masked if secret."""
314
+ if self.is_secret:
315
+ return "********"
316
+ return repr(self.value)
317
+
318
+ def set_value(self, value: typing.Any, source: str) -> None:
253
319
  self.check_type(value)
254
320
  self.value = value
255
321
  self.source = source
256
322
  self.is_set = True
257
323
 
258
- def check_type(self, obj):
324
+ def check_type(self, obj: typing.Any) -> None:
259
325
  if not self.annotation:
260
326
  return
261
327
 
@@ -265,23 +331,29 @@ class SettingDefinition:
265
331
  )
266
332
 
267
333
  @staticmethod
268
- def _is_instance_of_type(value, type_hint) -> bool:
334
+ def _is_instance_of_type(value: typing.Any, type_hint: typing.Any) -> bool:
269
335
  # Simple types
270
336
  if isinstance(type_hint, type):
271
337
  return isinstance(value, type_hint)
272
338
 
339
+ origin = typing.get_origin(type_hint)
340
+
341
+ # Secret[T] - check the inner type (Secret is just a marker)
342
+ if origin is Secret:
343
+ args = typing.get_args(type_hint)
344
+ if args:
345
+ return SettingDefinition._is_instance_of_type(value, args[0])
346
+ return True
347
+
273
348
  # Union types
274
- if (
275
- typing.get_origin(type_hint) is typing.Union
276
- or typing.get_origin(type_hint) is types.UnionType
277
- ):
349
+ if origin is typing.Union or origin is types.UnionType:
278
350
  return any(
279
351
  SettingDefinition._is_instance_of_type(value, arg)
280
352
  for arg in typing.get_args(type_hint)
281
353
  )
282
354
 
283
355
  # List types
284
- if typing.get_origin(type_hint) is list:
356
+ if origin is list:
285
357
  return isinstance(value, list) and all(
286
358
  SettingDefinition._is_instance_of_type(
287
359
  item, typing.get_args(type_hint)[0]
@@ -290,7 +362,7 @@ class SettingDefinition:
290
362
  )
291
363
 
292
364
  # Tuple types
293
- if typing.get_origin(type_hint) is tuple:
365
+ if origin is tuple:
294
366
  return isinstance(value, tuple) and all(
295
367
  SettingDefinition._is_instance_of_type(
296
368
  item, typing.get_args(type_hint)[i]
@@ -300,5 +372,5 @@ class SettingDefinition:
300
372
 
301
373
  raise ValueError(f"Unsupported type hint: {type_hint}")
302
374
 
303
- def __str__(self):
375
+ def __str__(self) -> str:
304
376
  return f"SettingDefinition(name={self.name}, value={self.value}, source={self.source})"
plain/runtime/utils.py CHANGED
@@ -2,7 +2,7 @@ import tomllib
2
2
  from pathlib import Path
3
3
 
4
4
 
5
- def get_app_info_from_pyproject():
5
+ def get_app_info_from_pyproject() -> tuple[str, str]:
6
6
  """Get the project name and version from the nearest pyproject.toml file."""
7
7
  current_path = Path.cwd()
8
8
 
plain/server/LICENSE ADDED
@@ -0,0 +1,35 @@
1
+ Plain HTTP Server - License and Attribution
2
+ ============================================
3
+
4
+ This module is based on gunicorn (https://gunicorn.org), integrated from
5
+ commit 1dc4ce9d59c3458305d701c4c6d63aa6b1d1b309 (gunicorn 23.0.0, October 2024).
6
+
7
+ The gunicorn code has been integrated into Plain and modified for Plain's
8
+ specific use case. All files should be considered modified from the original.
9
+
10
+ Original repository: https://github.com/benoitc/gunicorn
11
+
12
+ --------------------------------------------------------------------------------
13
+
14
+ MIT License
15
+
16
+ Copyright (c) 2009-2024 Benoît Chesneau <benoitc@gunicorn.org>
17
+ Copyright (c) 2009-2015 Paul J. Davis <paul.joseph.davis@gmail.com>
18
+
19
+ Permission is hereby granted, free of charge, to any person obtaining a copy
20
+ of this software and associated documentation files (the "Software"), to deal
21
+ in the Software without restriction, including without limitation the rights
22
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
23
+ copies of the Software, and to permit persons to whom the Software is
24
+ furnished to do so, subject to the following conditions:
25
+
26
+ The above copyright notice and this permission notice shall be included in all
27
+ copies or substantial portions of the Software.
28
+
29
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
30
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
31
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
32
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
33
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
34
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
35
+ SOFTWARE.