rolo 0.1.0.dev0__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.
- rolo/__init__.py +6 -0
- rolo/asgi.py +628 -0
- rolo/client.py +144 -0
- rolo/dispatcher.py +79 -0
- rolo/proxy.py +180 -0
- rolo/request.py +313 -0
- rolo/resource.py +111 -0
- rolo/response.py +78 -0
- rolo/router.py +616 -0
- rolo/testing/__init__.py +0 -0
- rolo/testing/pytest.py +197 -0
- rolo/websocket/__init__.py +0 -0
- rolo/websocket/websocket.py +333 -0
- rolo-0.1.0.dev0.dist-info/METADATA +85 -0
- rolo-0.1.0.dev0.dist-info/RECORD +17 -0
- rolo-0.1.0.dev0.dist-info/WHEEL +5 -0
- rolo-0.1.0.dev0.dist-info/top_level.txt +1 -0
rolo/__init__.py
ADDED
rolo/asgi.py
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
"""This module contains code to make ASGI play nice with WSGI."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import io
|
|
4
|
+
import logging
|
|
5
|
+
import math
|
|
6
|
+
import typing as t
|
|
7
|
+
from asyncio import AbstractEventLoop
|
|
8
|
+
from concurrent.futures import Executor
|
|
9
|
+
from io import BufferedReader, RawIOBase
|
|
10
|
+
from urllib.parse import quote, unquote, urlparse
|
|
11
|
+
|
|
12
|
+
if t.TYPE_CHECKING:
|
|
13
|
+
from _typeshed import WSGIApplication, WSGIEnvironment
|
|
14
|
+
from hypercorn.typing import (
|
|
15
|
+
ASGIReceiveCallable,
|
|
16
|
+
ASGISendCallable,
|
|
17
|
+
HTTPScope,
|
|
18
|
+
Scope,
|
|
19
|
+
WebsocketAcceptEvent,
|
|
20
|
+
WebsocketCloseEvent,
|
|
21
|
+
WebsocketConnectEvent,
|
|
22
|
+
WebsocketDisconnectEvent,
|
|
23
|
+
WebsocketReceiveEvent,
|
|
24
|
+
WebsocketResponseBodyEvent,
|
|
25
|
+
WebsocketResponseStartEvent,
|
|
26
|
+
WebsocketScope,
|
|
27
|
+
WebsocketSendEvent,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_WebsocketResponse = t.Union[
|
|
31
|
+
WebsocketAcceptEvent,
|
|
32
|
+
WebsocketSendEvent,
|
|
33
|
+
WebsocketResponseStartEvent,
|
|
34
|
+
WebsocketResponseBodyEvent,
|
|
35
|
+
WebsocketCloseEvent,
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
_WebsocketRequest = t.Union[
|
|
39
|
+
WebsocketConnectEvent,
|
|
40
|
+
WebsocketReceiveEvent,
|
|
41
|
+
WebsocketDisconnectEvent,
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
LOG = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
WebSocketEnvironment: t.TypeAlias = t.Dict[str, t.Any]
|
|
47
|
+
"""Special WSGIEnvironment that has an `asgi.websocket` key that stores a `Websocket` instance."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def populate_wsgi_environment(
|
|
51
|
+
environ: t.Union["WSGIEnvironment", WebSocketEnvironment],
|
|
52
|
+
scope: t.Union["HTTPScope", "WebsocketScope"],
|
|
53
|
+
):
|
|
54
|
+
"""
|
|
55
|
+
Adds the non-IO parts (e.g., excluding wsgi.input) from the ASGI HTTPScope to the WSGI Environment. See
|
|
56
|
+
WSGI Compatibility for more information on why this works:
|
|
57
|
+
https://asgi.readthedocs.io/en/latest/specs/www.html#wsgi-compatibility
|
|
58
|
+
|
|
59
|
+
:param environ: the WSGI environment to populate
|
|
60
|
+
:param scope: the ASGI scope as source
|
|
61
|
+
"""
|
|
62
|
+
environ["REQUEST_METHOD"] = scope.get("method", "GET")
|
|
63
|
+
# path/uri info
|
|
64
|
+
# prepare the paths for the "WSGI decoding dance" done by werkzeug
|
|
65
|
+
environ["SCRIPT_NAME"] = unquote(quote(scope.get("root_path", "").rstrip("/")), "latin-1")
|
|
66
|
+
|
|
67
|
+
path = scope["path"]
|
|
68
|
+
path = path if path[0] == "/" else urlparse(path).path
|
|
69
|
+
environ["PATH_INFO"] = unquote(quote(path), "latin-1")
|
|
70
|
+
|
|
71
|
+
query_string = scope.get("query_string")
|
|
72
|
+
if query_string:
|
|
73
|
+
raw_uri = scope["raw_path"] + b"?" + query_string
|
|
74
|
+
environ["QUERY_STRING"] = query_string.decode("latin1")
|
|
75
|
+
else:
|
|
76
|
+
raw_uri = scope["raw_path"]
|
|
77
|
+
environ["QUERY_STRING"] = ""
|
|
78
|
+
|
|
79
|
+
environ["RAW_URI"] = environ["REQUEST_URI"] = raw_uri.decode("utf-8")
|
|
80
|
+
|
|
81
|
+
# server address / host
|
|
82
|
+
server = scope.get("server") or ("localhost", 80)
|
|
83
|
+
environ["SERVER_NAME"] = server[0]
|
|
84
|
+
environ["SERVER_PORT"] = str(server[1]) if server[1] else "80"
|
|
85
|
+
|
|
86
|
+
# http version
|
|
87
|
+
environ["SERVER_PROTOCOL"] = "HTTP/" + scope["http_version"]
|
|
88
|
+
|
|
89
|
+
# client (remote) address
|
|
90
|
+
client = scope.get("client")
|
|
91
|
+
if client:
|
|
92
|
+
environ["REMOTE_ADDR"] = client[0]
|
|
93
|
+
environ["REMOTE_PORT"] = str(client[1])
|
|
94
|
+
|
|
95
|
+
# headers
|
|
96
|
+
for name, value in scope["headers"]:
|
|
97
|
+
key = name.decode("latin1").upper().replace("-", "_")
|
|
98
|
+
|
|
99
|
+
if key not in ["CONTENT_TYPE", "CONTENT_LENGTH"]:
|
|
100
|
+
key = f"HTTP_{key}"
|
|
101
|
+
|
|
102
|
+
environ[key] = value.decode("latin1")
|
|
103
|
+
|
|
104
|
+
# wsgi specific keys
|
|
105
|
+
environ["wsgi.version"] = (1, 0)
|
|
106
|
+
environ["wsgi.url_scheme"] = scope.get("scheme", "http")
|
|
107
|
+
environ["wsgi.errors"] = io.BytesIO()
|
|
108
|
+
environ["wsgi.multithread"] = True
|
|
109
|
+
environ["wsgi.multiprocess"] = False
|
|
110
|
+
environ["wsgi.run_once"] = False
|
|
111
|
+
|
|
112
|
+
# asgi.headers: a custom key to allow downstream applications to circumvent WSGI header processing. these headers
|
|
113
|
+
# should preserve the original casing as the client sends them.
|
|
114
|
+
headers = scope.get("headers")
|
|
115
|
+
environ["asgi.headers"] = headers
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class _AsyncGeneratorWrapper:
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
it: t.Iterator,
|
|
122
|
+
loop: t.Optional[AbstractEventLoop] = None,
|
|
123
|
+
executor: t.Optional[Executor] = None,
|
|
124
|
+
):
|
|
125
|
+
"""
|
|
126
|
+
Wraps a given synchronous Iterator as an async generator, where each invocation to ``next(it)``
|
|
127
|
+
will be wrapped in a coroutine execution.
|
|
128
|
+
|
|
129
|
+
:param it: the iterator to wrap
|
|
130
|
+
:param loop: the event loop to run the next invocations
|
|
131
|
+
:param executor: the executor to run the synchronous code
|
|
132
|
+
"""
|
|
133
|
+
self.it = it
|
|
134
|
+
self.loop = loop or asyncio.get_event_loop()
|
|
135
|
+
self.executor = executor
|
|
136
|
+
|
|
137
|
+
def _next_sync(self):
|
|
138
|
+
try:
|
|
139
|
+
return next(self.it)
|
|
140
|
+
except StopIteration:
|
|
141
|
+
raise StopAsyncIteration
|
|
142
|
+
|
|
143
|
+
def __aiter__(self):
|
|
144
|
+
return self
|
|
145
|
+
|
|
146
|
+
async def __anext__(self):
|
|
147
|
+
val = await self.loop.run_in_executor(self.executor, self._next_sync)
|
|
148
|
+
return val
|
|
149
|
+
|
|
150
|
+
async def aclose(self):
|
|
151
|
+
if close := getattr(self.it, "close", None):
|
|
152
|
+
return await self.loop.run_in_executor(self.executor, close)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def create_wsgi_input(
|
|
156
|
+
receive: "ASGIReceiveCallable", event_loop: t.Optional[AbstractEventLoop] = None
|
|
157
|
+
) -> t.IO[bytes]:
|
|
158
|
+
"""
|
|
159
|
+
Factory for exposing an ASGIReceiveCallable as an IO stream.
|
|
160
|
+
|
|
161
|
+
:param receive: the receive callable
|
|
162
|
+
:param event_loop: the event loop used by the event stream adapter
|
|
163
|
+
:return: a new IO stream that wraps the given receive callable.
|
|
164
|
+
"""
|
|
165
|
+
return BufferedReader(RawHTTPRequestEventStreamAdapter(receive, event_loop))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class RawHTTPRequestEventStreamAdapter(RawIOBase):
|
|
169
|
+
"""
|
|
170
|
+
An adapter to expose an ASGIReceiveCallable coroutine that returns HTTPRequestEvent instances as an IO
|
|
171
|
+
stream for synchronous WSGI/Werkzeug code. The adapter is a Raw IO stream, meaning it does not have
|
|
172
|
+
optimized ``read``, ``readline``, or ``readlines`` methods. Make sure to use a ``BufferedReader`` around
|
|
173
|
+
the stream adapter.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
def __init__(
|
|
177
|
+
self, receive: "ASGIReceiveCallable", event_loop: t.Optional[AbstractEventLoop] = None
|
|
178
|
+
) -> None:
|
|
179
|
+
super().__init__()
|
|
180
|
+
self.receive = receive
|
|
181
|
+
self.event_loop = event_loop or asyncio.get_event_loop()
|
|
182
|
+
|
|
183
|
+
# internal state
|
|
184
|
+
self._more_body = True
|
|
185
|
+
self._buffered_body = None
|
|
186
|
+
self._buffered_body_pos = 0
|
|
187
|
+
|
|
188
|
+
def readable(self) -> bool:
|
|
189
|
+
return True
|
|
190
|
+
|
|
191
|
+
def readinto(self, buf: bytearray | memoryview) -> int:
|
|
192
|
+
if not self._more_body:
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
# max bytes we can write into the buffer
|
|
196
|
+
buf_size = len(buf)
|
|
197
|
+
|
|
198
|
+
# _buffered_body holds the carry-over of what we didn't read in the last iteration
|
|
199
|
+
if self._buffered_body is None:
|
|
200
|
+
# read from the underlying socket stream
|
|
201
|
+
recv_future = asyncio.run_coroutine_threadsafe(self.receive(), self.event_loop)
|
|
202
|
+
event = recv_future.result()
|
|
203
|
+
# TODO: disconnect events
|
|
204
|
+
more = event.get("more_body", False)
|
|
205
|
+
|
|
206
|
+
if not more:
|
|
207
|
+
self._more_body = False
|
|
208
|
+
return 0
|
|
209
|
+
|
|
210
|
+
body = self._buffered_body = event["body"]
|
|
211
|
+
pos = self._buffered_body_pos = 0
|
|
212
|
+
else:
|
|
213
|
+
body = self._buffered_body
|
|
214
|
+
pos = self._buffered_body_pos
|
|
215
|
+
|
|
216
|
+
remaining = len(body) - pos
|
|
217
|
+
|
|
218
|
+
if remaining <= buf_size:
|
|
219
|
+
# the easiest case, where we write the entire remaining event body into the buffer. we may return
|
|
220
|
+
# less than the buffer size allows, but that's ok for raw IO streams.
|
|
221
|
+
buf[:remaining] = body[pos:]
|
|
222
|
+
self._buffered_body = None
|
|
223
|
+
return remaining
|
|
224
|
+
|
|
225
|
+
# in this case, we can read at max buf_size from the body into the buffer, and need to save the
|
|
226
|
+
# rest for the next call
|
|
227
|
+
buf[:buf_size] = body[pos : pos + buf_size]
|
|
228
|
+
self._buffered_body_pos = pos + buf_size
|
|
229
|
+
|
|
230
|
+
return buf_size
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class WsgiStartResponse:
|
|
234
|
+
"""
|
|
235
|
+
A wrapper that exposes an async ``ASGISendCallable`` as synchronous a WSGI ``StartResponse`` protocol callable.
|
|
236
|
+
See this stackoverflow post for a good explanation: https://stackoverflow.com/a/16775731/804840.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
def __init__(
|
|
240
|
+
self,
|
|
241
|
+
send: "ASGISendCallable",
|
|
242
|
+
event_loop: AbstractEventLoop = None,
|
|
243
|
+
):
|
|
244
|
+
self.send = send
|
|
245
|
+
self.event_loop = event_loop or asyncio.get_event_loop()
|
|
246
|
+
self.sent = 0
|
|
247
|
+
self.content_length = math.inf
|
|
248
|
+
self.finalized = False
|
|
249
|
+
self.started = False
|
|
250
|
+
|
|
251
|
+
def __call__(
|
|
252
|
+
self, status: str, headers: t.List[t.Tuple[str, str]], exec_info=None
|
|
253
|
+
) -> t.Callable[[bytes], t.Any]:
|
|
254
|
+
return self.start_response_sync(status, headers, exec_info)
|
|
255
|
+
|
|
256
|
+
def start_response_sync(
|
|
257
|
+
self, status: str, headers: t.List[t.Tuple[str, str]], exec_info=None
|
|
258
|
+
) -> t.Callable[[bytes], t.Any]:
|
|
259
|
+
"""
|
|
260
|
+
The WSGI start_response protocol.
|
|
261
|
+
|
|
262
|
+
:param status: the HTTP status (e.g., ``200 OK``) to write
|
|
263
|
+
:param headers: the HTTP headers to write
|
|
264
|
+
:param exec_info: ignored
|
|
265
|
+
:return: a callable that lets you write bytes to the response body
|
|
266
|
+
"""
|
|
267
|
+
send = self.send
|
|
268
|
+
loop = self.event_loop
|
|
269
|
+
|
|
270
|
+
# start sending response
|
|
271
|
+
asyncio.run_coroutine_threadsafe(
|
|
272
|
+
send(
|
|
273
|
+
{
|
|
274
|
+
"type": "http.response.start",
|
|
275
|
+
"status": int(status[:3]),
|
|
276
|
+
"headers": [(h[0].encode("latin1"), h[1].encode("latin1")) for h in headers],
|
|
277
|
+
}
|
|
278
|
+
),
|
|
279
|
+
loop,
|
|
280
|
+
).result()
|
|
281
|
+
|
|
282
|
+
self.started = True
|
|
283
|
+
# find out content length if set
|
|
284
|
+
self.content_length = math.inf # unknown content-length
|
|
285
|
+
for k, v in headers:
|
|
286
|
+
if k.lower() == "content-length":
|
|
287
|
+
self.content_length = int(v)
|
|
288
|
+
break
|
|
289
|
+
|
|
290
|
+
return self.write_sync
|
|
291
|
+
|
|
292
|
+
def write_sync(self, data: bytes) -> None:
|
|
293
|
+
return asyncio.run_coroutine_threadsafe(self.write(data), self.event_loop).result()
|
|
294
|
+
|
|
295
|
+
async def write(self, data: bytes) -> None:
|
|
296
|
+
if not self.started:
|
|
297
|
+
raise ValueError("not started the response yet")
|
|
298
|
+
if getattr(self.send.__self__, "closed", None):
|
|
299
|
+
# the connection has been closed from the client side, set finalized=True to avoid sending more responses
|
|
300
|
+
self.finalized = True
|
|
301
|
+
raise BrokenPipeError("Connection closed")
|
|
302
|
+
await self.send({"type": "http.response.body", "body": data, "more_body": True})
|
|
303
|
+
self.sent += len(data)
|
|
304
|
+
if self.sent >= self.content_length:
|
|
305
|
+
await self.close()
|
|
306
|
+
|
|
307
|
+
async def close(self):
|
|
308
|
+
if not self.started:
|
|
309
|
+
raise ValueError("not started the response yet")
|
|
310
|
+
|
|
311
|
+
if not self.finalized:
|
|
312
|
+
self.finalized = True
|
|
313
|
+
await self.send({"type": "http.response.body", "body": b"", "more_body": False})
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class ASGILifespanListener:
|
|
317
|
+
"""
|
|
318
|
+
Simple event handler that is attached to the ASGIAdapter and called on ASGI lifespan events. See
|
|
319
|
+
https://asgi.readthedocs.io/en/latest/specs/lifespan.html.
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
def on_startup(self):
|
|
323
|
+
pass
|
|
324
|
+
|
|
325
|
+
def on_shutdown(self):
|
|
326
|
+
pass
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class ASGIWebSocket:
|
|
330
|
+
"""
|
|
331
|
+
A wrapper around an ASGI ``WebsocketScope`` and relevant IO objects that can be used to interact with the websocket
|
|
332
|
+
in synchronous code.
|
|
333
|
+
|
|
334
|
+
For send and receive event formats, see https://asgi.readthedocs.io/en/latest/specs/www.html#websocket.
|
|
335
|
+
"""
|
|
336
|
+
|
|
337
|
+
_scope: "WebsocketScope"
|
|
338
|
+
_receive: "ASGIReceiveCallable"
|
|
339
|
+
_send: "ASGISendCallable"
|
|
340
|
+
|
|
341
|
+
def __init__(
|
|
342
|
+
self,
|
|
343
|
+
scope: "WebsocketScope",
|
|
344
|
+
receive: "ASGIReceiveCallable",
|
|
345
|
+
send: "ASGISendCallable",
|
|
346
|
+
loop: AbstractEventLoop,
|
|
347
|
+
):
|
|
348
|
+
self._scope = scope
|
|
349
|
+
self._receive = receive
|
|
350
|
+
self._send = send
|
|
351
|
+
self._loop = loop
|
|
352
|
+
|
|
353
|
+
async def send_async(self, event: "_WebsocketResponse"):
|
|
354
|
+
await self._send(event)
|
|
355
|
+
|
|
356
|
+
async def receive_async(self) -> "_WebsocketRequest":
|
|
357
|
+
return await self._receive()
|
|
358
|
+
|
|
359
|
+
def send(self, event: "_WebsocketResponse", timeout: float = None) -> None:
|
|
360
|
+
"""
|
|
361
|
+
Sends an event to the Websocket. Events can be:
|
|
362
|
+
|
|
363
|
+
- websocket.accept: https://asgi.readthedocs.io/en/latest/specs/www.html#accept-send-event
|
|
364
|
+
- websocket.send: https://asgi.readthedocs.io/en/latest/specs/www.html#send-send-event
|
|
365
|
+
- websocket.close: https://asgi.readthedocs.io/en/latest/specs/www.html#close-send-event
|
|
366
|
+
|
|
367
|
+
:param event: The event to send
|
|
368
|
+
:param timeout: The number of seconds to wait for the result of the async call
|
|
369
|
+
"""
|
|
370
|
+
return asyncio.run_coroutine_threadsafe(self.send_async(event), self._loop).result(
|
|
371
|
+
timeout=timeout
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
def receive(self, timeout: float = None) -> "_WebsocketRequest":
|
|
375
|
+
"""
|
|
376
|
+
Listens on the websocket and returns the next event. Events can be:
|
|
377
|
+
|
|
378
|
+
- websocket.connect: https://asgi.readthedocs.io/en/latest/specs/www.html#connect-receive-event
|
|
379
|
+
- websocket.receive: https://asgi.readthedocs.io/en/latest/specs/www.html#receive-receive-event
|
|
380
|
+
- websocket.disconnect: https://asgi.readthedocs.io/en/latest/specs/www.html#disconnect-receive-event-ws
|
|
381
|
+
|
|
382
|
+
:param timeout: The number of seconds to wait for the event
|
|
383
|
+
:return: The received event
|
|
384
|
+
"""
|
|
385
|
+
return asyncio.run_coroutine_threadsafe(self.receive_async(), self._loop).result(timeout)
|
|
386
|
+
|
|
387
|
+
def respond(
|
|
388
|
+
self, status: int, headers: list[tuple[str, str]] = None, body: t.Iterable[bytes] = None
|
|
389
|
+
):
|
|
390
|
+
self.send(
|
|
391
|
+
{
|
|
392
|
+
"type": "websocket.http.response.start",
|
|
393
|
+
"status": status,
|
|
394
|
+
"headers": [(h[0].encode("latin1"), h[1].encode("latin1")) for h in headers],
|
|
395
|
+
}
|
|
396
|
+
)
|
|
397
|
+
if body:
|
|
398
|
+
for chunk in body:
|
|
399
|
+
self.send(
|
|
400
|
+
{
|
|
401
|
+
"type": "websocket.http.response.body",
|
|
402
|
+
"body": chunk,
|
|
403
|
+
"more_body": True,
|
|
404
|
+
}
|
|
405
|
+
)
|
|
406
|
+
self.send(
|
|
407
|
+
{
|
|
408
|
+
"type": "websocket.http.response.body",
|
|
409
|
+
"body": b"",
|
|
410
|
+
"more_body": False,
|
|
411
|
+
}
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class WebSocketListener(t.Protocol):
|
|
416
|
+
"""
|
|
417
|
+
Similar protocol to a WSGIApplication, only it expects a Websocket instead of a WSGIEnvironment.
|
|
418
|
+
"""
|
|
419
|
+
|
|
420
|
+
def __call__(self, environ: WebSocketEnvironment):
|
|
421
|
+
"""
|
|
422
|
+
Called when a new Websocket connection is established. To initiate the connection, you need to perform the
|
|
423
|
+
connect handshake yourself. First, receive the ``websocket.connect`` event, and then send the
|
|
424
|
+
``websocket.accept`` event. Here's a minimal example::
|
|
425
|
+
|
|
426
|
+
def accept(self, environ: WebsocketEnvironment):
|
|
427
|
+
websocket = environ['asgi.websocket']
|
|
428
|
+
event = websocket.receive()
|
|
429
|
+
if event['type'] == "websocket.connect":
|
|
430
|
+
websocket.send({
|
|
431
|
+
"type": "websocket.accept",
|
|
432
|
+
"subprotocol": None,
|
|
433
|
+
"headers": [],
|
|
434
|
+
})
|
|
435
|
+
else:
|
|
436
|
+
websocket.send({
|
|
437
|
+
"type": "websocket.close",
|
|
438
|
+
"code": 1002, # protocol error
|
|
439
|
+
"reason": None,
|
|
440
|
+
})
|
|
441
|
+
return
|
|
442
|
+
|
|
443
|
+
while True:
|
|
444
|
+
event = websocket.receive()
|
|
445
|
+
if event["type"] == "websocket.disconnect":
|
|
446
|
+
return
|
|
447
|
+
print(event)
|
|
448
|
+
|
|
449
|
+
:param environ: The new Websocket environment
|
|
450
|
+
"""
|
|
451
|
+
raise NotImplementedError
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
class ASGIAdapter:
|
|
455
|
+
"""
|
|
456
|
+
Adapter to expose a WSGIApplication as an ASGI3Application. This allows you to serve synchronous WSGI applications
|
|
457
|
+
through ASGI servers (e.g., Hypercorn).
|
|
458
|
+
|
|
459
|
+
IMPORTANT: The ASGIAdapter needs to use the same event loop as the underlying server. If you pass a new event
|
|
460
|
+
loop to the server, you need to also pass it to the ASGIAdapter.
|
|
461
|
+
|
|
462
|
+
https://asgi.readthedocs.io/en/latest/specs/main.html
|
|
463
|
+
"""
|
|
464
|
+
|
|
465
|
+
def __init__(
|
|
466
|
+
self,
|
|
467
|
+
wsgi_app: "WSGIApplication",
|
|
468
|
+
event_loop: AbstractEventLoop = None,
|
|
469
|
+
executor: Executor = None,
|
|
470
|
+
lifespan_listener: ASGILifespanListener = None,
|
|
471
|
+
websocket_listener: WebSocketListener = None,
|
|
472
|
+
):
|
|
473
|
+
self.wsgi_app = wsgi_app
|
|
474
|
+
self.event_loop = event_loop or asyncio.get_event_loop()
|
|
475
|
+
self.executor = executor
|
|
476
|
+
self.lifespan_listener = lifespan_listener or ASGILifespanListener()
|
|
477
|
+
self.websocket_listener = websocket_listener
|
|
478
|
+
|
|
479
|
+
async def __call__(
|
|
480
|
+
self, scope: "Scope", receive: "ASGIReceiveCallable", send: "ASGISendCallable"
|
|
481
|
+
):
|
|
482
|
+
"""
|
|
483
|
+
The ASGI 3 interface. Can only handle HTTP calls.
|
|
484
|
+
|
|
485
|
+
:param scope: the connection scope
|
|
486
|
+
:param receive: the receive callable
|
|
487
|
+
:param send: the send callable
|
|
488
|
+
"""
|
|
489
|
+
if scope["type"] == "http":
|
|
490
|
+
return await self.handle_http(scope, receive, send)
|
|
491
|
+
|
|
492
|
+
if scope["type"] == "lifespan":
|
|
493
|
+
return await self.handle_lifespan(scope, receive, send)
|
|
494
|
+
|
|
495
|
+
if scope["type"] == "websocket":
|
|
496
|
+
return await self.handle_websocket(scope, receive, send)
|
|
497
|
+
|
|
498
|
+
raise NotImplementedError("Unhandled protocol %s" % scope["type"])
|
|
499
|
+
|
|
500
|
+
def to_wsgi_environment(
|
|
501
|
+
self,
|
|
502
|
+
scope: "HTTPScope",
|
|
503
|
+
receive: "ASGIReceiveCallable",
|
|
504
|
+
) -> "WSGIEnvironment":
|
|
505
|
+
"""
|
|
506
|
+
Creates an IO-ready WSGIEnvironment from the given ASGI HTTP call.
|
|
507
|
+
|
|
508
|
+
:param scope: the ASGI HTTP Scope
|
|
509
|
+
:param receive: the ASGI callable to receive the HTTP request
|
|
510
|
+
:return: a WSGIEnvironment
|
|
511
|
+
"""
|
|
512
|
+
environ: "WSGIEnvironment" = {}
|
|
513
|
+
populate_wsgi_environment(environ, scope)
|
|
514
|
+
# add IO wrappers
|
|
515
|
+
environ["wsgi.input"] = create_wsgi_input(receive, event_loop=self.event_loop)
|
|
516
|
+
# indicate that the stream is EOF terminated per request
|
|
517
|
+
environ["wsgi.input_terminated"] = True
|
|
518
|
+
return environ
|
|
519
|
+
|
|
520
|
+
async def handle_http(
|
|
521
|
+
self, scope: "HTTPScope", receive: "ASGIReceiveCallable", send: "ASGISendCallable"
|
|
522
|
+
):
|
|
523
|
+
env = self.to_wsgi_environment(scope, receive)
|
|
524
|
+
|
|
525
|
+
try:
|
|
526
|
+
response = WsgiStartResponse(send, self.event_loop)
|
|
527
|
+
|
|
528
|
+
iterable = await self.event_loop.run_in_executor(
|
|
529
|
+
self.executor, self.wsgi_app, env, response
|
|
530
|
+
)
|
|
531
|
+
except Exception as e:
|
|
532
|
+
LOG.error(
|
|
533
|
+
"Error while trying to schedule execution: %s with environment %s",
|
|
534
|
+
e,
|
|
535
|
+
env,
|
|
536
|
+
exc_info=LOG.isEnabledFor(logging.DEBUG),
|
|
537
|
+
)
|
|
538
|
+
raise
|
|
539
|
+
|
|
540
|
+
try:
|
|
541
|
+
if iterable:
|
|
542
|
+
# Generators are also Iterators
|
|
543
|
+
if isinstance(iterable, t.Iterator):
|
|
544
|
+
iterable = _AsyncGeneratorWrapper(iterable)
|
|
545
|
+
|
|
546
|
+
if isinstance(iterable, (t.AsyncIterator, t.AsyncIterable)):
|
|
547
|
+
async for packet in iterable:
|
|
548
|
+
await response.write(packet)
|
|
549
|
+
else:
|
|
550
|
+
for packet in iterable:
|
|
551
|
+
await response.write(packet)
|
|
552
|
+
except ConnectionError as e:
|
|
553
|
+
client_info = "unknown"
|
|
554
|
+
if client := scope.get("client"):
|
|
555
|
+
address, port = client
|
|
556
|
+
client_info = f"{address}:{port}"
|
|
557
|
+
LOG.debug("Error while writing responses: %s (client_info: %s)", e, client_info)
|
|
558
|
+
finally:
|
|
559
|
+
if iterable and hasattr(iterable, "aclose"):
|
|
560
|
+
await iterable.aclose()
|
|
561
|
+
await response.close()
|
|
562
|
+
|
|
563
|
+
def to_websocket_environment(
|
|
564
|
+
self,
|
|
565
|
+
scope: "WebsocketScope",
|
|
566
|
+
receive: "ASGIReceiveCallable",
|
|
567
|
+
send: "ASGISendCallable",
|
|
568
|
+
) -> WebSocketEnvironment:
|
|
569
|
+
"""
|
|
570
|
+
Creates an IO-ready pseudo-WSGI environment from the given ASGI Websocket scope.
|
|
571
|
+
|
|
572
|
+
:param scope: the websocket scope
|
|
573
|
+
:param receive: receive callable
|
|
574
|
+
:param send: send callable
|
|
575
|
+
:return: a new websocket environment
|
|
576
|
+
"""
|
|
577
|
+
environ: WebSocketEnvironment = {}
|
|
578
|
+
populate_wsgi_environment(environ, scope)
|
|
579
|
+
environ["REQUEST_METHOD"] = "WEBSOCKET"
|
|
580
|
+
environ["asgi.websocket"] = ASGIWebSocket(scope, receive, send, self.event_loop)
|
|
581
|
+
return environ
|
|
582
|
+
|
|
583
|
+
async def handle_websocket(
|
|
584
|
+
self, scope: "WebsocketScope", receive: "ASGIReceiveCallable", send: "ASGISendCallable"
|
|
585
|
+
):
|
|
586
|
+
if not self.websocket_listener:
|
|
587
|
+
raise NotImplementedError("No websocket listener attached")
|
|
588
|
+
|
|
589
|
+
# populate a pseudo-WSGI environment with "WEBSOCKET" as method
|
|
590
|
+
# this can later be used to construct a sans-IO Werkzeug request
|
|
591
|
+
environ = self.to_websocket_environment(scope, receive, send)
|
|
592
|
+
|
|
593
|
+
try:
|
|
594
|
+
await self.event_loop.run_in_executor(self.executor, self.websocket_listener, environ)
|
|
595
|
+
except Exception as e:
|
|
596
|
+
LOG.error(
|
|
597
|
+
"Error while trying to schedule execution: %s with environment %s",
|
|
598
|
+
e,
|
|
599
|
+
environ,
|
|
600
|
+
exc_info=LOG.isEnabledFor(logging.DEBUG),
|
|
601
|
+
)
|
|
602
|
+
raise
|
|
603
|
+
|
|
604
|
+
async def handle_lifespan(
|
|
605
|
+
self, scope: "HTTPScope", receive: "ASGIReceiveCallable", send: "ASGISendCallable"
|
|
606
|
+
):
|
|
607
|
+
while True:
|
|
608
|
+
message = await receive()
|
|
609
|
+
if message["type"] == "lifespan.startup":
|
|
610
|
+
try:
|
|
611
|
+
await self.event_loop.run_in_executor(
|
|
612
|
+
self.executor, self.lifespan_listener.on_startup
|
|
613
|
+
)
|
|
614
|
+
await send({"type": "lifespan.startup.complete"})
|
|
615
|
+
except Exception as e:
|
|
616
|
+
await send({"type": "lifespan.startup.failed", "message": f"{e}"})
|
|
617
|
+
|
|
618
|
+
elif message["type"] == "lifespan.shutdown":
|
|
619
|
+
try:
|
|
620
|
+
await self.event_loop.run_in_executor(
|
|
621
|
+
self.executor, self.lifespan_listener.on_shutdown
|
|
622
|
+
)
|
|
623
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
624
|
+
except Exception as e:
|
|
625
|
+
await send({"type": "lifespan.shutdown.failed", "message": f"{e}"})
|
|
626
|
+
return
|
|
627
|
+
else:
|
|
628
|
+
return
|