Pyjoyment 0.1.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 (200) hide show
  1. Pyjo/App/ASGI.py +735 -0
  2. Pyjo/App/CGI.py +773 -0
  3. Pyjo/App/FastCGI.py +631 -0
  4. Pyjo/App/Lambda.py +987 -0
  5. Pyjo/App/WSGI.py +427 -0
  6. Pyjo/App/__init__.py +409 -0
  7. Pyjo/Asset/File.py +377 -0
  8. Pyjo/Asset/Memory.py +235 -0
  9. Pyjo/Asset/__init__.py +200 -0
  10. Pyjo/Base.py +215 -0
  11. Pyjo/Cache.py +119 -0
  12. Pyjo/Collection.py +570 -0
  13. Pyjo/Content/MultiPart.py +325 -0
  14. Pyjo/Content/Single.py +193 -0
  15. Pyjo/Content/__init__.py +806 -0
  16. Pyjo/Cookie/Request.py +90 -0
  17. Pyjo/Cookie/Response.py +294 -0
  18. Pyjo/Cookie/__init__.py +143 -0
  19. Pyjo/DOM/CSS.py +1019 -0
  20. Pyjo/DOM/HTML.py +550 -0
  21. Pyjo/DOM/__init__.py +1329 -0
  22. Pyjo/Date.py +294 -0
  23. Pyjo/EventEmitter.py +522 -0
  24. Pyjo/Exception.py +494 -0
  25. Pyjo/File.py +959 -0
  26. Pyjo/GetOpt.py +605 -0
  27. Pyjo/Headers.py +760 -0
  28. Pyjo/HelloWorld.py +77 -0
  29. Pyjo/Home.py +109 -0
  30. Pyjo/IOLoop/ChildProcess.py +591 -0
  31. Pyjo/IOLoop/Client.py +736 -0
  32. Pyjo/IOLoop/Datagram.py +982 -0
  33. Pyjo/IOLoop/Messages.py +664 -0
  34. Pyjo/IOLoop/Server.py +458 -0
  35. Pyjo/IOLoop/Stdio.py +446 -0
  36. Pyjo/IOLoop/Stream.py +644 -0
  37. Pyjo/IOLoop/Subprocess.py +419 -0
  38. Pyjo/IOLoop/TLS.py +360 -0
  39. Pyjo/IOLoop/__init__.py +1036 -0
  40. Pyjo/IOLoop/certs/README.md +41 -0
  41. Pyjo/IOLoop/certs/ca.crt +20 -0
  42. Pyjo/IOLoop/certs/server.crt +21 -0
  43. Pyjo/IOLoop/certs/server.key +28 -0
  44. Pyjo/JSON/Pointer.py +135 -0
  45. Pyjo/JSON/RPC.py +772 -0
  46. Pyjo/JSON/Schema.py +2506 -0
  47. Pyjo/JSON/__init__.py +573 -0
  48. Pyjo/Loader.py +342 -0
  49. Pyjo/Log.py +723 -0
  50. Pyjo/Message/Request.py +725 -0
  51. Pyjo/Message/Response.py +476 -0
  52. Pyjo/Message/__init__.py +830 -0
  53. Pyjo/Parameters.py +472 -0
  54. Pyjo/Path.py +419 -0
  55. Pyjo/Promise.py +894 -0
  56. Pyjo/Prove.py +1305 -0
  57. Pyjo/Reactor/Asyncio.py +854 -0
  58. Pyjo/Reactor/Poll.py +439 -0
  59. Pyjo/Reactor/__init__.py +525 -0
  60. Pyjo/SSE.py +119 -0
  61. Pyjo/Server/ASGI.py +486 -0
  62. Pyjo/Server/CGI.py +259 -0
  63. Pyjo/Server/Daemon.py +1007 -0
  64. Pyjo/Server/FastCGI.py +517 -0
  65. Pyjo/Server/Hot/__init__.py +687 -0
  66. Pyjo/Server/Hot/__main__.py +5 -0
  67. Pyjo/Server/Lambda.py +641 -0
  68. Pyjo/Server/Prefork.py +917 -0
  69. Pyjo/Server/Prethread.py +394 -0
  70. Pyjo/Server/Reload/Backend/Poll.py +114 -0
  71. Pyjo/Server/Reload/Backend/__init__.py +126 -0
  72. Pyjo/Server/Reload/__init__.py +482 -0
  73. Pyjo/Server/Reload/__main__.py +5 -0
  74. Pyjo/Server/Stdio.py +228 -0
  75. Pyjo/Server/WSGI.py +349 -0
  76. Pyjo/Server/__init__.py +396 -0
  77. Pyjo/String/Bytes.py +532 -0
  78. Pyjo/String/HTML.py +210 -0
  79. Pyjo/String/Mixin.py +333 -0
  80. Pyjo/String/Template.py +470 -0
  81. Pyjo/String/Unicode.py +553 -0
  82. Pyjo/String/__init__.py +0 -0
  83. Pyjo/TOML.py +232 -0
  84. Pyjo/Template.py +1290 -0
  85. Pyjo/Test/Pyjo.py +1454 -0
  86. Pyjo/Test/__init__.py +1233 -0
  87. Pyjo/Test/pytest_plugin.py +305 -0
  88. Pyjo/Transaction/HTTP.py +374 -0
  89. Pyjo/Transaction/WebSocket.py +709 -0
  90. Pyjo/Transaction/__init__.py +429 -0
  91. Pyjo/URL/Template.py +459 -0
  92. Pyjo/URL/__init__.py +651 -0
  93. Pyjo/Upload.py +139 -0
  94. Pyjo/UserAgent/CookieJar.py +391 -0
  95. Pyjo/UserAgent/Proxy.py +138 -0
  96. Pyjo/UserAgent/Server.py +239 -0
  97. Pyjo/UserAgent/Transactor.py +766 -0
  98. Pyjo/UserAgent/__init__.py +1243 -0
  99. Pyjo/Util.py +3567 -0
  100. Pyjo/WebSocket.py +332 -0
  101. Pyjo/YAML.py +1167 -0
  102. Pyjo/__init__.py +441 -0
  103. Pyjo/__main__.py +35 -0
  104. Pyjo/py.typed +0 -0
  105. Pyjoyment/Command/Author/__init__.py +20 -0
  106. Pyjoyment/Command/Author/generate/__init__.py +106 -0
  107. Pyjoyment/Command/Author/generate/app.py +299 -0
  108. Pyjoyment/Command/Author/generate/dockerfile.py +281 -0
  109. Pyjoyment/Command/Author/generate/kubernetes.py +514 -0
  110. Pyjoyment/Command/Author/generate/lambda_zip.py +372 -0
  111. Pyjoyment/Command/Author/generate/lite_app.py +113 -0
  112. Pyjoyment/Command/Author/generate/plugin.py +267 -0
  113. Pyjoyment/Command/Author/generate/pyproject.py +123 -0
  114. Pyjoyment/Command/Author/inflate.py +101 -0
  115. Pyjoyment/Command/__init__.py +332 -0
  116. Pyjoyment/Command/asgi.py +92 -0
  117. Pyjoyment/Command/cgi.py +90 -0
  118. Pyjoyment/Command/daemon.py +160 -0
  119. Pyjoyment/Command/eval.py +128 -0
  120. Pyjoyment/Command/fastcgi.py +99 -0
  121. Pyjoyment/Command/get.py +305 -0
  122. Pyjoyment/Command/lambda.py +117 -0
  123. Pyjoyment/Command/prefork.py +181 -0
  124. Pyjoyment/Command/prethread.py +126 -0
  125. Pyjoyment/Command/routes.py +133 -0
  126. Pyjoyment/Command/stdio.py +92 -0
  127. Pyjoyment/Command/version.py +141 -0
  128. Pyjoyment/Command/wsgi.py +93 -0
  129. Pyjoyment/Commands.py +546 -0
  130. Pyjoyment/Controller.py +1307 -0
  131. Pyjoyment/HelloWorld.py +56 -0
  132. Pyjoyment/Lite.py +494 -0
  133. Pyjoyment/Plugin/AllowedHosts.py +205 -0
  134. Pyjoyment/Plugin/Config.py +248 -0
  135. Pyjoyment/Plugin/DefaultHelpers.py +1202 -0
  136. Pyjoyment/Plugin/EPLRenderer.py +134 -0
  137. Pyjoyment/Plugin/EPRenderer.py +212 -0
  138. Pyjoyment/Plugin/HeaderCondition.py +98 -0
  139. Pyjoyment/Plugin/Health.py +325 -0
  140. Pyjoyment/Plugin/JSONConfig.py +172 -0
  141. Pyjoyment/Plugin/Mount.py +223 -0
  142. Pyjoyment/Plugin/PyRenderer.py +209 -0
  143. Pyjoyment/Plugin/TOMLConfig.py +141 -0
  144. Pyjoyment/Plugin/TagHelpers.py +959 -0
  145. Pyjoyment/Plugin/YAMLConfig.py +178 -0
  146. Pyjoyment/Plugin/__init__.py +80 -0
  147. Pyjoyment/Plugins.py +215 -0
  148. Pyjoyment/Renderer.py +673 -0
  149. Pyjoyment/Routes/Match.py +251 -0
  150. Pyjoyment/Routes/Pattern.py +443 -0
  151. Pyjoyment/Routes/Route.py +827 -0
  152. Pyjoyment/Routes/__init__.py +419 -0
  153. Pyjoyment/Sessions.py +255 -0
  154. Pyjoyment/Static.py +421 -0
  155. Pyjoyment/Types.py +238 -0
  156. Pyjoyment/Validator/Validation.py +395 -0
  157. Pyjoyment/Validator/__init__.py +304 -0
  158. Pyjoyment/__init__.py +1083 -0
  159. Pyjoyment/__main__.py +5 -0
  160. Pyjoyment/py.typed +0 -0
  161. Pyjoyment/resources/public/favicon.ico +0 -0
  162. Pyjoyment/resources/public/favicon.svg +345 -0
  163. Pyjoyment/resources/public/pyjo/bootstrap/LICENSE +21 -0
  164. Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.bundle.min.js.map +1 -0
  165. Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.css +6 -0
  166. Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.js +7 -0
  167. Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.min.css.map +1 -0
  168. Pyjoyment/resources/public/pyjo/failunicat.png +0 -0
  169. Pyjoyment/resources/public/pyjo/failunicat.svg +50 -0
  170. Pyjoyment/resources/public/pyjo/highlight.js/LICENSE +29 -0
  171. Pyjoyment/resources/public/pyjo/highlight.js/highlight-mojo-dark.css +56 -0
  172. Pyjoyment/resources/public/pyjo/highlight.js/highlight.min.js +1262 -0
  173. Pyjoyment/resources/public/pyjo/highlight.js/pyjoyment.min.js +1 -0
  174. Pyjoyment/resources/public/pyjo/logo-black-4x.png +0 -0
  175. Pyjoyment/resources/public/pyjo/logo-black-4x.svg +346 -0
  176. Pyjoyment/resources/public/pyjo/logo-white-2x.png +0 -0
  177. Pyjoyment/resources/public/pyjo/logo-white-2x.svg +346 -0
  178. Pyjoyment/resources/public/pyjo/logo-white-4x.png +0 -0
  179. Pyjoyment/resources/public/pyjo/logo-white-4x.svg +346 -0
  180. Pyjoyment/resources/public/pyjo/logo-white.png +0 -0
  181. Pyjoyment/resources/public/pyjo/logo-white.svg +346 -0
  182. Pyjoyment/resources/public/pyjo/logo.png +0 -0
  183. Pyjoyment/resources/public/pyjo/logo.svg +346 -0
  184. Pyjoyment/resources/public/pyjo/notfound.png +0 -0
  185. Pyjoyment/resources/public/pyjo/notfound.svg +3 -0
  186. Pyjoyment/resources/public/pyjo/nounicat.png +0 -0
  187. Pyjoyment/resources/public/pyjo/nounicat.svg +5 -0
  188. Pyjoyment/resources/public/pyjo/pinstripe-dark.png +0 -0
  189. Pyjoyment/resources/public/pyjo/pinstripe-dark.svg +4 -0
  190. Pyjoyment/resources/public/pyjo/pinstripe-light.png +0 -0
  191. Pyjoyment/resources/public/pyjo/pinstripe-light.svg +4 -0
  192. Pyjoyment/resources/public/pyjo/pyjo.css +204 -0
  193. Pyjoyment/resources/templates/pyjo/debug.html.ep +346 -0
  194. Pyjoyment/resources/templates/pyjo/exception.html.ep +32 -0
  195. Pyjoyment/resources/templates/pyjo/not_found.html.ep +45 -0
  196. pyjoyment-0.1.0.dist-info/METADATA +376 -0
  197. pyjoyment-0.1.0.dist-info/RECORD +200 -0
  198. pyjoyment-0.1.0.dist-info/WHEEL +4 -0
  199. pyjoyment-0.1.0.dist-info/entry_points.txt +8 -0
  200. pyjoyment-0.1.0.dist-info/licenses/LICENSE +201 -0
Pyjo/App/ASGI.py ADDED
@@ -0,0 +1,735 @@
1
+ """
2
+ Pyjo.App.ASGI - ASGI application
3
+ ================================
4
+ ::
5
+
6
+ from Pyjo.App.ASGI import ASGI
7
+
8
+
9
+ async def hello(scope, receive, send):
10
+ await send({'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/plain')]})
11
+ await send({'type': 'http.response.body', 'body': b'Hello World!'})
12
+
13
+
14
+ app = ASGI(app=hello)
15
+
16
+ # Or import the module, as in Google Python Style Guide
17
+
18
+ import Pyjo.App.ASGI
19
+
20
+ app = Pyjo.App.ASGI.new(app=hello)
21
+
22
+ # Mount it in an application of Pyjoyment
23
+ import Pyjoyment
24
+
25
+ pyjoyment = Pyjoyment.new()
26
+ pyjoyment.plugin('Mount', {'/hello': app})
27
+
28
+ :mod:`Pyjo.App.ASGI` is an application of :mod:`Pyjo` that runs an ``ASGI``
29
+ application, as https://asgi.readthedocs.io describes it, so that a server of
30
+ :mod:`Pyjo` serves it, a route of :mod:`Pyjoyment` embeds it, as
31
+ :mod:`Pyjoyment.Plugin.Mount` does, and :mod:`Pyjo.Test.Pyjo` tests it.
32
+
33
+ The part of the path that a route embeds the application under is
34
+ ``root_path``, after the base path of the request, and ``path`` is the whole
35
+ path, with ``root_path`` in front of it. A route that embeds the application
36
+ at a part of the path that does not end with a slash, as ``/php`` of
37
+ ``/phpx``, which ``root_path`` cannot be, does not find it. The body of the request is the
38
+ message ``http.request`` of ``receive``, in chunks with ``more_body``, and
39
+ the next ``receive`` waits for the end of the connection, the message
40
+ ``http.disconnect``.
41
+
42
+ The application runs as a coroutine of the reactor, as
43
+ :meth:`Pyjo.Reactor.Pyjo_Reactor.run_coroutine` runs it: with
44
+ :mod:`Pyjo.Reactor.Asyncio`, a task of the event loop of :mod:`asyncio`,
45
+ where it can await anything of the loop, and with another reactor, as
46
+ :mod:`Pyjo.Reactor.Poll`, a coroutine that awaits only ``receive`` and
47
+ ``send``, so that an application that awaits anything else fails there. A
48
+ response that the application gives in one message ``http.response.body`` is
49
+ written with its ``Content-Length``. Another one is written as the application
50
+ sends it, with the chunked transfer encoding when the application does not
51
+ give its ``Content-Length``, and ``send`` returns once the chunk has been
52
+ written, so that a stream of server-sent events goes at the pace of the
53
+ client. Once the connection is gone, ``send`` does nothing.
54
+
55
+ An exception of the application before its response starts is a response
56
+ ``500``, the exception page of :mod:`Pyjoyment` in a route that embeds it, an
57
+ exception after that is logged and closes the connection, as does an
58
+ application that returns before the end of its response, and an exception
59
+ after the end of the response is logged.
60
+
61
+ A WebSocket handshake is the connection ``websocket``, whose
62
+ ``subprotocols`` are the ones that the client offers. The response of the
63
+ handshake waits for the message ``websocket.accept``, whose ``subprotocol``
64
+ and ``headers`` are those of the response ``101``, and a ``websocket.close``
65
+ before it rejects the handshake with the response ``403``, as does a response
66
+ of the application with the extension ``websocket.http.response``. The
67
+ messages of the WebSocket are ``websocket.receive``, and ``send`` of
68
+ ``websocket.send`` returns once the message has been written. The end of the
69
+ WebSocket, also of a connection that is gone before the response of its
70
+ handshake, is the message ``websocket.disconnect`` with the status of the
71
+ close, after which ``send`` does nothing. An exception of the application, or
72
+ its return, before the response of the handshake is a response ``500``, and
73
+ after it is logged and closes the WebSocket with the status ``1011``, unless
74
+ the application has closed it.
75
+
76
+ The WebSockets are pinged, as by an ``ASGI`` server, with
77
+ :meth:`Pyjo.Transaction.WebSocket.Pyjo_Transaction_WebSocket.heartbeat` and
78
+ the intervals of :attr:`ping_interval` and :attr:`ping_timeout`, so that the
79
+ inactivity timeout of the server does not close a WebSocket without messages,
80
+ and a client that is gone is found.
81
+
82
+ The lifespan of the application, the connection ``lifespan``, starts with
83
+ the first request in each event loop, as of each process of
84
+ :mod:`Pyjo.Server.Prefork` and each thread of :mod:`Pyjo.Server.Prethread`,
85
+ or with :meth:`startup`, and the requests wait for its
86
+ ``lifespan.startup.complete``, after which each gets a copy of its ``state``.
87
+ A ``lifespan.startup.failed`` is logged, and the requests get the response
88
+ ``500``. An application that fails or returns before its startup has no
89
+ lifespan, as uvicorn takes it, and one that neither answers nor fails keeps
90
+ the requests waiting. The lifespan shuts down once the server has stopped
91
+ gracefully, with :meth:`server_stopped`, which
92
+ :mod:`Pyjoyment.Plugin.Mount` calls with the hook ``after_server_stop``,
93
+ with :meth:`close`, as at the end of a ``with`` block, and at the end of the
94
+ interpreter, where nothing has shut it down.
95
+
96
+ Attributes
97
+ ----------
98
+
99
+ :mod:`Pyjo.App.ASGI` inherits all attributes from :mod:`Pyjo.App` and
100
+ implements the following new ones.
101
+
102
+ See also
103
+ --------
104
+
105
+ :mod:`Pyjoyment`, :mod:`Pyjoyment.Plugin.Mount`, :mod:`Pyjo.App.WSGI`,
106
+ :mod:`Pyjo.Server.ASGI`, https://mojolicious.org.
107
+
108
+ It has no counterpart in Mojolicious; it is the reverse of
109
+ :mod:`Pyjo.Server.ASGI`.
110
+
111
+ Classes
112
+ -------
113
+ """
114
+
115
+ import Pyjo.App
116
+ import Pyjo.Exception
117
+ import Pyjo.IOLoop
118
+ import Pyjo.Promise
119
+ import Pyjo.Transaction
120
+
121
+ from Pyjo.Server.ASGI import Application, Message, Scope
122
+ from Pyjo.Util import url_unescape
123
+
124
+ import atexit
125
+ import collections
126
+ import traceback
127
+ import weakref
128
+ import wsgiref.util
129
+
130
+ from typing import Any
131
+
132
+
133
+ class Pyjo_App_ASGI(Pyjo.App.object):
134
+ """
135
+ :mod:`Pyjo.App.ASGI` inherits all methods from :mod:`Pyjo.App` and
136
+ implements the following new ones.
137
+ """
138
+
139
+ def __init__(self, attrs: dict[str, Any] | None = None, /, **kwargs: Any) -> None:
140
+ """::
141
+
142
+ app = Pyjo.App.ASGI.new(app=application)
143
+
144
+ Construct a new :mod:`Pyjo.App.ASGI` object.
145
+ """
146
+ self.app: Application | None = None
147
+ """::
148
+
149
+ application = app.app
150
+ app.app = application
151
+
152
+ The ``ASGI`` application to run.
153
+ """
154
+
155
+ self.ping_interval: float | None = 20
156
+ """::
157
+
158
+ interval = app.ping_interval
159
+ app.ping_interval = 60
160
+
161
+ Interval in seconds of the pings of the WebSockets, defaults to
162
+ ``20``, as of uvicorn. ``None`` or ``0`` sends no pings.
163
+ """
164
+
165
+ self.ping_timeout: float | None = 20
166
+ """::
167
+
168
+ timeout = app.ping_timeout
169
+ app.ping_timeout = 60
170
+
171
+ Timeout in seconds of the answer of a client to a ping of a WebSocket,
172
+ after which its connection is closed, defaults to ``20``, as of
173
+ uvicorn. ``None`` or ``0`` waits for no answer.
174
+ """
175
+
176
+ # The lifespan of the application in each event loop, as each thread has one
177
+ self._lifespans: weakref.WeakKeyDictionary[Any, _Lifespan] = weakref.WeakKeyDictionary()
178
+
179
+ super().__init__(attrs, **kwargs)
180
+
181
+ def close(self) -> None:
182
+ """::
183
+
184
+ app.close()
185
+
186
+ Shut the lifespan of the application down, and wait for it, unless
187
+ the event loop runs, as the end of a ``with`` block does, and close
188
+ the connections and the servers of :attr:`ua`.
189
+ """
190
+ if (promise := self.shutdown()) is not None and not Pyjo.IOLoop.is_running():
191
+ promise.wait()
192
+ super().close()
193
+
194
+ def handler(self, tx: Any) -> None:
195
+ """::
196
+
197
+ app.handler(Pyjo.Transaction.HTTP.new())
198
+ app.handler(Pyjoyment.Controller.new())
199
+
200
+ Run the application for the request of the transaction, or of the
201
+ controller of a route that embeds the application, and write its
202
+ response.
203
+ """
204
+ if (application := self.app) is None:
205
+ raise Pyjo.Exception.new('No ASGI application')
206
+ c = None
207
+ if not isinstance(tx, Pyjo.Transaction.object):
208
+ c = tx
209
+ tx = c.tx
210
+ if not Pyjo.App._within(c): # pyright: ignore[reportPrivateUsage]
211
+ return c.helpers.reply.not_found()
212
+ c.render_later()
213
+ call = _WebSocket(self, application, tx, c) if tx.is_websocket else _Call(self, application, tx, c)
214
+
215
+ # The lifespan of the application starts with its first request, which waits for its startup
216
+ if (lifespan := self._lifespan()).ready:
217
+ return call.start(lifespan)
218
+ lifespan.started.then(lambda *args: call.start(lifespan))
219
+
220
+ def server_stopped(self, server: Any) -> Pyjo.Promise.object | None:
221
+ """::
222
+
223
+ promise = app.server_stopped(Pyjo.Server.Daemon.new())
224
+
225
+ Shut the lifespan of the application down, as :meth:`shutdown` does,
226
+ once the server has stopped.
227
+ """
228
+ return self.shutdown()
229
+
230
+ def shutdown(self) -> Pyjo.Promise.object | None:
231
+ """::
232
+
233
+ promise = app.shutdown()
234
+
235
+ Shut the lifespan of the application in the event loop of the thread
236
+ down, with the message ``lifespan.shutdown``, and return a promise
237
+ that is fulfilled once the application has answered it, or ``None``
238
+ when the lifespan has not started.
239
+ """
240
+ if (lifespan := self._lifespans.pop(Pyjo.IOLoop.object.singleton(), None)) is None:
241
+ return None
242
+ return lifespan.shutdown()
243
+
244
+ def startup(self) -> Pyjo.Promise.object:
245
+ """::
246
+
247
+ promise = app.startup()
248
+
249
+ Start the lifespan of the application in the event loop of the
250
+ thread, with the message ``lifespan.startup``, unless it has started,
251
+ and return a promise that is fulfilled once the application has
252
+ answered it, as the first request does.
253
+ """
254
+ return self._lifespan().started
255
+
256
+ def _lifespan(self) -> '_Lifespan':
257
+ loop = Pyjo.IOLoop.object.singleton()
258
+ if (lifespan := self._lifespans.get(loop)) is None:
259
+ if (application := self.app) is None:
260
+ raise Pyjo.Exception.new('No ASGI application')
261
+ lifespan = self._lifespans[loop] = _Lifespan(self, application)
262
+ lifespan.start()
263
+ return lifespan
264
+
265
+
266
+ # A call of the application, whose messages are the request and the response of the transaction
267
+ class _Call:
268
+ def __init__(self, app: Pyjo_App_ASGI, application: Application, tx: Any, c: Any) -> None:
269
+ self.app = app
270
+ self.application = application
271
+ self.c = c
272
+ self.tx = tx
273
+ self.chunked = False
274
+ self.closed = False
275
+ self.ended = False
276
+ self.finished = Pyjo.Promise.new()
277
+ self.headers: list[tuple[str, str]] = []
278
+ self.offset = 0
279
+ self.read = False
280
+ self.started = False
281
+ self.status: int | None = None
282
+ self.written: Pyjo.Promise.object | None = None
283
+
284
+ def start(self, lifespan: '_Lifespan') -> None:
285
+ self.tx.on('finish', self._finish)
286
+ if lifespan.failed is not None:
287
+ return self._error(Pyjo.Exception.new(f'ASGI application failed to start: {lifespan.failed}'))
288
+ try:
289
+ coro = self.application(lifespan.scope(_scope(self.tx, self.c)), self._receive, self._send)
290
+ except Exception as e:
291
+ return self._error(e)
292
+ Pyjo.Promise.new(coro).then(self._returned, self._error)
293
+
294
+ # End of the response
295
+ def _end(self) -> None:
296
+ self.ended = True
297
+ content = self.tx.res.content
298
+ (content.write_chunk if self.chunked else content.write)(b'')
299
+ self._rendered()
300
+
301
+ def _error(self, e: Any) -> None:
302
+ message = _message(e)
303
+
304
+ # The response has started, so the connection is closed, as the response cannot change, unless it has ended, as
305
+ # Starlette raises the exception after its response 500
306
+ if self.started:
307
+ if self.ended or not self.closed:
308
+ self.app.log.error(f'ASGI application failed: {message}')
309
+ if not self.ended:
310
+ self._close()
311
+ return
312
+
313
+ # The exception page of Pyjoyment
314
+ self.started = self.ended = True
315
+ if (c := self.c) is not None:
316
+ c.helpers.reply.exception(e)
317
+ return
318
+
319
+ self.app.log.error(f'ASGI application failed: {message}')
320
+ res = self.tx.res
321
+ res.code = 500
322
+ res.headers.content_type = 'text/plain'
323
+ res.body = b'Internal Server Error'
324
+ self.tx.resume()
325
+
326
+ def _close(self) -> None:
327
+ if (connection := self.tx.connection) is not None and (stream := Pyjo.IOLoop.stream(connection)) is not None:
328
+ stream.close()
329
+
330
+ # The end of the transaction, also when the connection is gone before the end of the response, after which receive
331
+ # gives http.disconnect and send does nothing
332
+ def _finish(self, *args: Any) -> None:
333
+ self.closed = True
334
+ self.finished.resolve()
335
+ if (written := self.written) is not None:
336
+ self.written = None
337
+ written.resolve()
338
+
339
+ # The status and the headers of http.response.start, and the chunked transfer encoding for a body that comes in more
340
+ # than one message without its length
341
+ def _headers(self, whole: bool) -> None:
342
+ self.started = True
343
+ res = self.tx.res
344
+ res.code = self.status
345
+ headers = res.headers
346
+ for name, value in self.headers:
347
+ if not wsgiref.util.is_hop_by_hop(name):
348
+ headers.add(name, value)
349
+ self.chunked = not whole and headers.content_length is None and self.tx.req.version == '1.1'
350
+
351
+ async def _receive(self) -> Message:
352
+ # The body of the request, which has been read whole, in chunks
353
+ if not self.read:
354
+ content = self.tx.req.content
355
+ chunk = content.get_body_chunk(self.offset) or b''
356
+ self.offset += len(chunk)
357
+ more = len(chunk) > 0 and self.offset < content.body_size
358
+ self.read = not more
359
+ return {'type': 'http.request', 'body': bytes(chunk), 'more_body': more}
360
+
361
+ # The end of the connection
362
+ if not self.closed:
363
+ await self.finished
364
+ return {'type': 'http.disconnect'}
365
+
366
+ def _rendered(self) -> None:
367
+ if (c := self.c) is not None:
368
+ c.rendered()
369
+ else:
370
+ self.tx.resume()
371
+
372
+ def _returned(self, *args: Any) -> None:
373
+ if not self.started:
374
+ return self._error(Pyjo.Exception.new('ASGI application returned without a response'))
375
+ if not self.ended and not self.closed:
376
+ self.app.log.error('ASGI application returned without the end of its response')
377
+ self._close()
378
+
379
+ async def _send(self, message: Message) -> None:
380
+ if self.closed:
381
+ return
382
+ type = message['type']
383
+
384
+ # Status and headers
385
+ if type == 'http.response.start':
386
+ if self.status is not None:
387
+ raise Pyjo.Exception.new('ASGI message "http.response.start" has already been sent')
388
+ self.status = int(message['status'])
389
+ self.headers = [
390
+ (bytes(name).decode('latin-1'), bytes(value).decode('latin-1')) for name, value in message['headers']
391
+ ]
392
+ return
393
+
394
+ if type != 'http.response.body':
395
+ raise Pyjo.Exception.new(f'Unknown ASGI message "{type}"')
396
+ if self.status is None:
397
+ raise Pyjo.Exception.new('ASGI message "http.response.start" has not been sent')
398
+ if self.ended:
399
+ # A HEAD request, whose response ends with its headers
400
+ if self.tx.is_empty:
401
+ return
402
+ raise Pyjo.Exception.new('ASGI response has already ended')
403
+ body = bytes(message.get('body', b''))
404
+ more = bool(message.get('more_body', False))
405
+
406
+ # Whole response
407
+ if not self.started:
408
+ self._headers(not more)
409
+ if not more:
410
+ self.ended = True
411
+ self.tx.res.body = body
412
+ return self._rendered()
413
+
414
+ # Response as the application sends it, with the headers only for a HEAD request
415
+ if self.tx.is_empty:
416
+ return self._end()
417
+ if len(body):
418
+ written = self.written = Pyjo.Promise.new()
419
+ content = self.tx.res.content
420
+
421
+ def drained(*args: Any) -> None:
422
+ written.resolve()
423
+
424
+ (content.write_chunk if self.chunked else content.write)(body, drained)
425
+ self._rendered()
426
+ await written
427
+ if not more and not self.closed:
428
+ self._end()
429
+
430
+
431
+ # A call of the application for a WebSocket, whose handshake waits for websocket.accept, and whose response of HTTP that
432
+ # rejects the handshake is the one of a call for a request
433
+ class _WebSocket(_Call):
434
+ def __init__(self, app: Pyjo_App_ASGI, application: Application, ws: Any, c: Any) -> None:
435
+ super().__init__(app, application, ws.handshake, c)
436
+ self.accepted = False
437
+ self.closing = False
438
+ self.connected = False
439
+ # A deque, whose popleft takes the first message, where pop(0) of a list moves all the others
440
+ self.messages: collections.deque[Message] = collections.deque()
441
+ self.responding = False
442
+ self.waiting: Pyjo.Promise.object | None = None
443
+ self.ws = ws
444
+
445
+ def start(self, lifespan: '_Lifespan') -> None:
446
+ ws = self.ws
447
+ ws.on('message', self._message)
448
+ ws.on('finish', self._disconnect)
449
+ if lifespan.failed is not None:
450
+ return self._error(Pyjo.Exception.new(f'ASGI application failed to start: {lifespan.failed}'))
451
+ ws.heartbeat(self.app.ping_interval, self.app.ping_timeout)
452
+ try:
453
+ coro = self.application(lifespan.scope(_scope(self.tx, self.c, ws)), self._receive, self._send)
454
+ except Exception as e:
455
+ return self._error(e)
456
+ Pyjo.Promise.new(coro).then(self._returned, self._error)
457
+
458
+ # The end of the WebSocket, also when the connection is gone before its handshake, after which receive gives
459
+ # websocket.disconnect and send does nothing
460
+ def _disconnect(self, ws: Any, code: int | None, reason: str | None) -> None:
461
+ self.messages.append({'type': 'websocket.disconnect', 'code': code or 1005, 'reason': reason or ''})
462
+ self._finish()
463
+ self._wake()
464
+
465
+ def _error(self, e: Any) -> None:
466
+ if not self.accepted:
467
+ return super()._error(e)
468
+
469
+ # The WebSocket is closed with the status of an error of the server, unless the application has closed it
470
+ if not self.closed:
471
+ self.app.log.error(f'ASGI application failed: {_message(e)}')
472
+ self._close_ws(1011)
473
+
474
+ def _message(self, ws: Any, message: str | bytes) -> None:
475
+ self.messages.append(
476
+ {'type': 'websocket.receive', 'text': message}
477
+ if isinstance(message, str)
478
+ else {'type': 'websocket.receive', 'bytes': message}
479
+ )
480
+ self._wake()
481
+
482
+ async def _receive(self) -> Message:
483
+ if not self.connected:
484
+ self.connected = True
485
+ return {'type': 'websocket.connect'}
486
+ while not self.messages:
487
+ self.waiting = Pyjo.Promise.new()
488
+ await self.waiting
489
+ return self.messages.popleft()
490
+
491
+ def _returned(self, *args: Any) -> None:
492
+ if self.closed and not self.accepted:
493
+ return
494
+ if not self.accepted:
495
+ return super()._returned(*args)
496
+ if not self.closed and not self.closing:
497
+ self.app.log.error('ASGI application returned without closing its WebSocket')
498
+ self._close_ws(1011)
499
+
500
+ async def _send(self, message: Message) -> None:
501
+ if self.closed or self.closing:
502
+ return
503
+ type = message['type']
504
+
505
+ # The response of HTTP that rejects the handshake
506
+ if self.responding or type == 'websocket.http.response.start':
507
+ if not type.startswith('websocket.http.response.'):
508
+ raise Pyjo.Exception.new(f'ASGI message "{type}" after "websocket.http.response.start"')
509
+ self.responding = True
510
+ return await super()._send({**message, 'type': type.removeprefix('websocket.')})
511
+
512
+ # Handshake
513
+ if not self.accepted:
514
+ if type == 'websocket.accept':
515
+ self.accepted = True
516
+ ws = self.ws
517
+ headers = ws.res.headers
518
+ for name, value in message.get('headers', []):
519
+ headers.add(bytes(name).decode('latin-1'), bytes(value).decode('latin-1'))
520
+ if (protocol := message.get('subprotocol')) is not None:
521
+ headers.sec_websocket_protocol = protocol
522
+ ws.with_compression()
523
+ return self._accepted()
524
+
525
+ # Rejected
526
+ if type == 'websocket.close':
527
+ self.started = self.ended = True
528
+ res = self.tx.res
529
+ res.code = 403
530
+ res.body = b''
531
+ return self._rendered()
532
+
533
+ raise Pyjo.Exception.new(f'ASGI message "{type}" before "websocket.accept"')
534
+
535
+ # Message
536
+ if type == 'websocket.send':
537
+ written = self.written = Pyjo.Promise.new()
538
+
539
+ def drained(*args: Any) -> None:
540
+ written.resolve()
541
+
542
+ if (data := message.get('bytes')) is not None:
543
+ self.ws.send({'binary': bytes(data)}, drained)
544
+ else:
545
+ self.ws.send(message.get('text') or '', drained)
546
+ await written
547
+ return
548
+
549
+ # Close
550
+ if type == 'websocket.close':
551
+ return self._close_ws(int(message.get('code') or 1000), message.get('reason') or None)
552
+
553
+ raise Pyjo.Exception.new(f'Unknown ASGI message "{type}"')
554
+
555
+ # The response 101 of the handshake
556
+ def _accepted(self) -> None:
557
+ if (c := self.c) is not None:
558
+ c.rendered(101)
559
+ else:
560
+ self.tx.res.code = 101
561
+ self.ws.resume()
562
+
563
+ def _close_ws(self, code: int, reason: str | None = None) -> None:
564
+ if not self.closing:
565
+ self.closing = True
566
+ self.ws.finish(code, reason)
567
+
568
+ def _wake(self) -> None:
569
+ if (waiting := self.waiting) is not None:
570
+ self.waiting = None
571
+ waiting.resolve()
572
+
573
+
574
+ # The lifespan of the application in an event loop, whose state the requests get a copy of, or nothing for an
575
+ # application without lifespan, which fails or returns before its startup, as uvicorn takes it
576
+ class _Lifespan:
577
+ def __init__(self, app: Pyjo_App_ASGI, application: Application) -> None:
578
+ self.app = app
579
+ self.application = application
580
+ self.failed: str | None = None
581
+ self.loop = Pyjo.IOLoop.object.singleton()
582
+ self.ready = False
583
+ self.received = False
584
+ self.requested = Pyjo.Promise.new()
585
+ self.started = Pyjo.Promise.new()
586
+ self.state: dict[str, Any] = {}
587
+ self.stopped = Pyjo.Promise.new()
588
+ self.supported = True
589
+
590
+ def scope(self, scope: Scope) -> Scope:
591
+ return {**scope, 'state': dict(self.state)} if self.supported else scope
592
+
593
+ def shutdown(self) -> Pyjo.Promise.object:
594
+ _running.discard(self)
595
+ if self.ready:
596
+ self.requested.resolve()
597
+ else:
598
+ self.started.then(lambda *args: self.requested.resolve())
599
+ return self.stopped
600
+
601
+ def start(self) -> None:
602
+ _exit()
603
+ _running.add(self)
604
+ scope = {'type': 'lifespan', 'asgi': {'version': '3.0', 'spec_version': '2.0'}, 'state': self.state}
605
+ try:
606
+ coro = self.application(scope, self._receive, self._send)
607
+ except Exception as e:
608
+ return self._error(e)
609
+ Pyjo.Promise.new(coro).then(self._returned, self._error)
610
+
611
+ def _end(self) -> None:
612
+ self._ready()
613
+ _running.discard(self)
614
+ self.stopped.resolve()
615
+
616
+ def _error(self, e: Any) -> None:
617
+ if not self.ready and self.failed is None:
618
+ self.supported = False
619
+ self.app.log.debug(f'ASGI application without lifespan: {e!r}')
620
+ elif self.ready:
621
+ self.app.log.error(f'ASGI application failed: {_message(e)}')
622
+ self._end()
623
+
624
+ def _ready(self) -> None:
625
+ if not self.ready:
626
+ self.ready = True
627
+ self.started.resolve()
628
+
629
+ async def _receive(self) -> Message:
630
+ if not self.received:
631
+ self.received = True
632
+ return {'type': 'lifespan.startup'}
633
+ await self.requested
634
+ return {'type': 'lifespan.shutdown'}
635
+
636
+ def _returned(self, *args: Any) -> None:
637
+ if not self.ready:
638
+ self.supported = False
639
+ self._end()
640
+
641
+ async def _send(self, message: Message) -> None:
642
+ type = message['type']
643
+ if type == 'lifespan.startup.complete':
644
+ self._ready()
645
+ elif type == 'lifespan.startup.failed':
646
+ self.failed = str(message.get('message') or '')
647
+ self.app.log.error(f'ASGI application failed to start: {self.failed}')
648
+ self._ready()
649
+ elif type == 'lifespan.shutdown.complete':
650
+ self._end()
651
+ elif type == 'lifespan.shutdown.failed':
652
+ self.app.log.error(f'ASGI application failed to stop: {message.get("message") or ""}')
653
+ self._end()
654
+ else:
655
+ raise Pyjo.Exception.new(f'Unknown ASGI message "{type}"')
656
+
657
+
658
+ # The lifespans that have started, which the end of the interpreter shuts down, where no server has stopped, as at the
659
+ # end of a test, before the reactor of asyncio cancels the tasks that are left, as its handler of the end of the
660
+ # interpreter is registered before
661
+ _running: weakref.WeakSet[_Lifespan] = weakref.WeakSet()
662
+ _registered: list[bool] = []
663
+
664
+
665
+ def _exit() -> None:
666
+ if not _registered:
667
+ _registered.append(True)
668
+ atexit.register(_shutdown)
669
+
670
+
671
+ def _shutdown() -> None:
672
+ try:
673
+ loop = Pyjo.IOLoop.object.singleton()
674
+ for lifespan in list(_running):
675
+ if lifespan.loop is loop and not loop.is_running:
676
+ lifespan.shutdown().wait()
677
+ except Exception:
678
+ pass
679
+
680
+
681
+ # The connection scope of the request of the transaction, or of the handshake of a WebSocket: the path that a route
682
+ # embeds the application under is root_path, which path begins with
683
+ def _scope(tx: Any, c: Any, ws: Any = None) -> Scope:
684
+ req = tx.req
685
+ url = req.url
686
+ base = url.base
687
+ script, path = Pyjo.App._mount(tx, c) # pyright: ignore[reportPrivateUsage]
688
+
689
+ # Headers, with the length of the body, which is whole, as it has been read
690
+ headers = [
691
+ (name.lower().encode('latin-1'), value.encode('latin-1'))
692
+ for name, value in req.headers.to_list()
693
+ if name.lower() not in ('content-length', 'transfer-encoding')
694
+ ]
695
+ if size := req.content.body_size:
696
+ headers.append((b'content-length', str(size).encode()))
697
+
698
+ https = req.is_secure
699
+ scope: dict[str, Any] = {
700
+ 'type': 'http' if ws is None else 'websocket',
701
+ 'asgi': {'version': '3.0', 'spec_version': '2.4'},
702
+ 'http_version': req.version,
703
+ 'scheme': ('https' if https else 'http') if ws is None else ('wss' if https else 'ws'),
704
+ 'path': _str(script + path),
705
+ 'raw_path': (script + path).encode(),
706
+ 'query_string': url.query.to_str().encode(),
707
+ 'root_path': _str(script),
708
+ 'headers': headers,
709
+ 'server': (base.host or tx.local_address or 'localhost', base.port or tx.local_port or (443 if https else 80)),
710
+ }
711
+ if ws is None:
712
+ scope['method'] = req.method
713
+ else:
714
+ # The subprotocols that the client offers, and the response of HTTP that rejects the handshake
715
+ offered = req.headers.sec_websocket_protocol or ''
716
+ scope['subprotocols'] = [protocol.strip() for protocol in offered.split(',') if protocol.strip()]
717
+ scope['extensions'] = {'websocket.http.response': {}}
718
+ if (address := tx.remote_address) is not None:
719
+ scope['client'] = (address, tx.remote_port or 0)
720
+ return scope
721
+
722
+
723
+ # An exception with its traceback, for the log
724
+ def _message(e: Any) -> str:
725
+ return ''.join(traceback.format_exception(e)).rstrip() if isinstance(e, BaseException) else str(e)
726
+
727
+
728
+ # A path of ASGI: the unescaped bytes as UTF-8
729
+ def _str(path: str) -> str:
730
+ return url_unescape(path.encode()).decode('utf-8', 'replace')
731
+
732
+
733
+ new = Pyjo_App_ASGI.new
734
+ object = Pyjo_App_ASGI
735
+ ASGI = Pyjo_App_ASGI