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 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