httpunk 0.1.0__cp315-cp315-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- httpunk/__init__.py +19 -0
- httpunk/_backend/__init__.py +65 -0
- httpunk/_backend/asyncio.py +320 -0
- httpunk/_backend/tonio.py +115 -0
- httpunk/_common.py +111 -0
- httpunk/_httpunk.cp315-win_amd64.pyd +0 -0
- httpunk/_httpunk.pyi +456 -0
- httpunk/asyncio.py +323 -0
- httpunk/exceptions.py +67 -0
- httpunk/h1/__init__.py +6 -0
- httpunk/h1/client.py +363 -0
- httpunk/h1/connection.py +104 -0
- httpunk/h1/server.py +559 -0
- httpunk/h1/share.py +139 -0
- httpunk/h2/__init__.py +2 -0
- httpunk/h2/client.py +422 -0
- httpunk/h2/connection.py +276 -0
- httpunk/h2/server.py +444 -0
- httpunk/h2/settings.py +167 -0
- httpunk/h2/share.py +55 -0
- httpunk/h2/stream.py +81 -0
- httpunk/h2/streams.py +834 -0
- httpunk/http.py +8 -0
- httpunk/py.typed +0 -0
- httpunk/types.py +121 -0
- httpunk/util/__init__.py +18 -0
- httpunk/util/auto.py +123 -0
- httpunk/util/client.py +66 -0
- httpunk/util/graceful.py +124 -0
- httpunk/util/pool.py +289 -0
- httpunk/util/proxy.py +21 -0
- httpunk-0.1.0.dist-info/METADATA +487 -0
- httpunk-0.1.0.dist-info/RECORD +36 -0
- httpunk-0.1.0.dist-info/WHEEL +4 -0
- httpunk-0.1.0.dist-info/licenses/LICENSE +28 -0
- httpunk-0.1.0.dist-info/sboms/httpunk.cyclonedx.json +1087 -0
httpunk/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from . import http as http
|
|
2
|
+
from ._backend import Backend as Backend
|
|
3
|
+
from ._httpunk import __version__ as __version__
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
ConnectionClosedError as ConnectionClosedError,
|
|
6
|
+
GoAwayError as GoAwayError,
|
|
7
|
+
H2Error as H2Error,
|
|
8
|
+
H2FlowControlError as H2FlowControlError,
|
|
9
|
+
H2ProtocolError as H2ProtocolError,
|
|
10
|
+
H2Reason as H2Reason,
|
|
11
|
+
H2StreamError as H2StreamError,
|
|
12
|
+
H2UserError as H2UserError,
|
|
13
|
+
HTTPunkError as HTTPunkError,
|
|
14
|
+
StreamResetError as StreamResetError,
|
|
15
|
+
)
|
|
16
|
+
from .h1 import H1Connection as H1Connection, H1Server as H1Server
|
|
17
|
+
from .h2 import H2Connection as H2Connection, H2Server as H2Server
|
|
18
|
+
from .http import HeaderMap as HeaderMap
|
|
19
|
+
from .types import Request as Request, Response as Response
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Runtime backends. Only this layer talks to a specific async runtime; the
|
|
2
|
+
drivers (httpunk.h1/h2/util) are backend-agnostic — they take a `backend` and use
|
|
3
|
+
only the seam it exposes.
|
|
4
|
+
|
|
5
|
+
There is **no default backend**: `tonio` needs free-threaded CPython >= 3.14, while
|
|
6
|
+
`asyncio` is available everywhere (incl. GIL builds), so the caller must choose.
|
|
7
|
+
Selection is explicit — a `Backend` enum member (resolved to an instance lazily, so
|
|
8
|
+
picking `asyncio` never imports tonio) or a backend instance. This module imports no
|
|
9
|
+
backend eagerly; `Backend.<member>.create()` does the import on demand.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import enum
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Backend(enum.Enum):
|
|
19
|
+
"""The available runtime backends. `create()` instantiates one, importing its
|
|
20
|
+
module lazily so an unavailable backend (e.g. tonio on a GIL build) is only
|
|
21
|
+
touched when actually selected."""
|
|
22
|
+
|
|
23
|
+
tonio = "tonio"
|
|
24
|
+
asyncio = "asyncio"
|
|
25
|
+
|
|
26
|
+
def create(self) -> Any:
|
|
27
|
+
if self is Backend.tonio:
|
|
28
|
+
try:
|
|
29
|
+
from .tonio import TonioBackend
|
|
30
|
+
except ImportError as exc:
|
|
31
|
+
raise ImportError(
|
|
32
|
+
"the tonio backend requires the 'tonio' package — install it with "
|
|
33
|
+
"`pip install httpunk[tonio]` (needs free-threaded CPython >= 3.14 on Unix)"
|
|
34
|
+
) from exc
|
|
35
|
+
return TonioBackend()
|
|
36
|
+
from .asyncio import AsyncioBackend
|
|
37
|
+
|
|
38
|
+
return AsyncioBackend()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from .asyncio import AsyncioBackend
|
|
43
|
+
from .tonio import TonioBackend
|
|
44
|
+
|
|
45
|
+
# What every public `backend=` parameter accepts: a `Backend` enum member (the
|
|
46
|
+
# documented path) or an already-created backend instance (`httpunk.asyncio`
|
|
47
|
+
# passes one; `resolve()` returns whatever it's given). The backends share an
|
|
48
|
+
# implicit interface but no nominal base, so this is a union of the concretes.
|
|
49
|
+
BackendLike = Backend | AsyncioBackend | TonioBackend
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve(backend) -> Any:
|
|
53
|
+
"""Resolve a `backend` argument to a backend instance: a `Backend` member →
|
|
54
|
+
a fresh instance (lazy import); an instance → itself. `None` raises — there is
|
|
55
|
+
no default (see the module docstring). Call this **module-qualified**
|
|
56
|
+
(`_backend.resolve(...)`) so it stays a single monkeypatch point for tests."""
|
|
57
|
+
if backend is None:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
"a backend is required: pass backend=Backend.asyncio or Backend.tonio "
|
|
60
|
+
"(or a backend instance). There is no default — tonio needs free-threaded "
|
|
61
|
+
"CPython >= 3.14, while asyncio is available everywhere."
|
|
62
|
+
)
|
|
63
|
+
if isinstance(backend, Backend):
|
|
64
|
+
return backend.create()
|
|
65
|
+
return backend
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""asyncio backend: the runtime primitives the drivers need, on `asyncio`.
|
|
2
|
+
|
|
3
|
+
The second backend (after tonio), validating that the drivers depend only on the
|
|
4
|
+
seam. `import asyncio` below is the stdlib (absolute imports) — this
|
|
5
|
+
module is `httpunk._backend.asyncio`, not the top-level package.
|
|
6
|
+
|
|
7
|
+
asyncio's TCP is transport/protocol-based and eagerly
|
|
8
|
+
drains the kernel socket into a userspace buffer, so — unlike tonio — a non-blocking
|
|
9
|
+
`receive_nowait` must peek *that* buffer. Rather than lean on `StreamReader`'s
|
|
10
|
+
private `_buffer`, we own a custom `asyncio.Protocol` (`_AsyncioStream`) that IS the
|
|
11
|
+
seam's stream: it buffers `data_received`, presents `receive_some`/`send_all`/`close`
|
|
12
|
+
/`read_nowait`, and manages its own read/write backpressure. This one class serves
|
|
13
|
+
both mode 1 (httpunk dials via `create_connection`) and — subclassed — Phase 6b's
|
|
14
|
+
reusable server protocols (`H1/H2/Auto` `ServerProtocol`), so keep it inert + subclassable:
|
|
15
|
+
a pure byte-mover + stream interface, no h1/h2 or driver logic.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import errno as _errno
|
|
20
|
+
import ssl as _ssl
|
|
21
|
+
import time as _time
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_READ_HIGH_WATER = 2**16 # 64 KiB — pause reading past this (matches StreamReader's default limit)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _AsyncioStream(asyncio.Protocol):
|
|
28
|
+
"""The dual-fed IO layer: an `asyncio.Protocol` that owns a byte buffer and
|
|
29
|
+
presents the seam's stream interface. Fed by a socket (mode 1) or a host loop
|
|
30
|
+
(mode 2). Protocol-agnostic and driver-less."""
|
|
31
|
+
|
|
32
|
+
def __init__(self):
|
|
33
|
+
self._loop = asyncio.get_running_loop()
|
|
34
|
+
self._transport = None
|
|
35
|
+
self._buffer = bytearray()
|
|
36
|
+
self._eof = False
|
|
37
|
+
self._error = None
|
|
38
|
+
self._read_waiter = None # Future parked in receive_some (single reader)
|
|
39
|
+
self._reading_paused = False
|
|
40
|
+
self._writing_paused = False
|
|
41
|
+
self._drain_waiter = None # Future parked in send_all under write backpressure
|
|
42
|
+
self._conn_lost = False # the socket died (connection_lost) — writes must now fail
|
|
43
|
+
|
|
44
|
+
# ----- asyncio.Protocol callbacks (fed from the socket / host loop) -----
|
|
45
|
+
|
|
46
|
+
def connection_made(self, transport):
|
|
47
|
+
self._transport = transport
|
|
48
|
+
|
|
49
|
+
def data_received(self, data):
|
|
50
|
+
self._buffer += data
|
|
51
|
+
self._wake_reader()
|
|
52
|
+
if not self._reading_paused and len(self._buffer) >= _READ_HIGH_WATER:
|
|
53
|
+
self._transport.pause_reading()
|
|
54
|
+
self._reading_paused = True
|
|
55
|
+
|
|
56
|
+
def eof_received(self):
|
|
57
|
+
self._eof = True
|
|
58
|
+
self._wake_reader()
|
|
59
|
+
# Keep the write half open — HTTP half-close: the peer closed its write side
|
|
60
|
+
# (our read sees EOF) but we still send the response. NOTE (F33b): asyncio
|
|
61
|
+
# honours this over plain TCP, but its SSL layer force-closes the transport on
|
|
62
|
+
# an unexpected EOF (no close_notify) regardless of this return — so a TLS
|
|
63
|
+
# half-close won't keep writing here the way tonio's does. That's an asyncio
|
|
64
|
+
# SSL-runtime limitation, not something this layer can paper over faithfully.
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
def connection_lost(self, exc):
|
|
68
|
+
self._error = exc
|
|
69
|
+
self._eof = True
|
|
70
|
+
self._conn_lost = True # writes to a dead socket must now raise (F32)
|
|
71
|
+
self._wake_reader()
|
|
72
|
+
self._writing_paused = False
|
|
73
|
+
if self._drain_waiter is not None and not self._drain_waiter.done():
|
|
74
|
+
if exc is None:
|
|
75
|
+
self._drain_waiter.set_result(None)
|
|
76
|
+
else:
|
|
77
|
+
self._drain_waiter.set_exception(exc)
|
|
78
|
+
|
|
79
|
+
def pause_writing(self):
|
|
80
|
+
self._writing_paused = True
|
|
81
|
+
|
|
82
|
+
def resume_writing(self):
|
|
83
|
+
self._writing_paused = False
|
|
84
|
+
if self._drain_waiter is not None and not self._drain_waiter.done():
|
|
85
|
+
self._drain_waiter.set_result(None)
|
|
86
|
+
self._drain_waiter = None
|
|
87
|
+
|
|
88
|
+
# ----- the seam's stream interface (called by the drivers) -----
|
|
89
|
+
|
|
90
|
+
async def receive_some(self, max_bytes=65536):
|
|
91
|
+
"""Up to `max_bytes` of the next available bytes; `b""` at EOF. Blocks only
|
|
92
|
+
when the buffer is empty and no EOF/error has arrived yet."""
|
|
93
|
+
if self._buffer:
|
|
94
|
+
return self._take(max_bytes)
|
|
95
|
+
if self._eof:
|
|
96
|
+
if self._error is not None:
|
|
97
|
+
raise self._error
|
|
98
|
+
return b""
|
|
99
|
+
if self._read_waiter is not None:
|
|
100
|
+
# Single-reader contract: a second concurrent receive_some would overwrite
|
|
101
|
+
# the first's waiter, and the first caller would then park forever. The
|
|
102
|
+
# drivers read one-at-a-time, so this is a bug — fail loudly rather than
|
|
103
|
+
# hang silently (F55).
|
|
104
|
+
raise RuntimeError("concurrent receive_some on one stream (single-reader contract)")
|
|
105
|
+
self._read_waiter = self._loop.create_future()
|
|
106
|
+
try:
|
|
107
|
+
await self._read_waiter
|
|
108
|
+
finally:
|
|
109
|
+
self._read_waiter = None
|
|
110
|
+
if self._buffer:
|
|
111
|
+
return self._take(max_bytes)
|
|
112
|
+
if self._error is not None:
|
|
113
|
+
raise self._error
|
|
114
|
+
return b""
|
|
115
|
+
|
|
116
|
+
async def send_all(self, data):
|
|
117
|
+
# A write to a dead socket must fail (F32): asyncio's transport silently
|
|
118
|
+
# DISCARDS writes after connection_lost, but tonio (like a raw socket) raises
|
|
119
|
+
# EPIPE/ECONNRESET — drivers detect a dead peer via that failure. Surface the
|
|
120
|
+
# real error, or EPIPE if the close carried none (a clean peer FIN).
|
|
121
|
+
if self._conn_lost:
|
|
122
|
+
raise self._error or BrokenPipeError(_errno.EPIPE, "connection lost")
|
|
123
|
+
self._transport.write(data)
|
|
124
|
+
if self._writing_paused: # transport buffer over high-water — wait for resume (drain)
|
|
125
|
+
if self._drain_waiter is None:
|
|
126
|
+
self._drain_waiter = self._loop.create_future()
|
|
127
|
+
await self._drain_waiter
|
|
128
|
+
|
|
129
|
+
def close(self):
|
|
130
|
+
if self._transport is None:
|
|
131
|
+
return
|
|
132
|
+
# Over TLS, abort() (no close_notify) rather than close() — an abortive close
|
|
133
|
+
# matching tonio's raw-socket close and hyper's hard close on a dropped
|
|
134
|
+
# connection (F33a); the async, caller-owned H1Upgraded.aclose path does the
|
|
135
|
+
# full close_notify dance instead. Plain TCP close() already sends a FIN.
|
|
136
|
+
if self._transport.get_extra_info("ssl_object") is not None:
|
|
137
|
+
self._transport.abort()
|
|
138
|
+
else:
|
|
139
|
+
self._transport.close()
|
|
140
|
+
|
|
141
|
+
def read_nowait(self, max_bytes=65536):
|
|
142
|
+
"""Synchronous non-blocking peek: whatever is buffered right now, else `b""`
|
|
143
|
+
(the `receive_nowait` primitive — approach B peeks *our* buffer)."""
|
|
144
|
+
return self._take(max_bytes) if self._buffer else b""
|
|
145
|
+
|
|
146
|
+
# ----- helpers -----
|
|
147
|
+
|
|
148
|
+
def _take(self, max_bytes):
|
|
149
|
+
if max_bytes >= len(self._buffer):
|
|
150
|
+
data = bytes(self._buffer)
|
|
151
|
+
self._buffer.clear()
|
|
152
|
+
else:
|
|
153
|
+
data = bytes(self._buffer[:max_bytes])
|
|
154
|
+
del self._buffer[:max_bytes]
|
|
155
|
+
if self._reading_paused and len(self._buffer) < _READ_HIGH_WATER:
|
|
156
|
+
self._transport.resume_reading()
|
|
157
|
+
self._reading_paused = False
|
|
158
|
+
return data
|
|
159
|
+
|
|
160
|
+
def _wake_reader(self):
|
|
161
|
+
waiter = self._read_waiter
|
|
162
|
+
if waiter is not None and not waiter.done():
|
|
163
|
+
waiter.set_result(None)
|
|
164
|
+
|
|
165
|
+
def interrupt_read(self):
|
|
166
|
+
"""Wake a parked `receive_some` so it returns b"" — lets a graceful shutdown release
|
|
167
|
+
an idle keep-alive read WITHOUT racing a coroutine (asyncio can wake a parked read from
|
|
168
|
+
another task; tonio can't, which is why it uses a select-based race instead)."""
|
|
169
|
+
self._wake_reader()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class _AsyncioScope:
|
|
173
|
+
"""A nursery over `asyncio` tasks, matching tonio's scope surface: `spawn`,
|
|
174
|
+
`cancel`, and `__aenter__`/`__aexit__` (which joins). Supports both lexical use
|
|
175
|
+
and the detached h2 pattern (`__aenter__` in `_begin`, `__aexit__` in `close`).
|
|
176
|
+
`asyncio.TaskGroup` can't do either (strictly lexical, no `cancel()`)."""
|
|
177
|
+
|
|
178
|
+
__slots__ = ["_tasks"]
|
|
179
|
+
|
|
180
|
+
def __init__(self):
|
|
181
|
+
self._tasks = set()
|
|
182
|
+
|
|
183
|
+
async def __aenter__(self):
|
|
184
|
+
return self
|
|
185
|
+
|
|
186
|
+
def spawn(self, coro):
|
|
187
|
+
task = asyncio.ensure_future(coro)
|
|
188
|
+
self._tasks.add(task)
|
|
189
|
+
task.add_done_callback(self._tasks.discard)
|
|
190
|
+
|
|
191
|
+
def cancel(self):
|
|
192
|
+
for task in list(self._tasks):
|
|
193
|
+
task.cancel()
|
|
194
|
+
|
|
195
|
+
async def __aexit__(self, exc_type, exc_value, exc_tb):
|
|
196
|
+
# Join-only teardown, matching tonio's scope exactly: `__aexit__` never
|
|
197
|
+
# cancels children on its own — a body exception still just JOINS the
|
|
198
|
+
# in-flight children (tonio's `_exit` aborts them only when the explicit
|
|
199
|
+
# `cancel()` flag is set). Callers that want children torn down on error
|
|
200
|
+
# must call `cancel()` themselves (h2 `close()`, h1 client, graceful watch,
|
|
201
|
+
# the h2 handler nursery in httpunk.asyncio all do). gather-and-swallow —
|
|
202
|
+
# a spawned task routes its own failures elsewhere, so exceptions surfacing
|
|
203
|
+
# here are teardown noise, not results.
|
|
204
|
+
if self._tasks:
|
|
205
|
+
await asyncio.gather(*list(self._tasks), return_exceptions=True)
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class _QueueSender:
|
|
210
|
+
__slots__ = ["_q"]
|
|
211
|
+
|
|
212
|
+
def __init__(self, q):
|
|
213
|
+
self._q = q
|
|
214
|
+
|
|
215
|
+
def send(self, item):
|
|
216
|
+
self._q.put_nowait(item)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class _QueueReceiver:
|
|
220
|
+
__slots__ = ["_q"]
|
|
221
|
+
|
|
222
|
+
def __init__(self, q):
|
|
223
|
+
self._q = q
|
|
224
|
+
|
|
225
|
+
def receive(self):
|
|
226
|
+
return self._q.get()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class AsyncioBackend:
|
|
230
|
+
async def connect_tcp(self, host, port):
|
|
231
|
+
loop = asyncio.get_running_loop()
|
|
232
|
+
_transport, stream = await loop.create_connection(_AsyncioStream, host, port)
|
|
233
|
+
return stream
|
|
234
|
+
|
|
235
|
+
async def connect_tls(self, host, port, *, alpn=None, ssl_context=None):
|
|
236
|
+
"""Dial `host:port` over TLS and return `(stream, selected_alpn)` (see the
|
|
237
|
+
tonio backend's `connect_tls`). `create_connection(ssl=...)` completes after
|
|
238
|
+
the TLS handshake, so ALPN is readable from the transport on return."""
|
|
239
|
+
if ssl_context is None:
|
|
240
|
+
ssl_context = _ssl.create_default_context()
|
|
241
|
+
if alpn:
|
|
242
|
+
ssl_context.set_alpn_protocols(list(alpn))
|
|
243
|
+
loop = asyncio.get_running_loop()
|
|
244
|
+
transport, stream = await loop.create_connection(
|
|
245
|
+
_AsyncioStream, host, port, ssl=ssl_context, server_hostname=host
|
|
246
|
+
)
|
|
247
|
+
ssl_obj = transport.get_extra_info("ssl_object")
|
|
248
|
+
selected = ssl_obj.selected_alpn_protocol() if ssl_obj is not None else None
|
|
249
|
+
return stream, selected
|
|
250
|
+
|
|
251
|
+
def receive_nowait(self, transport, max_bytes=65536):
|
|
252
|
+
"""Synchronous non-blocking peek of the userspace buffer (approach B)."""
|
|
253
|
+
return transport.read_nowait(max_bytes)
|
|
254
|
+
|
|
255
|
+
def close_transport(self, transport):
|
|
256
|
+
"""Close the transport (sync). `transport.close()` covers TLS too (asyncio
|
|
257
|
+
drives the `close_notify`), so no plain/TLS split like tonio."""
|
|
258
|
+
transport.close()
|
|
259
|
+
|
|
260
|
+
async def select(self, *coros):
|
|
261
|
+
"""Race `coros`; return the first-ready one's result, cancelling the losers.
|
|
262
|
+
Matches tonio's `select` (`_ctl.select`: spawn all in argument order, keep the
|
|
263
|
+
first stored outcome, then `scope.cancel()` the rest):
|
|
264
|
+
|
|
265
|
+
- only the WINNER's outcome propagates — a loser's result or exception is
|
|
266
|
+
discarded (tonio never fetches a loser's stored value);
|
|
267
|
+
- the winner is the first coro in ARGUMENT ORDER among any ready in the same
|
|
268
|
+
wakeup (asyncio.wait's `done` set is unordered — pick deterministically);
|
|
269
|
+
- if `select` itself is cancelled, every racer is cancelled too (tonio's scope
|
|
270
|
+
aborts its children on teardown) — no coro is orphaned.
|
|
271
|
+
"""
|
|
272
|
+
tasks = [asyncio.ensure_future(c) for c in coros]
|
|
273
|
+
try:
|
|
274
|
+
done, _pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
275
|
+
except asyncio.CancelledError:
|
|
276
|
+
for task in tasks:
|
|
277
|
+
task.cancel()
|
|
278
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
279
|
+
raise
|
|
280
|
+
winner = next(task for task in tasks if task in done)
|
|
281
|
+
losers = [task for task in tasks if task is not winner]
|
|
282
|
+
for task in losers:
|
|
283
|
+
task.cancel()
|
|
284
|
+
# Drain losers so their outcome is discarded (never surfaced here) and no
|
|
285
|
+
# "exception never retrieved" fires; return_exceptions swallows their
|
|
286
|
+
# error/cancellation. Losers that also finished this tick are drained too.
|
|
287
|
+
if losers:
|
|
288
|
+
await asyncio.gather(*losers, return_exceptions=True)
|
|
289
|
+
return winner.result()
|
|
290
|
+
|
|
291
|
+
def queue(self):
|
|
292
|
+
"""An unbounded queue as `(sender, receiver)`: `sender.send(x)` is sync;
|
|
293
|
+
`await receiver.receive()` yields items in order."""
|
|
294
|
+
q = asyncio.Queue()
|
|
295
|
+
return _QueueSender(q), _QueueReceiver(q)
|
|
296
|
+
|
|
297
|
+
# asyncio's Lock/Event/Semaphore already match the seam's neutral contract
|
|
298
|
+
# (Event: set/wait/clear/is_set; Semaphore: async acquire / sync release — the
|
|
299
|
+
# shape step 1 normalized the h1 slot to).
|
|
300
|
+
lock = asyncio.Lock
|
|
301
|
+
event = asyncio.Event
|
|
302
|
+
semaphore = asyncio.Semaphore
|
|
303
|
+
scope = _AsyncioScope
|
|
304
|
+
monotonic = staticmethod(_time.monotonic)
|
|
305
|
+
sleep = staticmethod(asyncio.sleep) # async sleep(seconds), for deadline races (`select`)
|
|
306
|
+
|
|
307
|
+
# Every backend implements `timeout` (above), so the h1 server always uses it for the
|
|
308
|
+
# head-read deadline. This extra capability is asyncio-only: `_AsyncioStream.interrupt_read`
|
|
309
|
+
# can wake a parked idle read from another task, so the h1 server can skip the per-read
|
|
310
|
+
# shutdown `select`. tonio can't (no such method) → it keeps the select there.
|
|
311
|
+
native_read_interrupt = True
|
|
312
|
+
|
|
313
|
+
async def timeout(self, coro, seconds):
|
|
314
|
+
"""Run `coro` bounded by `seconds`; return `(result, True)` on completion, or
|
|
315
|
+
`(None, False)` on timeout (the coro is cancelled). One task + one timer, far cheaper
|
|
316
|
+
than racing a `sleep()` coroutine via `select`."""
|
|
317
|
+
try:
|
|
318
|
+
return await asyncio.wait_for(coro, seconds), True
|
|
319
|
+
except (TimeoutError, asyncio.TimeoutError):
|
|
320
|
+
return None, False
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""tonio backend: wraps the runtime primitives the h2 driver needs.
|
|
2
|
+
|
|
3
|
+
The driver only depends on this small surface — connect, a scope to hold the
|
|
4
|
+
background read-pump, a lock to order socket writes, and an event to signal
|
|
5
|
+
across coroutines — so a future asyncio/trio backend is a drop-in replacement.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import ssl as _ssl
|
|
9
|
+
|
|
10
|
+
from tonio import colored as _colored
|
|
11
|
+
from tonio.colored.net import open_tcp_stream as _open_tcp_stream
|
|
12
|
+
from tonio.colored.net.tls import open_tls_over_tcp_stream as _open_tls_over_tcp_stream
|
|
13
|
+
from tonio.colored.sync import Lock as _Lock, Semaphore as _Semaphore
|
|
14
|
+
from tonio.colored.sync.channel import unbounded as _unbounded
|
|
15
|
+
from tonio.colored.time import time as _now, timeout as _timeout
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _TonioSemaphore:
|
|
19
|
+
"""Adapts tonio's `Semaphore` to the backend-neutral async-`acquire`/sync-`release`
|
|
20
|
+
contract. tonio's `acquire()` is sync and returns None (a permit was free) or an
|
|
21
|
+
`Event` that fires once a permit is handed to this waiter — so awaiting that event
|
|
22
|
+
*is* the acquisition (fair handoff, no retry)."""
|
|
23
|
+
|
|
24
|
+
__slots__ = ["_sem"]
|
|
25
|
+
|
|
26
|
+
def __init__(self, value):
|
|
27
|
+
self._sem = _Semaphore(value)
|
|
28
|
+
|
|
29
|
+
async def acquire(self):
|
|
30
|
+
event = self._sem.acquire()
|
|
31
|
+
if event is not None:
|
|
32
|
+
await event.waiter(None)
|
|
33
|
+
|
|
34
|
+
def release(self):
|
|
35
|
+
self._sem.release()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TonioBackend:
|
|
39
|
+
connect_tcp = staticmethod(_open_tcp_stream)
|
|
40
|
+
|
|
41
|
+
async def connect_tls(self, host, port, *, alpn=None, ssl_context=None):
|
|
42
|
+
"""Dial `host:port` over TLS and return `(stream, selected_alpn)` — the
|
|
43
|
+
one place TLS/ssl glue lives (behind the seam, like `receive_nowait`).
|
|
44
|
+
|
|
45
|
+
`alpn` is the ordered ALPN offer (e.g. `("h2", "http/1.1")`); it is set on
|
|
46
|
+
the context before the handshake and the peer's choice is read back from the
|
|
47
|
+
completed handshake (`None` if the peer declined / offered no ALPN). tonio's
|
|
48
|
+
`open_tls_over_tcp_stream` performs the TCP connect *and* the TLS handshake,
|
|
49
|
+
so the returned stream is ready to carry HTTP — httpunk never handshakes
|
|
50
|
+
itself. Used by `httpunk.util.connect` to pick h2 vs h1 (RFC 7301)."""
|
|
51
|
+
if ssl_context is None:
|
|
52
|
+
ssl_context = _ssl.create_default_context()
|
|
53
|
+
if alpn:
|
|
54
|
+
ssl_context.set_alpn_protocols(list(alpn))
|
|
55
|
+
stream = await _open_tls_over_tcp_stream(host, port, ssl_context=ssl_context)
|
|
56
|
+
return stream, stream._ssl.selected_alpn_protocol()
|
|
57
|
+
|
|
58
|
+
def receive_nowait(self, transport, max_bytes=65536):
|
|
59
|
+
"""A synchronous, non-blocking read: whatever bytes are immediately
|
|
60
|
+
available without suspending, or `b""` if none are ready right now (also
|
|
61
|
+
`b""` at EOF). The readiness primitive hyper's server drain (`poll_read_body`
|
|
62
|
+
inside `poll_drain_or_close_read`) relies on.
|
|
63
|
+
|
|
64
|
+
- **Plain socket**: tonio's sockets are non-blocking under the hood (its own
|
|
65
|
+
`recv` does exactly this `_sock.recv` inline before ever suspending), so
|
|
66
|
+
we read the raw socket directly rather than route through the timer-backed
|
|
67
|
+
`timeout(..., 0)`, which cannot express an instantaneous peek.
|
|
68
|
+
- **TLS (`TLSStream`)**: the raw socket carries *ciphertext*, so reading it
|
|
69
|
+
would bypass decryption (and a `TLSStream` has no `.socket` anyway). The
|
|
70
|
+
non-blocking-plaintext equivalent is the SSLObject's already-decrypted
|
|
71
|
+
buffer: `pending()` bytes can be `read()` without touching the BIO/socket.
|
|
72
|
+
(Necessarily conservative — tonio exposes no non-blocking "decrypt more",
|
|
73
|
+
so unread ciphertext on the socket reads as "nothing ready"; the drain
|
|
74
|
+
then closes rather than reuses, which matches hyper's cheap-drain-or-close.)"""
|
|
75
|
+
ssl_obj = getattr(transport, "_ssl", None)
|
|
76
|
+
if ssl_obj is not None: # a TLSStream — peek only already-decrypted plaintext
|
|
77
|
+
pending = ssl_obj.pending()
|
|
78
|
+
return ssl_obj.read(min(max_bytes, pending)) if pending else b""
|
|
79
|
+
try:
|
|
80
|
+
return transport.socket._sock.recv(max_bytes)
|
|
81
|
+
except (BlockingIOError, InterruptedError):
|
|
82
|
+
return b""
|
|
83
|
+
|
|
84
|
+
def close_transport(self, transport):
|
|
85
|
+
"""Synchronously close a transport, unblocking the peer's read. The driver
|
|
86
|
+
closes from both sync (body-release, failure) and async paths, so this must
|
|
87
|
+
be sync.
|
|
88
|
+
|
|
89
|
+
- **Plain socket**: its `close()` is synchronous.
|
|
90
|
+
- **TLS (`TLSStream`)**: its own `close()` is a coroutine (it writes a TLS
|
|
91
|
+
`close_notify`), which a sync caller can't await — so close the underlying
|
|
92
|
+
socket (`.transport`) directly. The `close_notify` is skipped (a
|
|
93
|
+
best-effort/abortive close, like hyper's hard close on a dropped
|
|
94
|
+
connection), but the socket is really closed so the peer's read ends.
|
|
95
|
+
(`H1Upgraded.aclose`, which is async and caller-owned, awaits the full
|
|
96
|
+
`close_notify` dance instead.)"""
|
|
97
|
+
ssl_obj = getattr(transport, "_ssl", None)
|
|
98
|
+
if ssl_obj is not None: # a TLSStream — close the underlying socket synchronously
|
|
99
|
+
transport.transport.close()
|
|
100
|
+
else:
|
|
101
|
+
transport.close()
|
|
102
|
+
|
|
103
|
+
select = staticmethod(_colored.select)
|
|
104
|
+
scope = staticmethod(_colored.scope)
|
|
105
|
+
lock = _Lock
|
|
106
|
+
event = _colored.Event
|
|
107
|
+
semaphore = _TonioSemaphore
|
|
108
|
+
queue = staticmethod(_unbounded)
|
|
109
|
+
monotonic = staticmethod(_now)
|
|
110
|
+
sleep = staticmethod(_colored.sleep) # async sleep(seconds)
|
|
111
|
+
# `timeout(coro, seconds) -> (result, completed)` — tonio's native deadline; the h1 server
|
|
112
|
+
# uses it to bound the request-head read (measured ~12-16% faster than racing sleep() via
|
|
113
|
+
# select). Same contract as AsyncioBackend.timeout. tonio still can't wake a parked recv from
|
|
114
|
+
# another task, so it does NOT advertise `native_read_interrupt` (idle shutdown stays select).
|
|
115
|
+
timeout = staticmethod(_timeout)
|
httpunk/_common.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Cross-protocol glue shared by the h1 and h2 public API + drivers.
|
|
2
|
+
|
|
3
|
+
Pure orchestration with **no protocol behavior** — the one place h1 and h2
|
|
4
|
+
(separate crates upstream, sharing nothing at the protocol level) legitimately
|
|
5
|
+
share code: body-chunk normalization, and the public connection/server facade
|
|
6
|
+
shells that collapse the driver into an async-context-managed handle. Everything
|
|
7
|
+
protocol-specific stays in the h1/h2 role files.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Awaitable
|
|
13
|
+
from typing import TYPE_CHECKING, Generic, TypeVar
|
|
14
|
+
|
|
15
|
+
from .types import Body, HeadersInput, Request, Response
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from typing import Self
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_RequestT = TypeVar("_RequestT")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def aiter_body(body):
|
|
26
|
+
"""Normalize a message body — `bytes`, a sync iterable, or an async iterable
|
|
27
|
+
of `bytes` — into an async stream of chunks. Protocol-neutral: the caller
|
|
28
|
+
frames + sends each chunk (h1 via the codec, h2 via flow-controlled DATA).
|
|
29
|
+
`None` (a bodyless message) is handled by the caller, not here."""
|
|
30
|
+
if isinstance(body, (bytes, bytearray)):
|
|
31
|
+
yield bytes(body)
|
|
32
|
+
elif hasattr(body, "__aiter__"):
|
|
33
|
+
async for chunk in body:
|
|
34
|
+
yield chunk
|
|
35
|
+
elif hasattr(body, "__iter__"):
|
|
36
|
+
for chunk in body:
|
|
37
|
+
yield chunk
|
|
38
|
+
else:
|
|
39
|
+
raise TypeError("body must be None, bytes, or an (async) iterable of bytes")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def read_all(aiter):
|
|
43
|
+
"""Drain an async byte-chunk iterator into a single `bytes`."""
|
|
44
|
+
return b"".join([chunk async for chunk in aiter])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BaseClientConnection:
|
|
48
|
+
"""Shared public client-facade glue (h1/h2): async-context-manager entry/exit
|
|
49
|
+
+ the `request` wrapper over the protocol-specific `send_request`. Subclasses
|
|
50
|
+
build `self._conn` (which exposes `connect`/`close`) and implement
|
|
51
|
+
`send_request` + `ready`.
|
|
52
|
+
|
|
53
|
+
`request(method, target, ...)` is a thin convenience over `send_request(Request)`;
|
|
54
|
+
it takes the method explicitly (no per-verb helpers like `get()` — httpunk is
|
|
55
|
+
low-level, and a single arbitrary shortcut would be inconsistent)."""
|
|
56
|
+
|
|
57
|
+
async def __aenter__(self) -> Self:
|
|
58
|
+
await self._conn.connect()
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
async def __aexit__(self, exc_type: object, exc_value: object, exc_tb: object) -> bool:
|
|
62
|
+
await self._conn.close()
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
def request(
|
|
66
|
+
self,
|
|
67
|
+
method: str,
|
|
68
|
+
target: str,
|
|
69
|
+
*,
|
|
70
|
+
headers: HeadersInput = None,
|
|
71
|
+
body: Body = None,
|
|
72
|
+
trailers: HeadersInput = None,
|
|
73
|
+
) -> Awaitable[Response]:
|
|
74
|
+
return self.send_request(Request(method, target, headers=headers, body=body, trailers=trailers))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class BaseServer(Generic[_RequestT]):
|
|
78
|
+
"""Shared public server-facade glue (h1/h2): async-context-manager entry/exit
|
|
79
|
+
+ the accept iterator over `self._conn.next_request()`. Subclasses build
|
|
80
|
+
`self._conn` (which exposes `start`/`close`/`next_request`)."""
|
|
81
|
+
|
|
82
|
+
async def __aenter__(self) -> Self:
|
|
83
|
+
await self._conn.start()
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
async def __aexit__(self, exc_type: object, exc_value: object, exc_tb: object) -> bool:
|
|
87
|
+
await self._conn.close()
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
def __aiter__(self) -> Self:
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
async def __anext__(self) -> _RequestT:
|
|
94
|
+
request = await self._conn.next_request()
|
|
95
|
+
if request is None: # connection closed / can serve no more
|
|
96
|
+
raise StopAsyncIteration
|
|
97
|
+
return request
|
|
98
|
+
|
|
99
|
+
def accept(self) -> Awaitable[_RequestT | None]:
|
|
100
|
+
"""Return the next incoming `ServerRequest`, or None once the connection
|
|
101
|
+
can serve no more. (`async for` over the server is the ergonomic form.)"""
|
|
102
|
+
return self._conn.next_request()
|
|
103
|
+
|
|
104
|
+
def graceful_shutdown(self) -> Awaitable[None]:
|
|
105
|
+
"""Signal a graceful shutdown (non-blocking, like hyper's
|
|
106
|
+
`Connection::graceful_shutdown`): h2 sends GOAWAY and refuses new streams;
|
|
107
|
+
h1 stops reusing the connection and releases an idle read. In-flight work
|
|
108
|
+
finishes as the caller keeps driving the accept loop, which then ends and
|
|
109
|
+
closes. `httpunk.util.GracefulShutdown` coordinates this over many
|
|
110
|
+
connections (§11.3)."""
|
|
111
|
+
return self._conn.graceful_shutdown()
|
|
Binary file
|