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
@@ -0,0 +1,555 @@
1
+ from __future__ import annotations
2
+
3
+ #
4
+ #
5
+ # This file is part of gunicorn released under the MIT license.
6
+ # See the LICENSE for more information.
7
+ #
8
+ # Vendored and modified for Plain.
9
+ import errno
10
+ import os
11
+ import random
12
+ import select
13
+ import signal
14
+ import sys
15
+ import time
16
+ import traceback
17
+ from types import FrameType
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ import plain.runtime
21
+
22
+ from . import sock, util
23
+ from .errors import AppImportError, HaltServer
24
+ from .pidfile import Pidfile
25
+
26
+ if TYPE_CHECKING:
27
+ from .app import ServerApplication
28
+ from .config import Config
29
+ from .glogging import Logger
30
+ from .workers.base import Worker
31
+
32
+
33
+ class Arbiter:
34
+ """
35
+ Arbiter maintain the workers processes alive. It launches or
36
+ kills them if needed. It also manages application reloading
37
+ via SIGHUP.
38
+ """
39
+
40
+ # A flag indicating if a worker failed to
41
+ # to boot. If a worker process exist with
42
+ # this error code, the arbiter will terminate.
43
+ WORKER_BOOT_ERROR: int = 3
44
+
45
+ # A flag indicating if an application failed to be loaded
46
+ APP_LOAD_ERROR: int = 4
47
+
48
+ START_CTX: dict[int | str, Any] = {}
49
+
50
+ LISTENERS: list[sock.BaseSocket] = []
51
+ WORKERS: dict[int, Worker] = {}
52
+ PIPE: list[int] = []
53
+
54
+ # I love dynamic languages
55
+ SIG_QUEUE: list[int] = []
56
+ SIGNALS: list[int] = [
57
+ getattr(signal, f"SIG{x}")
58
+ for x in "HUP QUIT INT TERM TTIN TTOU USR1 USR2 WINCH".split()
59
+ ]
60
+ SIG_NAMES: dict[int, str] = {
61
+ getattr(signal, name): name[3:].lower()
62
+ for name in dir(signal)
63
+ if name[:3] == "SIG" and name[3] != "_"
64
+ }
65
+
66
+ def __init__(self, app: ServerApplication):
67
+ os.environ["SERVER_SOFTWARE"] = f"plain/{plain.runtime.__version__}"
68
+
69
+ self._num_workers: int | None = None
70
+ self._last_logged_active_worker_count: int | None = None
71
+
72
+ self.setup(app)
73
+
74
+ self.pidfile: Pidfile | None = None
75
+ self.worker_age: int = 0
76
+
77
+ cwd = util.getcwd()
78
+
79
+ args = sys.argv[:]
80
+ args.insert(0, sys.executable)
81
+
82
+ # init start context
83
+ self.START_CTX = {"args": args, "cwd": cwd, 0: sys.executable}
84
+
85
+ def _get_num_workers(self) -> int:
86
+ assert self._num_workers is not None, "num_workers not initialized"
87
+ return self._num_workers
88
+
89
+ def _set_num_workers(self, value: int) -> None:
90
+ self._num_workers = value
91
+
92
+ num_workers = property(_get_num_workers, _set_num_workers)
93
+
94
+ def setup(self, app: ServerApplication) -> None:
95
+ self.app: ServerApplication = app
96
+ assert app.cfg is not None, "Application config must be initialized"
97
+ self.cfg: Config = app.cfg
98
+
99
+ if not hasattr(self, "log"):
100
+ from .glogging import Logger
101
+
102
+ self.log: Logger = Logger(self.cfg)
103
+
104
+ self.worker_class: type[Worker] = self.cfg.worker_class
105
+ self.address: str = self.cfg.address
106
+ self.num_workers = self.cfg.workers
107
+ self.timeout: int = self.cfg.timeout
108
+
109
+ def start(self) -> None:
110
+ """\
111
+ Initialize the arbiter. Start listening and set pidfile if needed.
112
+ """
113
+ self.pid: int = os.getpid()
114
+ if self.cfg.pidfile is not None:
115
+ self.pidfile = Pidfile(self.cfg.pidfile)
116
+ self.pidfile.create(self.pid)
117
+
118
+ self.init_signals()
119
+
120
+ if not self.LISTENERS:
121
+ self.LISTENERS = sock.create_sockets(self.cfg, self.log)
122
+
123
+ listeners_str = ",".join([str(lnr) for lnr in self.LISTENERS])
124
+ self.log.info(
125
+ "Plain server started address=%s pid=%s worker=%s version=%s",
126
+ listeners_str,
127
+ self.pid,
128
+ self.cfg.worker_class_str,
129
+ plain.runtime.__version__,
130
+ )
131
+
132
+ # check worker class requirements
133
+ if check_config := getattr(self.worker_class, "check_config", None):
134
+ check_config(self.cfg, self.log)
135
+
136
+ def init_signals(self) -> None:
137
+ """\
138
+ Initialize master signal handling. Most of the signals
139
+ are queued. Child signals only wake up the master.
140
+ """
141
+ # close old PIPE
142
+ for p in self.PIPE:
143
+ os.close(p)
144
+
145
+ # initialize the pipe
146
+ pair = os.pipe()
147
+ self.PIPE = list(pair)
148
+ for p in pair:
149
+ util.set_non_blocking(p)
150
+ util.close_on_exec(p)
151
+
152
+ self.log.close_on_exec()
153
+
154
+ # initialize all signals
155
+ for s in self.SIGNALS:
156
+ signal.signal(s, self.signal)
157
+ signal.signal(signal.SIGCHLD, self.handle_chld)
158
+
159
+ def signal(self, sig: int, frame: FrameType | None) -> None:
160
+ if len(self.SIG_QUEUE) < 5:
161
+ self.SIG_QUEUE.append(sig)
162
+ self.wakeup()
163
+
164
+ def run(self) -> None:
165
+ "Main master loop."
166
+ self.start()
167
+
168
+ try:
169
+ self.manage_workers()
170
+
171
+ while True:
172
+ sig = self.SIG_QUEUE.pop(0) if self.SIG_QUEUE else None
173
+ if sig is None:
174
+ self.sleep()
175
+ self.murder_workers()
176
+ self.manage_workers()
177
+ continue
178
+
179
+ if sig not in self.SIG_NAMES:
180
+ self.log.info("Ignoring unknown signal: %s", sig)
181
+ continue
182
+
183
+ signame = self.SIG_NAMES.get(sig)
184
+ handler = getattr(self, f"handle_{signame}", None)
185
+ if not handler:
186
+ self.log.error("Unhandled signal: %s", signame)
187
+ continue
188
+ self.log.info("Handling signal: %s", signame)
189
+ handler()
190
+ self.wakeup()
191
+ except (StopIteration, KeyboardInterrupt):
192
+ self.halt()
193
+ except HaltServer as inst:
194
+ self.halt(reason=inst.reason, exit_status=inst.exit_status)
195
+ except SystemExit:
196
+ raise
197
+ except Exception:
198
+ self.log.error("Unhandled exception in main loop", exc_info=True)
199
+ self.stop(False)
200
+ if self.pidfile is not None:
201
+ self.pidfile.unlink()
202
+ sys.exit(-1)
203
+
204
+ def handle_chld(self, sig: int, frame: FrameType | None) -> None:
205
+ "SIGCHLD handling"
206
+ self.reap_workers()
207
+ self.wakeup()
208
+
209
+ def handle_hup(self) -> None:
210
+ """\
211
+ HUP handling.
212
+ - Reload configuration
213
+ - Start the new worker processes with a new configuration
214
+ - Gracefully shutdown the old worker processes
215
+ """
216
+ self.log.info("Hang up: Master")
217
+ self.reload()
218
+
219
+ def handle_term(self) -> None:
220
+ "SIGTERM handling"
221
+ raise StopIteration
222
+
223
+ def handle_int(self) -> None:
224
+ "SIGINT handling"
225
+ self.stop(False)
226
+ raise StopIteration
227
+
228
+ def handle_quit(self) -> None:
229
+ "SIGQUIT handling"
230
+ self.stop(False)
231
+ raise StopIteration
232
+
233
+ def handle_ttin(self) -> None:
234
+ """\
235
+ SIGTTIN handling.
236
+ Increases the number of workers by one.
237
+ """
238
+ self.num_workers += 1
239
+ self.manage_workers()
240
+
241
+ def handle_ttou(self) -> None:
242
+ """\
243
+ SIGTTOU handling.
244
+ Decreases the number of workers by one.
245
+ """
246
+ if self.num_workers <= 1:
247
+ return None
248
+ self.num_workers -= 1
249
+ self.manage_workers()
250
+
251
+ def handle_usr1(self) -> None:
252
+ """\
253
+ SIGUSR1 handling.
254
+ Kill all workers by sending them a SIGUSR1
255
+ """
256
+ self.log.reopen_files()
257
+ self.kill_workers(signal.SIGUSR1)
258
+
259
+ def handle_usr2(self) -> None:
260
+ """SIGUSR2 handling"""
261
+ # USR2 for graceful restart is not supported
262
+ self.log.debug("SIGUSR2 ignored")
263
+
264
+ def handle_winch(self) -> None:
265
+ """SIGWINCH handling"""
266
+ # SIGWINCH is typically used to gracefully stop workers when running as daemon
267
+ # Since we don't support daemon mode, just log that it's ignored
268
+ self.log.debug("SIGWINCH ignored")
269
+
270
+ def wakeup(self) -> None:
271
+ """\
272
+ Wake up the arbiter by writing to the PIPE
273
+ """
274
+ try:
275
+ os.write(self.PIPE[1], b".")
276
+ except OSError as e:
277
+ if e.errno not in [errno.EAGAIN, errno.EINTR]:
278
+ raise
279
+
280
+ def halt(self, reason: str | None = None, exit_status: int = 0) -> None:
281
+ """halt arbiter"""
282
+ self.stop()
283
+
284
+ log_func = self.log.info if exit_status == 0 else self.log.error
285
+ log_func("Shutting down: Master")
286
+ if reason is not None:
287
+ log_func("Reason: %s", reason)
288
+
289
+ if self.pidfile is not None:
290
+ self.pidfile.unlink()
291
+ sys.exit(exit_status)
292
+
293
+ def sleep(self) -> None:
294
+ """\
295
+ Sleep until PIPE is readable or we timeout.
296
+ A readable PIPE means a signal occurred.
297
+ """
298
+ try:
299
+ ready = select.select([self.PIPE[0]], [], [], 1.0)
300
+ if not ready[0]:
301
+ return
302
+ while os.read(self.PIPE[0], 1):
303
+ pass
304
+ except OSError as e:
305
+ # TODO: select.error is a subclass of OSError since Python 3.3.
306
+ error_number = getattr(e, "errno", e.args[0])
307
+ if error_number not in [errno.EAGAIN, errno.EINTR]:
308
+ raise
309
+ except KeyboardInterrupt:
310
+ sys.exit()
311
+
312
+ def stop(self, graceful: bool = True) -> None:
313
+ """\
314
+ Stop workers
315
+
316
+ :attr graceful: boolean, If True (the default) workers will be
317
+ killed gracefully (ie. trying to wait for the current connection)
318
+ """
319
+ sock.close_sockets(self.LISTENERS, unlink=True)
320
+
321
+ self.LISTENERS = []
322
+ sig = signal.SIGTERM
323
+ if not graceful:
324
+ sig = signal.SIGQUIT
325
+ limit = time.time() + self.cfg.graceful_timeout
326
+ # instruct the workers to exit
327
+ self.kill_workers(sig)
328
+ # wait until the graceful timeout
329
+ while self.WORKERS and time.time() < limit:
330
+ time.sleep(0.1)
331
+
332
+ self.kill_workers(signal.SIGKILL)
333
+
334
+ def reload(self) -> None:
335
+ old_address = self.cfg.address
336
+
337
+ self.setup(self.app)
338
+
339
+ # reopen log files
340
+ self.log.reopen_files()
341
+
342
+ # do we need to change listener ?
343
+ if old_address != self.cfg.address:
344
+ # close all listeners
345
+ for lnr in self.LISTENERS:
346
+ lnr.close()
347
+ # init new listeners
348
+ self.LISTENERS = sock.create_sockets(self.cfg, self.log)
349
+ listeners_str = ",".join([str(lnr) for lnr in self.LISTENERS])
350
+ self.log.info("Listening at: %s", listeners_str)
351
+
352
+ # unlink pidfile
353
+ if self.pidfile is not None:
354
+ self.pidfile.unlink()
355
+
356
+ # create new pidfile
357
+ if self.cfg.pidfile is not None:
358
+ self.pidfile = Pidfile(self.cfg.pidfile)
359
+ self.pidfile.create(self.pid)
360
+
361
+ # spawn new workers
362
+ for _ in range(self.cfg.workers):
363
+ self.spawn_worker()
364
+
365
+ # manage workers
366
+ self.manage_workers()
367
+
368
+ def murder_workers(self) -> None:
369
+ """\
370
+ Kill unused/idle workers
371
+ """
372
+ if not self.timeout:
373
+ return None
374
+ workers = list(self.WORKERS.items())
375
+ for pid, worker in workers:
376
+ try:
377
+ if time.monotonic() - worker.tmp.last_update() <= self.timeout:
378
+ continue
379
+ except (OSError, ValueError):
380
+ continue
381
+
382
+ if not worker.aborted:
383
+ self.log.critical("WORKER TIMEOUT (pid:%s)", pid)
384
+ worker.aborted = True
385
+ self.kill_worker(pid, signal.SIGABRT)
386
+ else:
387
+ self.kill_worker(pid, signal.SIGKILL)
388
+
389
+ def reap_workers(self) -> None:
390
+ """\
391
+ Reap workers to avoid zombie processes
392
+ """
393
+ try:
394
+ while True:
395
+ wpid, status = os.waitpid(-1, os.WNOHANG)
396
+ if not wpid:
397
+ break
398
+
399
+ # A worker was terminated. If the termination reason was
400
+ # that it could not boot, we'll shut it down to avoid
401
+ # infinite start/stop cycles.
402
+ exitcode = status >> 8
403
+ if exitcode != 0:
404
+ self.log.error(
405
+ "Worker (pid:%s) exited with code %s", wpid, exitcode
406
+ )
407
+ if exitcode == self.WORKER_BOOT_ERROR:
408
+ reason = "Worker failed to boot."
409
+ raise HaltServer(reason, self.WORKER_BOOT_ERROR)
410
+ if exitcode == self.APP_LOAD_ERROR:
411
+ reason = "App failed to load."
412
+ raise HaltServer(reason, self.APP_LOAD_ERROR)
413
+
414
+ if exitcode > 0:
415
+ # If the exit code of the worker is greater than 0,
416
+ # let the user know.
417
+ self.log.error(
418
+ "Worker (pid:%s) exited with code %s.", wpid, exitcode
419
+ )
420
+ elif status > 0:
421
+ # If the exit code of the worker is 0 and the status
422
+ # is greater than 0, then it was most likely killed
423
+ # via a signal.
424
+ try:
425
+ sig_name = signal.Signals(status).name
426
+ except ValueError:
427
+ sig_name = f"code {status}"
428
+ msg = f"Worker (pid:{wpid}) was sent {sig_name}!"
429
+
430
+ # Additional hint for SIGKILL
431
+ if status == signal.SIGKILL:
432
+ msg += " Perhaps out of memory?"
433
+ self.log.error(msg)
434
+
435
+ worker = self.WORKERS.pop(wpid, None)
436
+ if not worker:
437
+ continue
438
+ worker.tmp.close()
439
+ except OSError as e:
440
+ if e.errno != errno.ECHILD:
441
+ raise
442
+
443
+ def manage_workers(self) -> None:
444
+ """\
445
+ Maintain the number of workers by spawning or killing
446
+ as required.
447
+ """
448
+ if len(self.WORKERS) < self.num_workers:
449
+ self.spawn_workers()
450
+
451
+ workers = self.WORKERS.items()
452
+ workers = sorted(workers, key=lambda w: w[1].age)
453
+ while len(workers) > self.num_workers:
454
+ (pid, _) = workers.pop(0)
455
+ self.kill_worker(pid, signal.SIGTERM)
456
+
457
+ active_worker_count = len(workers)
458
+ if self._last_logged_active_worker_count != active_worker_count:
459
+ self._last_logged_active_worker_count = active_worker_count
460
+ self.log.debug(
461
+ f"{active_worker_count} workers",
462
+ extra={
463
+ "metric": "plain.server.workers",
464
+ "value": active_worker_count,
465
+ "mtype": "gauge",
466
+ },
467
+ )
468
+
469
+ def spawn_worker(self) -> int:
470
+ self.worker_age += 1
471
+ worker = self.worker_class(
472
+ self.worker_age,
473
+ self.pid,
474
+ self.LISTENERS,
475
+ self.app,
476
+ self.timeout / 2.0,
477
+ self.cfg,
478
+ self.log,
479
+ )
480
+ pid = os.fork()
481
+ if pid != 0:
482
+ worker.pid = pid
483
+ self.WORKERS[pid] = worker
484
+ return pid
485
+
486
+ # Do not inherit the temporary files of other workers
487
+ for sibling in self.WORKERS.values():
488
+ sibling.tmp.close()
489
+
490
+ # Process Child
491
+ worker.pid = os.getpid()
492
+ try:
493
+ self.log.info("Server worker started pid=%s", worker.pid)
494
+ worker.init_process()
495
+ sys.exit(0)
496
+ except SystemExit:
497
+ raise
498
+ except AppImportError as e:
499
+ self.log.debug("Exception while loading the application", exc_info=True)
500
+ print(f"{e}", file=sys.stderr)
501
+ sys.stderr.flush()
502
+ sys.exit(self.APP_LOAD_ERROR)
503
+ except Exception:
504
+ self.log.exception("Exception in worker process")
505
+ if not worker.booted:
506
+ sys.exit(self.WORKER_BOOT_ERROR)
507
+ sys.exit(-1)
508
+ finally:
509
+ self.log.info("Server worker exiting (pid: %s)", worker.pid)
510
+ try:
511
+ worker.tmp.close()
512
+ except Exception:
513
+ self.log.warning(
514
+ "Exception during worker exit:\n%s", traceback.format_exc()
515
+ )
516
+
517
+ def spawn_workers(self) -> None:
518
+ """\
519
+ Spawn new workers as needed.
520
+
521
+ This is where a worker process leaves the main loop
522
+ of the master process.
523
+ """
524
+
525
+ for _ in range(self.num_workers - len(self.WORKERS)):
526
+ self.spawn_worker()
527
+ time.sleep(0.1 * random.random())
528
+
529
+ def kill_workers(self, sig: int) -> None:
530
+ """\
531
+ Kill all workers with the signal `sig`
532
+ :attr sig: `signal.SIG*` value
533
+ """
534
+ worker_pids = list(self.WORKERS.keys())
535
+ for pid in worker_pids:
536
+ self.kill_worker(pid, sig)
537
+
538
+ def kill_worker(self, pid: int, sig: int) -> None:
539
+ """\
540
+ Kill a worker
541
+
542
+ :attr pid: int, worker pid
543
+ :attr sig: `signal.SIG*` value
544
+ """
545
+ try:
546
+ os.kill(pid, sig)
547
+ except OSError as e:
548
+ if e.errno == errno.ESRCH:
549
+ try:
550
+ worker = self.WORKERS.pop(pid)
551
+ worker.tmp.close()
552
+ return None
553
+ except (KeyError, OSError):
554
+ return None
555
+ raise
plain/server/config.py ADDED
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ #
4
+ #
5
+ # This file is part of gunicorn released under the MIT license.
6
+ # See the LICENSE for more information.
7
+ #
8
+ # Vendored and modified for Plain.
9
+ import os
10
+ from dataclasses import dataclass
11
+
12
+ from . import util
13
+ from .workers.sync import SyncWorker
14
+ from .workers.thread import ThreadWorker
15
+
16
+
17
+ @dataclass
18
+ class Config:
19
+ """Plain server configuration.
20
+
21
+ All configuration values are required and provided by the CLI.
22
+ Defaults are defined in the CLI layer, not here.
23
+ """
24
+
25
+ # Core settings (from CLI)
26
+ bind: list[str]
27
+ workers: int
28
+ threads: int
29
+ timeout: int
30
+ max_requests: int
31
+ reload: bool
32
+ pidfile: str | None
33
+ certfile: str | None
34
+ keyfile: str | None
35
+ loglevel: str
36
+ accesslog: str
37
+ errorlog: str
38
+ log_format: str
39
+ access_log_format: str
40
+
41
+ @property
42
+ def worker_class_str(self) -> str:
43
+ # Auto-select based on threads
44
+ if self.threads > 1:
45
+ return "thread"
46
+ return "sync"
47
+
48
+ @property
49
+ def worker_class(self) -> type:
50
+ # Auto-select based on threads
51
+ if self.threads > 1:
52
+ worker_class = ThreadWorker
53
+ else:
54
+ worker_class = SyncWorker
55
+
56
+ if hasattr(worker_class, "setup"):
57
+ worker_class.setup()
58
+ return worker_class
59
+
60
+ @property
61
+ def address(self) -> list[tuple[str, int] | str]:
62
+ return [util.parse_address(util.bytes_to_str(bind)) for bind in self.bind]
63
+
64
+ @property
65
+ def is_ssl(self) -> bool:
66
+ return self.certfile is not None or self.keyfile is not None
67
+
68
+ @property
69
+ def sendfile(self) -> bool:
70
+ if "SENDFILE" in os.environ:
71
+ sendfile = os.environ["SENDFILE"].lower()
72
+ return sendfile in ["y", "1", "yes", "true"]
73
+
74
+ return True
75
+
76
+ @property
77
+ def graceful_timeout(self) -> int:
78
+ """Timeout for graceful worker shutdown in seconds."""
79
+ return 30
80
+
81
+ @property
82
+ def forwarded_allow_ips(self) -> list[str]:
83
+ """
84
+ Trusted proxy IPs allowed to set secure headers.
85
+ Default: ['127.0.0.1', '::1'] (localhost only)
86
+ Can be overridden via FORWARDED_ALLOW_IPS environment variable.
87
+ """
88
+ val = os.environ.get("FORWARDED_ALLOW_IPS", "127.0.0.1,::1")
89
+ return [v.strip() for v in val.split(",") if v]
90
+
91
+ @property
92
+ def secure_scheme_headers(self) -> dict[str, str]:
93
+ """
94
+ Headers that indicate HTTPS when set by a trusted proxy.
95
+ Default headers: X-FORWARDED-PROTOCOL, X-FORWARDED-PROTO, X-FORWARDED-SSL
96
+ """
97
+ return {
98
+ "X-FORWARDED-PROTOCOL": "ssl",
99
+ "X-FORWARDED-PROTO": "https",
100
+ "X-FORWARDED-SSL": "on",
101
+ }
102
+
103
+ @property
104
+ def forwarder_headers(self) -> list[str]:
105
+ """
106
+ Header names that proxies can use to override WSGI environment.
107
+ Default: ['SCRIPT_NAME', 'PATH_INFO']
108
+ """
109
+ return ["SCRIPT_NAME", "PATH_INFO"]
110
+
111
+ @property
112
+ def header_map(self) -> str:
113
+ """
114
+ How to handle header names with underscores.
115
+ Default: 'drop' (silently drop ambiguous headers)
116
+ Options: 'drop', 'refuse', 'dangerous'
117
+ """
118
+ return "drop"
plain/server/errors.py ADDED
@@ -0,0 +1,31 @@
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
+ # We don't need to call super() in __init__ methods of our
9
+ # BaseException and Exception classes because we also define
10
+ # our own __str__ methods so there is no need to pass 'message'
11
+ # to the base class to get a meaningful output from 'str(exc)'.
12
+ # pylint: disable=super-init-not-called
13
+
14
+
15
+ # we inherit from BaseException here to make sure to not be caught
16
+ # at application level
17
+ class HaltServer(BaseException):
18
+ def __init__(self, reason: str, exit_status: int = 1) -> None:
19
+ self.reason = reason
20
+ self.exit_status = exit_status
21
+
22
+ def __str__(self) -> str:
23
+ return f"<HaltServer {self.reason!r} {self.exit_status}>"
24
+
25
+
26
+ class ConfigError(Exception):
27
+ """Exception raised on config error"""
28
+
29
+
30
+ class AppImportError(Exception):
31
+ """Exception raised when loading an application"""