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/Server/ASGI.py ADDED
@@ -0,0 +1,486 @@
1
+ r"""
2
+ Pyjo.Server.ASGI - ASGI server
3
+ ==============================
4
+ ::
5
+
6
+ from Pyjo.Server.ASGI import ASGI
7
+
8
+ asgi = ASGI()
9
+ asgi.unsubscribe('request')
10
+
11
+ @asgi.on('request')
12
+ def request(asgi, tx):
13
+ # Request
14
+ method = tx.req.method
15
+ path = tx.req.url.path
16
+
17
+ # Response
18
+ tx.res.code = 200
19
+ tx.res.headers.content_type = 'text/plain'
20
+ tx.res.body = f'{method} request for {path}!'.encode()
21
+
22
+ # Resume transaction
23
+ tx.resume()
24
+
25
+ app = asgi.to_asgi_app()
26
+
27
+ # Or import the module, as in Google Python Style Guide
28
+
29
+ import Pyjo.Server.ASGI
30
+
31
+ asgi = Pyjo.Server.ASGI.new()
32
+ asgi.unsubscribe('request')
33
+
34
+ @asgi.on('request')
35
+ def request(asgi, tx):
36
+ # Request
37
+ method = tx.req.method
38
+ path = tx.req.url.path
39
+
40
+ # Response
41
+ tx.res.code = 200
42
+ tx.res.headers.content_type = 'text/plain'
43
+ tx.res.body = f'{method} request for {path}!'.encode()
44
+
45
+ # Resume transaction
46
+ tx.resume()
47
+
48
+ app = asgi.to_asgi_app()
49
+
50
+ # uvicorn myapp:app
51
+ # hypercorn myapp:app
52
+
53
+ :mod:`Pyjo.Server.ASGI` allows :mod:`Pyjoyment` applications to run on all
54
+ `ASGI <https://asgi.readthedocs.io>`_ compatible servers, with HTTP,
55
+ WebSocket and lifespan connections.
56
+
57
+ The application runs in the event loop of the ASGI server: the reactor of
58
+ :mod:`Pyjo.IOLoop` follows the loop that is running, so timers, promises and
59
+ the I/O of :mod:`Pyjoyment` share it, and the response can be given later
60
+ than the ``request`` event, once the transaction is resumed.
61
+
62
+ An HTTP request is read from the connection scope as the ``CGI`` environment
63
+ that :meth:`Pyjo.Message.Request.Pyjo_Message_Request.parse` takes, as
64
+ :mod:`Pyjo.Server.WSGI` does. The ASGI server frames the response itself, so
65
+ chunked content is sent without its framing and without the
66
+ ``Transfer-Encoding`` header, and the ``Date`` header is the one of the ASGI
67
+ server, unless the application sets one. A WebSocket handshake is a
68
+ :mod:`Pyjo.Transaction.WebSocket`, as with :mod:`Pyjo.Server.Daemon`; a
69
+ response other than ``101`` rejects it, the ASGI server negotiates
70
+ ``permessage-deflate`` on its own, and a ``Ping`` frame, which ASGI cannot
71
+ send, is answered with a ``Pong`` frame, as the ASGI server pings the client
72
+ itself.
73
+
74
+ The lifespan of the ASGI server is the one of the server of the application:
75
+ its startup calls :meth:`Pyjo.App.Pyjo_App.server`, which emits the hook
76
+ ``before_server_start`` of :mod:`Pyjoyment`, and its shutdown calls
77
+ :meth:`Pyjo.App.Pyjo_App.server_stopped`, which emits the hook
78
+ ``after_server_stop``, and waits for the promise that it returns, as of the
79
+ lifespan of a mounted ASGI application. An exception of either fails the
80
+ lifespan of the ASGI server with its message.
81
+
82
+ This needs a reactor that runs in the event loop of the ASGI server and
83
+ gives awaitables for it. :mod:`Pyjo.Reactor.Asyncio`, the default, does so in
84
+ an ASGI server on :mod:`asyncio`, whose loop may be another implementation of
85
+ the event loop of :mod:`asyncio`, as uvloop is, as the reactor takes the loop
86
+ that the server runs. :mod:`Pyjo.Reactor.Poll` runs a loop of its own, which
87
+ nothing runs in an ASGI server, so a response given later than the
88
+ ``request`` event fails, with ``Task got bad yield`` in a server on
89
+ :mod:`asyncio`. A server on another framework, as Hypercorn on Trio, needs a
90
+ reactor for it, which may come from another distribution, as
91
+ :mod:`Pyjo.Reactor` describes.
92
+
93
+ ASGI servers
94
+ ------------
95
+
96
+ * uvicorn, on :mod:`asyncio` or uvloop: HTTP, WebSocket and lifespan.
97
+ * Hypercorn, on :mod:`asyncio` or uvloop: HTTP, WebSocket and lifespan; on
98
+ Trio only with a reactor for Trio, which Pyjoyment does not have.
99
+ * Daphne: HTTP and WebSocket. It adds no ``Date`` header, so a response has
100
+ one only when the application sets it, and it sends no reason with a
101
+ WebSocket close code. It has no lifespan, so the start and the end of the
102
+ server are not emitted, and the lifespan of a mounted ASGI application does
103
+ not shut down.
104
+ * Granian: HTTP, WebSocket and lifespan. A ``websocket.close`` that the
105
+ application sends is not delivered while a ``receive`` is waiting, and an
106
+ application of WebSocket always waits for the next message, so a WebSocket
107
+ that the application closes stays open until the client closes it.
108
+
109
+ Events
110
+ ------
111
+
112
+ :mod:`Pyjo.Server.ASGI` inherits all events from :mod:`Pyjo.Server`.
113
+
114
+ See also
115
+ --------
116
+
117
+ :mod:`Pyjoyment`, :mod:`Pyjo.Server.WSGI`, https://asgi.readthedocs.io.
118
+
119
+ It has no counterpart in Mojolicious; :mod:`Pyjo.Server.WSGI` is the
120
+ counterpart of
121
+ `Mojo::Server::PSGI <https://github.com/mojolicious/mojo/blob/v9.49/lib/Mojo/Server/PSGI.pm>`_.
122
+ """
123
+
124
+ import Pyjo.Content.Single
125
+ import Pyjo.Promise
126
+ import Pyjo.Server
127
+ import Pyjo.Transaction
128
+ import Pyjo.Transaction.HTTP
129
+ import Pyjo.Transaction.WebSocket
130
+
131
+ from Pyjo.Util import url_escape
132
+ from Pyjo.WebSocket import (
133
+ WS_BINARY,
134
+ WS_CLOSE,
135
+ WS_CONTINUATION,
136
+ WS_PING,
137
+ WS_PONG,
138
+ WS_TEXT,
139
+ Frame,
140
+ parse_frame,
141
+ server_handshake,
142
+ )
143
+
144
+ import struct
145
+ import sys
146
+
147
+ from collections.abc import Awaitable, Callable, Mapping
148
+ from typing import Any
149
+
150
+
151
+ # The connection scope, a message, and the functions that receive and send messages
152
+ type Scope = Mapping[str, Any]
153
+ type Message = Mapping[str, Any]
154
+ type Receive = Callable[[], Awaitable[Message]]
155
+ type Send = Callable[[Message], Awaitable[None]]
156
+ type Application = Callable[[Scope, Receive, Send], Awaitable[None]]
157
+
158
+
159
+ class Pyjo_Server_ASGI(Pyjo.Server.object):
160
+ """
161
+ :mod:`Pyjo.Server.ASGI` inherits all attributes and methods from
162
+ :mod:`Pyjo.Server` and implements the following new ones.
163
+ """
164
+
165
+ # The run of Pyjo.Server takes no arguments, as the one of Mojo::Server; the run of an ASGI server is a coroutine for
166
+ # one connection, as the one of Mojo::Server::PSGI takes the environment
167
+ async def run(self, scope: Scope, receive: Receive, send: Send) -> None: # pyright: ignore[reportIncompatibleMethodOverride]
168
+ """::
169
+
170
+ await asgi.run(scope, receive, send)
171
+
172
+ Run ``ASGI`` for one connection.
173
+ """
174
+ type = scope['type']
175
+ if type == 'http':
176
+ await self._http(scope, receive, send)
177
+ elif type == 'websocket':
178
+ await self._websocket(scope, receive, send)
179
+ elif type == 'lifespan':
180
+ await self._lifespan(receive, send)
181
+ else:
182
+ raise ValueError(f'Unsupported ASGI connection type "{type}"')
183
+
184
+ def to_asgi_app(self) -> Application:
185
+ """::
186
+
187
+ app = asgi.to_asgi_app()
188
+
189
+ Turn the application into an ``ASGI`` application.
190
+ """
191
+ # Preload application and wrap it
192
+ self.app
193
+
194
+ async def application(scope: Scope, receive: Receive, send: Send) -> None:
195
+ await self.run(scope, receive, send)
196
+
197
+ return application
198
+
199
+ def _build(self, scope: Scope) -> Pyjo.Transaction.HTTP.object:
200
+ tx = self.build_tx()
201
+ if (client := scope.get('client')) is not None:
202
+ tx.remote_address, tx.remote_port = client[0], client[1]
203
+ if (server := scope.get('server')) is not None and server[1] is not None:
204
+ tx.local_address, tx.local_port = server[0], server[1]
205
+ return tx
206
+
207
+ async def _http(self, scope: Scope, receive: Receive, send: Send) -> None:
208
+ env = _environ(scope)
209
+
210
+ # Request body of unknown length, as the ASGI server removes the chunked framing
211
+ body: bytes | None = None
212
+ if 'CONTENT_LENGTH' not in env:
213
+ whole = b''
214
+ async for chunk in _body(receive):
215
+ whole += chunk
216
+ if len(whole):
217
+ env['CONTENT_LENGTH'] = str(len(whole))
218
+ body = whole
219
+
220
+ tx = self._build(scope)
221
+ req = tx.req.parse(env)
222
+ if body is not None:
223
+ req.parse(body)
224
+ else:
225
+ async for chunk in _body(receive):
226
+ req.parse(chunk)
227
+ if req.is_finished:
228
+ break
229
+
230
+ resumed = _Resumed(tx)
231
+ self.emit('request', tx)
232
+ await resumed.wait()
233
+
234
+ # Response headers, without the framing of chunked content, and without the Date header that fix_headers adds,
235
+ # as the ASGI server adds its own
236
+ res = tx.res
237
+ dated = res.headers.date is not None
238
+ res.fix_headers()
239
+ if not dated:
240
+ res.headers.remove('Date')
241
+ content = res.content
242
+ chunked = content.is_chunked
243
+ headers = [
244
+ (name.lower().encode('latin-1'), value.encode('latin-1'))
245
+ for name, value in res.headers.to_list()
246
+ if not chunked or name.lower() != 'transfer-encoding'
247
+ ]
248
+ await send({'type': 'http.response.start', 'status': res.code or 404, 'headers': headers})
249
+
250
+ # Response body
251
+ if not tx.is_empty:
252
+ unchunk = _Unchunk() if chunked else None
253
+ offset = 0
254
+ while True:
255
+ # No content yet, try again later
256
+ if (chunk := res.get_body_chunk(offset)) is None:
257
+ await resumed.wait()
258
+ continue
259
+
260
+ # End of content
261
+ if not len(chunk):
262
+ break
263
+
264
+ offset += len(chunk)
265
+ if unchunk is not None:
266
+ chunk = unchunk.parse(chunk)
267
+ if len(chunk):
268
+ await send({'type': 'http.response.body', 'body': chunk, 'more_body': True})
269
+
270
+ await send({'type': 'http.response.body', 'body': b'', 'more_body': False})
271
+ tx.closed()
272
+
273
+ # The start and the end of the server, which the application learns, and whose cleanup the ASGI server waits for
274
+ async def _lifespan(self, receive: Receive, send: Send) -> None:
275
+ while True:
276
+ message = await receive()
277
+ if message['type'] == 'lifespan.startup':
278
+ try:
279
+ self.app.server(self)
280
+ except Exception as e:
281
+ await send({'type': 'lifespan.startup.failed', 'message': str(e)})
282
+ return
283
+ await send({'type': 'lifespan.startup.complete'})
284
+ elif message['type'] == 'lifespan.shutdown':
285
+ try:
286
+ if isinstance(promise := self.app.server_stopped(self), Pyjo.Promise.object):
287
+ await promise
288
+ except Exception as e:
289
+ await send({'type': 'lifespan.shutdown.failed', 'message': str(e)})
290
+ return
291
+ await send({'type': 'lifespan.shutdown.complete'})
292
+ return
293
+
294
+ async def _websocket(self, scope: Scope, receive: Receive, send: Send) -> None:
295
+ message = await receive()
296
+ if message['type'] != 'websocket.connect':
297
+ return
298
+
299
+ # Handshake
300
+ tx = self._build(scope)
301
+ tx.req.parse(_environ(scope))
302
+ if not tx.req.is_handshake:
303
+ tx.req.headers.upgrade = 'websocket'
304
+ ws = Pyjo.Transaction.WebSocket.new(handshake=tx)
305
+ handshake = _Resumed(tx)
306
+ self.emit('request', server_handshake(ws))
307
+ await handshake.wait()
308
+
309
+ # Rejected
310
+ if ws.res.code != 101:
311
+ await send({'type': 'websocket.close', 'code': 1008})
312
+ tx.closed()
313
+ ws.closed()
314
+ return
315
+
316
+ # Established, the ASGI server compresses messages itself
317
+ await send({'type': 'websocket.accept', 'subprotocol': ws.protocol})
318
+ tx.closed()
319
+ ws.set(established=True, compressed=False)
320
+
321
+ # Write messages as they are sent, and read them as they arrive
322
+ writer = _Writer(ws, send)
323
+ while not writer.closed:
324
+ message = await receive()
325
+ if message['type'] == 'websocket.receive':
326
+ if (text := message.get('text')) is not None:
327
+ ws.parse_message((1, 0, 0, 0, WS_TEXT, text.encode()))
328
+ else:
329
+ ws.parse_message((1, 0, 0, 0, WS_BINARY, message.get('bytes') or b''))
330
+ elif message['type'] == 'websocket.disconnect':
331
+ writer.stop()
332
+ code = message.get('code', 1005)
333
+ if code == 1005:
334
+ ws.parse_message((1, 0, 0, 0, WS_CLOSE, b''))
335
+ elif code != 1006:
336
+ reason = (message.get('reason') or '').encode()
337
+ ws.parse_message((1, 0, 0, 0, WS_CLOSE, struct.pack('>H', code) + reason))
338
+ break
339
+ await writer.done
340
+ ws.closed()
341
+
342
+
343
+ # The next resume event of a transaction, which may come before it is waited for
344
+ class _Resumed:
345
+ def __init__(self, tx: Pyjo.Transaction.object) -> None:
346
+ self._resumed = False
347
+ self._promise: Pyjo.Promise.object | None = None
348
+ tx.on('resume', self.resume)
349
+
350
+ def resume(self, *args: Any) -> None:
351
+ self._resumed = True
352
+ if (promise := self._promise) is not None:
353
+ self._promise = None
354
+ promise.resolve()
355
+
356
+ async def wait(self) -> None:
357
+ if not self._resumed:
358
+ self._promise = Pyjo.Promise.new()
359
+ await self._promise
360
+ self._resumed = False
361
+
362
+
363
+ # Chunked content without its framing
364
+ class _Unchunk:
365
+ def __init__(self) -> None:
366
+ self._chunks: list[bytes] = []
367
+ self._parser = Pyjo.Content.Single.new()
368
+ self._parser.unsubscribe('read').on('read', self._read)
369
+ self._parser.parse(b'Transfer-Encoding: chunked\x0d\x0a\x0d\x0a')
370
+
371
+ def _read(self, content: Pyjo.Content.Single.object, chunk: bytes) -> None:
372
+ self._chunks.append(chunk)
373
+
374
+ def parse(self, chunk: bytes) -> bytes:
375
+ self._parser.parse(chunk)
376
+ chunks, self._chunks = self._chunks, []
377
+ return b''.join(chunks)
378
+
379
+
380
+ # The frames that a WebSocket transaction writes, as messages of ASGI
381
+ class _Writer:
382
+ def __init__(self, ws: Pyjo.Transaction.WebSocket.object, send: Send) -> None:
383
+ self.closed = False
384
+ self._buffer = bytearray()
385
+ self._message: list[Any] = []
386
+ self._resumed = _Resumed(ws)
387
+ self._send = send
388
+ self._stopped = False
389
+ self._ws = ws
390
+ self.done = Pyjo.Promise.new(self._write())
391
+
392
+ def stop(self) -> None:
393
+ self._stopped = True
394
+ self._resumed.resume()
395
+
396
+ async def _write(self) -> None:
397
+ while not self._stopped:
398
+ chunk = self._ws.server_write()
399
+ self._buffer += chunk
400
+ while not self._stopped and isinstance(frame := parse_frame(self._buffer, sys.maxsize), tuple):
401
+ await self._frame(frame)
402
+ if self.closed or self._ws.is_finished:
403
+ return
404
+
405
+ # What has been written, after which the next write, of nothing, emits the event drain, as a server does
406
+ # once the data has been written
407
+ if not len(chunk):
408
+ await self._resumed.wait()
409
+
410
+ async def _frame(self, frame: Frame) -> None:
411
+ fin, _, _, _, op, payload = frame
412
+
413
+ # Close
414
+ if op == WS_CLOSE:
415
+ code = struct.unpack('>H', payload[:2])[0] if len(payload) >= 2 else 1000
416
+ await self._send(
417
+ {'type': 'websocket.close', 'code': code, 'reason': payload[2:].decode('utf-8', 'replace')}
418
+ )
419
+ self.closed = True
420
+ return
421
+
422
+ # A ping, which ASGI cannot send, is answered as by the client, as the ASGI server keeps the connection alive
423
+ if op == WS_PING:
424
+ self._ws.parse_message((1, 0, 0, 0, WS_PONG, payload))
425
+ return
426
+
427
+ # Text or binary message, which may come in fragments
428
+ if op in (WS_TEXT, WS_BINARY):
429
+ self._message = [op, payload]
430
+ elif op == WS_CONTINUATION and self._message:
431
+ self._message[1] += payload
432
+ else:
433
+ return
434
+ if not fin:
435
+ return
436
+ op, payload = self._message
437
+ self._message = []
438
+ if op == WS_TEXT:
439
+ await self._send({'type': 'websocket.send', 'text': payload.decode('utf-8', 'replace')})
440
+ else:
441
+ await self._send({'type': 'websocket.send', 'bytes': payload})
442
+
443
+
444
+ # The body of a request, from http.request messages
445
+ async def _body(receive: Receive):
446
+ while True:
447
+ message = await receive()
448
+ if message['type'] == 'http.disconnect':
449
+ return
450
+ if len(chunk := message.get('body', b'')):
451
+ yield chunk
452
+ if not message.get('more_body'):
453
+ return
454
+
455
+
456
+ # The CGI environment of a connection scope
457
+ def _environ(scope: Scope) -> dict[str, str]:
458
+ # The raw path is bytes, which a path of Pyjoyment takes as characters, so the bytes beyond ASCII are percent-encoded,
459
+ # as the path is escaped
460
+ raw = scope.get('raw_path')
461
+ path = url_escape(raw.split(b'?', 1)[0], rb'\x80-\xff').decode('ascii') if raw else scope.get('path', '')
462
+ env = {
463
+ 'PATH_INFO': path,
464
+ 'QUERY_STRING': scope.get('query_string', b'').decode('latin-1'),
465
+ 'REQUEST_METHOD': scope.get('method', 'GET'),
466
+ 'SCRIPT_NAME': scope.get('root_path', ''),
467
+ 'SERVER_PROTOCOL': f'HTTP/{scope.get("http_version", "1.1")}',
468
+ }
469
+ if scope.get('scheme') in ('https', 'wss'):
470
+ env['HTTPS'] = 'ON'
471
+
472
+ # Headers, joined as CGI has them
473
+ for name, value in scope.get('headers', []):
474
+ key, value = name.decode('latin-1').upper().replace('-', '_'), value.decode('latin-1')
475
+ if key in ('CONTENT_LENGTH', 'CONTENT_TYPE'):
476
+ env[key] = value
477
+ elif key != 'TRANSFER_ENCODING':
478
+ key = f'HTTP_{key}'
479
+ env[key] = f'{env[key]}{"; " if key == "HTTP_COOKIE" else ", "}{value}' if key in env else value
480
+
481
+ return env
482
+
483
+
484
+ new = Pyjo_Server_ASGI.new
485
+ object = Pyjo_Server_ASGI
486
+ ASGI = Pyjo_Server_ASGI