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 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: Literal["invalid_request"]
43
- | Literal["invalid_token"]
44
- | Literal["insufficient_scope"],
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 Any, Awaitable, Callable, TypeAlias, TypeVar
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 .asgi_iface import HTTPReceiveCallable, HTTPScope, HTTPSendCallable
12
- from .auth import Authorization, get_signer_sub
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
- T = TypeVar("T")
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: HTTPScope
21
- receive: HTTPReceiveCallable
22
- send: HTTPSendCallable
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
- current_http_request_span().set_attributes(dict_to_attrs(data, prefix))
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
@@ -0,0 +1,7 @@
1
+ from typing import TypeAlias
2
+
3
+ from opentelemetry.trace import get_tracer
4
+
5
+ Headers: TypeAlias = dict[str | bytes, str | bytes]
6
+
7
+ tracer = get_tracer(__name__)
@@ -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 simdjson
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 .asgi_iface import (
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
- res = await receive_data(receive)
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 send_data(
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 send_data(
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 send_data(send, status, data, headers=headers)
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 Context, Route
36
- from .framework import (
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
- send_data,
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
- routes: dict[str, Route]
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__(self, routes: dict[str, Route], startup_tasks: list[Awaitable] = [], shutdown_tasks: list[Awaitable] = []):
56
- self.routes = routes
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, scope: LifespanScope, receive: LifespanReceiveCallable, send: LifespanSendCallable
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("lifespan.startup.complete")
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("lifespan.shutdown.complete")
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.routes.get(scope.path)
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 send_data(
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 send_data(send, HTTPStatus.NOT_FOUND, "Not found")
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
- async def raw_app(self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
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(f"unknown lifespan event type: {repr(x['type'])}")
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(validate(scope, HTTPScope), http_receive, http_send)
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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 1.4.0
2
+ Generator: pdm-backend (2.1.8)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -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,,