without-http 0.0.4__tar.gz → 0.0.6__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_http-0.0.4 → without_http-0.0.6}/PKG-INFO +6 -5
- {without_http-0.0.4 → without_http-0.0.6}/README.md +2 -2
- {without_http-0.0.4 → without_http-0.0.6}/pyproject.toml +8 -4
- {without_http-0.0.4 → without_http-0.0.6}/pyproject.toml.orig +6 -4
- {without_http-0.0.4 → without_http-0.0.6}/src/without_http/__init__.py +20 -0
- {without_http-0.0.4 → without_http-0.0.6}/src/without_http/client.py +62 -62
- {without_http-0.0.4 → without_http-0.0.6}/src/without_http/lifespan.py +1 -1
- {without_http-0.0.4 → without_http-0.0.6}/src/without_http/server.py +3 -3
- {without_http-0.0.4 → without_http-0.0.6}/src/without_http/socket_options.py +15 -11
- without_http-0.0.6/src/without_http/sse.py +247 -0
- {without_http-0.0.4 → without_http-0.0.6}/src/without_http/testing.py +1 -1
- {without_http-0.0.4 → without_http-0.0.6}/src/without_http/timeouts.py +1 -1
- {without_http-0.0.4 → without_http-0.0.6}/src/without_http/h11_wire.py +0 -0
- {without_http-0.0.4 → without_http-0.0.6}/src/without_http/h2_wire.py +0 -0
- {without_http-0.0.4 → without_http-0.0.6}/src/without_http/py.typed +0 -0
- {without_http-0.0.4 → without_http-0.0.6}/src/without_http/tls.py +0 -0
- {without_http-0.0.4 → without_http-0.0.6}/src/without_http/ws_wire.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: without-http
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.6
|
|
4
4
|
Summary: A sans-IO-backed ASGI server and HTTP client for without: h11/h2/wsproto over asyncio sockets.
|
|
5
5
|
Author: Josh Karpel
|
|
6
6
|
Author-email: Josh Karpel <josh.karpel@gmail.com>
|
|
@@ -16,8 +16,9 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
|
16
16
|
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
17
17
|
Classifier: Topic :: Software Development :: Libraries
|
|
18
18
|
Classifier: Typing :: Typed
|
|
19
|
-
Requires-Dist: without-
|
|
20
|
-
Requires-Dist: without-
|
|
19
|
+
Requires-Dist: without-async==0.0.6
|
|
20
|
+
Requires-Dist: without-streams==0.0.6
|
|
21
|
+
Requires-Dist: without-asgi==0.0.6
|
|
21
22
|
Requires-Dist: h11>=0.16
|
|
22
23
|
Requires-Dist: h2>=4.1
|
|
23
24
|
Requires-Dist: wsproto>=1.2
|
|
@@ -29,7 +30,7 @@ Description-Content-Type: text/markdown
|
|
|
29
30
|
# without-http
|
|
30
31
|
|
|
31
32
|
A sans-IO-backed ASGI **server** and **HTTP client** for `without`. Where
|
|
32
|
-
[`without-asgi`](../
|
|
33
|
+
[`without-asgi`](../without_asgi) is the *app* side of the ASGI boundary (it turns
|
|
33
34
|
a server's `receive`/`send` into typed streams), `without-http` is the *server*
|
|
34
35
|
side: it owns the socket and the HTTP wire protocol, and drives any ASGI app via
|
|
35
36
|
`app(scope, receive, send)`.
|
|
@@ -43,7 +44,7 @@ those state machines, and uses `without-asgi`'s server-direction codecs to
|
|
|
43
44
|
translate between typed events and the ASGI dicts an app expects.
|
|
44
45
|
|
|
45
46
|
```python
|
|
46
|
-
from
|
|
47
|
+
from without_async import sleep_forever
|
|
47
48
|
from without_asgi import make_asgi_app
|
|
48
49
|
from without_http import ConnectionPool, request, serving
|
|
49
50
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# without-http
|
|
2
2
|
|
|
3
3
|
A sans-IO-backed ASGI **server** and **HTTP client** for `without`. Where
|
|
4
|
-
[`without-asgi`](../
|
|
4
|
+
[`without-asgi`](../without_asgi) is the *app* side of the ASGI boundary (it turns
|
|
5
5
|
a server's `receive`/`send` into typed streams), `without-http` is the *server*
|
|
6
6
|
side: it owns the socket and the HTTP wire protocol, and drives any ASGI app via
|
|
7
7
|
`app(scope, receive, send)`.
|
|
@@ -15,7 +15,7 @@ those state machines, and uses `without-asgi`'s server-direction codecs to
|
|
|
15
15
|
translate between typed events and the ASGI dicts an app expects.
|
|
16
16
|
|
|
17
17
|
```python
|
|
18
|
-
from
|
|
18
|
+
from without_async import sleep_forever
|
|
19
19
|
from without_asgi import make_asgi_app
|
|
20
20
|
from without_http import ConnectionPool, request, serving
|
|
21
21
|
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "without-http"
|
|
7
|
-
version = "0.0.
|
|
7
|
+
version = "0.0.6"
|
|
8
8
|
description = "A sans-IO-backed ASGI server and HTTP client for without: h11/h2/wsproto over asyncio sockets."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -23,8 +23,9 @@ classifiers = [
|
|
|
23
23
|
"Typing :: Typed",
|
|
24
24
|
]
|
|
25
25
|
dependencies = [
|
|
26
|
-
"without-
|
|
27
|
-
"without-
|
|
26
|
+
"without-async==0.0.6",
|
|
27
|
+
"without-streams==0.0.6",
|
|
28
|
+
"without-asgi==0.0.6",
|
|
28
29
|
"h11>=0.16",
|
|
29
30
|
"h2>=4.1",
|
|
30
31
|
"wsproto>=1.2",
|
|
@@ -36,7 +37,10 @@ dependencies = [
|
|
|
36
37
|
name = "Josh Karpel"
|
|
37
38
|
email = "josh.karpel@gmail.com"
|
|
38
39
|
|
|
39
|
-
[tool.uv.sources.without-
|
|
40
|
+
[tool.uv.sources.without-async]
|
|
41
|
+
workspace = true
|
|
42
|
+
|
|
43
|
+
[tool.uv.sources.without-streams]
|
|
40
44
|
workspace = true
|
|
41
45
|
|
|
42
46
|
[tool.uv.sources.without-asgi]
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "without-http"
|
|
7
|
-
version = "0.0.
|
|
7
|
+
version = "0.0.6"
|
|
8
8
|
description = "A sans-IO-backed ASGI server and HTTP client for without: h11/h2/wsproto over asyncio sockets."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -26,8 +26,9 @@ classifiers = [
|
|
|
26
26
|
"Typing :: Typed",
|
|
27
27
|
]
|
|
28
28
|
dependencies = [
|
|
29
|
-
"without-
|
|
30
|
-
"without-
|
|
29
|
+
"without-async==0.0.6",
|
|
30
|
+
"without-streams==0.0.6",
|
|
31
|
+
"without-asgi==0.0.6",
|
|
31
32
|
"h11>=0.16",
|
|
32
33
|
"h2>=4.1",
|
|
33
34
|
"wsproto>=1.2",
|
|
@@ -36,5 +37,6 @@ dependencies = [
|
|
|
36
37
|
]
|
|
37
38
|
|
|
38
39
|
[tool.uv.sources]
|
|
39
|
-
without-
|
|
40
|
+
without-async = { workspace = true }
|
|
41
|
+
without-streams = { workspace = true }
|
|
40
42
|
without-asgi = { workspace = true }
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from without_http.client import DEFAULT_DECOMPRESSORS
|
|
2
|
+
from without_http.client import GZIP_CONTAINER
|
|
2
3
|
from without_http.client import USER_AGENT
|
|
3
4
|
from without_http.client import Client
|
|
4
5
|
from without_http.client import ClientMiddleware
|
|
@@ -13,10 +14,12 @@ from without_http.client import Resolve
|
|
|
13
14
|
from without_http.client import ResponseBody
|
|
14
15
|
from without_http.client import ResponseHead
|
|
15
16
|
from without_http.client import ResponseTrailers
|
|
17
|
+
from without_http.client import StreamingCompressor
|
|
16
18
|
from without_http.client import add_headers
|
|
17
19
|
from without_http.client import basic_auth
|
|
18
20
|
from without_http.client import bearer_auth
|
|
19
21
|
from without_http.client import brotli_compress
|
|
22
|
+
from without_http.client import brotli_compressor
|
|
20
23
|
from without_http.client import compressing
|
|
21
24
|
from without_http.client import cookies
|
|
22
25
|
from without_http.client import deadline
|
|
@@ -24,12 +27,14 @@ from without_http.client import decompress
|
|
|
24
27
|
from without_http.client import default_headers
|
|
25
28
|
from without_http.client import follow_redirects
|
|
26
29
|
from without_http.client import gzip_compress
|
|
30
|
+
from without_http.client import gzip_compressor
|
|
27
31
|
from without_http.client import request
|
|
28
32
|
from without_http.client import stack
|
|
29
33
|
from without_http.client import tcp_connect
|
|
30
34
|
from without_http.client import user_agent
|
|
31
35
|
from without_http.client import wrap
|
|
32
36
|
from without_http.client import zstd_compress
|
|
37
|
+
from without_http.client import zstd_compressor
|
|
33
38
|
from without_http.h2_wire import early_hint_headers
|
|
34
39
|
from without_http.h2_wire import request_headers
|
|
35
40
|
from without_http.h2_wire import response_headers
|
|
@@ -46,6 +51,11 @@ from without_http.socket_options import SocketOptions
|
|
|
46
51
|
from without_http.socket_options import receive_buffer_size
|
|
47
52
|
from without_http.socket_options import send_buffer_size
|
|
48
53
|
from without_http.socket_options import tcp_keepalive
|
|
54
|
+
from without_http.sse import DEFAULT_RECONNECT
|
|
55
|
+
from without_http.sse import MAXIMUM_RECONNECT
|
|
56
|
+
from without_http.sse import MINIMUM_RECONNECT
|
|
57
|
+
from without_http.sse import NotAnEventStream
|
|
58
|
+
from without_http.sse import subscribe
|
|
49
59
|
from without_http.timeouts import ConnectTimeout
|
|
50
60
|
from without_http.timeouts import HTTPTimeout
|
|
51
61
|
from without_http.timeouts import PoolTimeout
|
|
@@ -64,6 +74,10 @@ from without_http.ws_wire import ws_events_from_outbound
|
|
|
64
74
|
__all__ = [
|
|
65
75
|
"ALPN_PROTOCOLS",
|
|
66
76
|
"DEFAULT_DECOMPRESSORS",
|
|
77
|
+
"DEFAULT_RECONNECT",
|
|
78
|
+
"GZIP_CONTAINER",
|
|
79
|
+
"MAXIMUM_RECONNECT",
|
|
80
|
+
"MINIMUM_RECONNECT",
|
|
67
81
|
"USER_AGENT",
|
|
68
82
|
"Client",
|
|
69
83
|
"ClientMiddleware",
|
|
@@ -77,6 +91,7 @@ __all__ = [
|
|
|
77
91
|
"Decompressor",
|
|
78
92
|
"HTTPTimeout",
|
|
79
93
|
"LifespanError",
|
|
94
|
+
"NotAnEventStream",
|
|
80
95
|
"PoolTimeout",
|
|
81
96
|
"ReadTimeout",
|
|
82
97
|
"Resolve",
|
|
@@ -85,12 +100,14 @@ __all__ = [
|
|
|
85
100
|
"ResponseTrailers",
|
|
86
101
|
"Server",
|
|
87
102
|
"SocketOptions",
|
|
103
|
+
"StreamingCompressor",
|
|
88
104
|
"Timeout",
|
|
89
105
|
"WriteTimeout",
|
|
90
106
|
"add_headers",
|
|
91
107
|
"basic_auth",
|
|
92
108
|
"bearer_auth",
|
|
93
109
|
"brotli_compress",
|
|
110
|
+
"brotli_compressor",
|
|
94
111
|
"compressing",
|
|
95
112
|
"cookies",
|
|
96
113
|
"deadline",
|
|
@@ -101,6 +118,7 @@ __all__ = [
|
|
|
101
118
|
"extensions_with_tls",
|
|
102
119
|
"follow_redirects",
|
|
103
120
|
"gzip_compress",
|
|
121
|
+
"gzip_compressor",
|
|
104
122
|
"h11_events_from_outbound",
|
|
105
123
|
"inbound_from_event",
|
|
106
124
|
"is_websocket_upgrade",
|
|
@@ -116,6 +134,7 @@ __all__ = [
|
|
|
116
134
|
"server_ssl_context",
|
|
117
135
|
"serving",
|
|
118
136
|
"stack",
|
|
137
|
+
"subscribe",
|
|
119
138
|
"tcp_connect",
|
|
120
139
|
"tcp_keepalive",
|
|
121
140
|
"tls_extension",
|
|
@@ -124,4 +143,5 @@ __all__ = [
|
|
|
124
143
|
"wrap",
|
|
125
144
|
"ws_events_from_outbound",
|
|
126
145
|
"zstd_compress",
|
|
146
|
+
"zstd_compressor",
|
|
127
147
|
]
|
|
@@ -23,6 +23,7 @@ from datetime import datetime
|
|
|
23
23
|
from datetime import timedelta
|
|
24
24
|
from email.utils import parsedate_to_datetime
|
|
25
25
|
from importlib import metadata
|
|
26
|
+
from types import MappingProxyType
|
|
26
27
|
from typing import NamedTuple
|
|
27
28
|
from typing import Protocol
|
|
28
29
|
from typing import Self
|
|
@@ -38,14 +39,20 @@ import h2.exceptions
|
|
|
38
39
|
import h11
|
|
39
40
|
from aiohappyeyeballs import AddrInfoType
|
|
40
41
|
from aiohappyeyeballs import start_connection
|
|
41
|
-
from without import Endo
|
|
42
|
-
from without import Stream
|
|
43
|
-
from without import cancel_futures
|
|
44
|
-
from without import stack
|
|
45
42
|
from without_asgi import Content
|
|
46
43
|
from without_asgi import RawHeaders
|
|
47
44
|
from without_asgi import StreamingContent
|
|
45
|
+
from without_asgi.compression import GZIP_CONTAINER
|
|
46
|
+
from without_asgi.compression import Compressor
|
|
47
|
+
from without_asgi.compression import StreamingCompressor
|
|
48
|
+
from without_asgi.compression import brotli_compressor
|
|
49
|
+
from without_asgi.compression import gzip_compressor
|
|
50
|
+
from without_asgi.compression import zstd_compressor
|
|
48
51
|
from without_asgi.headers import merge
|
|
52
|
+
from without_async import cancel_futures
|
|
53
|
+
from without_streams import Endo
|
|
54
|
+
from without_streams import Stream
|
|
55
|
+
from without_streams import stack
|
|
49
56
|
|
|
50
57
|
from without_http.h2_wire import request_headers
|
|
51
58
|
from without_http.h2_wire import response_status_and_headers
|
|
@@ -1615,26 +1622,28 @@ def follow_redirects(max_hops: int = 5) -> ClientMiddleware:
|
|
|
1615
1622
|
|
|
1616
1623
|
|
|
1617
1624
|
_REQUEST_FRAMING_HEADERS = frozenset({b"content-length", b"transfer-encoding"})
|
|
1618
|
-
_GZIP_CONTAINER = zlib.MAX_WBITS | 16 # the wbits offset that selects the gzip wrapper around DEFLATE
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
class Compressor(Protocol):
|
|
1622
|
-
"""
|
|
1623
|
-
The incremental compressor shape `compressing` drives: feed chunks through
|
|
1624
|
-
`compress`, then `flush` ends the stream. `zlib.compressobj` and
|
|
1625
|
-
`zstd.ZstdCompressor` satisfy it as-is; a third-party codec plugs in with
|
|
1626
|
-
whatever thin adapter its own surface needs.
|
|
1627
|
-
"""
|
|
1628
|
-
|
|
1629
|
-
def compress(self, data: bytes, /) -> bytes: ...
|
|
1630
|
-
def flush(self) -> bytes: ...
|
|
1631
1625
|
|
|
1632
1626
|
|
|
1633
1627
|
async def _compressed(body: Stream[bytes], compressor: Compressor) -> AsyncIterator[bytes]:
|
|
1628
|
+
# A codec decides for itself what any one `compress` call returns, and fed the
|
|
1629
|
+
# pieces a streamed body arrives in it usually returns nothing, holding the whole
|
|
1630
|
+
# upload until the stream ends. Ending a block per chunk is what releases it; a
|
|
1631
|
+
# codec with no way to do that (a plain `Compressor`) has to hold it, since unlike
|
|
1632
|
+
# a negotiated response there is no unencoded answer available to a caller who
|
|
1633
|
+
# asked for this coding by name.
|
|
1634
|
+
flush_block: Callable[[], bytes] = (
|
|
1635
|
+
compressor.flush_block if isinstance(compressor, StreamingCompressor) else lambda: b""
|
|
1636
|
+
)
|
|
1637
|
+
# One chunk of lookahead, so the last one is known to be last and rides out on the
|
|
1638
|
+
# flush that ends the stream rather than paying for a block of its own. A body that
|
|
1639
|
+
# arrives whole is one chunk, so it still encodes to exactly the bytes it would
|
|
1640
|
+
# have without any of this.
|
|
1641
|
+
held: bytes | None = None
|
|
1634
1642
|
async for chunk in body:
|
|
1635
|
-
if compressed := compressor.compress(
|
|
1643
|
+
if held and (compressed := compressor.compress(held) + flush_block()):
|
|
1636
1644
|
yield compressed
|
|
1637
|
-
|
|
1645
|
+
held = chunk
|
|
1646
|
+
yield compressor.compress(held or b"") + compressor.flush()
|
|
1638
1647
|
|
|
1639
1648
|
|
|
1640
1649
|
def compressing(coding: bytes, make_compressor: Callable[[], Compressor]) -> ClientMiddleware:
|
|
@@ -1648,11 +1657,21 @@ def compressing(coding: bytes, make_compressor: Callable[[], Compressor]) -> Cli
|
|
|
1648
1657
|
reimplemented.
|
|
1649
1658
|
|
|
1650
1659
|
That rest: the lazy body `Stream[bytes]` is wrapped in the incremental compressor,
|
|
1651
|
-
so a streamed upload compresses chunk by chunk and
|
|
1652
|
-
framing follows the rewrite, `content-length` no longer describes the body,
|
|
1653
|
-
is dropped and the compressed stream goes out `transfer-encoding: chunked`
|
|
1654
|
-
HTTP/2 the framing header is dropped with the other hop-by-hop headers and
|
|
1655
|
-
body rides DATA frames as usual).
|
|
1660
|
+
so a streamed upload compresses chunk by chunk and holds no more than one of them;
|
|
1661
|
+
and the framing follows the rewrite, `content-length` no longer describes the body,
|
|
1662
|
+
so it is dropped and the compressed stream goes out `transfer-encoding: chunked`
|
|
1663
|
+
(over HTTP/2 the framing header is dropped with the other hop-by-hop headers and
|
|
1664
|
+
the body rides DATA frames as usual).
|
|
1665
|
+
|
|
1666
|
+
Holding no more than a chunk is a demand on the codec, not just on the loop: what
|
|
1667
|
+
`compress` returns is the codec's choice, and fed a chunk at a time zlib and zstd
|
|
1668
|
+
return almost nothing until the stream ends, which would buffer the whole upload
|
|
1669
|
+
inside the codec while looking like it streamed. `make_compressor` should therefore
|
|
1670
|
+
produce a `StreamingCompressor`, as `gzip_compressor`, `zstd_compressor`, and
|
|
1671
|
+
`brotli_compressor` all do; a plain `Compressor` still encodes correctly and still
|
|
1672
|
+
buffers, because a coding named by the caller has no unencoded answer to fall back
|
|
1673
|
+
on the way a negotiated response does. A body that arrives whole is one chunk
|
|
1674
|
+
either way, and encodes to the same bytes under both.
|
|
1656
1675
|
|
|
1657
1676
|
Two kinds of request pass through untouched: one already carrying a
|
|
1658
1677
|
`content-encoding` (the body is already encoded; re-compressing would corrupt it),
|
|
@@ -1694,7 +1713,7 @@ def gzip_compress(level: int = zlib.Z_DEFAULT_COMPRESSION) -> ClientMiddleware:
|
|
|
1694
1713
|
codings, and `compressing` is the shared mechanism for any coding beyond those;
|
|
1695
1714
|
the response-side counterpart to all of them is `decompress`.
|
|
1696
1715
|
"""
|
|
1697
|
-
return compressing(b"gzip", lambda:
|
|
1716
|
+
return compressing(b"gzip", lambda: gzip_compressor(level))
|
|
1698
1717
|
|
|
1699
1718
|
|
|
1700
1719
|
def zstd_compress(level: int | None = None) -> ClientMiddleware:
|
|
@@ -1708,33 +1727,7 @@ def zstd_compress(level: int | None = None) -> ClientMiddleware:
|
|
|
1708
1727
|
|
|
1709
1728
|
`level` is zstd's compression level, defaulting to the library's own default.
|
|
1710
1729
|
"""
|
|
1711
|
-
return compressing(b"zstd", lambda:
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
class _RawBrotliCompressor(Protocol):
|
|
1715
|
-
"""The slice of `brotli.Compressor` the adapter drives (the bindings ship no types)."""
|
|
1716
|
-
|
|
1717
|
-
def process(self, data: bytes, /) -> bytes: ...
|
|
1718
|
-
def finish(self) -> bytes: ...
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
@dataclass(slots=True, eq=False)
|
|
1722
|
-
class _BrotliCompressor:
|
|
1723
|
-
"""
|
|
1724
|
-
Adapt `brotli.Compressor` to the `Compressor` shape.
|
|
1725
|
-
|
|
1726
|
-
Brotli's bindings spell the incremental surface `process`/`finish` (their `flush`
|
|
1727
|
-
is a mid-stream flush that keeps the stream open), so the adapter maps `compress`
|
|
1728
|
-
to `process` and `flush` to `finish`.
|
|
1729
|
-
"""
|
|
1730
|
-
|
|
1731
|
-
_raw: _RawBrotliCompressor
|
|
1732
|
-
|
|
1733
|
-
def compress(self, data: bytes, /) -> bytes:
|
|
1734
|
-
return self._raw.process(data)
|
|
1735
|
-
|
|
1736
|
-
def flush(self) -> bytes:
|
|
1737
|
-
return self._raw.finish()
|
|
1730
|
+
return compressing(b"zstd", lambda: zstd_compressor(level))
|
|
1738
1731
|
|
|
1739
1732
|
|
|
1740
1733
|
def brotli_compress(quality: int = 11) -> ClientMiddleware:
|
|
@@ -1744,9 +1737,12 @@ def brotli_compress(quality: int = 11) -> ClientMiddleware:
|
|
|
1744
1737
|
`gzip_compress`'s sibling over brotli ([Google's own bindings](https://github.com/google/brotli),
|
|
1745
1738
|
a bundled dependency since the stdlib has no brotli): everything there holds here,
|
|
1746
1739
|
and only the coding differs. `quality` is brotli's compression quality (0-11),
|
|
1747
|
-
defaulting to the
|
|
1740
|
+
defaulting to the bindings' own default of 11, the maximum, because a client
|
|
1741
|
+
compressing an upload it holds whole is the case that ratio is worth paying for.
|
|
1742
|
+
The server-side `compress` table defaults lower, since it encodes per response;
|
|
1743
|
+
see `without_asgi.compression.brotli_compressor`, the shared codec behind both.
|
|
1748
1744
|
"""
|
|
1749
|
-
return compressing(b"br", lambda:
|
|
1745
|
+
return compressing(b"br", lambda: brotli_compressor(quality))
|
|
1750
1746
|
|
|
1751
1747
|
|
|
1752
1748
|
class Decompressor(Protocol):
|
|
@@ -1767,7 +1763,7 @@ class Decompressor(Protocol):
|
|
|
1767
1763
|
|
|
1768
1764
|
|
|
1769
1765
|
def _gzip_decompressor() -> Decompressor:
|
|
1770
|
-
return zlib.decompressobj(wbits=
|
|
1766
|
+
return zlib.decompressobj(wbits=GZIP_CONTAINER)
|
|
1771
1767
|
|
|
1772
1768
|
|
|
1773
1769
|
class _RawBrotliDecompressor(Protocol):
|
|
@@ -1802,12 +1798,16 @@ def _brotli_decompressor() -> Decompressor:
|
|
|
1802
1798
|
|
|
1803
1799
|
|
|
1804
1800
|
# The codings decoded out of the box: gzip and zstd from the stdlib, brotli from the
|
|
1805
|
-
# bundled bindings. `decompress`'s default table
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1801
|
+
# bundled bindings. `decompress`'s default table, and the mirror of the server-side
|
|
1802
|
+
# `DEFAULT_COMPRESSORS`, a proxy for the same two reasons: nothing can mutate the table
|
|
1803
|
+
# every other caller starts from, and `|` extends it into a fresh `dict`.
|
|
1804
|
+
DEFAULT_DECOMPRESSORS: MappingProxyType[bytes, Callable[[], Decompressor]] = MappingProxyType(
|
|
1805
|
+
{
|
|
1806
|
+
b"br": _brotli_decompressor,
|
|
1807
|
+
b"gzip": _gzip_decompressor,
|
|
1808
|
+
b"zstd": zstd.ZstdDecompressor,
|
|
1809
|
+
}
|
|
1810
|
+
)
|
|
1811
1811
|
|
|
1812
1812
|
_CONTENT_CODING_HEADERS = frozenset({b"content-encoding", b"content-length"})
|
|
1813
1813
|
|
|
@@ -1859,7 +1859,7 @@ def decompress(
|
|
|
1859
1859
|
the bundled bindings). The offer is *derived from its keys*, so what is
|
|
1860
1860
|
advertised and what is decoded cannot disagree; register a coding this package
|
|
1861
1861
|
does not ship by extending the table
|
|
1862
|
-
(`decompress(
|
|
1862
|
+
(`decompress(DEFAULT_DECOMPRESSORS | {b"lzma": make_lzma})`) with any factory
|
|
1863
1863
|
whose product satisfies `Decompressor`. The mapping is snapshotted, keys
|
|
1864
1864
|
lowercased, when the middleware is built.
|
|
1865
1865
|
|
|
@@ -6,7 +6,6 @@ from contextlib import asynccontextmanager
|
|
|
6
6
|
from contextlib import suppress
|
|
7
7
|
from typing import assert_never
|
|
8
8
|
|
|
9
|
-
from without import cancel_futures
|
|
10
9
|
from without_asgi import Asgi
|
|
11
10
|
from without_asgi import ASGIApp
|
|
12
11
|
from without_asgi import LifespanScope
|
|
@@ -20,6 +19,7 @@ from without_asgi import StartupFailed
|
|
|
20
19
|
from without_asgi import encode_lifespan_event
|
|
21
20
|
from without_asgi import encode_scope
|
|
22
21
|
from without_asgi import parse_lifespan_reply
|
|
22
|
+
from without_async import cancel_futures
|
|
23
23
|
|
|
24
24
|
# Lifespan uses its own spec version, distinct from the HTTP/WebSocket scopes.
|
|
25
25
|
_LIFESPAN = LifespanScope(asgi=Asgi(version="3.0", spec_version="2.0"))
|
|
@@ -22,9 +22,6 @@ import h2.events
|
|
|
22
22
|
import h2.exceptions
|
|
23
23
|
import h2.settings
|
|
24
24
|
import h11
|
|
25
|
-
from without import background_task
|
|
26
|
-
from without import cancel_futures
|
|
27
|
-
from without import timeout
|
|
28
25
|
from without_asgi import ASGIApp
|
|
29
26
|
from without_asgi import Disconnect
|
|
30
27
|
from without_asgi import EarlyHint
|
|
@@ -50,6 +47,9 @@ from without_asgi import encode_websocket_inbound
|
|
|
50
47
|
from without_asgi import encode_websocket_scope
|
|
51
48
|
from without_asgi import parse_outbound
|
|
52
49
|
from without_asgi import parse_websocket_outbound
|
|
50
|
+
from without_async import background_task
|
|
51
|
+
from without_async import cancel_futures
|
|
52
|
+
from without_async import timeout
|
|
53
53
|
from wsproto import ConnectionType
|
|
54
54
|
from wsproto import WSConnection
|
|
55
55
|
from wsproto.events import BytesMessage
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import socket
|
|
4
|
-
|
|
4
|
+
|
|
5
|
+
from without_async import Seconds
|
|
5
6
|
|
|
6
7
|
type SocketOptions = tuple[tuple[int, int, int], ...]
|
|
7
8
|
|
|
9
|
+
# Shared immutable defaults for the keepalive probe timing: a frozen count is a value, so
|
|
10
|
+
# every caller can hold the same one.
|
|
11
|
+
_KEEPALIVE_IDLE = Seconds(60)
|
|
12
|
+
_KEEPALIVE_INTERVAL = Seconds(10)
|
|
13
|
+
|
|
8
14
|
|
|
9
15
|
def apply_socket_options(sock: socket.socket | None, options: SocketOptions) -> None:
|
|
10
16
|
"""Apply each `(level, option, value)` triple to `sock`."""
|
|
@@ -16,8 +22,8 @@ def apply_socket_options(sock: socket.socket | None, options: SocketOptions) ->
|
|
|
16
22
|
|
|
17
23
|
def tcp_keepalive(
|
|
18
24
|
*,
|
|
19
|
-
idle:
|
|
20
|
-
interval:
|
|
25
|
+
idle: Seconds = _KEEPALIVE_IDLE,
|
|
26
|
+
interval: Seconds = _KEEPALIVE_INTERVAL,
|
|
21
27
|
count: int = 6,
|
|
22
28
|
) -> SocketOptions:
|
|
23
29
|
"""
|
|
@@ -35,9 +41,10 @@ def tcp_keepalive(
|
|
|
35
41
|
- `count`: unanswered probes before the connection is declared dead.
|
|
36
42
|
|
|
37
43
|
So a broken idle connection is dropped roughly `idle + interval * count` after it goes
|
|
38
|
-
quiet. `idle`/`interval`
|
|
39
|
-
|
|
40
|
-
truncated
|
|
44
|
+
quiet. `idle`/`interval` are counts of `Seconds` because the OS options carry only
|
|
45
|
+
integer seconds: a finer duration is not something either can be built from, so
|
|
46
|
+
nothing is silently truncated on the way to the socket. `count` is a plain probe
|
|
47
|
+
count, not a duration.
|
|
41
48
|
|
|
42
49
|
Enabling keepalive (`SO_KEEPALIVE`) is portable, but the per-probe tuning is not
|
|
43
50
|
uniformly spelled or present, so the result includes each knob only where the running
|
|
@@ -50,16 +57,13 @@ def tcp_keepalive(
|
|
|
50
57
|
available", Windows 10+); older Windows exposes none of them, so only `SO_KEEPALIVE`
|
|
51
58
|
is enabled and the probe timing stays at the system default.
|
|
52
59
|
"""
|
|
53
|
-
for name, duration in (("idle", idle), ("interval", interval)):
|
|
54
|
-
if duration.microseconds:
|
|
55
|
-
raise ValueError(f"{name} must be a whole number of seconds, got {duration}")
|
|
56
60
|
# macOS has no TCP_KEEPIDLE and spells the idle knob TCP_KEEPALIVE; Linux and modern
|
|
57
61
|
# Windows use TCP_KEEPIDLE, so prefer it and fall back to the macOS name.
|
|
58
62
|
idle_option = getattr(socket, "TCP_KEEPIDLE", None) or getattr(socket, "TCP_KEEPALIVE", None)
|
|
59
63
|
options = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)]
|
|
60
64
|
for option, value in (
|
|
61
|
-
(idle_option,
|
|
62
|
-
(getattr(socket, "TCP_KEEPINTVL", None),
|
|
65
|
+
(idle_option, idle.count),
|
|
66
|
+
(getattr(socket, "TCP_KEEPINTVL", None), interval.count),
|
|
63
67
|
(getattr(socket, "TCP_KEEPCNT", None), count),
|
|
64
68
|
):
|
|
65
69
|
# The skip path only fires on a platform missing a knob (e.g. older Windows),
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
5
|
+
from collections.abc import Awaitable
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
from typing import assert_never
|
|
9
|
+
|
|
10
|
+
from without_asgi import RawHeaders
|
|
11
|
+
from without_asgi.headers import first
|
|
12
|
+
from without_asgi.sse import EVENT_STREAM_MEDIA_TYPE
|
|
13
|
+
from without_asgi.sse import Checkpoint
|
|
14
|
+
from without_asgi.sse import ReceivedEvent
|
|
15
|
+
from without_asgi.sse import Retry
|
|
16
|
+
from without_asgi.sse import parse_events_with_directives
|
|
17
|
+
from without_streams import close_stream
|
|
18
|
+
|
|
19
|
+
from without_http.client import ClientResponse
|
|
20
|
+
from without_http.client import ResponseHead
|
|
21
|
+
from without_http.timeouts import HTTPTimeout
|
|
22
|
+
|
|
23
|
+
# The half of Server-Sent Events that needs a transport. The format itself is two pure
|
|
24
|
+
# stream transforms in `without-asgi` (`without_asgi.sse`), which is why a handler can
|
|
25
|
+
# emit an event stream under uvicorn and a caller can parse one out of any
|
|
26
|
+
# `Stream[bytes]`. What lives here is the loop those two cannot express on their own:
|
|
27
|
+
# reconnecting a dropped stream and resuming it from the last id, which needs a client
|
|
28
|
+
# exchange to reconnect *with* — the `ClientResponse` it opens each attempt against, and
|
|
29
|
+
# the timeouts it treats as a stream that dropped rather than a fault.
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"DEFAULT_RECONNECT",
|
|
33
|
+
"MAXIMUM_RECONNECT",
|
|
34
|
+
"MINIMUM_RECONNECT",
|
|
35
|
+
"NotAnEventStream",
|
|
36
|
+
"subscribe",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# How long to wait before reconnecting until a producer says otherwise with `retry:`.
|
|
40
|
+
# The spec leaves the initial value to the implementation, "probably in the region of a
|
|
41
|
+
# few seconds"; browsers land between two and five.
|
|
42
|
+
DEFAULT_RECONNECT = timedelta(seconds=3)
|
|
43
|
+
|
|
44
|
+
# The floor a producer's `retry:` is clamped to, so a hostile or broken `retry: 0`
|
|
45
|
+
# cannot spin a consumer into a hot reconnect loop.
|
|
46
|
+
MINIMUM_RECONNECT = timedelta(milliseconds=100)
|
|
47
|
+
|
|
48
|
+
# The ceiling, for the mirrored reason: a `retry: 8640000000000` that was meant to be
|
|
49
|
+
# `retry: 86400000` parks a consumer for centuries, and a subscription that is silent
|
|
50
|
+
# forever with nothing raised is harder to notice than one that reconnects too often.
|
|
51
|
+
# Well past any real backoff, which browsers spell in seconds.
|
|
52
|
+
MAXIMUM_RECONNECT = timedelta(minutes=5)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class NotAnEventStream(Exception):
|
|
56
|
+
"""
|
|
57
|
+
The endpoint did not answer with a `200 text/event-stream` response.
|
|
58
|
+
|
|
59
|
+
Terminal, never retried: the spec fails the connection on exactly these two
|
|
60
|
+
conditions and does not reconnect afterwards, because an endpoint answering `404`
|
|
61
|
+
or `text/html` is not a stream that dropped, it is one that was never there.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def _sleep(duration: timedelta) -> None:
|
|
66
|
+
await asyncio.sleep(duration.total_seconds())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _is_event_stream(head: ResponseHead) -> bool:
|
|
70
|
+
content_type = first(head.headers, b"content-type")
|
|
71
|
+
if content_type is None:
|
|
72
|
+
return False
|
|
73
|
+
return content_type.split(b";")[0].strip().lower() == EVENT_STREAM_MEDIA_TYPE
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def subscribe(
|
|
77
|
+
attempt: Callable[[RawHeaders], Awaitable[ClientResponse]],
|
|
78
|
+
*,
|
|
79
|
+
reconnect: timedelta = DEFAULT_RECONNECT,
|
|
80
|
+
minimum_reconnect: timedelta = MINIMUM_RECONNECT,
|
|
81
|
+
maximum_reconnect: timedelta = MAXIMUM_RECONNECT,
|
|
82
|
+
max_event_size: int | None = None,
|
|
83
|
+
sleep: Callable[[timedelta], Awaitable[None]] = _sleep,
|
|
84
|
+
) -> AsyncGenerator[ReceivedEvent]:
|
|
85
|
+
"""
|
|
86
|
+
Consume an event stream, reconnecting and resuming when it drops.
|
|
87
|
+
|
|
88
|
+
The composition the two halves of Server-Sent Events exist to be assembled into: it
|
|
89
|
+
opens a connection, parses the response body with
|
|
90
|
+
`without_asgi.sse.parse_events_with_directives`, and when the stream ends it waits
|
|
91
|
+
and opens another one carrying `Last-Event-ID`, so the producer can resume where the
|
|
92
|
+
consumer stopped. What a caller sees is one uninterrupted stream of events across
|
|
93
|
+
however many connections it took.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
async for event in subscribe(lambda headers: client(ClientRequest("GET", url, headers))):
|
|
97
|
+
print(event.type, event.data)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`attempt` opens one connection: given the headers this loop wants on the request, it
|
|
101
|
+
answers with the response. A *function* rather than a `ClientRequest`, because a
|
|
102
|
+
request is not replayable. Its body is a `Stream[bytes]`, which the interface allows
|
|
103
|
+
to be iterated exactly once, and the bodies this package builds are one-shot async
|
|
104
|
+
generators, so re-sending one request value would put a full body on the wire for
|
|
105
|
+
the first attempt and an empty one on every attempt after it. Building the request
|
|
106
|
+
inside `attempt` makes that unrepresentable rather than documented, and it is what
|
|
107
|
+
lets an event stream ride a `POST` (the shape MCP's Streamable HTTP uses) instead of
|
|
108
|
+
only the bodyless `GET` a reused request survives.
|
|
109
|
+
|
|
110
|
+
The headers handed to `attempt` are `accept: text/event-stream` and, once the stream
|
|
111
|
+
has a resumption point, `last-event-id`. Pass them through as above, or `merge` them
|
|
112
|
+
with your own to decide which side wins on a name you also set.
|
|
113
|
+
|
|
114
|
+
Descending a layer is the whole point of the split, and costs one line. A caller
|
|
115
|
+
that wants exactly one connection, or its own reconnection policy, skips this and
|
|
116
|
+
parses the body directly:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
head, body = await client(request)
|
|
120
|
+
async for event in parse_events(body):
|
|
121
|
+
...
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## What it retries, and what it does not
|
|
125
|
+
|
|
126
|
+
This is the only retry loop `without-http` ships, and the register's position
|
|
127
|
+
against a `retry()` middleware is why it can be: that position rejects *policy*
|
|
128
|
+
the library would have to invent (how many attempts, which statuses, what backoff),
|
|
129
|
+
and here there is none to invent. The backoff arrives on the wire as `retry:`, the
|
|
130
|
+
resumption token arrives as `id:`, and the terminal condition is written into the
|
|
131
|
+
protocol. What the settings below decide is how far to trust the peer that supplies
|
|
132
|
+
them.
|
|
133
|
+
|
|
134
|
+
- A **non-`200` status or a content type other than `text/event-stream`** raises
|
|
135
|
+
`NotAnEventStream` and never reconnects, per the spec's terminal failure.
|
|
136
|
+
- The **first connection's errors propagate**. A caller that cannot reach the
|
|
137
|
+
endpoint at all learns so immediately instead of watching a silent loop.
|
|
138
|
+
- Once a stream has been established, a **connection error or timeout, on the
|
|
139
|
+
stream or on any later attempt, reconnects** after the current wait. A stream
|
|
140
|
+
that a proxy reaps every 60 seconds is the ordinary case, not the exceptional
|
|
141
|
+
one, which is why the protocol has a resumption token at all.
|
|
142
|
+
|
|
143
|
+
`reconnect` is the wait until the producer names one with `retry:`, after which its
|
|
144
|
+
value is used, clamped to between `minimum_reconnect` (100ms) and
|
|
145
|
+
`maximum_reconnect` (five minutes). Both ends guard the same thing, a `retry:` that
|
|
146
|
+
is hostile or merely wrong: at zero it would spin a consumer into a hot reconnect
|
|
147
|
+
loop, and a few orders of magnitude too large it would park one on a subscription
|
|
148
|
+
that goes silent forever with nothing raised to notice. Widen either end for a
|
|
149
|
+
producer you trust to name its own backoff, or narrow them to hold a peer to a
|
|
150
|
+
window you chose. `max_event_size` is passed through to the parser. `sleep` is the
|
|
151
|
+
delay, injected so a test drives the loop without waiting (and so a caller can add
|
|
152
|
+
jitter).
|
|
153
|
+
|
|
154
|
+
Reflecting a producer's value into a request header is safe here because the parser
|
|
155
|
+
only ever hands on an id a header can carry unchanged: a carriage return or line
|
|
156
|
+
feed is what ended the field, and an `id:` a field value could not spell, or would
|
|
157
|
+
silently alter, is ignored rather than resumed from.
|
|
158
|
+
|
|
159
|
+
An `AsyncGenerator` rather than a bare `AsyncIterator`, because this holds a live
|
|
160
|
+
connection and a caller that stops early should be able to say so: `aclose()`
|
|
161
|
+
releases it there and then, rather than at whenever the collector gets to it.
|
|
162
|
+
"""
|
|
163
|
+
# An ordinary function wrapping the generator, so a contradictory window is refused
|
|
164
|
+
# where the caller wrote it. The same guards inside the generator body would not run
|
|
165
|
+
# until the first `anext`, which is after the first request is on the wire and may be
|
|
166
|
+
# arbitrarily long after the mistake was made.
|
|
167
|
+
if not timedelta() <= minimum_reconnect <= maximum_reconnect:
|
|
168
|
+
raise ValueError(
|
|
169
|
+
f"the reconnection window runs from zero upwards, not from {minimum_reconnect!r} to {maximum_reconnect!r}"
|
|
170
|
+
)
|
|
171
|
+
if reconnect < timedelta():
|
|
172
|
+
raise ValueError(f"an initial reconnection wait cannot be negative: {reconnect!r}")
|
|
173
|
+
return _subscribed(
|
|
174
|
+
attempt,
|
|
175
|
+
reconnect=reconnect,
|
|
176
|
+
minimum_reconnect=minimum_reconnect,
|
|
177
|
+
maximum_reconnect=maximum_reconnect,
|
|
178
|
+
max_event_size=max_event_size,
|
|
179
|
+
sleep=sleep,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
async def _subscribed(
|
|
184
|
+
attempt: Callable[[RawHeaders], Awaitable[ClientResponse]],
|
|
185
|
+
*,
|
|
186
|
+
reconnect: timedelta,
|
|
187
|
+
minimum_reconnect: timedelta,
|
|
188
|
+
maximum_reconnect: timedelta,
|
|
189
|
+
max_event_size: int | None,
|
|
190
|
+
sleep: Callable[[timedelta], Awaitable[None]],
|
|
191
|
+
) -> AsyncGenerator[ReceivedEvent]:
|
|
192
|
+
last_id = ""
|
|
193
|
+
wait = reconnect
|
|
194
|
+
established = False
|
|
195
|
+
# What every attempt offers. `last-event-id` joins it once the stream has a
|
|
196
|
+
# resumption point, and the caller decides how these meet the request's own headers.
|
|
197
|
+
offered: RawHeaders = ((b"accept", EVENT_STREAM_MEDIA_TYPE),)
|
|
198
|
+
|
|
199
|
+
while True:
|
|
200
|
+
headers = (*offered, (b"last-event-id", last_id.encode())) if last_id else offered
|
|
201
|
+
try:
|
|
202
|
+
head, body = await attempt(headers)
|
|
203
|
+
except OSError, HTTPTimeout:
|
|
204
|
+
if not established:
|
|
205
|
+
raise
|
|
206
|
+
await sleep(wait)
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
if head.status != 200 or not _is_event_stream(head):
|
|
210
|
+
await body.aclose()
|
|
211
|
+
raise NotAnEventStream(
|
|
212
|
+
f"expected a 200 text/event-stream response, got {head.status} "
|
|
213
|
+
f"{(first(head.headers, b'content-type') or b'with no content-type').decode('latin-1')}"
|
|
214
|
+
)
|
|
215
|
+
established = True
|
|
216
|
+
|
|
217
|
+
# Each connection parses from the id it resumed with, so an event carrying no
|
|
218
|
+
# `id:` of its own reports that rather than nothing: the spec seeds a reconnected
|
|
219
|
+
# parser's last event ID buffer for exactly this reason, and a parser starting
|
|
220
|
+
# from empty would erase the resumption point the next reconnect needs.
|
|
221
|
+
items = parse_events_with_directives(body, last_event_id=last_id, max_event_size=max_event_size)
|
|
222
|
+
try:
|
|
223
|
+
async for item in items:
|
|
224
|
+
match item:
|
|
225
|
+
case ReceivedEvent():
|
|
226
|
+
last_id = item.id
|
|
227
|
+
yield item
|
|
228
|
+
case Retry(after):
|
|
229
|
+
wait = min(max(after.duration, minimum_reconnect), maximum_reconnect)
|
|
230
|
+
case Checkpoint(moved_to):
|
|
231
|
+
# A frame that moved the resumption point without delivering an
|
|
232
|
+
# event. Missing these resumes from before whatever the producer
|
|
233
|
+
# skipped, which is the whole reason it sent one.
|
|
234
|
+
last_id = moved_to
|
|
235
|
+
case _ as unreachable:
|
|
236
|
+
assert_never(unreachable)
|
|
237
|
+
except OSError, HTTPTimeout:
|
|
238
|
+
# A dropped stream is the ordinary end of a long-lived connection, not a
|
|
239
|
+
# fault to surface: that is what the resumption token is for.
|
|
240
|
+
pass
|
|
241
|
+
finally:
|
|
242
|
+
# The parser as well as the body, because a caller closing the subscription
|
|
243
|
+
# stops this generator at the `yield` above and leaves both of them open.
|
|
244
|
+
await close_stream(items)
|
|
245
|
+
await body.aclose()
|
|
246
|
+
|
|
247
|
+
await sleep(wait)
|
|
@@ -16,7 +16,6 @@ from typing import assert_never
|
|
|
16
16
|
from urllib.parse import unquote
|
|
17
17
|
from urllib.parse import urlsplit
|
|
18
18
|
|
|
19
|
-
from without import cancel_futures
|
|
20
19
|
from without_asgi import Asgi
|
|
21
20
|
from without_asgi import ASGIApp
|
|
22
21
|
from without_asgi import Disconnect
|
|
@@ -35,6 +34,7 @@ from without_asgi import encode_inbound
|
|
|
35
34
|
from without_asgi import parse_outbound
|
|
36
35
|
from without_asgi.outbound import ResponseBody as OutboundBody
|
|
37
36
|
from without_asgi.outbound import ResponseTrailers as OutboundTrailers
|
|
37
|
+
from without_async import cancel_futures
|
|
38
38
|
|
|
39
39
|
from without_http.client import _NO_TIMEOUT
|
|
40
40
|
from without_http.client import Client
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|