latch-asgi 0.1.3__py3-none-any.whl → 0.3.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.
- latch_asgi/asgi_iface.py +128 -0
- latch_asgi/auth.py +7 -7
- latch_asgi/context/__init__.py +0 -0
- latch_asgi/{context.py → context/common.py} +13 -39
- latch_asgi/context/http.py +42 -0
- latch_asgi/context/websocket.py +34 -0
- latch_asgi/framework/__init__.py +0 -0
- latch_asgi/framework/common.py +7 -0
- latch_asgi/{framework.py → framework/http.py} +9 -22
- latch_asgi/framework/websocket.py +266 -0
- latch_asgi/server.py +131 -20
- latch_asgi-0.3.0.dist-info/METADATA +19 -0
- latch_asgi-0.3.0.dist-info/RECORD +19 -0
- {latch_asgi-0.1.3.dist-info → latch_asgi-0.3.0.dist-info}/WHEEL +1 -1
- latch_asgi-0.3.0.dist-info/licenses/COPYING +121 -0
- latch_asgi-0.1.3.dist-info/METADATA +0 -25
- latch_asgi-0.1.3.dist-info/RECORD +0 -12
latch_asgi/asgi_iface.py
CHANGED
|
@@ -198,3 +198,131 @@ HTTPSendEvent: TypeAlias = (
|
|
|
198
198
|
| HTTPDisconnectEvent
|
|
199
199
|
)
|
|
200
200
|
HTTPSendCallable: TypeAlias = Callable[[HTTPSendEvent], Awaitable[None]]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# >>> Websocket
|
|
204
|
+
@dataclass(frozen=True)
|
|
205
|
+
class WebsocketScope:
|
|
206
|
+
type: Literal["websocket"]
|
|
207
|
+
asgi: ASGIVersions
|
|
208
|
+
http_version: str
|
|
209
|
+
scheme: str
|
|
210
|
+
path: str
|
|
211
|
+
raw_path: bytes
|
|
212
|
+
query_string: bytes
|
|
213
|
+
root_path: str
|
|
214
|
+
headers: Iterable[tuple[bytes, bytes]]
|
|
215
|
+
client: tuple[str, int] | None
|
|
216
|
+
server: tuple[str, int | None] | None
|
|
217
|
+
subprotocols: Iterable[str]
|
|
218
|
+
extensions: dict[str, object]
|
|
219
|
+
|
|
220
|
+
def as_dict(self):
|
|
221
|
+
return validate(dataclasses.asdict(self), htyping.WebsocketScope)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass(frozen=True)
|
|
225
|
+
class WebsocketConnectEvent:
|
|
226
|
+
type: Literal["websocket.connect"]
|
|
227
|
+
|
|
228
|
+
def as_dict(self):
|
|
229
|
+
return validate(dataclasses.asdict(self), htyping.WebsocketConnectEvent)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass(frozen=True)
|
|
233
|
+
class WebsocketAcceptEvent:
|
|
234
|
+
type: Literal["websocket.accept"]
|
|
235
|
+
subprotocol: str | None
|
|
236
|
+
headers: Iterable[tuple[bytes, bytes]]
|
|
237
|
+
|
|
238
|
+
def as_dict(self):
|
|
239
|
+
return validate(dataclasses.asdict(self), htyping.WebsocketAcceptEvent)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass(frozen=True)
|
|
243
|
+
class WebsocketReceiveEvent:
|
|
244
|
+
type: Literal["websocket.receive"]
|
|
245
|
+
bytes: bytes | None
|
|
246
|
+
text: str | None
|
|
247
|
+
|
|
248
|
+
def as_dict(self):
|
|
249
|
+
return validate(dataclasses.asdict(self), htyping.WebsocketReceiveEvent)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass(frozen=True)
|
|
253
|
+
class WebsocketSendEvent:
|
|
254
|
+
type: Literal["websocket.send"]
|
|
255
|
+
bytes: bytes | None
|
|
256
|
+
text: str | None
|
|
257
|
+
|
|
258
|
+
def as_dict(self):
|
|
259
|
+
return validate(dataclasses.asdict(self), htyping.WebsocketSendEvent)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@dataclass(frozen=True)
|
|
263
|
+
class WebsocketResponseStartEvent:
|
|
264
|
+
type: Literal["websocket.http.response.start"]
|
|
265
|
+
status: int
|
|
266
|
+
headers: Iterable[tuple[bytes, bytes]]
|
|
267
|
+
|
|
268
|
+
def as_dict(self):
|
|
269
|
+
return validate(dataclasses.asdict(self), htyping.WebsocketResponseStartEvent)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass(frozen=True)
|
|
273
|
+
class WebsocketResponseBodyEvent:
|
|
274
|
+
type: Literal["websocket.http.response.body"]
|
|
275
|
+
body: bytes
|
|
276
|
+
more_body: bool
|
|
277
|
+
|
|
278
|
+
def as_dict(self):
|
|
279
|
+
return validate(dataclasses.asdict(self), htyping.WebsocketResponseBodyEvent)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@dataclass(frozen=True)
|
|
283
|
+
class WebsocketDisconnectEvent:
|
|
284
|
+
type: Literal["websocket.disconnect"]
|
|
285
|
+
code: int
|
|
286
|
+
|
|
287
|
+
def as_dict(self):
|
|
288
|
+
return validate(dataclasses.asdict(self), htyping.WebsocketDisconnectEvent)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass(frozen=True)
|
|
292
|
+
class WebsocketCloseEvent:
|
|
293
|
+
type: Literal["websocket.close"]
|
|
294
|
+
code: int
|
|
295
|
+
reason: str | None
|
|
296
|
+
|
|
297
|
+
def as_dict(self):
|
|
298
|
+
return validate(dataclasses.asdict(self), htyping.WebsocketCloseEvent)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
WebsocketSendEventT: TypeAlias = (
|
|
302
|
+
WebsocketAcceptEvent
|
|
303
|
+
| WebsocketSendEvent
|
|
304
|
+
| WebsocketResponseBodyEvent
|
|
305
|
+
| WebsocketResponseStartEvent
|
|
306
|
+
| WebsocketCloseEvent
|
|
307
|
+
)
|
|
308
|
+
WebsocketSendCallable: TypeAlias = Callable[[WebsocketSendEventT], Awaitable[None]]
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
WebsocketReceiveEventT: TypeAlias = (
|
|
312
|
+
WebsocketConnectEvent | WebsocketReceiveEvent | WebsocketDisconnectEvent
|
|
313
|
+
)
|
|
314
|
+
WebsocketReceiveCallable: TypeAlias = Callable[[], Awaitable[WebsocketReceiveEventT]]
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
WWWScope: TypeAlias = HTTPScope | WebsocketScope
|
|
318
|
+
Scope: TypeAlias = HTTPScope | WebsocketScope | LifespanScope
|
|
319
|
+
|
|
320
|
+
WWWSendCallable: TypeAlias = HTTPSendCallable | WebsocketSendCallable
|
|
321
|
+
SendCallable: TypeAlias = (
|
|
322
|
+
HTTPSendCallable | WebsocketSendCallable | LifespanSendCallable
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
WWWReceiveCallable: TypeAlias = HTTPReceiveCallable | WebsocketReceiveCallable
|
|
326
|
+
ReceiveCallable: TypeAlias = (
|
|
327
|
+
HTTPReceiveCallable | WebsocketReceiveCallable | LifespanReceiveCallable
|
|
328
|
+
)
|
latch_asgi/auth.py
CHANGED
|
@@ -10,7 +10,7 @@ from jwt import PyJWKClient
|
|
|
10
10
|
from latch_o11y.o11y import app_tracer, trace_app_function
|
|
11
11
|
|
|
12
12
|
from .config import config
|
|
13
|
-
from .framework import HTTPErrorResponse
|
|
13
|
+
from .framework.http import HTTPErrorResponse
|
|
14
14
|
|
|
15
15
|
jwk_client = PyJWKClient("https://latchai.us.auth0.com/.well-known/jwks.json")
|
|
16
16
|
debug_authentication_header_regex = re.compile(
|
|
@@ -39,9 +39,11 @@ class _HTTPUnauthorized(HTTPErrorResponse):
|
|
|
39
39
|
def __init__(
|
|
40
40
|
self,
|
|
41
41
|
error_description: str,
|
|
42
|
-
error:
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
error: (
|
|
43
|
+
Literal["invalid_request"]
|
|
44
|
+
| Literal["invalid_token"]
|
|
45
|
+
| Literal["insufficient_scope"]
|
|
46
|
+
),
|
|
45
47
|
):
|
|
46
48
|
escaped_description = error_description.replace('"', '\\"')
|
|
47
49
|
super().__init__(
|
|
@@ -138,9 +140,7 @@ def get_signer_sub(auth_header: str) -> Authorization:
|
|
|
138
140
|
algorithms=["RS256", "HS256"],
|
|
139
141
|
# fixme(maximsmol): gut this abomination
|
|
140
142
|
audience=(
|
|
141
|
-
config.audience
|
|
142
|
-
if jwt_key != config.self_signed_jwk
|
|
143
|
-
else None
|
|
143
|
+
config.audience if jwt_key != config.self_signed_jwk else None
|
|
144
144
|
),
|
|
145
145
|
)
|
|
146
146
|
except jwt.exceptions.InvalidTokenError as e:
|
|
File without changes
|
|
@@ -1,25 +1,22 @@
|
|
|
1
1
|
from dataclasses import dataclass, field
|
|
2
|
-
from typing import
|
|
2
|
+
from typing import Generic, TypeVar
|
|
3
3
|
|
|
4
|
-
from latch_o11y.o11y import
|
|
5
|
-
AttributesDict,
|
|
6
|
-
app_tracer,
|
|
7
|
-
dict_to_attrs,
|
|
8
|
-
trace_app_function,
|
|
9
|
-
)
|
|
4
|
+
from latch_o11y.o11y import AttributesDict, app_tracer, trace_app_function
|
|
10
5
|
|
|
11
|
-
from
|
|
12
|
-
from
|
|
13
|
-
from .framework import HTTPMethod, current_http_request_span, receive_class_ext
|
|
6
|
+
from ..asgi_iface import WWWReceiveCallable, WWWScope, WWWSendCallable
|
|
7
|
+
from ..auth import Authorization, get_signer_sub
|
|
14
8
|
|
|
15
|
-
|
|
9
|
+
# todo(ayush): this sucks
|
|
10
|
+
Scope = TypeVar("Scope", bound=WWWScope)
|
|
11
|
+
Send = TypeVar("Send", bound=WWWSendCallable)
|
|
12
|
+
Receive = TypeVar("Receive", bound=WWWReceiveCallable)
|
|
16
13
|
|
|
17
14
|
|
|
18
15
|
@dataclass
|
|
19
|
-
class Context:
|
|
20
|
-
scope:
|
|
21
|
-
receive:
|
|
22
|
-
send:
|
|
16
|
+
class Context(Generic[Scope, Receive, Send]):
|
|
17
|
+
scope: Scope
|
|
18
|
+
receive: Receive
|
|
19
|
+
send: Send
|
|
23
20
|
|
|
24
21
|
auth: Authorization = field(default_factory=Authorization, init=False)
|
|
25
22
|
|
|
@@ -33,9 +30,6 @@ class Context:
|
|
|
33
30
|
if auth_header is not None:
|
|
34
31
|
self.auth = get_signer_sub(auth_header)
|
|
35
32
|
|
|
36
|
-
if self.auth.oauth_sub is not None:
|
|
37
|
-
current_http_request_span().set_attribute("enduser.id", self.auth.oauth_sub)
|
|
38
|
-
|
|
39
33
|
def header(self, x: str | bytes):
|
|
40
34
|
if isinstance(x, str):
|
|
41
35
|
x = x.encode("utf-8")
|
|
@@ -58,7 +52,7 @@ class Context:
|
|
|
58
52
|
return res.decode("latin-1")
|
|
59
53
|
|
|
60
54
|
def add_request_span_attrs(self, data: AttributesDict, prefix: str):
|
|
61
|
-
|
|
55
|
+
raise NotImplementedError()
|
|
62
56
|
|
|
63
57
|
@trace_app_function
|
|
64
58
|
def add_db_response(self, data: AttributesDict):
|
|
@@ -68,23 +62,3 @@ class Context:
|
|
|
68
62
|
# )
|
|
69
63
|
self.add_request_span_attrs(data, f"db.response.{self._db_response_idx}")
|
|
70
64
|
self._db_response_idx += 1
|
|
71
|
-
|
|
72
|
-
@trace_app_function
|
|
73
|
-
async def receive_request_payload(self, cls: type[T]) -> T:
|
|
74
|
-
json, res = await receive_class_ext(self.receive, cls)
|
|
75
|
-
|
|
76
|
-
# todo(maximsmol): datadog has shit support for events
|
|
77
|
-
# current_http_request_span().add_event(
|
|
78
|
-
# "request payload", dict_to_attrs(json, "data")
|
|
79
|
-
# )
|
|
80
|
-
self.add_request_span_attrs(json, "http.request_payload")
|
|
81
|
-
|
|
82
|
-
return res
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
HandlerResult: TypeAlias = Any | None
|
|
86
|
-
Handler: TypeAlias = Callable[
|
|
87
|
-
[Context],
|
|
88
|
-
Awaitable[HandlerResult],
|
|
89
|
-
]
|
|
90
|
-
Route: TypeAlias = Handler | tuple[list[HTTPMethod], Handler]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any, Awaitable, Callable, TypeAlias, TypeVar
|
|
3
|
+
|
|
4
|
+
from latch_o11y.o11y import AttributesDict, dict_to_attrs, trace_app_function
|
|
5
|
+
|
|
6
|
+
from ..asgi_iface import HTTPReceiveCallable, HTTPScope, HTTPSendCallable
|
|
7
|
+
from ..framework.http import HTTPMethod, current_http_request_span, receive_class_ext
|
|
8
|
+
from . import common
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Context(common.Context[HTTPScope, HTTPReceiveCallable, HTTPSendCallable]):
|
|
15
|
+
def __post_init__(self):
|
|
16
|
+
super().__post_init__()
|
|
17
|
+
|
|
18
|
+
if self.auth.oauth_sub is not None:
|
|
19
|
+
current_http_request_span().set_attribute("enduser.id", self.auth.oauth_sub)
|
|
20
|
+
|
|
21
|
+
def add_request_span_attrs(self, data: AttributesDict, prefix: str):
|
|
22
|
+
current_http_request_span().set_attributes(dict_to_attrs(data, prefix))
|
|
23
|
+
|
|
24
|
+
@trace_app_function
|
|
25
|
+
async def receive_request_payload(self, cls: type[T]) -> T:
|
|
26
|
+
json, res = await receive_class_ext(self.receive, cls)
|
|
27
|
+
|
|
28
|
+
# todo(maximsmol): datadog has shit support for events
|
|
29
|
+
# current_http_request_span().add_event(
|
|
30
|
+
# "request payload", dict_to_attrs(json, "data")
|
|
31
|
+
# )
|
|
32
|
+
self.add_request_span_attrs(json, "http.request_payload")
|
|
33
|
+
|
|
34
|
+
return res
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
HandlerResult: TypeAlias = Any | None
|
|
38
|
+
Handler: TypeAlias = Callable[
|
|
39
|
+
[Context],
|
|
40
|
+
Awaitable[HandlerResult],
|
|
41
|
+
]
|
|
42
|
+
Route: TypeAlias = Handler | tuple[list[HTTPMethod], Handler]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Awaitable, Callable, TypeAlias, TypeVar
|
|
3
|
+
|
|
4
|
+
from latch_o11y.o11y import AttributesDict, dict_to_attrs
|
|
5
|
+
|
|
6
|
+
from ..asgi_iface import WebsocketReceiveCallable, WebsocketScope, WebsocketSendCallable
|
|
7
|
+
from ..framework.websocket import WebsocketStatus, current_websocket_request_span
|
|
8
|
+
from . import common
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Context(
|
|
15
|
+
common.Context[WebsocketScope, WebsocketReceiveCallable, WebsocketSendCallable]
|
|
16
|
+
):
|
|
17
|
+
def __post_init__(self):
|
|
18
|
+
super().__post_init__()
|
|
19
|
+
|
|
20
|
+
if self.auth.oauth_sub is not None:
|
|
21
|
+
current_websocket_request_span().set_attribute(
|
|
22
|
+
"enduser.id", self.auth.oauth_sub
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def add_request_span_attrs(self, data: AttributesDict, prefix: str):
|
|
26
|
+
current_websocket_request_span().set_attributes(dict_to_attrs(data, prefix))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
HandlerResult = str | tuple[WebsocketStatus, str]
|
|
30
|
+
Handler: TypeAlias = Callable[
|
|
31
|
+
[Context],
|
|
32
|
+
Awaitable[HandlerResult],
|
|
33
|
+
]
|
|
34
|
+
Route: TypeAlias = Handler
|
|
File without changes
|
|
@@ -2,26 +2,21 @@ from http import HTTPStatus
|
|
|
2
2
|
from typing import Any, Literal, TypeAlias, TypeVar, cast
|
|
3
3
|
|
|
4
4
|
import opentelemetry.context as context
|
|
5
|
-
import
|
|
5
|
+
import orjson
|
|
6
6
|
from latch_data_validation.data_validation import DataValidationError, validate
|
|
7
7
|
from latch_o11y.o11y import trace_function, trace_function_with_span
|
|
8
|
-
from opentelemetry.trace import get_tracer
|
|
9
8
|
from opentelemetry.trace.span import Span
|
|
10
|
-
from orjson import dumps
|
|
11
9
|
|
|
12
|
-
from
|
|
10
|
+
from ..asgi_iface import (
|
|
13
11
|
HTTPReceiveCallable,
|
|
14
12
|
HTTPResponseBodyEvent,
|
|
15
13
|
HTTPResponseStartEvent,
|
|
16
14
|
HTTPSendCallable,
|
|
17
15
|
)
|
|
18
|
-
|
|
19
|
-
Headers: TypeAlias = dict[str | bytes, str | bytes]
|
|
16
|
+
from .common import Headers, tracer
|
|
20
17
|
|
|
21
18
|
T = TypeVar("T")
|
|
22
19
|
|
|
23
|
-
tracer = get_tracer(__name__)
|
|
24
|
-
|
|
25
20
|
HTTPMethod: TypeAlias = (
|
|
26
21
|
Literal["GET"]
|
|
27
22
|
| Literal["HEAD"]
|
|
@@ -48,7 +43,6 @@ def current_http_request_span() -> Span:
|
|
|
48
43
|
|
|
49
44
|
class HTTPErrorResponse(RuntimeError):
|
|
50
45
|
def __init__(self, status: HTTPStatus, data: Any, *, headers: Headers = {}):
|
|
51
|
-
super().__init__()
|
|
52
46
|
self.status = status
|
|
53
47
|
self.data = data
|
|
54
48
|
self.headers = headers
|
|
@@ -69,8 +63,7 @@ class HTTPForbidden(HTTPErrorResponse):
|
|
|
69
63
|
super().__init__(HTTPStatus.FORBIDDEN, data, headers=headers)
|
|
70
64
|
|
|
71
65
|
|
|
72
|
-
class HTTPConnectionClosedError(RuntimeError):
|
|
73
|
-
...
|
|
66
|
+
class HTTPConnectionClosedError(RuntimeError): ...
|
|
74
67
|
|
|
75
68
|
|
|
76
69
|
# >>> I/O
|
|
@@ -96,13 +89,7 @@ async def receive_class(receive: HTTPReceiveCallable, cls: type[T]) -> T:
|
|
|
96
89
|
|
|
97
90
|
@trace_function(tracer)
|
|
98
91
|
async def receive_json(receive: HTTPReceiveCallable) -> Any:
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
p = simdjson.Parser()
|
|
102
|
-
try:
|
|
103
|
-
return p.parse(res, True)
|
|
104
|
-
except ValueError as e:
|
|
105
|
-
raise HTTPBadRequest("Failed to parse JSON") from e
|
|
92
|
+
return orjson.loads(await receive_data(receive))
|
|
106
93
|
|
|
107
94
|
|
|
108
95
|
async def receive_data(receive: HTTPReceiveCallable):
|
|
@@ -127,7 +114,7 @@ async def receive_data(receive: HTTPReceiveCallable):
|
|
|
127
114
|
|
|
128
115
|
|
|
129
116
|
@trace_function_with_span(tracer)
|
|
130
|
-
async def
|
|
117
|
+
async def send_http_data(
|
|
131
118
|
s: Span,
|
|
132
119
|
send: HTTPSendCallable,
|
|
133
120
|
status: HTTPStatus,
|
|
@@ -178,8 +165,8 @@ async def send_json(
|
|
|
178
165
|
content_type: str = "application/json",
|
|
179
166
|
headers: Headers = {},
|
|
180
167
|
):
|
|
181
|
-
return await
|
|
182
|
-
send, status, dumps(data), content_type=content_type, headers=headers
|
|
168
|
+
return await send_http_data(
|
|
169
|
+
send, status, orjson.dumps(data), content_type=content_type, headers=headers
|
|
183
170
|
)
|
|
184
171
|
|
|
185
172
|
|
|
@@ -193,6 +180,6 @@ async def send_auto(
|
|
|
193
180
|
headers: Headers = {},
|
|
194
181
|
):
|
|
195
182
|
if isinstance(data, str) or isinstance(data, bytes):
|
|
196
|
-
return await
|
|
183
|
+
return await send_http_data(send, status, data, headers=headers)
|
|
197
184
|
|
|
198
185
|
return await send_json(send, status, data, headers=headers)
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Any, TypeVar, cast
|
|
3
|
+
|
|
4
|
+
import opentelemetry.context as context
|
|
5
|
+
from latch_data_validation.data_validation import DataValidationError, validate
|
|
6
|
+
from latch_o11y.o11y import trace_function, trace_function_with_span
|
|
7
|
+
from opentelemetry.trace.span import Span
|
|
8
|
+
import orjson
|
|
9
|
+
|
|
10
|
+
from ..asgi_iface import (
|
|
11
|
+
WebsocketAcceptEvent,
|
|
12
|
+
WebsocketCloseEvent,
|
|
13
|
+
WebsocketReceiveCallable,
|
|
14
|
+
WebsocketSendCallable,
|
|
15
|
+
WebsocketSendEvent,
|
|
16
|
+
)
|
|
17
|
+
from .common import Headers, tracer
|
|
18
|
+
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
|
|
21
|
+
websocket_request_span_key = context.create_key("websocket_request_span")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def current_websocket_request_span() -> Span:
|
|
25
|
+
return cast(Span, context.get_value(websocket_request_span_key))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# >>> Error classes
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class WebsocketStatus(int, Enum):
|
|
32
|
+
"""
|
|
33
|
+
https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
normal = 1000
|
|
37
|
+
"""
|
|
38
|
+
1000 indicates a normal closure, meaning that the purpose for
|
|
39
|
+
which the connection was established has been fulfilled.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
going_away = 1001
|
|
43
|
+
"""
|
|
44
|
+
1001 indicates that an endpoint is "going away", such as a server
|
|
45
|
+
going down or a browser having navigated away from a page.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
protocol_error = 1002
|
|
49
|
+
"""
|
|
50
|
+
1002 indicates that an endpoint is terminating the connection due
|
|
51
|
+
to a protocol error.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
unsupported = 1003
|
|
55
|
+
"""
|
|
56
|
+
1003 indicates that an endpoint is terminating the connection
|
|
57
|
+
because it has received a type of data it cannot accept (e.g., an
|
|
58
|
+
endpoint that understands only text data MAY send this if it
|
|
59
|
+
receives a binary message).
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
reserved = 1004
|
|
63
|
+
"""
|
|
64
|
+
Reserved. The specific meaning might be defined in the future.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
no_status = 1005
|
|
68
|
+
"""
|
|
69
|
+
1005 is a reserved value and MUST NOT be set as a status code in a
|
|
70
|
+
Close control frame by an endpoint. It is designated for use in
|
|
71
|
+
applications expecting a status code to indicate that no status
|
|
72
|
+
code was actually present.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
abnormal = 1006
|
|
76
|
+
"""
|
|
77
|
+
1006 is a reserved value and MUST NOT be set as a status code in a
|
|
78
|
+
Close control frame by an endpoint. It is designated for use in
|
|
79
|
+
applications expecting a status code to indicate that the
|
|
80
|
+
connection was closed abnormally, e.g., without sending or
|
|
81
|
+
receiving a Close control frame.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
unsupported_payload = 1007
|
|
85
|
+
"""
|
|
86
|
+
1007 indicates that an endpoint is terminating the connection
|
|
87
|
+
because it has received data within a message that was not
|
|
88
|
+
consistent with the type of the message (e.g., non-UTF-8 [RFC3629]
|
|
89
|
+
data within a text message).
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
policy_violation = 1008
|
|
93
|
+
"""
|
|
94
|
+
1008 indicates that an endpoint is terminating the connection
|
|
95
|
+
because it has received a message that violates its policy. This
|
|
96
|
+
is a generic status code that can be returned when there is no
|
|
97
|
+
other more suitable status code (e.g., 1003 or 1009) or if there
|
|
98
|
+
is a need to hide specific details about the policy.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
too_large = 1009
|
|
102
|
+
"""
|
|
103
|
+
1009 indicates that an endpoint is terminating the connection
|
|
104
|
+
because it has received a message that is too big for it to
|
|
105
|
+
process.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
mandatory_extension = 1010
|
|
109
|
+
"""
|
|
110
|
+
1010 indicates that an endpoint (client) is terminating the
|
|
111
|
+
connection because it has expected the server to negotiate one or
|
|
112
|
+
more extension, but the server didn't return them in the response
|
|
113
|
+
message of the WebSocket handshake. The list of extensions that
|
|
114
|
+
are needed SHOULD appear in the /reason/ part of the Close frame.
|
|
115
|
+
Note that this status code is not used by the server, because it
|
|
116
|
+
can fail the WebSocket handshake instead.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
server_error = 1011
|
|
120
|
+
"""
|
|
121
|
+
1011 indicates that a server is terminating the connection because
|
|
122
|
+
it encountered an unexpected condition that prevented it from
|
|
123
|
+
fulfilling the request.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
tls_handshake_fail = 1015
|
|
127
|
+
"""
|
|
128
|
+
1015 is a reserved value and MUST NOT be set as a status code in a
|
|
129
|
+
Close control frame by an endpoint. It is designated for use in
|
|
130
|
+
applications expecting a status code to indicate that the
|
|
131
|
+
connection was closed due to a failure to perform a TLS handshake
|
|
132
|
+
(e.g., the server certificate can't be verified).
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class WebsocketErrorResponse(RuntimeError):
|
|
137
|
+
def __init__(self, status: WebsocketStatus, data: Any, *, headers: Headers = {}):
|
|
138
|
+
super().__init__()
|
|
139
|
+
self.status = status
|
|
140
|
+
self.data = data
|
|
141
|
+
self.headers = headers
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class WebsocketBadMessage(WebsocketErrorResponse):
|
|
145
|
+
def __init__(self, data: Any, *, headers: Headers = {}):
|
|
146
|
+
super().__init__(WebsocketStatus.policy_violation, data, headers=headers)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class WebsocketInternalServerError(WebsocketErrorResponse):
|
|
150
|
+
def __init__(self, data: Any, *, headers: Headers = {}):
|
|
151
|
+
super().__init__(WebsocketStatus.server_error, data, headers=headers)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class WebsocketConnectionClosedError(RuntimeError): ...
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# >>> I/O
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def receive_websocket_data(receive: WebsocketReceiveCallable):
|
|
161
|
+
with tracer.start_as_current_span("read websocket message") as s:
|
|
162
|
+
msg = await receive()
|
|
163
|
+
|
|
164
|
+
s.set_attribute("type", msg.type)
|
|
165
|
+
|
|
166
|
+
if msg.type == "websocket.connect":
|
|
167
|
+
# todo(ayush): allow upgrades here as well?
|
|
168
|
+
raise WebsocketBadMessage("connection has already been established")
|
|
169
|
+
|
|
170
|
+
if msg.type == "websocket.disconnect":
|
|
171
|
+
raise WebsocketConnectionClosedError()
|
|
172
|
+
|
|
173
|
+
if msg.bytes is not None:
|
|
174
|
+
res = msg.bytes
|
|
175
|
+
elif msg.text is not None:
|
|
176
|
+
res = msg.text.encode("utf-8")
|
|
177
|
+
else:
|
|
178
|
+
raise WebsocketBadMessage("empty message")
|
|
179
|
+
|
|
180
|
+
s.set_attribute("size", len(res))
|
|
181
|
+
return res
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@trace_function(tracer)
|
|
185
|
+
async def receive_websocket_json(receive: WebsocketReceiveCallable) -> Any:
|
|
186
|
+
orjson.loads(await receive_websocket_data(receive))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@trace_function(tracer)
|
|
190
|
+
async def receive_websocket_class_ext(
|
|
191
|
+
receive: WebsocketReceiveCallable, cls: type[T]
|
|
192
|
+
) -> tuple[Any, T]:
|
|
193
|
+
data = await receive_websocket_json(receive)
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
return data, validate(data, cls)
|
|
197
|
+
except DataValidationError as e:
|
|
198
|
+
raise WebsocketBadMessage(e.json()) from None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@trace_function(tracer)
|
|
202
|
+
async def receive_websocket_class(receive: WebsocketReceiveCallable, cls: type[T]) -> T:
|
|
203
|
+
return (await receive_websocket_class_ext(receive, cls))[1]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@trace_function_with_span(tracer)
|
|
207
|
+
async def send_websocket_data(
|
|
208
|
+
s: Span,
|
|
209
|
+
send: WebsocketSendCallable,
|
|
210
|
+
data: str | bytes,
|
|
211
|
+
):
|
|
212
|
+
if isinstance(data, str):
|
|
213
|
+
data = data.encode("utf-8")
|
|
214
|
+
|
|
215
|
+
s.set_attribute("body.size", len(data))
|
|
216
|
+
|
|
217
|
+
await send(WebsocketSendEvent(type="websocket.send", bytes=data, text=None))
|
|
218
|
+
|
|
219
|
+
current_websocket_request_span().set_attribute(
|
|
220
|
+
"websocket.sent_message_content_length", len(data)
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@trace_function(tracer)
|
|
225
|
+
async def accept_websocket_connection(
|
|
226
|
+
send: WebsocketSendCallable,
|
|
227
|
+
receive: WebsocketReceiveCallable,
|
|
228
|
+
/,
|
|
229
|
+
*,
|
|
230
|
+
subprotocol: str | None = None,
|
|
231
|
+
headers: Headers = {},
|
|
232
|
+
):
|
|
233
|
+
msg = await receive()
|
|
234
|
+
if msg.type != "websocket.connect":
|
|
235
|
+
raise WebsocketBadMessage("cannot accept connection without connection request")
|
|
236
|
+
|
|
237
|
+
headers_to_send: list[tuple[bytes, bytes]] = []
|
|
238
|
+
|
|
239
|
+
for k, v in headers.items():
|
|
240
|
+
if isinstance(k, str):
|
|
241
|
+
k = k.encode("latin-1")
|
|
242
|
+
if isinstance(v, str):
|
|
243
|
+
v = v.encode("latin-1")
|
|
244
|
+
headers_to_send.append((k, v))
|
|
245
|
+
|
|
246
|
+
await send(
|
|
247
|
+
WebsocketAcceptEvent(
|
|
248
|
+
type="websocket.accept", subprotocol=subprotocol, headers=headers_to_send
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@trace_function_with_span(tracer)
|
|
254
|
+
async def close_websocket_connection(
|
|
255
|
+
s: Span,
|
|
256
|
+
send: WebsocketSendCallable,
|
|
257
|
+
/,
|
|
258
|
+
*,
|
|
259
|
+
status: WebsocketStatus,
|
|
260
|
+
data: str,
|
|
261
|
+
):
|
|
262
|
+
s.set_attribute("reason", data)
|
|
263
|
+
|
|
264
|
+
await send(WebsocketCloseEvent("websocket.close", status.value, data))
|
|
265
|
+
|
|
266
|
+
current_websocket_request_span().set_attribute("websocket.http.close_reason", data)
|
latch_asgi/server.py
CHANGED
|
@@ -2,17 +2,15 @@ import asyncio
|
|
|
2
2
|
import dataclasses
|
|
3
3
|
import traceback
|
|
4
4
|
from http import HTTPStatus
|
|
5
|
+
from typing import Awaitable, get_args
|
|
5
6
|
|
|
6
7
|
import opentelemetry.context as context
|
|
7
8
|
from hypercorn.typing import ASGIReceiveCallable, ASGISendCallable, Scope
|
|
8
|
-
from typing import Awaitable
|
|
9
9
|
from latch_data_validation.data_validation import untraced_validate, validate
|
|
10
10
|
from latch_o11y.o11y import log
|
|
11
11
|
from opentelemetry.propagate import set_global_textmap
|
|
12
12
|
from opentelemetry.trace import get_current_span, get_tracer
|
|
13
13
|
|
|
14
|
-
from .datadog_propagator import DDTraceContextTextMapPropagator
|
|
15
|
-
|
|
16
14
|
from .asgi_iface import (
|
|
17
15
|
HTTPDisconnectEvent,
|
|
18
16
|
HTTPReceiveCallable,
|
|
@@ -30,16 +28,31 @@ from .asgi_iface import (
|
|
|
30
28
|
LifespanStartupCompleteEvent,
|
|
31
29
|
LifespanStartupEvent,
|
|
32
30
|
LifespanStartupFailedEvent,
|
|
31
|
+
WebsocketReceiveCallable,
|
|
32
|
+
WebsocketReceiveEventT,
|
|
33
|
+
WebsocketScope,
|
|
34
|
+
WebsocketSendCallable,
|
|
35
|
+
WebsocketSendEventT,
|
|
33
36
|
type_str,
|
|
34
37
|
)
|
|
35
|
-
from .context import
|
|
36
|
-
from .
|
|
38
|
+
from .context import http, websocket
|
|
39
|
+
from .datadog_propagator import DDTraceContextTextMapPropagator
|
|
40
|
+
from .framework.http import (
|
|
37
41
|
HTTPErrorResponse,
|
|
38
42
|
HTTPInternalServerError,
|
|
39
43
|
current_http_request_span,
|
|
40
44
|
http_request_span_key,
|
|
41
45
|
send_auto,
|
|
42
|
-
|
|
46
|
+
send_http_data,
|
|
47
|
+
)
|
|
48
|
+
from .framework.websocket import (
|
|
49
|
+
WebsocketErrorResponse,
|
|
50
|
+
WebsocketInternalServerError,
|
|
51
|
+
WebsocketStatus,
|
|
52
|
+
accept_websocket_connection,
|
|
53
|
+
close_websocket_connection,
|
|
54
|
+
current_websocket_request_span,
|
|
55
|
+
websocket_request_span_key,
|
|
43
56
|
)
|
|
44
57
|
|
|
45
58
|
tracer = get_tracer(__name__)
|
|
@@ -47,18 +60,30 @@ tracer = get_tracer(__name__)
|
|
|
47
60
|
|
|
48
61
|
# todo(maximsmol): ASGI instrumentation should trace lifespan
|
|
49
62
|
|
|
63
|
+
|
|
50
64
|
class LatchASGIServer:
|
|
51
|
-
|
|
65
|
+
http_routes: dict[str, http.Route]
|
|
66
|
+
websocket_routes: dict[str, websocket.Route]
|
|
52
67
|
startup_tasks: list[Awaitable] = []
|
|
53
68
|
shutdown_tasks: list[Awaitable] = []
|
|
54
69
|
|
|
55
|
-
def __init__(
|
|
56
|
-
self
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
http_routes: dict[str, http.Route],
|
|
73
|
+
websocket_routes: dict[str, websocket.Route],
|
|
74
|
+
startup_tasks: list[Awaitable] = [],
|
|
75
|
+
shutdown_tasks: list[Awaitable] = [],
|
|
76
|
+
):
|
|
77
|
+
self.http_routes = http_routes
|
|
78
|
+
self.websocket_routes = websocket_routes
|
|
57
79
|
self.startup_tasks = startup_tasks
|
|
58
80
|
self.shutdown_tasks = shutdown_tasks
|
|
59
81
|
|
|
60
82
|
async def scope_lifespan(
|
|
61
|
-
self,
|
|
83
|
+
self,
|
|
84
|
+
scope: LifespanScope,
|
|
85
|
+
receive: LifespanReceiveCallable,
|
|
86
|
+
send: LifespanSendCallable,
|
|
62
87
|
):
|
|
63
88
|
await log.info(
|
|
64
89
|
f"Waiting for lifespan events (ASGI v{scope.asgi.version} @ spec"
|
|
@@ -77,7 +102,9 @@ class LatchASGIServer:
|
|
|
77
102
|
|
|
78
103
|
with tracer.start_as_current_span("send completion event"):
|
|
79
104
|
await send(
|
|
80
|
-
LifespanStartupCompleteEvent(
|
|
105
|
+
LifespanStartupCompleteEvent(
|
|
106
|
+
"lifespan.startup.complete"
|
|
107
|
+
)
|
|
81
108
|
)
|
|
82
109
|
except Exception as e:
|
|
83
110
|
with tracer.start_as_current_span("send failure event"):
|
|
@@ -95,7 +122,9 @@ class LatchASGIServer:
|
|
|
95
122
|
|
|
96
123
|
with tracer.start_as_current_span("send completion event"):
|
|
97
124
|
await send(
|
|
98
|
-
LifespanShutdownCompleteEvent(
|
|
125
|
+
LifespanShutdownCompleteEvent(
|
|
126
|
+
"lifespan.shutdown.complete"
|
|
127
|
+
)
|
|
99
128
|
)
|
|
100
129
|
except Exception as e:
|
|
101
130
|
with tracer.start_as_current_span("send failure event"):
|
|
@@ -109,6 +138,60 @@ class LatchASGIServer:
|
|
|
109
138
|
|
|
110
139
|
break
|
|
111
140
|
|
|
141
|
+
async def scope_websocket(
|
|
142
|
+
self,
|
|
143
|
+
scope: WebsocketScope,
|
|
144
|
+
receive: WebsocketReceiveCallable,
|
|
145
|
+
send: WebsocketSendCallable,
|
|
146
|
+
):
|
|
147
|
+
ctx_reset_token: object | None = None
|
|
148
|
+
try:
|
|
149
|
+
new_ctx = context.set_value(websocket_request_span_key, get_current_span())
|
|
150
|
+
ctx_reset_token = context.attach(new_ctx)
|
|
151
|
+
|
|
152
|
+
current_websocket_request_span().set_attribute("resource.name", scope.path)
|
|
153
|
+
|
|
154
|
+
handler = self.websocket_routes.get(scope.path)
|
|
155
|
+
if handler is None:
|
|
156
|
+
msg = f"Websocket {scope.path} not found"
|
|
157
|
+
|
|
158
|
+
await log.info(msg)
|
|
159
|
+
await close_websocket_connection(
|
|
160
|
+
send, status=WebsocketStatus.policy_violation, data=msg
|
|
161
|
+
)
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
await log.info(f"Websocket {scope.path}")
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
try:
|
|
168
|
+
ctx = websocket.Context(scope, receive, send)
|
|
169
|
+
|
|
170
|
+
await accept_websocket_connection(ctx.send, ctx.receive)
|
|
171
|
+
res = await handler(ctx)
|
|
172
|
+
|
|
173
|
+
if isinstance(res, tuple):
|
|
174
|
+
status, data = res
|
|
175
|
+
else:
|
|
176
|
+
status = WebsocketStatus.normal
|
|
177
|
+
data = res
|
|
178
|
+
|
|
179
|
+
except WebsocketErrorResponse as e:
|
|
180
|
+
raise e
|
|
181
|
+
except Exception as e:
|
|
182
|
+
raise WebsocketInternalServerError(str(e)) from e
|
|
183
|
+
except WebsocketErrorResponse as e:
|
|
184
|
+
await close_websocket_connection(
|
|
185
|
+
send, status=WebsocketStatus.server_error, data=str(e.data)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if e.status == HTTPStatus.INTERNAL_SERVER_ERROR:
|
|
189
|
+
traceback.print_exc()
|
|
190
|
+
else:
|
|
191
|
+
await close_websocket_connection(send, status=status, data=data)
|
|
192
|
+
finally:
|
|
193
|
+
if ctx_reset_token is not None:
|
|
194
|
+
context.detach(ctx_reset_token)
|
|
112
195
|
|
|
113
196
|
async def scope_http(
|
|
114
197
|
self, scope: HTTPScope, receive: HTTPReceiveCallable, send: HTTPSendCallable
|
|
@@ -122,7 +205,7 @@ class LatchASGIServer:
|
|
|
122
205
|
await log.info(f"{scope.method} {scope.path}")
|
|
123
206
|
|
|
124
207
|
with tracer.start_as_current_span("find route handler"):
|
|
125
|
-
route = self.
|
|
208
|
+
route = self.http_routes.get(scope.path)
|
|
126
209
|
|
|
127
210
|
if not isinstance(route, tuple):
|
|
128
211
|
methods = ["POST"]
|
|
@@ -138,7 +221,7 @@ class LatchASGIServer:
|
|
|
138
221
|
else:
|
|
139
222
|
methods_str = ", and ".join([", ".join(methods[:-1]), methods[-1]])
|
|
140
223
|
|
|
141
|
-
await
|
|
224
|
+
await send_http_data(
|
|
142
225
|
send,
|
|
143
226
|
HTTPStatus.METHOD_NOT_ALLOWED,
|
|
144
227
|
f"Only {methods_str} requests are supported",
|
|
@@ -148,12 +231,12 @@ class LatchASGIServer:
|
|
|
148
231
|
if handler is None:
|
|
149
232
|
# todo(maximsmol): better error message
|
|
150
233
|
await log.info("Not found")
|
|
151
|
-
await
|
|
234
|
+
await send_http_data(send, HTTPStatus.NOT_FOUND, "Not found")
|
|
152
235
|
return
|
|
153
236
|
|
|
154
237
|
try:
|
|
155
238
|
try:
|
|
156
|
-
ctx = Context(scope, receive, send)
|
|
239
|
+
ctx = http.Context(scope, receive, send)
|
|
157
240
|
res = await handler(ctx)
|
|
158
241
|
|
|
159
242
|
if res is not None:
|
|
@@ -175,8 +258,9 @@ class LatchASGIServer:
|
|
|
175
258
|
if ctx_reset_token is not None:
|
|
176
259
|
context.detach(ctx_reset_token)
|
|
177
260
|
|
|
178
|
-
|
|
179
|
-
|
|
261
|
+
async def raw_app(
|
|
262
|
+
self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable
|
|
263
|
+
):
|
|
180
264
|
try:
|
|
181
265
|
if scope["type"] == "lifespan":
|
|
182
266
|
|
|
@@ -189,7 +273,9 @@ class LatchASGIServer:
|
|
|
189
273
|
if x["type"] == type_str(LifespanShutdownEvent):
|
|
190
274
|
return untraced_validate(x, LifespanShutdownEvent)
|
|
191
275
|
|
|
192
|
-
raise RuntimeError(
|
|
276
|
+
raise RuntimeError(
|
|
277
|
+
f"unknown lifespan event type: {repr(x['type'])}"
|
|
278
|
+
)
|
|
193
279
|
|
|
194
280
|
async def ls_send(e: LifespanSendEvent):
|
|
195
281
|
data = dataclasses.asdict(e)
|
|
@@ -199,6 +285,29 @@ class LatchASGIServer:
|
|
|
199
285
|
untraced_validate(scope, LifespanScope), ls_receive, ls_send
|
|
200
286
|
)
|
|
201
287
|
|
|
288
|
+
if scope["type"] == "websocket":
|
|
289
|
+
|
|
290
|
+
async def ws_receive():
|
|
291
|
+
x = await receive()
|
|
292
|
+
|
|
293
|
+
for e in get_args(WebsocketReceiveEventT):
|
|
294
|
+
if x["type"] != type_str(e):
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
return untraced_validate(x, e)
|
|
298
|
+
|
|
299
|
+
raise RuntimeError(
|
|
300
|
+
f"unknown websocket event type: {repr(x['type'])}"
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
async def ws_send(e: WebsocketSendEventT):
|
|
304
|
+
data = dataclasses.asdict(e)
|
|
305
|
+
await send(data)
|
|
306
|
+
|
|
307
|
+
return await self.scope_websocket(
|
|
308
|
+
untraced_validate(scope, WebsocketScope), ws_receive, ws_send
|
|
309
|
+
)
|
|
310
|
+
|
|
202
311
|
if scope["type"] == "http":
|
|
203
312
|
|
|
204
313
|
async def http_receive():
|
|
@@ -216,7 +325,9 @@ class LatchASGIServer:
|
|
|
216
325
|
data = dataclasses.asdict(e)
|
|
217
326
|
await send(data)
|
|
218
327
|
|
|
219
|
-
return await self.scope_http(
|
|
328
|
+
return await self.scope_http(
|
|
329
|
+
validate(scope, HTTPScope), http_receive, http_send
|
|
330
|
+
)
|
|
220
331
|
|
|
221
332
|
raise RuntimeError(f"unsupported protocol: {repr(scope['type'])}")
|
|
222
333
|
except Exception as e:
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: latch-asgi
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: ASGI python server
|
|
5
|
+
Author-Email: Max Smolin <max@latch.bio>
|
|
6
|
+
License: CC0-1.0
|
|
7
|
+
Requires-Python: <4.0,>=3.11
|
|
8
|
+
Requires-Dist: hypercorn[uvloop]<1.0.0,>=0.14.3
|
|
9
|
+
Requires-Dist: latch-data-validation<1.0.0,>=0.1.3
|
|
10
|
+
Requires-Dist: latch-o11y<1.0.0,>=0.1.4
|
|
11
|
+
Requires-Dist: latch-config<1.0.0,>=0.1.6
|
|
12
|
+
Requires-Dist: PyJWT[crypto]<3.0.0,>=2.6.0
|
|
13
|
+
Requires-Dist: orjson<4.0.0,>=3.8.5
|
|
14
|
+
Requires-Dist: opentelemetry-sdk<2.0.0,>=1.15.0
|
|
15
|
+
Requires-Dist: opentelemetry-api<2.0.0,>=1.15.0
|
|
16
|
+
Requires-Dist: opentelemetry-instrumentation-asgi<1.0,>=0.36b0
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# python-asgi
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
latch_asgi-0.3.0.dist-info/METADATA,sha256=x1WxOrLQcsLzJzLEaFCl6yCsK0AkZ8EHdhp82vIYYPo,643
|
|
2
|
+
latch_asgi-0.3.0.dist-info/WHEEL,sha256=N2J68yzZqJh3mI_Wg92rwhw0rtJDFpZj9bwQIMJgaVg,90
|
|
3
|
+
latch_asgi-0.3.0.dist-info/licenses/COPYING,sha256=ogEPNDSH0_dhiv_lT3ifVIdgIzHAqNA_SemnxUfPBJk,7048
|
|
4
|
+
latch_asgi/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
latch_asgi/asgi_iface.py,sha256=8cltPG2YEf20RWcMpn2FE5g4wtauiUNNlrVnD4UYDqc,8397
|
|
6
|
+
latch_asgi/auth.py,sha256=gXGVIzsvDt_kBlcS_Sx9939iGlNNs1i8KDOs-tpEBiw,4996
|
|
7
|
+
latch_asgi/config.py,sha256=F-78OqEGqrYoDKAdo5OVWwLB8cm6A2qvAC0xJQUufy0,241
|
|
8
|
+
latch_asgi/context/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
latch_asgi/context/common.py,sha256=EQeVLIbdfX3m5tLkLdzLynEvPUKrN2HWuvjhDrYwpoo,2029
|
|
10
|
+
latch_asgi/context/http.py,sha256=-zwxko3ao1mirdfJLcOacQRCOW1U1bXr40HEA_50Z24,1424
|
|
11
|
+
latch_asgi/context/websocket.py,sha256=NA0pJYdJI2kUnSu1AkPnf9heG8_B4M4_op7S8MPlyT4,1024
|
|
12
|
+
latch_asgi/datadog_propagator.py,sha256=PFKaM87B8Ia_5RBNGIIDSgcgTjxhN0uJou2o8359kzs,3306
|
|
13
|
+
latch_asgi/framework/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
latch_asgi/framework/common.py,sha256=My4ePrmLD8aZd7_PC-ZJPZ9CUD90j5UZvHwA0jfTmFw,157
|
|
15
|
+
latch_asgi/framework/http.py,sha256=8AMzA1FjyaFVexVPQbi1NBdbWaH6594R5AAsjoSL67Y,4992
|
|
16
|
+
latch_asgi/framework/websocket.py,sha256=Xyf4EUurKPxdHGV3CXUpCMLYCE41h_zz9GjJaR3NZgw,7993
|
|
17
|
+
latch_asgi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
+
latch_asgi/server.py,sha256=kMZ7ivOk7VkZ5HjrIHcveXd2ZMawnnW7QM5-JuHDXZA,12047
|
|
19
|
+
latch_asgi-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Creative Commons Legal Code
|
|
2
|
+
|
|
3
|
+
CC0 1.0 Universal
|
|
4
|
+
|
|
5
|
+
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
|
|
6
|
+
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
|
|
7
|
+
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
|
|
8
|
+
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
|
|
9
|
+
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
|
|
10
|
+
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
|
|
11
|
+
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
|
|
12
|
+
HEREUNDER.
|
|
13
|
+
|
|
14
|
+
Statement of Purpose
|
|
15
|
+
|
|
16
|
+
The laws of most jurisdictions throughout the world automatically confer
|
|
17
|
+
exclusive Copyright and Related Rights (defined below) upon the creator
|
|
18
|
+
and subsequent owner(s) (each and all, an "owner") of an original work of
|
|
19
|
+
authorship and/or a database (each, a "Work").
|
|
20
|
+
|
|
21
|
+
Certain owners wish to permanently relinquish those rights to a Work for
|
|
22
|
+
the purpose of contributing to a commons of creative, cultural and
|
|
23
|
+
scientific works ("Commons") that the public can reliably and without fear
|
|
24
|
+
of later claims of infringement build upon, modify, incorporate in other
|
|
25
|
+
works, reuse and redistribute as freely as possible in any form whatsoever
|
|
26
|
+
and for any purposes, including without limitation commercial purposes.
|
|
27
|
+
These owners may contribute to the Commons to promote the ideal of a free
|
|
28
|
+
culture and the further production of creative, cultural and scientific
|
|
29
|
+
works, or to gain reputation or greater distribution for their Work in
|
|
30
|
+
part through the use and efforts of others.
|
|
31
|
+
|
|
32
|
+
For these and/or other purposes and motivations, and without any
|
|
33
|
+
expectation of additional consideration or compensation, the person
|
|
34
|
+
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
|
|
35
|
+
is an owner of Copyright and Related Rights in the Work, voluntarily
|
|
36
|
+
elects to apply CC0 to the Work and publicly distribute the Work under its
|
|
37
|
+
terms, with knowledge of his or her Copyright and Related Rights in the
|
|
38
|
+
Work and the meaning and intended legal effect of CC0 on those rights.
|
|
39
|
+
|
|
40
|
+
1. Copyright and Related Rights. A Work made available under CC0 may be
|
|
41
|
+
protected by copyright and related or neighboring rights ("Copyright and
|
|
42
|
+
Related Rights"). Copyright and Related Rights include, but are not
|
|
43
|
+
limited to, the following:
|
|
44
|
+
|
|
45
|
+
i. the right to reproduce, adapt, distribute, perform, display,
|
|
46
|
+
communicate, and translate a Work;
|
|
47
|
+
ii. moral rights retained by the original author(s) and/or performer(s);
|
|
48
|
+
iii. publicity and privacy rights pertaining to a person's image or
|
|
49
|
+
likeness depicted in a Work;
|
|
50
|
+
iv. rights protecting against unfair competition in regards to a Work,
|
|
51
|
+
subject to the limitations in paragraph 4(a), below;
|
|
52
|
+
v. rights protecting the extraction, dissemination, use and reuse of data
|
|
53
|
+
in a Work;
|
|
54
|
+
vi. database rights (such as those arising under Directive 96/9/EC of the
|
|
55
|
+
European Parliament and of the Council of 11 March 1996 on the legal
|
|
56
|
+
protection of databases, and under any national implementation
|
|
57
|
+
thereof, including any amended or successor version of such
|
|
58
|
+
directive); and
|
|
59
|
+
vii. other similar, equivalent or corresponding rights throughout the
|
|
60
|
+
world based on applicable law or treaty, and any national
|
|
61
|
+
implementations thereof.
|
|
62
|
+
|
|
63
|
+
2. Waiver. To the greatest extent permitted by, but not in contravention
|
|
64
|
+
of, applicable law, Affirmer hereby overtly, fully, permanently,
|
|
65
|
+
irrevocably and unconditionally waives, abandons, and surrenders all of
|
|
66
|
+
Affirmer's Copyright and Related Rights and associated claims and causes
|
|
67
|
+
of action, whether now known or unknown (including existing as well as
|
|
68
|
+
future claims and causes of action), in the Work (i) in all territories
|
|
69
|
+
worldwide, (ii) for the maximum duration provided by applicable law or
|
|
70
|
+
treaty (including future time extensions), (iii) in any current or future
|
|
71
|
+
medium and for any number of copies, and (iv) for any purpose whatsoever,
|
|
72
|
+
including without limitation commercial, advertising or promotional
|
|
73
|
+
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
|
|
74
|
+
member of the public at large and to the detriment of Affirmer's heirs and
|
|
75
|
+
successors, fully intending that such Waiver shall not be subject to
|
|
76
|
+
revocation, rescission, cancellation, termination, or any other legal or
|
|
77
|
+
equitable action to disrupt the quiet enjoyment of the Work by the public
|
|
78
|
+
as contemplated by Affirmer's express Statement of Purpose.
|
|
79
|
+
|
|
80
|
+
3. Public License Fallback. Should any part of the Waiver for any reason
|
|
81
|
+
be judged legally invalid or ineffective under applicable law, then the
|
|
82
|
+
Waiver shall be preserved to the maximum extent permitted taking into
|
|
83
|
+
account Affirmer's express Statement of Purpose. In addition, to the
|
|
84
|
+
extent the Waiver is so judged Affirmer hereby grants to each affected
|
|
85
|
+
person a royalty-free, non transferable, non sublicensable, non exclusive,
|
|
86
|
+
irrevocable and unconditional license to exercise Affirmer's Copyright and
|
|
87
|
+
Related Rights in the Work (i) in all territories worldwide, (ii) for the
|
|
88
|
+
maximum duration provided by applicable law or treaty (including future
|
|
89
|
+
time extensions), (iii) in any current or future medium and for any number
|
|
90
|
+
of copies, and (iv) for any purpose whatsoever, including without
|
|
91
|
+
limitation commercial, advertising or promotional purposes (the
|
|
92
|
+
"License"). The License shall be deemed effective as of the date CC0 was
|
|
93
|
+
applied by Affirmer to the Work. Should any part of the License for any
|
|
94
|
+
reason be judged legally invalid or ineffective under applicable law, such
|
|
95
|
+
partial invalidity or ineffectiveness shall not invalidate the remainder
|
|
96
|
+
of the License, and in such case Affirmer hereby affirms that he or she
|
|
97
|
+
will not (i) exercise any of his or her remaining Copyright and Related
|
|
98
|
+
Rights in the Work or (ii) assert any associated claims and causes of
|
|
99
|
+
action with respect to the Work, in either case contrary to Affirmer's
|
|
100
|
+
express Statement of Purpose.
|
|
101
|
+
|
|
102
|
+
4. Limitations and Disclaimers.
|
|
103
|
+
|
|
104
|
+
a. No trademark or patent rights held by Affirmer are waived, abandoned,
|
|
105
|
+
surrendered, licensed or otherwise affected by this document.
|
|
106
|
+
b. Affirmer offers the Work as-is and makes no representations or
|
|
107
|
+
warranties of any kind concerning the Work, express, implied,
|
|
108
|
+
statutory or otherwise, including without limitation warranties of
|
|
109
|
+
title, merchantability, fitness for a particular purpose, non
|
|
110
|
+
infringement, or the absence of latent or other defects, accuracy, or
|
|
111
|
+
the present or absence of errors, whether or not discoverable, all to
|
|
112
|
+
the greatest extent permissible under applicable law.
|
|
113
|
+
c. Affirmer disclaims responsibility for clearing rights of other persons
|
|
114
|
+
that may apply to the Work or any use thereof, including without
|
|
115
|
+
limitation any person's Copyright and Related Rights in the Work.
|
|
116
|
+
Further, Affirmer disclaims responsibility for obtaining any necessary
|
|
117
|
+
consents, permissions or other rights required for any use of the
|
|
118
|
+
Work.
|
|
119
|
+
d. Affirmer understands and acknowledges that Creative Commons is not a
|
|
120
|
+
party to this document and has no duty or obligation with respect to
|
|
121
|
+
this CC0 or use of the Work.
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.1
|
|
2
|
-
Name: latch-asgi
|
|
3
|
-
Version: 0.1.3
|
|
4
|
-
Summary: ASGI python server
|
|
5
|
-
License: CC0 1.0
|
|
6
|
-
Author: Max Smolin
|
|
7
|
-
Author-email: max@latch.bio
|
|
8
|
-
Requires-Python: >=3.11,<4.0
|
|
9
|
-
Classifier: License :: Other/Proprietary License
|
|
10
|
-
Classifier: Programming Language :: Python :: 3
|
|
11
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
-
Requires-Dist: PyJWT[crypto] (>=2.6.0,<3.0.0)
|
|
13
|
-
Requires-Dist: hypercorn[uvloop] (>=0.14.3,<0.15.0)
|
|
14
|
-
Requires-Dist: latch-config (>=0.1.6,<0.2.0)
|
|
15
|
-
Requires-Dist: latch-data-validation (>=0.1.3,<0.2.0)
|
|
16
|
-
Requires-Dist: latch-o11y (>=0.1.4,<0.2.0)
|
|
17
|
-
Requires-Dist: opentelemetry-api (>=1.15.0,<2.0.0)
|
|
18
|
-
Requires-Dist: opentelemetry-instrumentation-asgi (>=0.36b0,<0.37)
|
|
19
|
-
Requires-Dist: opentelemetry-sdk (>=1.15.0,<2.0.0)
|
|
20
|
-
Requires-Dist: orjson (>=3.8.5,<4.0.0)
|
|
21
|
-
Requires-Dist: pysimdjson (>=5.0.2,<6.0.0)
|
|
22
|
-
Description-Content-Type: text/markdown
|
|
23
|
-
|
|
24
|
-
# python-asgi
|
|
25
|
-
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
latch_asgi/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
latch_asgi/asgi_iface.py,sha256=yDSA2lyymjv66oGjRmweFPy8M48uUe0zNdz4-5c0cMU,5002
|
|
3
|
-
latch_asgi/auth.py,sha256=3ROSIoOTaxJvWB8gGEdLWAK8XEvlbF8GlWwXb8StzjA,4999
|
|
4
|
-
latch_asgi/config.py,sha256=F-78OqEGqrYoDKAdo5OVWwLB8cm6A2qvAC0xJQUufy0,241
|
|
5
|
-
latch_asgi/context.py,sha256=3WQG9-EGzKrDFPw1ASQiG70rrthJic85d-x0DdvEJp4,2802
|
|
6
|
-
latch_asgi/datadog_propagator.py,sha256=PFKaM87B8Ia_5RBNGIIDSgcgTjxhN0uJou2o8359kzs,3306
|
|
7
|
-
latch_asgi/framework.py,sha256=kMlTEkGwfboc_8yCcKGKtqH5_8ElN16FRhoIg1jNeE0,5261
|
|
8
|
-
latch_asgi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
-
latch_asgi/server.py,sha256=oZHDIaDPPChvEE5jmE2a4u9jM3uudA5-wNQdMDxSzdE,8366
|
|
10
|
-
latch_asgi-0.1.3.dist-info/WHEEL,sha256=vVCvjcmxuUltf8cYhJ0sJMRDLr1XsPuxEId8YDzbyCY,88
|
|
11
|
-
latch_asgi-0.1.3.dist-info/METADATA,sha256=bSPFmNOLkbSgZoS2XF5FKaQLwErPXk29EJTzuCyPLcQ,870
|
|
12
|
-
latch_asgi-0.1.3.dist-info/RECORD,,
|