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.
- without_asgi-0.0.1/PKG-INFO +61 -0
- without_asgi-0.0.1/README.md +40 -0
- without_asgi-0.0.1/pyproject.toml +32 -0
- without_asgi-0.0.1/src/without_asgi/__init__.py +173 -0
- without_asgi-0.0.1/src/without_asgi/app.py +191 -0
- without_asgi-0.0.1/src/without_asgi/inbound.py +144 -0
- without_asgi-0.0.1/src/without_asgi/narrow.py +27 -0
- without_asgi-0.0.1/src/without_asgi/outbound.py +382 -0
- without_asgi-0.0.1/src/without_asgi/py.typed +0 -0
- without_asgi-0.0.1/src/without_asgi/routing.py +203 -0
- without_asgi-0.0.1/src/without_asgi/scope.py +417 -0
- without_asgi-0.0.1/src/without_asgi/shell.py +122 -0
- without_asgi-0.0.1/src/without_asgi/types.py +66 -0
|
@@ -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)
|