without-asgi 0.0.1__tar.gz

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.
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: without-asgi
3
+ Version: 0.0.1
4
+ Summary: without adapters that turn an ASGI app's receive/send into typed event streams and back.
5
+ Author: Josh Karpel
6
+ Author-email: Josh Karpel <josh.karpel@gmail.com>
7
+ License-Expression: MIT
8
+ Classifier: Development Status :: 2 - Pre-Alpha
9
+ Classifier: Framework :: AsyncIO
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Topic :: Internet :: WWW/HTTP
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Classifier: Typing :: Typed
18
+ Requires-Dist: without-core==0.0.1
19
+ Requires-Python: >=3.14
20
+ Description-Content-Type: text/markdown
21
+
22
+ # without-asgi
23
+
24
+ `without` adapters that turn an [ASGI](https://asgi.readthedocs.io/) application's
25
+ `receive`/`send` into typed event streams and back. This package is *only* the
26
+ boundary: it parses raw ASGI
27
+ event dicts into typed values, encodes typed values back into the dicts a server
28
+ expects, and exposes `receive` as a `Stream` and `send` as a `Sink`. Routing,
29
+ middleware, and handlers are left to the application. The one piece of protocol
30
+ the adapter does drive is lifespan, because that is boundary work, not app policy.
31
+
32
+ An ASGI app is `async def app(scope, receive, send)`. The adapters let the body
33
+ of that callable read as plain `without` wiring:
34
+
35
+ ```python
36
+ from without_asgi import http_inbound, http_outbound, parse_http_scope
37
+
38
+ async def app(scope, receive, send):
39
+ head = parse_http_scope(scope)
40
+ handler = select(head) # your routing, your processor
41
+ outbound = handler(http_inbound(receive)) # Stream[Inbound] -> Stream[Outbound]
42
+ await http_outbound(send)(outbound) # drive ASGI send
43
+ ```
44
+
45
+ `make_asgi_app(lifespan, http=..., websocket=...)` builds the ASGI app, driving
46
+ the lifespan protocol and wiring each connection's `receive`/`send` around the
47
+ `Processor` a router selects. The same typed vocabulary parses and encodes in
48
+ *both* directions, so a transport that owns the wire (like
49
+ [`without-http`](../without-http)) can talk ASGI to any app in typed values; and
50
+ the optional `without_asgi.routing` submodule ships the unopinionated
51
+ `Middleware` / `stack` / `wrap` / `buffered` tools you assemble a router from.
52
+
53
+ For a full, opinionated router you don't have to hand-roll, the sibling
54
+ [`without-web`](../without-web) package snaps onto this boundary through nothing
55
+ but the `HttpRouter` type.
56
+
57
+ See the
58
+ [`without-asgi` guide](https://without.help/guides/without-asgi/)
59
+ (with the [API reference](https://without.help/reference/without_asgi/))
60
+ for the full surface, including the codec's server direction and the middleware
61
+ body shapes.
@@ -0,0 +1,40 @@
1
+ # without-asgi
2
+
3
+ `without` adapters that turn an [ASGI](https://asgi.readthedocs.io/) application's
4
+ `receive`/`send` into typed event streams and back. This package is *only* the
5
+ boundary: it parses raw ASGI
6
+ event dicts into typed values, encodes typed values back into the dicts a server
7
+ expects, and exposes `receive` as a `Stream` and `send` as a `Sink`. Routing,
8
+ middleware, and handlers are left to the application. The one piece of protocol
9
+ the adapter does drive is lifespan, because that is boundary work, not app policy.
10
+
11
+ An ASGI app is `async def app(scope, receive, send)`. The adapters let the body
12
+ of that callable read as plain `without` wiring:
13
+
14
+ ```python
15
+ from without_asgi import http_inbound, http_outbound, parse_http_scope
16
+
17
+ async def app(scope, receive, send):
18
+ head = parse_http_scope(scope)
19
+ handler = select(head) # your routing, your processor
20
+ outbound = handler(http_inbound(receive)) # Stream[Inbound] -> Stream[Outbound]
21
+ await http_outbound(send)(outbound) # drive ASGI send
22
+ ```
23
+
24
+ `make_asgi_app(lifespan, http=..., websocket=...)` builds the ASGI app, driving
25
+ the lifespan protocol and wiring each connection's `receive`/`send` around the
26
+ `Processor` a router selects. The same typed vocabulary parses and encodes in
27
+ *both* directions, so a transport that owns the wire (like
28
+ [`without-http`](../without-http)) can talk ASGI to any app in typed values; and
29
+ the optional `without_asgi.routing` submodule ships the unopinionated
30
+ `Middleware` / `stack` / `wrap` / `buffered` tools you assemble a router from.
31
+
32
+ For a full, opinionated router you don't have to hand-roll, the sibling
33
+ [`without-web`](../without-web) package snaps onto this boundary through nothing
34
+ but the `HttpRouter` type.
35
+
36
+ See the
37
+ [`without-asgi` guide](https://without.help/guides/without-asgi/)
38
+ (with the [API reference](https://without.help/reference/without_asgi/))
39
+ for the full surface, including the codec's server direction and the middleware
40
+ body shapes.
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.11.25,<0.12"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "without-asgi"
7
+ version = "0.0.1"
8
+ description = "without adapters that turn an ASGI app's receive/send into typed event streams and back."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ authors = [
12
+ { name = "Josh Karpel", email = "josh.karpel@gmail.com" },
13
+ ]
14
+ requires-python = ">=3.14"
15
+ classifiers = [
16
+ "Development Status :: 2 - Pre-Alpha",
17
+ "Framework :: AsyncIO",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Topic :: Internet :: WWW/HTTP",
24
+ "Topic :: Software Development :: Libraries",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = [
28
+ "without-core==0.0.1",
29
+ ]
30
+
31
+ [tool.uv.sources]
32
+ without-core = { workspace = true }
@@ -0,0 +1,173 @@
1
+ from without_asgi.app import HttpHandler
2
+ from without_asgi.app import HttpRouter
3
+ from without_asgi.app import Lifespan
4
+ from without_asgi.app import WebsocketHandler
5
+ from without_asgi.app import WebsocketRouter
6
+ from without_asgi.app import make_asgi_app
7
+ from without_asgi.app import refuse_http
8
+ from without_asgi.app import refuse_websocket
9
+ from without_asgi.inbound import Disconnect
10
+ from without_asgi.inbound import Inbound
11
+ from without_asgi.inbound import LifespanEvent
12
+ from without_asgi.inbound import RequestBody
13
+ from without_asgi.inbound import Shutdown
14
+ from without_asgi.inbound import Startup
15
+ from without_asgi.inbound import WebsocketConnect
16
+ from without_asgi.inbound import WebsocketDisconnect
17
+ from without_asgi.inbound import WebsocketInbound
18
+ from without_asgi.inbound import WebsocketReceive
19
+ from without_asgi.inbound import encode_inbound
20
+ from without_asgi.inbound import encode_lifespan_event
21
+ from without_asgi.inbound import encode_websocket_inbound
22
+ from without_asgi.inbound import parse_inbound
23
+ from without_asgi.inbound import parse_lifespan_event
24
+ from without_asgi.inbound import parse_websocket_inbound
25
+ from without_asgi.outbound import EarlyHint
26
+ from without_asgi.outbound import LifespanReply
27
+ from without_asgi.outbound import Outbound
28
+ from without_asgi.outbound import PathSend
29
+ from without_asgi.outbound import Response
30
+ from without_asgi.outbound import ResponseBody
31
+ from without_asgi.outbound import ResponseDebug
32
+ from without_asgi.outbound import ResponseStart
33
+ from without_asgi.outbound import ResponseTrailers
34
+ from without_asgi.outbound import ServerPush
35
+ from without_asgi.outbound import ShutdownComplete
36
+ from without_asgi.outbound import ShutdownFailed
37
+ from without_asgi.outbound import StartupComplete
38
+ from without_asgi.outbound import StartupFailed
39
+ from without_asgi.outbound import SupportsFileno
40
+ from without_asgi.outbound import WebsocketAccept
41
+ from without_asgi.outbound import WebsocketClose
42
+ from without_asgi.outbound import WebsocketOutbound
43
+ from without_asgi.outbound import WebsocketResponseBody
44
+ from without_asgi.outbound import WebsocketResponseStart
45
+ from without_asgi.outbound import WebsocketSend
46
+ from without_asgi.outbound import ZeroCopySend
47
+ from without_asgi.outbound import encode_lifespan_reply
48
+ from without_asgi.outbound import encode_outbound
49
+ from without_asgi.outbound import encode_response
50
+ from without_asgi.outbound import encode_websocket_outbound
51
+ from without_asgi.outbound import parse_lifespan_reply
52
+ from without_asgi.outbound import parse_outbound
53
+ from without_asgi.outbound import parse_websocket_outbound
54
+ from without_asgi.scope import Asgi
55
+ from without_asgi.scope import ConnectionScope
56
+ from without_asgi.scope import HttpScope
57
+ from without_asgi.scope import LifespanScope
58
+ from without_asgi.scope import Scope
59
+ from without_asgi.scope import Tls
60
+ from without_asgi.scope import WebsocketScope
61
+ from without_asgi.scope import encode_http_scope
62
+ from without_asgi.scope import encode_scope
63
+ from without_asgi.scope import encode_websocket_scope
64
+ from without_asgi.scope import extension
65
+ from without_asgi.scope import parse_http_scope
66
+ from without_asgi.scope import parse_scope
67
+ from without_asgi.scope import parse_tls
68
+ from without_asgi.scope import parse_websocket_scope
69
+ from without_asgi.shell import ClientDisconnect
70
+ from without_asgi.shell import http_inbound
71
+ from without_asgi.shell import http_outbound
72
+ from without_asgi.shell import lifespan_inbound
73
+ from without_asgi.shell import lifespan_outbound
74
+ from without_asgi.shell import read_body
75
+ from without_asgi.shell import websocket_inbound
76
+ from without_asgi.shell import websocket_outbound
77
+ from without_asgi.types import ASGIApp
78
+ from without_asgi.types import RawHeaders
79
+ from without_asgi.types import RawMessage
80
+ from without_asgi.types import RawScope
81
+ from without_asgi.types import Receive
82
+ from without_asgi.types import Send
83
+ from without_asgi.types import WebsocketBinary
84
+ from without_asgi.types import WebsocketData
85
+ from without_asgi.types import WebsocketText
86
+
87
+ __all__ = [
88
+ "ASGIApp",
89
+ "Asgi",
90
+ "ClientDisconnect",
91
+ "ConnectionScope",
92
+ "Disconnect",
93
+ "EarlyHint",
94
+ "HttpHandler",
95
+ "HttpRouter",
96
+ "HttpScope",
97
+ "Inbound",
98
+ "Lifespan",
99
+ "LifespanEvent",
100
+ "LifespanReply",
101
+ "LifespanScope",
102
+ "Outbound",
103
+ "PathSend",
104
+ "RawHeaders",
105
+ "RawMessage",
106
+ "RawScope",
107
+ "Receive",
108
+ "RequestBody",
109
+ "Response",
110
+ "ResponseBody",
111
+ "ResponseDebug",
112
+ "ResponseStart",
113
+ "ResponseTrailers",
114
+ "Scope",
115
+ "Send",
116
+ "ServerPush",
117
+ "Shutdown",
118
+ "ShutdownComplete",
119
+ "ShutdownFailed",
120
+ "Startup",
121
+ "StartupComplete",
122
+ "StartupFailed",
123
+ "SupportsFileno",
124
+ "Tls",
125
+ "WebsocketAccept",
126
+ "WebsocketBinary",
127
+ "WebsocketClose",
128
+ "WebsocketConnect",
129
+ "WebsocketData",
130
+ "WebsocketDisconnect",
131
+ "WebsocketHandler",
132
+ "WebsocketInbound",
133
+ "WebsocketOutbound",
134
+ "WebsocketReceive",
135
+ "WebsocketResponseBody",
136
+ "WebsocketResponseStart",
137
+ "WebsocketRouter",
138
+ "WebsocketScope",
139
+ "WebsocketSend",
140
+ "WebsocketText",
141
+ "ZeroCopySend",
142
+ "encode_http_scope",
143
+ "encode_inbound",
144
+ "encode_lifespan_event",
145
+ "encode_lifespan_reply",
146
+ "encode_outbound",
147
+ "encode_response",
148
+ "encode_scope",
149
+ "encode_websocket_inbound",
150
+ "encode_websocket_outbound",
151
+ "encode_websocket_scope",
152
+ "extension",
153
+ "http_inbound",
154
+ "http_outbound",
155
+ "lifespan_inbound",
156
+ "lifespan_outbound",
157
+ "make_asgi_app",
158
+ "parse_http_scope",
159
+ "parse_inbound",
160
+ "parse_lifespan_event",
161
+ "parse_lifespan_reply",
162
+ "parse_outbound",
163
+ "parse_scope",
164
+ "parse_tls",
165
+ "parse_websocket_inbound",
166
+ "parse_websocket_outbound",
167
+ "parse_websocket_scope",
168
+ "read_body",
169
+ "refuse_http",
170
+ "refuse_websocket",
171
+ "websocket_inbound",
172
+ "websocket_outbound",
173
+ ]
@@ -0,0 +1,191 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from contextlib import AbstractAsyncContextManager
5
+ from contextlib import AsyncExitStack
6
+ from dataclasses import dataclass
7
+ from typing import assert_never
8
+
9
+ from without import Processor
10
+ from without import Stream
11
+ from without import stream_from_iterable
12
+
13
+ from without_asgi.inbound import Inbound
14
+ from without_asgi.inbound import Shutdown
15
+ from without_asgi.inbound import Startup
16
+ from without_asgi.inbound import WebsocketInbound
17
+ from without_asgi.outbound import Outbound
18
+ from without_asgi.outbound import Response
19
+ from without_asgi.outbound import ShutdownComplete
20
+ from without_asgi.outbound import ShutdownFailed
21
+ from without_asgi.outbound import StartupComplete
22
+ from without_asgi.outbound import StartupFailed
23
+ from without_asgi.outbound import WebsocketClose
24
+ from without_asgi.outbound import WebsocketOutbound
25
+ from without_asgi.outbound import encode_lifespan_reply
26
+ from without_asgi.outbound import encode_response
27
+ from without_asgi.scope import HttpScope
28
+ from without_asgi.scope import LifespanScope
29
+ from without_asgi.scope import WebsocketScope
30
+ from without_asgi.scope import parse_scope
31
+ from without_asgi.shell import http_inbound
32
+ from without_asgi.shell import http_outbound
33
+ from without_asgi.shell import lifespan_inbound
34
+ from without_asgi.shell import websocket_inbound
35
+ from without_asgi.shell import websocket_outbound
36
+ from without_asgi.types import ASGIApp
37
+ from without_asgi.types import RawScope
38
+ from without_asgi.types import Receive
39
+ from without_asgi.types import Send
40
+
41
+ # A `Lifespan` is a plain async context manager that sets up some state `T`,
42
+ # yields it for the server's lifetime, and tears it down. It names no ASGI types
43
+ # on purpose: the same value drives any shell (an ASGI server here, a queue
44
+ # processor or a test elsewhere). Interdependent resources compose *inside* it
45
+ # with nested `async with`, which also gives reverse-order teardown for free.
46
+ type Lifespan[T] = Callable[[], AbstractAsyncContextManager[T]]
47
+
48
+ # A `*Handler` is the `Processor` that serves one connection: it maps that
49
+ # connection's inbound event stream to its outbound one, the same `Processor`
50
+ # shape as every other `without` node, and is the only thing an app writes per
51
+ # connection. A `*Router` selects the handler for a connection from the lifespan
52
+ # state and the parsed scope: `make_asgi_app` calls it once per connection, then
53
+ # owns the receive/send wiring around the returned handler (parsing inbound,
54
+ # encoding outbound), so neither the router nor the handler touches the raw ASGI
55
+ # callables. "Router" is the role even when it always returns the same handler (a
56
+ # constant router that never dispatches on path is still one). The state is
57
+ # threaded in per call rather than captured, so it stays a value the router is
58
+ # handed, not a place it reaches into. HTTP and WebSocket have separate
59
+ # router/handler pairs because their event types differ, which keeps an HTTP
60
+ # handler from emitting a WebSocket event (and vice versa) by construction.
61
+ type HttpHandler = Processor[Inbound, Outbound]
62
+ type HttpRouter[T] = Callable[[T, HttpScope], HttpHandler]
63
+ type WebsocketHandler = Processor[WebsocketInbound, WebsocketOutbound]
64
+ type WebsocketRouter[T] = Callable[[T, WebsocketScope], WebsocketHandler]
65
+
66
+
67
+ class _Unset:
68
+ pass
69
+
70
+
71
+ _UNSET = _Unset()
72
+
73
+
74
+ @dataclass(slots=True)
75
+ class _Cell[T]:
76
+ # The one shared reference the ASGI process model forces: lifespan startup
77
+ # and each request are separate `app()` calls, so the state set up by the
78
+ # former must reach the latter through a place in the wrapper's closure. ASGI
79
+ # guarantees startup completes before any request, so `require` is never
80
+ # reached before `value` is set; the guard turns the can't-happen case into a
81
+ # loud failure rather than a silent `None`.
82
+ value: T | _Unset = _UNSET
83
+
84
+ def require(self) -> T:
85
+ if isinstance(self.value, _Unset):
86
+ raise RuntimeError("lifespan startup has not completed")
87
+ return self.value
88
+
89
+
90
+ async def _drive[T](lifespan: Lifespan[T], cell: _Cell[T], receive: Receive, send: Send) -> None:
91
+ # The stack outlives the `startup` branch: it is entered when startup arrives
92
+ # and closed when shutdown arrives, two separate server messages with the
93
+ # `await receive()` for shutdown in between. A plain `async with` cannot
94
+ # straddle that gap, which is exactly why the lifespan protocol exists. The
95
+ # enclosing `async with` also guarantees teardown if the lifespan task is
96
+ # cancelled before shutdown is ever sent.
97
+ async with AsyncExitStack() as stack:
98
+ async for event in lifespan_inbound(receive):
99
+ match event:
100
+ case Startup():
101
+ try:
102
+ cell.value = await stack.enter_async_context(lifespan())
103
+ except Exception as exc: # noqa: BLE001 - ASGI lifespan reports any startup failure as StartupFailed
104
+ await send(encode_lifespan_reply(StartupFailed(message=str(exc))))
105
+ return
106
+ await send(encode_lifespan_reply(StartupComplete()))
107
+ case Shutdown():
108
+ try:
109
+ await stack.aclose()
110
+ except Exception as exc: # noqa: BLE001 - ASGI lifespan reports any shutdown failure as ShutdownFailed
111
+ await send(encode_lifespan_reply(ShutdownFailed(message=str(exc))))
112
+ return
113
+ await send(encode_lifespan_reply(ShutdownComplete()))
114
+ case _ as unreachable:
115
+ assert_never(unreachable)
116
+
117
+
118
+ # What the default routers refuse a connection with. An `http` scope gets `501
119
+ # Not Implemented` (the HTTP status for "the server has no handler for this", as
120
+ # opposed to `500` for an unexpected failure); a `websocket` scope is closed
121
+ # before `accept`, which the ASGI server is required to turn into a `403`.
122
+ _HTTP_UNSUPPORTED = Response(
123
+ status=501,
124
+ headers=((b"content-type", b"text/plain; charset=utf-8"),),
125
+ body=b"this application does not serve http\n",
126
+ )
127
+ _WEBSOCKET_UNSUPPORTED = WebsocketClose()
128
+
129
+
130
+ # The default routers each `make_asgi_app` protocol falls back to, so an app
131
+ # serves a protocol only by passing its own router to override the default. They
132
+ # ignore the threaded state, hence `object`, which keeps them assignable as the
133
+ # default for any `make_asgi_app[T]`.
134
+ def refuse_http(state: object, head: HttpScope) -> HttpHandler:
135
+ """An `HttpRouter` that refuses every request with `501 Not Implemented`."""
136
+
137
+ def handler(inputs: Stream[Inbound]) -> Stream[Outbound]:
138
+ return stream_from_iterable(encode_response(_HTTP_UNSUPPORTED))
139
+
140
+ return handler
141
+
142
+
143
+ def refuse_websocket(state: object, head: WebsocketScope) -> WebsocketHandler:
144
+ """A `WebsocketRouter` that refuses every connection by closing before `accept` (a `403`)."""
145
+
146
+ def handler(inputs: Stream[WebsocketInbound]) -> Stream[WebsocketOutbound]:
147
+ return stream_from_iterable((_WEBSOCKET_UNSUPPORTED,))
148
+
149
+ return handler
150
+
151
+
152
+ def make_asgi_app[T](
153
+ lifespan: Lifespan[T],
154
+ http: HttpRouter[T] = refuse_http,
155
+ websocket: WebsocketRouter[T] = refuse_websocket,
156
+ ) -> ASGIApp:
157
+ """
158
+ Build the ASGI app that drives `lifespan` and runs a per-connection
159
+ `Processor` over each connection's event stream.
160
+
161
+ This is the ASGI entrypoint: it parses each raw scope into its typed value
162
+ and owns all the receive/send wiring. The `lifespan` scope is set up once on
163
+ `startup` and torn down on `shutdown`, with boot failures reported as
164
+ `lifespan.startup.failed` / `lifespan.shutdown.failed`. For a connection
165
+ scope it calls the matching handler with the state threaded in, wraps
166
+ `receive` into the inbound event stream, runs the returned `Processor`, and
167
+ drains its outbound stream into `send`: the handler only ever sees streams.
168
+
169
+ Each protocol's router defaults to one that refuses the connection, so an app
170
+ serves a protocol only by passing its own router (an HTTP-only app passes
171
+ `http`, a WebSocket-only app passes `websocket`). The default refusal never
172
+ reaches app code: an HTTP scope gets a `501 Not Implemented` response, a
173
+ WebSocket scope is closed before `accept` (which the server turns into a
174
+ `403`). Drilling under this driver, e.g. to build a handler that needs the raw
175
+ `receive`/`send`, is `parse_scope` plus the `http_inbound` / `http_outbound`
176
+ (and websocket) shell functions this wires together.
177
+ """
178
+ cell: _Cell[T] = _Cell()
179
+
180
+ async def app(scope: RawScope, receive: Receive, send: Send) -> None:
181
+ match parse_scope(scope):
182
+ case LifespanScope():
183
+ await _drive(lifespan, cell, receive, send)
184
+ case HttpScope() as head:
185
+ await http_outbound(send)(http(cell.require(), head)(http_inbound(receive)))
186
+ case WebsocketScope() as head:
187
+ await websocket_outbound(send)(websocket(cell.require(), head)(websocket_inbound(receive)))
188
+ case _ as unreachable:
189
+ assert_never(unreachable)
190
+
191
+ return app
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import assert_never
5
+
6
+ from without_asgi.narrow import narrow_to_bytes
7
+ from without_asgi.narrow import narrow_to_int
8
+ from without_asgi.narrow import narrow_to_str
9
+ from without_asgi.types import RawMessage
10
+ from without_asgi.types import WebsocketData
11
+ from without_asgi.types import decode_websocket_data
12
+ from without_asgi.types import encode_websocket_data
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class RequestBody:
17
+ body: bytes
18
+ more_body: bool
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class Disconnect:
23
+ """The client went away before the request finished."""
24
+
25
+
26
+ type Inbound = RequestBody | Disconnect
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class WebsocketConnect:
31
+ """The client is opening a websocket and awaiting an accept or a close."""
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class WebsocketReceive:
36
+ data: WebsocketData
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class WebsocketDisconnect:
41
+ code: int
42
+ reason: str
43
+
44
+
45
+ type WebsocketInbound = WebsocketConnect | WebsocketReceive | WebsocketDisconnect
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class Startup:
50
+ pass
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class Shutdown:
55
+ pass
56
+
57
+
58
+ type LifespanEvent = Startup | Shutdown
59
+
60
+
61
+ def _as_reason(value: object) -> str:
62
+ return "" if value is None else narrow_to_str(value)
63
+
64
+
65
+ def parse_inbound(message: RawMessage) -> Inbound:
66
+ """Classify one inbound `http` event. An unknown event is a protocol fault, so it raises."""
67
+ match message.get("type"):
68
+ case "http.request":
69
+ return RequestBody(
70
+ body=narrow_to_bytes(message.get("body", b"")),
71
+ more_body=bool(message.get("more_body", False)),
72
+ )
73
+ case "http.disconnect":
74
+ return Disconnect()
75
+ case other:
76
+ raise ValueError(f"unexpected http event type: {other!r}")
77
+
78
+
79
+ def parse_websocket_inbound(message: RawMessage) -> WebsocketInbound:
80
+ """Classify one inbound `websocket` event. An unknown event is a protocol fault, so it raises."""
81
+ match message.get("type"):
82
+ case "websocket.connect":
83
+ return WebsocketConnect()
84
+ case "websocket.receive":
85
+ return WebsocketReceive(data=decode_websocket_data(message))
86
+ case "websocket.disconnect":
87
+ return WebsocketDisconnect(
88
+ code=narrow_to_int(message.get("code", 1005)),
89
+ reason=_as_reason(message.get("reason")),
90
+ )
91
+ case other:
92
+ raise ValueError(f"unexpected websocket event type: {other!r}")
93
+
94
+
95
+ def parse_lifespan_event(message: RawMessage) -> LifespanEvent:
96
+ """Classify one `lifespan` event. An unknown event is a protocol fault, so it raises."""
97
+ match message.get("type"):
98
+ case "lifespan.startup":
99
+ return Startup()
100
+ case "lifespan.shutdown":
101
+ return Shutdown()
102
+ case other:
103
+ raise ValueError(f"unexpected lifespan event type: {other!r}")
104
+
105
+
106
+ def encode_inbound(event: Inbound) -> RawMessage:
107
+ """
108
+ Render one inbound `http` event as the raw dict an ASGI `receive` returns.
109
+
110
+ The server-direction dual of `parse_inbound`: a transport that owns the wire
111
+ (without-http) builds typed `Inbound` events and hands them to the app as the
112
+ dicts ASGI `receive` yields.
113
+ """
114
+ match event:
115
+ case RequestBody(body, more_body):
116
+ return {"type": "http.request", "body": body, "more_body": more_body}
117
+ case Disconnect():
118
+ return {"type": "http.disconnect"}
119
+ case _ as unreachable:
120
+ assert_never(unreachable)
121
+
122
+
123
+ def encode_websocket_inbound(event: WebsocketInbound) -> RawMessage:
124
+ """Render one inbound `websocket` event as the raw dict an ASGI `receive` returns."""
125
+ match event:
126
+ case WebsocketConnect():
127
+ return {"type": "websocket.connect"}
128
+ case WebsocketReceive(data):
129
+ return {"type": "websocket.receive", **encode_websocket_data(data)}
130
+ case WebsocketDisconnect(code, reason):
131
+ return {"type": "websocket.disconnect", "code": code, "reason": reason}
132
+ case _ as unreachable:
133
+ assert_never(unreachable)
134
+
135
+
136
+ def encode_lifespan_event(event: LifespanEvent) -> RawMessage:
137
+ """Render one `lifespan` event as the raw dict an ASGI `receive` returns."""
138
+ match event:
139
+ case Startup():
140
+ return {"type": "lifespan.startup"}
141
+ case Shutdown():
142
+ return {"type": "lifespan.shutdown"}
143
+ case _ as unreachable:
144
+ assert_never(unreachable)
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def narrow[T](value: object, expected: type[T]) -> T:
5
+ """
6
+ Return `value` typed as `expected`, raising `TypeError` if it isn't.
7
+
8
+ The boundary reads ASGI scopes and messages as `Mapping[str, object]`, so
9
+ every field arrives as an untyped `object`. `narrow` turns one such value
10
+ into the concrete type the caller expects, failing loudly on a mismatch
11
+ rather than letting a wrong type flow inward.
12
+ """
13
+ if isinstance(value, expected):
14
+ return value
15
+ raise TypeError(f"expected {expected.__name__}, got {type(value).__name__}")
16
+
17
+
18
+ def narrow_to_str(value: object) -> str:
19
+ return narrow(value, str)
20
+
21
+
22
+ def narrow_to_bytes(value: object) -> bytes:
23
+ return narrow(value, bytes)
24
+
25
+
26
+ def narrow_to_int(value: object) -> int:
27
+ return narrow(value, int)