plain 0.68.0__py3-none-any.whl → 0.103.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. plain/CHANGELOG.md +684 -1
  2. plain/README.md +1 -1
  3. plain/agents/.claude/rules/plain.md +88 -0
  4. plain/agents/.claude/skills/plain-install/SKILL.md +26 -0
  5. plain/agents/.claude/skills/plain-upgrade/SKILL.md +35 -0
  6. plain/assets/compile.py +25 -12
  7. plain/assets/finders.py +24 -17
  8. plain/assets/fingerprints.py +10 -7
  9. plain/assets/urls.py +1 -1
  10. plain/assets/views.py +47 -33
  11. plain/chores/README.md +25 -23
  12. plain/chores/__init__.py +2 -1
  13. plain/chores/core.py +27 -0
  14. plain/chores/registry.py +23 -36
  15. plain/cli/README.md +185 -16
  16. plain/cli/__init__.py +2 -1
  17. plain/cli/agent.py +234 -0
  18. plain/cli/build.py +7 -8
  19. plain/cli/changelog.py +11 -5
  20. plain/cli/chores.py +32 -34
  21. plain/cli/core.py +110 -26
  22. plain/cli/docs.py +98 -21
  23. plain/cli/formatting.py +40 -17
  24. plain/cli/install.py +10 -54
  25. plain/cli/{agent/llmdocs.py → llmdocs.py} +45 -26
  26. plain/cli/output.py +6 -2
  27. plain/cli/preflight.py +27 -75
  28. plain/cli/print.py +4 -4
  29. plain/cli/registry.py +96 -10
  30. plain/cli/{agent/request.py → request.py} +67 -33
  31. plain/cli/runtime.py +45 -0
  32. plain/cli/scaffold.py +2 -7
  33. plain/cli/server.py +153 -0
  34. plain/cli/settings.py +53 -49
  35. plain/cli/shell.py +15 -12
  36. plain/cli/startup.py +9 -8
  37. plain/cli/upgrade.py +17 -104
  38. plain/cli/urls.py +12 -7
  39. plain/cli/utils.py +3 -3
  40. plain/csrf/README.md +65 -40
  41. plain/csrf/middleware.py +53 -43
  42. plain/debug.py +5 -2
  43. plain/exceptions.py +22 -114
  44. plain/forms/README.md +453 -24
  45. plain/forms/__init__.py +55 -4
  46. plain/forms/boundfield.py +15 -8
  47. plain/forms/exceptions.py +1 -1
  48. plain/forms/fields.py +346 -143
  49. plain/forms/forms.py +75 -45
  50. plain/http/README.md +356 -9
  51. plain/http/__init__.py +41 -26
  52. plain/http/cookie.py +15 -7
  53. plain/http/exceptions.py +65 -0
  54. plain/http/middleware.py +32 -0
  55. plain/http/multipartparser.py +99 -88
  56. plain/http/request.py +362 -250
  57. plain/http/response.py +99 -197
  58. plain/internal/__init__.py +8 -1
  59. plain/internal/files/base.py +35 -19
  60. plain/internal/files/locks.py +19 -11
  61. plain/internal/files/move.py +8 -3
  62. plain/internal/files/temp.py +25 -6
  63. plain/internal/files/uploadedfile.py +47 -28
  64. plain/internal/files/uploadhandler.py +64 -58
  65. plain/internal/files/utils.py +24 -10
  66. plain/internal/handlers/base.py +34 -23
  67. plain/internal/handlers/exception.py +68 -65
  68. plain/internal/handlers/wsgi.py +65 -54
  69. plain/internal/middleware/headers.py +37 -11
  70. plain/internal/middleware/hosts.py +11 -8
  71. plain/internal/middleware/https.py +17 -7
  72. plain/internal/middleware/slash.py +14 -9
  73. plain/internal/reloader.py +77 -0
  74. plain/json.py +2 -1
  75. plain/logs/README.md +161 -62
  76. plain/logs/__init__.py +1 -1
  77. plain/logs/{loggers.py → app.py} +71 -67
  78. plain/logs/configure.py +63 -14
  79. plain/logs/debug.py +17 -6
  80. plain/logs/filters.py +15 -0
  81. plain/logs/formatters.py +7 -4
  82. plain/packages/README.md +105 -23
  83. plain/packages/config.py +15 -7
  84. plain/packages/registry.py +27 -16
  85. plain/paginator.py +31 -21
  86. plain/preflight/README.md +209 -24
  87. plain/preflight/__init__.py +1 -0
  88. plain/preflight/checks.py +3 -1
  89. plain/preflight/files.py +3 -1
  90. plain/preflight/registry.py +26 -11
  91. plain/preflight/results.py +15 -7
  92. plain/preflight/security.py +15 -13
  93. plain/preflight/settings.py +54 -0
  94. plain/preflight/urls.py +4 -1
  95. plain/runtime/README.md +115 -47
  96. plain/runtime/__init__.py +10 -6
  97. plain/runtime/global_settings.py +34 -25
  98. plain/runtime/secret.py +20 -0
  99. plain/runtime/user_settings.py +110 -38
  100. plain/runtime/utils.py +1 -1
  101. plain/server/LICENSE +35 -0
  102. plain/server/README.md +155 -0
  103. plain/server/__init__.py +9 -0
  104. plain/server/app.py +52 -0
  105. plain/server/arbiter.py +555 -0
  106. plain/server/config.py +118 -0
  107. plain/server/errors.py +31 -0
  108. plain/server/glogging.py +292 -0
  109. plain/server/http/__init__.py +12 -0
  110. plain/server/http/body.py +283 -0
  111. plain/server/http/errors.py +155 -0
  112. plain/server/http/message.py +400 -0
  113. plain/server/http/parser.py +70 -0
  114. plain/server/http/unreader.py +88 -0
  115. plain/server/http/wsgi.py +421 -0
  116. plain/server/pidfile.py +92 -0
  117. plain/server/sock.py +240 -0
  118. plain/server/util.py +317 -0
  119. plain/server/workers/__init__.py +6 -0
  120. plain/server/workers/base.py +304 -0
  121. plain/server/workers/sync.py +212 -0
  122. plain/server/workers/thread.py +399 -0
  123. plain/server/workers/workertmp.py +50 -0
  124. plain/signals/README.md +170 -1
  125. plain/signals/__init__.py +0 -1
  126. plain/signals/dispatch/dispatcher.py +49 -27
  127. plain/signing.py +131 -35
  128. plain/templates/README.md +211 -20
  129. plain/templates/jinja/__init__.py +13 -5
  130. plain/templates/jinja/environments.py +5 -4
  131. plain/templates/jinja/extensions.py +12 -5
  132. plain/templates/jinja/filters.py +7 -2
  133. plain/templates/jinja/globals.py +2 -2
  134. plain/test/README.md +184 -22
  135. plain/test/client.py +340 -222
  136. plain/test/encoding.py +9 -6
  137. plain/test/exceptions.py +7 -2
  138. plain/urls/README.md +157 -73
  139. plain/urls/converters.py +18 -15
  140. plain/urls/exceptions.py +2 -2
  141. plain/urls/patterns.py +38 -22
  142. plain/urls/resolvers.py +35 -25
  143. plain/urls/utils.py +5 -1
  144. plain/utils/README.md +250 -3
  145. plain/utils/cache.py +17 -11
  146. plain/utils/crypto.py +21 -5
  147. plain/utils/datastructures.py +89 -56
  148. plain/utils/dateparse.py +9 -6
  149. plain/utils/deconstruct.py +15 -7
  150. plain/utils/decorators.py +5 -1
  151. plain/utils/dotenv.py +373 -0
  152. plain/utils/duration.py +8 -4
  153. plain/utils/encoding.py +14 -7
  154. plain/utils/functional.py +66 -49
  155. plain/utils/hashable.py +5 -1
  156. plain/utils/html.py +36 -22
  157. plain/utils/http.py +16 -9
  158. plain/utils/inspect.py +14 -6
  159. plain/utils/ipv6.py +7 -3
  160. plain/utils/itercompat.py +6 -1
  161. plain/utils/module_loading.py +7 -3
  162. plain/utils/regex_helper.py +37 -23
  163. plain/utils/safestring.py +14 -6
  164. plain/utils/text.py +41 -23
  165. plain/utils/timezone.py +33 -22
  166. plain/utils/tree.py +35 -19
  167. plain/validators.py +94 -52
  168. plain/views/README.md +156 -79
  169. plain/views/__init__.py +0 -1
  170. plain/views/base.py +25 -18
  171. plain/views/errors.py +13 -5
  172. plain/views/exceptions.py +4 -1
  173. plain/views/forms.py +6 -6
  174. plain/views/objects.py +52 -49
  175. plain/views/redirect.py +18 -15
  176. plain/views/templates.py +5 -3
  177. plain/wsgi.py +3 -1
  178. {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/METADATA +4 -2
  179. plain-0.103.0.dist-info/RECORD +198 -0
  180. {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/WHEEL +1 -1
  181. plain-0.103.0.dist-info/entry_points.txt +2 -0
  182. plain/AGENTS.md +0 -18
  183. plain/cli/agent/__init__.py +0 -20
  184. plain/cli/agent/docs.py +0 -80
  185. plain/cli/agent/md.py +0 -87
  186. plain/cli/agent/prompt.py +0 -45
  187. plain/csrf/views.py +0 -31
  188. plain/logs/utils.py +0 -46
  189. plain/templates/AGENTS.md +0 -3
  190. plain-0.68.0.dist-info/RECORD +0 -169
  191. plain-0.68.0.dist-info/entry_points.txt +0 -5
  192. {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/licenses/LICENSE +0 -0
@@ -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.
plain/server/README.md ADDED
@@ -0,0 +1,155 @@
1
+ # Server
2
+
3
+ **A production-ready WSGI HTTP server based on gunicorn.**
4
+
5
+ - [Overview](#overview)
6
+ - [Worker types](#worker-types)
7
+ - [Configuration options](#configuration-options)
8
+ - [Environment variables](#environment-variables)
9
+ - [Signals](#signals)
10
+ - [Using a different WSGI server](#using-a-different-wsgi-server)
11
+ - [FAQs](#faqs)
12
+ - [Installation](#installation)
13
+
14
+ ## Overview
15
+
16
+ You can run the built-in HTTP server with the `plain server` command.
17
+
18
+ ```bash
19
+ plain server
20
+ ```
21
+
22
+ By default, the server binds to `127.0.0.1:8000` and uses a single worker process. In production, you will typically want to increase the number of workers and optionally enable threading.
23
+
24
+ ```bash
25
+ # Run with 4 worker processes
26
+ plain server --workers 4
27
+
28
+ # Auto-detect based on available CPUs
29
+ plain server --workers auto
30
+
31
+ # Run with 2 workers and 4 threads each
32
+ plain server --workers 2 --threads 4
33
+ ```
34
+
35
+ For local development, you can enable auto-reload to restart workers when code changes.
36
+
37
+ ```bash
38
+ plain server --reload
39
+ ```
40
+
41
+ ## Worker types
42
+
43
+ The server automatically selects the worker type based on your configuration.
44
+
45
+ **Sync workers** handle one request at a time per worker. These are simple and predictable.
46
+
47
+ ```bash
48
+ # Single-threaded (uses sync worker)
49
+ plain server --workers 4
50
+ ```
51
+
52
+ **Threaded workers** handle multiple concurrent requests per worker using a thread pool. These are useful when your application does blocking I/O.
53
+
54
+ ```bash
55
+ # Multi-threaded (uses threaded worker)
56
+ plain server --workers 2 --threads 8
57
+ ```
58
+
59
+ For advanced worker customization, see the [`SyncWorker`](./workers/sync.py#SyncWorker) and [`ThreadWorker`](./workers/thread.py#ThreadWorker) classes.
60
+
61
+ ## Configuration options
62
+
63
+ All options are available via the command line. Run `plain server --help` to see the full list.
64
+
65
+ | Option | Default | Description |
66
+ | ------------------ | ---------------- | ----------------------------------------------------- |
67
+ | `--bind` / `-b` | `127.0.0.1:8000` | Address to bind (can be used multiple times) |
68
+ | `--workers` / `-w` | `1` | Number of worker processes (or `auto` for CPU count) |
69
+ | `--threads` | `1` | Number of threads per worker |
70
+ | `--timeout` / `-t` | `30` | Worker timeout in seconds |
71
+ | `--reload` | `False` | Restart workers when code changes |
72
+ | `--certfile` | - | Path to SSL certificate file |
73
+ | `--keyfile` | - | Path to SSL key file |
74
+ | `--log-level` | `info` | Logging level (debug, info, warning, error, critical) |
75
+ | `--access-log` | `-` (stdout) | Access log file path |
76
+ | `--error-log` | `-` (stderr) | Error log file path |
77
+ | `--max-requests` | `0` (disabled) | Max requests before worker restart |
78
+ | `--pidfile` | - | PID file path |
79
+
80
+ ## Environment variables
81
+
82
+ | Variable | Description |
83
+ | --------------------- | ------------------------------------------------------------------------ |
84
+ | `WEB_CONCURRENCY` | Sets the number of workers (use `auto` to detect CPU cores, or a number) |
85
+ | `SENDFILE` | Enable sendfile() syscall (`1`, `yes`, `true`, or `y` to enable) |
86
+ | `FORWARDED_ALLOW_IPS` | Comma-separated list of trusted proxy IPs (default: `127.0.0.1,::1`) |
87
+
88
+ ## Signals
89
+
90
+ The server responds to UNIX signals for process management.
91
+
92
+ | Signal | Effect |
93
+ | --------- | -------------------------------- |
94
+ | `SIGTERM` | Graceful shutdown |
95
+ | `SIGINT` | Quick shutdown |
96
+ | `SIGQUIT` | Quick shutdown |
97
+ | `SIGHUP` | Reload configuration and workers |
98
+ | `SIGTTIN` | Increase worker count by 1 |
99
+ | `SIGTTOU` | Decrease worker count by 1 |
100
+ | `SIGUSR1` | Reopen log files |
101
+
102
+ ## Using a different WSGI server
103
+
104
+ You can use any WSGI-compatible server instead of the built-in one. Plain provides a standard WSGI application interface at `plain.wsgi:app`.
105
+
106
+ ```bash
107
+ # Using uvicorn
108
+ uvicorn plain.wsgi:app --port 8000
109
+
110
+ # Using waitress
111
+ waitress-serve --port=8000 plain.wsgi:app
112
+
113
+ # Using gunicorn directly
114
+ gunicorn plain.wsgi:app --workers 4
115
+ ```
116
+
117
+ ## FAQs
118
+
119
+ #### How do I run with SSL/TLS?
120
+
121
+ Provide both `--certfile` and `--keyfile` options pointing to your certificate and key files.
122
+
123
+ ```bash
124
+ plain server --certfile cert.pem --keyfile key.pem
125
+ ```
126
+
127
+ #### How do I run behind a reverse proxy?
128
+
129
+ Configure your proxy to pass the appropriate headers, then set `FORWARDED_ALLOW_IPS` to include your proxy's IP address.
130
+
131
+ ```bash
132
+ FORWARDED_ALLOW_IPS="10.0.0.1,10.0.0.2" plain server --bind 0.0.0.0:8000
133
+ ```
134
+
135
+ The server recognizes `X-Forwarded-Proto`, `X-Forwarded-Protocol`, and `X-Forwarded-SSL` headers from trusted proxies.
136
+
137
+ #### How do I handle worker timeouts?
138
+
139
+ If workers are being killed due to timeouts, increase the `--timeout` value. This is common when handling long-running requests.
140
+
141
+ ```bash
142
+ plain server --timeout 120
143
+ ```
144
+
145
+ #### How do I rotate log files?
146
+
147
+ Send `SIGUSR1` to the master process to reopen log files. This works with tools like `logrotate`.
148
+
149
+ ```bash
150
+ kill -USR1 $(cat /path/to/pidfile)
151
+ ```
152
+
153
+ ## Installation
154
+
155
+ The server module is included with Plain. No additional installation is required.
@@ -0,0 +1,9 @@
1
+ #
2
+ # This file is part of gunicorn released under the MIT license.
3
+ # See the LICENSE for more information.
4
+ #
5
+ # Vendored and modified for Plain.
6
+
7
+ from .app import ServerApplication
8
+
9
+ __all__ = ["ServerApplication"]
plain/server/app.py ADDED
@@ -0,0 +1,52 @@
1
+ #
2
+ #
3
+ # This file is part of gunicorn released under the MIT license.
4
+ # See the LICENSE for more information.
5
+ #
6
+ # Vendored and modified for Plain.
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from .arbiter import Arbiter
14
+
15
+ if TYPE_CHECKING:
16
+ from .config import Config
17
+
18
+
19
+ class ServerApplication:
20
+ """
21
+ Plain's server application.
22
+
23
+ This class provides the interface for running the WSGI server.
24
+ """
25
+
26
+ def __init__(self, cfg: Config) -> None:
27
+ self.cfg: Config = cfg
28
+ self.callable: Any = None
29
+
30
+ def load(self) -> Any:
31
+ """Load the WSGI application."""
32
+ # Import locally to avoid circular dependencies and allow
33
+ # the WSGI module to handle Plain runtime setup
34
+ from plain.wsgi import app
35
+
36
+ return app
37
+
38
+ def wsgi(self) -> Any:
39
+ """Get the WSGI application."""
40
+ if self.callable is None:
41
+ self.callable = self.load()
42
+ return self.callable
43
+
44
+ def run(self) -> None:
45
+ """Run the server."""
46
+
47
+ try:
48
+ Arbiter(self).run()
49
+ except RuntimeError as e:
50
+ print(f"\nError: {e}\n", file=sys.stderr)
51
+ sys.stderr.flush()
52
+ sys.exit(1)