plain 0.68.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 (195) hide show
  1. plain/CHANGELOG.md +656 -1
  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 -36
  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 +110 -26
  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 +27 -75
  25. plain/cli/print.py +4 -4
  26. plain/cli/registry.py +96 -10
  27. plain/cli/{agent/request.py → request.py} +67 -33
  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 -8
  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 +27 -16
  82. plain/paginator.py +31 -21
  83. plain/preflight/README.md +209 -24
  84. plain/preflight/__init__.py +1 -0
  85. plain/preflight/checks.py +3 -1
  86. plain/preflight/files.py +3 -1
  87. plain/preflight/registry.py +26 -11
  88. plain/preflight/results.py +15 -7
  89. plain/preflight/security.py +15 -13
  90. plain/preflight/settings.py +54 -0
  91. plain/preflight/urls.py +4 -1
  92. plain/runtime/README.md +115 -47
  93. plain/runtime/__init__.py +10 -6
  94. plain/runtime/global_settings.py +34 -25
  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 +13 -5
  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 +38 -22
  145. plain/urls/resolvers.py +35 -25
  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.68.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.68.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/csrf/views.py +0 -31
  191. plain/logs/utils.py +0 -46
  192. plain/templates/AGENTS.md +0 -3
  193. plain-0.68.0.dist-info/RECORD +0 -169
  194. plain-0.68.0.dist-info/entry_points.txt +0 -5
  195. {plain-0.68.0.dist-info → plain-0.101.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,399 @@
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
+ # design:
10
+ # A threaded worker accepts connections in the main loop, accepted
11
+ # connections are added to the thread pool as a connection job.
12
+ # Keepalive connections are put back in the loop waiting for an event.
13
+ # If no event happen after the keep alive timeout, the connection is
14
+ # closed.
15
+ # pylint: disable=no-else-break
16
+ import errno
17
+ import os
18
+ import selectors
19
+ import socket
20
+ import ssl
21
+ import sys
22
+ import time
23
+ from collections import deque
24
+ from concurrent import futures
25
+ from datetime import datetime
26
+ from functools import partial
27
+ from threading import RLock
28
+ from types import FrameType
29
+ from typing import TYPE_CHECKING, Any
30
+
31
+ from .. import http, sock, util
32
+ from ..http import wsgi
33
+ from . import base
34
+
35
+ if TYPE_CHECKING:
36
+ from ..config import Config
37
+ from ..glogging import Logger
38
+
39
+ # Keep-alive connection timeout in seconds
40
+ KEEPALIVE = 2
41
+
42
+ # Maximum number of simultaneous client connections
43
+ WORKER_CONNECTIONS = 1000
44
+
45
+
46
+ class TConn:
47
+ def __init__(
48
+ self,
49
+ cfg: Config,
50
+ sock: socket.socket,
51
+ client: tuple[str, int],
52
+ server: tuple[str, int],
53
+ ) -> None:
54
+ self.cfg = cfg
55
+ self.sock = sock
56
+ self.client = client
57
+ self.server = server
58
+
59
+ self.timeout: float | None = None
60
+ self.parser: http.RequestParser | None = None
61
+ self.initialized: bool = False
62
+
63
+ # set the socket to non blocking
64
+ self.sock.setblocking(False)
65
+
66
+ def init(self) -> None:
67
+ self.initialized = True
68
+ self.sock.setblocking(True)
69
+
70
+ if self.parser is None:
71
+ # wrap the socket if needed
72
+ if self.cfg.is_ssl:
73
+ self.sock = sock.ssl_wrap_socket(self.sock, self.cfg)
74
+
75
+ # initialize the parser
76
+ self.parser = http.RequestParser(self.cfg, self.sock, self.client)
77
+
78
+ def set_timeout(self) -> None:
79
+ # set the timeout
80
+ self.timeout = time.time() + KEEPALIVE
81
+
82
+ def close(self) -> None:
83
+ util.close(self.sock)
84
+
85
+
86
+ class ThreadWorker(base.Worker):
87
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
88
+ super().__init__(*args, **kwargs)
89
+ self.worker_connections: int = WORKER_CONNECTIONS
90
+ self.max_keepalived: int = WORKER_CONNECTIONS - self.cfg.threads
91
+ self.futures: deque[futures.Future[tuple[bool, TConn]]] = deque()
92
+ self._keep: deque[TConn] = deque()
93
+ self.nr_conns: int = 0
94
+
95
+ @classmethod
96
+ def check_config(cls, cfg: Config, log: Logger) -> None:
97
+ max_keepalived = WORKER_CONNECTIONS - cfg.threads
98
+
99
+ if max_keepalived <= 0:
100
+ log.warning(
101
+ "No keepalived connections can be handled. "
102
+ "Check the number of worker connections and threads."
103
+ )
104
+
105
+ def init_process(self) -> None:
106
+ # These are always initialized before any worker methods are called
107
+ self.tpool: futures.ThreadPoolExecutor = self.get_thread_pool()
108
+ self.poller: selectors.DefaultSelector = selectors.DefaultSelector()
109
+ self._lock: RLock = RLock()
110
+ super().init_process()
111
+
112
+ def get_thread_pool(self) -> futures.ThreadPoolExecutor:
113
+ """Override this method to customize how the thread pool is created"""
114
+ return futures.ThreadPoolExecutor(max_workers=self.cfg.threads)
115
+
116
+ def handle_quit(self, sig: int, frame: FrameType | None) -> None:
117
+ self.alive = False
118
+ self.tpool.shutdown(wait=False, cancel_futures=True)
119
+ time.sleep(0.1)
120
+ sys.exit(0)
121
+
122
+ def handle_abort(self, sig: int, frame: FrameType | None) -> None:
123
+ self.alive = False
124
+ self.tpool.shutdown(wait=False, cancel_futures=True)
125
+ sys.exit(1)
126
+
127
+ def _wrap_future(self, fs: futures.Future[tuple[bool, TConn]], conn: TConn) -> None:
128
+ fs.conn = conn # type: ignore[attr-defined]
129
+ self.futures.append(fs)
130
+ fs.add_done_callback(self.finish_request)
131
+
132
+ def enqueue_req(self, conn: TConn) -> None:
133
+ conn.init()
134
+ # submit the connection to a worker
135
+ fs = self.tpool.submit(self.handle, conn)
136
+ self._wrap_future(fs, conn)
137
+
138
+ def accept(self, server: tuple[str, int], listener: socket.socket) -> None:
139
+ try:
140
+ sock, client = listener.accept()
141
+ # initialize the connection object
142
+ conn = TConn(self.cfg, sock, client, server)
143
+
144
+ self.nr_conns += 1
145
+ # wait until socket is readable
146
+ with self._lock:
147
+ self.poller.register(
148
+ conn.sock,
149
+ selectors.EVENT_READ,
150
+ partial(self.on_client_socket_readable, conn),
151
+ )
152
+ except OSError as e:
153
+ if e.errno not in (errno.EAGAIN, errno.ECONNABORTED, errno.EWOULDBLOCK):
154
+ raise
155
+
156
+ def on_client_socket_readable(self, conn: TConn, client: socket.socket) -> None:
157
+ with self._lock:
158
+ # unregister the client from the poller
159
+ self.poller.unregister(client)
160
+
161
+ if conn.initialized:
162
+ # remove the connection from keepalive
163
+ try:
164
+ self._keep.remove(conn)
165
+ except ValueError:
166
+ # race condition
167
+ return
168
+
169
+ # submit the connection to a worker
170
+ self.enqueue_req(conn)
171
+
172
+ def murder_keepalived(self) -> None:
173
+ now = time.time()
174
+ while True:
175
+ with self._lock:
176
+ try:
177
+ # remove the connection from the queue
178
+ conn = self._keep.popleft()
179
+ except IndexError:
180
+ break
181
+
182
+ # Connections in _keep always have timeout set via set_timeout()
183
+ assert conn.timeout is not None, (
184
+ "timeout should be set for keepalive connections"
185
+ )
186
+ delta = conn.timeout - now
187
+ if delta > 0:
188
+ # add the connection back to the queue
189
+ with self._lock:
190
+ self._keep.appendleft(conn)
191
+ break
192
+ else:
193
+ self.nr_conns -= 1
194
+ # remove the socket from the poller
195
+ with self._lock:
196
+ try:
197
+ self.poller.unregister(conn.sock)
198
+ except OSError as e:
199
+ if e.errno != errno.EBADF:
200
+ raise
201
+ except KeyError:
202
+ # already removed by the system, continue
203
+ pass
204
+ except ValueError:
205
+ # already removed by the system continue
206
+ pass
207
+
208
+ # close the socket
209
+ conn.close()
210
+
211
+ def is_parent_alive(self) -> bool:
212
+ # If our parent changed then we shut down.
213
+ if self.ppid != os.getppid():
214
+ self.log.info("Parent changed, shutting down: %s", self)
215
+ return False
216
+ return True
217
+
218
+ def run(self) -> None:
219
+ # init listeners, add them to the event loop
220
+ for listener in self.sockets:
221
+ listener.setblocking(False)
222
+ # a race condition during graceful shutdown may make the listener
223
+ # name unavailable in the request handler so capture it once here
224
+ server = listener.getsockname()
225
+ acceptor = partial(self.accept, server)
226
+ self.poller.register(listener, selectors.EVENT_READ, acceptor)
227
+
228
+ while self.alive:
229
+ # notify the arbiter we are alive
230
+ self.notify()
231
+
232
+ # can we accept more connections?
233
+ if self.nr_conns < self.worker_connections:
234
+ # wait for an event
235
+ events = self.poller.select(1.0)
236
+ for key, _ in events:
237
+ callback = key.data
238
+ callback(key.fileobj)
239
+
240
+ # check (but do not wait) for finished requests
241
+ result = futures.wait(
242
+ self.futures, timeout=0, return_when=futures.FIRST_COMPLETED
243
+ )
244
+ else:
245
+ # wait for a request to finish
246
+ result = futures.wait(
247
+ self.futures, timeout=1.0, return_when=futures.FIRST_COMPLETED
248
+ )
249
+
250
+ # clean up finished requests
251
+ for fut in result.done:
252
+ self.futures.remove(fut)
253
+
254
+ if not self.is_parent_alive():
255
+ break
256
+
257
+ # handle keepalive timeouts
258
+ self.murder_keepalived()
259
+
260
+ self.tpool.shutdown(False)
261
+ self.poller.close()
262
+
263
+ for s in self.sockets:
264
+ s.close()
265
+
266
+ futures.wait(self.futures, timeout=self.cfg.graceful_timeout)
267
+
268
+ def finish_request(self, fs: futures.Future[tuple[bool, TConn]]) -> None:
269
+ if fs.cancelled():
270
+ self.nr_conns -= 1
271
+ fs.conn.close() # type: ignore[attr-defined]
272
+ return
273
+
274
+ try:
275
+ (keepalive, conn) = fs.result()
276
+ # if the connection should be kept alived add it
277
+ # to the eventloop and record it
278
+ if keepalive and self.alive:
279
+ # flag the socket as non blocked
280
+ conn.sock.setblocking(False)
281
+
282
+ # register the connection
283
+ conn.set_timeout()
284
+ with self._lock:
285
+ self._keep.append(conn)
286
+
287
+ # add the socket to the event loop
288
+ self.poller.register(
289
+ conn.sock,
290
+ selectors.EVENT_READ,
291
+ partial(self.on_client_socket_readable, conn),
292
+ )
293
+ else:
294
+ self.nr_conns -= 1
295
+ conn.close()
296
+ except Exception:
297
+ # an exception happened, make sure to close the
298
+ # socket.
299
+ self.nr_conns -= 1
300
+ fs.conn.close() # type: ignore[attr-defined]
301
+
302
+ def handle(self, conn: TConn) -> tuple[bool, TConn]:
303
+ keepalive = False
304
+ req = None
305
+ try:
306
+ # conn.parser is guaranteed to be initialized by enqueue_req -> conn.init()
307
+ assert conn.parser is not None
308
+ req = next(conn.parser)
309
+ if not req:
310
+ return (False, conn)
311
+
312
+ # handle the request
313
+ keepalive = self.handle_request(req, conn)
314
+ if keepalive:
315
+ return (keepalive, conn)
316
+ except http.errors.NoMoreData as e:
317
+ self.log.debug("Ignored premature client disconnection. %s", e)
318
+
319
+ except StopIteration as e:
320
+ self.log.debug("Closing connection. %s", e)
321
+ except ssl.SSLError as e:
322
+ if e.args[0] == ssl.SSL_ERROR_EOF:
323
+ self.log.debug("ssl connection closed")
324
+ conn.sock.close()
325
+ else:
326
+ self.log.debug("Error processing SSL request.")
327
+ self.handle_error(req, conn.sock, conn.client, e)
328
+
329
+ except OSError as e:
330
+ if e.errno not in (errno.EPIPE, errno.ECONNRESET, errno.ENOTCONN):
331
+ self.log.exception("Socket error processing request.")
332
+ else:
333
+ if e.errno == errno.ECONNRESET:
334
+ self.log.debug("Ignoring connection reset")
335
+ elif e.errno == errno.ENOTCONN:
336
+ self.log.debug("Ignoring socket not connected")
337
+ else:
338
+ self.log.debug("Ignoring connection epipe")
339
+ except Exception as e:
340
+ self.handle_error(req, conn.sock, conn.client, e)
341
+
342
+ return (False, conn)
343
+
344
+ def handle_request(self, req: Any, conn: TConn) -> bool:
345
+ environ: dict[str, Any] = {}
346
+ resp: wsgi.Response | None = None
347
+ try:
348
+ request_start = datetime.now()
349
+ resp, environ = wsgi.create(
350
+ req, conn.sock, conn.client, conn.server, self.cfg
351
+ )
352
+ environ["wsgi.multithread"] = True
353
+ self.nr += 1
354
+ if self.nr >= self.max_requests:
355
+ if self.alive:
356
+ self.log.info("Autorestarting worker after current request.")
357
+ self.alive = False
358
+ resp.force_close()
359
+
360
+ if not self.alive:
361
+ resp.force_close()
362
+ elif len(self._keep) >= self.max_keepalived:
363
+ resp.force_close()
364
+
365
+ respiter = self.wsgi(environ, resp.start_response)
366
+ try:
367
+ if isinstance(respiter, environ["wsgi.file_wrapper"]):
368
+ resp.write_file(respiter)
369
+ else:
370
+ for item in respiter:
371
+ resp.write(item)
372
+
373
+ resp.close()
374
+ finally:
375
+ request_time = datetime.now() - request_start
376
+ self.log.access(resp, req, environ, request_time)
377
+ if hasattr(respiter, "close"):
378
+ respiter.close()
379
+
380
+ if resp.should_close():
381
+ self.log.debug("Closing connection.")
382
+ return False
383
+ except OSError:
384
+ # pass to next try-except level
385
+ util.reraise(*sys.exc_info())
386
+ except Exception:
387
+ if resp and resp.headers_sent:
388
+ # If the requests have already been sent, we should close the
389
+ # connection to indicate the error.
390
+ self.log.exception("Error handling request")
391
+ try:
392
+ conn.sock.shutdown(socket.SHUT_RDWR)
393
+ conn.sock.close()
394
+ except OSError:
395
+ pass
396
+ raise StopIteration()
397
+ raise
398
+
399
+ return True
@@ -0,0 +1,50 @@
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
+ import platform
11
+ import tempfile
12
+ import time
13
+ from typing import TYPE_CHECKING
14
+
15
+ from .. import util
16
+
17
+ if TYPE_CHECKING:
18
+ from ..config import Config
19
+
20
+ PLATFORM = platform.system()
21
+ IS_CYGWIN = PLATFORM.startswith("CYGWIN")
22
+
23
+
24
+ class WorkerTmp:
25
+ def __init__(self, cfg: Config) -> None:
26
+ fd, name = tempfile.mkstemp(prefix="wplain-")
27
+
28
+ # unlink the file so we don't leak temporary files
29
+ try:
30
+ if not IS_CYGWIN:
31
+ util.unlink(name)
32
+ # In Python 3.8, open() emits RuntimeWarning if buffering=1 for binary mode.
33
+ # Because we never write to this file, pass 0 to switch buffering off.
34
+ self._tmp = os.fdopen(fd, "w+b", 0)
35
+ except Exception:
36
+ os.close(fd)
37
+ raise
38
+
39
+ def notify(self) -> None:
40
+ new_time = time.monotonic()
41
+ os.utime(self._tmp.fileno(), (new_time, new_time))
42
+
43
+ def last_update(self) -> float:
44
+ return os.fstat(self._tmp.fileno()).st_mtime
45
+
46
+ def fileno(self) -> int:
47
+ return self._tmp.fileno()
48
+
49
+ def close(self) -> None:
50
+ return self._tmp.close()
plain/signals/README.md CHANGED
@@ -1,11 +1,23 @@
1
1
  # Signals
2
2
 
3
- **Run code when certain events happen.**
3
+ **Run code when certain events happen in your application.**
4
4
 
5
5
  - [Overview](#overview)
6
+ - [Using the receiver decorator](#using-the-receiver-decorator)
7
+ - [Creating custom signals](#creating-custom-signals)
8
+ - [Filtering by sender](#filtering-by-sender)
9
+ - [Signal methods](#signal-methods)
10
+ - [send](#send)
11
+ - [send_robust](#send_robust)
12
+ - [disconnect](#disconnect)
13
+ - [has_listeners](#has_listeners)
14
+ - [FAQs](#faqs)
15
+ - [Installation](#installation)
6
16
 
7
17
  ## Overview
8
18
 
19
+ Signals let you decouple parts of your application by allowing certain senders to notify receivers when specific events occur. You connect a receiver function to a signal, and it gets called whenever that signal is sent.
20
+
9
21
  ```python
10
22
  from plain.signals import request_finished
11
23
 
@@ -16,3 +28,160 @@ def on_request_finished(sender, **kwargs):
16
28
 
17
29
  request_finished.connect(on_request_finished)
18
30
  ```
31
+
32
+ Plain provides two built-in signals:
33
+
34
+ - `request_started` - sent when a request begins processing
35
+ - `request_finished` - sent when a request finishes processing
36
+
37
+ Your receiver function must accept `**kwargs` because signals may pass additional arguments in the future.
38
+
39
+ ## Using the receiver decorator
40
+
41
+ Instead of calling `.connect()` manually, you can use the `@receiver` decorator to connect a function to a signal.
42
+
43
+ ```python
44
+ from plain.signals import request_finished
45
+ from plain.signals.dispatch import receiver
46
+
47
+
48
+ @receiver(request_finished)
49
+ def on_request_finished(sender, **kwargs):
50
+ print("Request finished!")
51
+ ```
52
+
53
+ You can also connect to multiple signals at once by passing a list.
54
+
55
+ ```python
56
+ from plain.signals import request_started, request_finished
57
+ from plain.signals.dispatch import receiver
58
+
59
+
60
+ @receiver([request_started, request_finished])
61
+ def on_request_event(sender, **kwargs):
62
+ print("Request event occurred!")
63
+ ```
64
+
65
+ ## Creating custom signals
66
+
67
+ You can define your own signals for custom events in your application.
68
+
69
+ ```python
70
+ from plain.signals.dispatch import Signal
71
+
72
+ # Define a custom signal
73
+ order_placed = Signal()
74
+
75
+
76
+ # Connect a receiver
77
+ @receiver(order_placed)
78
+ def send_order_confirmation(sender, order, **kwargs):
79
+ print(f"Order {order.id} placed, sending confirmation email...")
80
+
81
+
82
+ # Send the signal from your code
83
+ def create_order(data):
84
+ order = Order.objects.create(**data)
85
+ order_placed.send(sender=Order, order=order)
86
+ return order
87
+ ```
88
+
89
+ ## Filtering by sender
90
+
91
+ You can connect a receiver to only respond to signals from a specific sender.
92
+
93
+ ```python
94
+ from plain.signals.dispatch import Signal, receiver
95
+
96
+ payment_received = Signal()
97
+
98
+
99
+ @receiver(payment_received, sender="stripe")
100
+ def handle_stripe_payment(sender, **kwargs):
101
+ # Only called when sender="stripe"
102
+ print("Stripe payment received!")
103
+
104
+
105
+ # This will trigger the receiver
106
+ payment_received.send(sender="stripe", amount=100)
107
+
108
+ # This will NOT trigger the receiver
109
+ payment_received.send(sender="paypal", amount=100)
110
+ ```
111
+
112
+ ## Signal methods
113
+
114
+ ### send
115
+
116
+ Sends the signal to all connected receivers. If a receiver raises an exception, it propagates immediately and stops further receivers from being called.
117
+
118
+ ```python
119
+ responses = my_signal.send(sender=MyClass, data="example")
120
+ for receiver, response in responses:
121
+ print(f"{receiver} returned {response}")
122
+ ```
123
+
124
+ ### send_robust
125
+
126
+ Like `send()`, but catches exceptions from receivers and returns them as part of the response list instead of propagating them. This ensures all receivers get called.
127
+
128
+ ```python
129
+ responses = my_signal.send_robust(sender=MyClass, data="example")
130
+ for receiver, response in responses:
131
+ if isinstance(response, Exception):
132
+ print(f"{receiver} raised {response}")
133
+ else:
134
+ print(f"{receiver} returned {response}")
135
+ ```
136
+
137
+ ### disconnect
138
+
139
+ Removes a receiver from the signal. You typically don't need to call this since receivers use weak references by default and are automatically removed when garbage collected.
140
+
141
+ ```python
142
+ my_signal.disconnect(my_receiver)
143
+ ```
144
+
145
+ ### has_listeners
146
+
147
+ Checks if any receivers are connected to the signal.
148
+
149
+ ```python
150
+ if my_signal.has_listeners():
151
+ my_signal.send(sender=MyClass)
152
+ ```
153
+
154
+ ## FAQs
155
+
156
+ #### Why must receivers accept `**kwargs`?
157
+
158
+ This allows signals to add new arguments in the future without breaking existing receivers. When `DEBUG` is enabled, Plain validates that your receivers accept `**kwargs` and raises an error if they don't.
159
+
160
+ #### What is `dispatch_uid` for?
161
+
162
+ When connecting a receiver, you can provide a `dispatch_uid` to prevent the same receiver from being connected multiple times. This is useful when your connection code might run more than once.
163
+
164
+ ```python
165
+ request_finished.connect(my_receiver, dispatch_uid="my_unique_id")
166
+ ```
167
+
168
+ #### Can I use strong references instead of weak references?
169
+
170
+ By default, signals use weak references to receivers, so receivers are automatically disconnected when they go out of scope. If you need to keep a receiver connected even after its normal lifecycle, pass `weak=False` to `.connect()`.
171
+
172
+ ```python
173
+ my_signal.connect(my_receiver, weak=False)
174
+ ```
175
+
176
+ #### How do I see all receivers connected to a signal?
177
+
178
+ You can inspect the `receivers` attribute on the signal, though this is primarily for debugging. For checking if any receivers exist, use [`has_listeners()`](./dispatch/dispatcher.py#has_listeners).
179
+
180
+ ## Installation
181
+
182
+ Signals are included as part of Plain and do not require separate installation.
183
+
184
+ ```python
185
+ from plain.signals import request_started, request_finished
186
+ from plain.signals.dispatch import Signal, receiver
187
+ ```
plain/signals/__init__.py CHANGED
@@ -2,4 +2,3 @@ from plain.signals.dispatch import Signal
2
2
 
3
3
  request_started = Signal()
4
4
  request_finished = Signal()
5
- got_request_exception = Signal()