without-http 0.0.2__tar.gz → 0.0.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: without-http
3
- Version: 0.0.2
3
+ Version: 0.0.3
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,8 @@ 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-core==0.0.2
20
- Requires-Dist: without-asgi==0.0.2
19
+ Requires-Dist: without-core==0.0.3
20
+ Requires-Dist: without-asgi==0.0.3
21
21
  Requires-Dist: h11>=0.16
22
22
  Requires-Dist: h2>=4.1
23
23
  Requires-Dist: wsproto>=1.2
@@ -43,15 +43,15 @@ translate between typed events and the ASGI dicts an app expects.
43
43
  ```python
44
44
  from without import sleep_forever
45
45
  from without_asgi import make_asgi_app
46
- from without_http import ConnectionPool, serving
46
+ from without_http import ConnectionPool, request, serving
47
47
 
48
48
  app = make_asgi_app(lifespan, http=router.dispatch, websocket=sockets.dispatch)
49
49
 
50
50
  async with serving(app, host="127.0.0.1", port=8000):
51
- await sleep_forever() # run until cancelled
51
+ await sleep_forever() # run until cancelled
52
52
 
53
53
  async with ConnectionPool() as pool:
54
- async with pool.request("GET", "http://127.0.0.1:8000/items") as (head, body):
54
+ async with request(pool, "GET", "http://127.0.0.1:8000/items") as (head, body):
55
55
  assert head.status == 200
56
56
  data = await body.read()
57
57
  ```
@@ -59,11 +59,16 @@ async with ConnectionPool() as pool:
59
59
  Because `without-http` speaks plain ASGI to the app, *any* ASGI app runs over it,
60
60
  interchangeably with uvicorn. The server handles TLS, HTTP/2 (by ALPN or prior
61
61
  knowledge), keep-alive, WebSockets over the HTTP/1.1 upgrade, per-handler
62
- isolation, and flow control. The client is a `ConnectionPool` with a `(head,
63
- body)` response split, buffered and streaming bodies in both directions,
64
- opt-in trailers, HTTP/2 multiplexing, per-host connection bounds, per-phase
65
- request timeouts, consumer-driven duplex (with bidirectional streaming over
66
- HTTP/2), and `stack`-composed middleware.
62
+ isolation, and flow control. A client is a function from a request to a response, and
63
+ a `ConnectionPool` is the one that answers over the network: a `(head, body)` response
64
+ split, buffered and streaming bodies in both directions, opt-in trailers, HTTP/2
65
+ multiplexing, per-host connection bounds, per-phase request timeouts, consumer-driven
66
+ duplex (with bidirectional streaming over HTTP/2), and `stack`-composed middleware.
67
+
68
+ `without_http.testing` carries the same interface into a test: a mock client that answers
69
+ from a function, an ASGI client that drives an app in memory, and a loopback client that
70
+ runs the real wire protocols over no socket at all, plus the raw in-memory endpoints
71
+ those are built from, for a test that writes frames rather than requests.
67
72
 
68
73
  See the
69
74
  [`without-http` guide](https://without.help/without-http/)
@@ -17,15 +17,15 @@ translate between typed events and the ASGI dicts an app expects.
17
17
  ```python
18
18
  from without import sleep_forever
19
19
  from without_asgi import make_asgi_app
20
- from without_http import ConnectionPool, serving
20
+ from without_http import ConnectionPool, request, serving
21
21
 
22
22
  app = make_asgi_app(lifespan, http=router.dispatch, websocket=sockets.dispatch)
23
23
 
24
24
  async with serving(app, host="127.0.0.1", port=8000):
25
- await sleep_forever() # run until cancelled
25
+ await sleep_forever() # run until cancelled
26
26
 
27
27
  async with ConnectionPool() as pool:
28
- async with pool.request("GET", "http://127.0.0.1:8000/items") as (head, body):
28
+ async with request(pool, "GET", "http://127.0.0.1:8000/items") as (head, body):
29
29
  assert head.status == 200
30
30
  data = await body.read()
31
31
  ```
@@ -33,11 +33,16 @@ async with ConnectionPool() as pool:
33
33
  Because `without-http` speaks plain ASGI to the app, *any* ASGI app runs over it,
34
34
  interchangeably with uvicorn. The server handles TLS, HTTP/2 (by ALPN or prior
35
35
  knowledge), keep-alive, WebSockets over the HTTP/1.1 upgrade, per-handler
36
- isolation, and flow control. The client is a `ConnectionPool` with a `(head,
37
- body)` response split, buffered and streaming bodies in both directions,
38
- opt-in trailers, HTTP/2 multiplexing, per-host connection bounds, per-phase
39
- request timeouts, consumer-driven duplex (with bidirectional streaming over
40
- HTTP/2), and `stack`-composed middleware.
36
+ isolation, and flow control. A client is a function from a request to a response, and
37
+ a `ConnectionPool` is the one that answers over the network: a `(head, body)` response
38
+ split, buffered and streaming bodies in both directions, opt-in trailers, HTTP/2
39
+ multiplexing, per-host connection bounds, per-phase request timeouts, consumer-driven
40
+ duplex (with bidirectional streaming over HTTP/2), and `stack`-composed middleware.
41
+
42
+ `without_http.testing` carries the same interface into a test: a mock client that answers
43
+ from a function, an ASGI client that drives an app in memory, and a loopback client that
44
+ runs the real wire protocols over no socket at all, plus the raw in-memory endpoints
45
+ those are built from, for a test that writes frames rather than requests.
41
46
 
42
47
  See the
43
48
  [`without-http` guide](https://without.help/without-http/)
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "without-http"
7
- version = "0.0.2"
7
+ version = "0.0.3"
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,8 @@ classifiers = [
23
23
  "Typing :: Typed",
24
24
  ]
25
25
  dependencies = [
26
- "without-core==0.0.2",
27
- "without-asgi==0.0.2",
26
+ "without-core==0.0.3",
27
+ "without-asgi==0.0.3",
28
28
  "h11>=0.16",
29
29
  "h2>=4.1",
30
30
  "wsproto>=1.2",
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "without-http"
7
- version = "0.0.2"
7
+ version = "0.0.3"
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,8 @@ classifiers = [
26
26
  "Typing :: Typed",
27
27
  ]
28
28
  dependencies = [
29
- "without-core==0.0.2",
30
- "without-asgi==0.0.2",
29
+ "without-core==0.0.3",
30
+ "without-asgi==0.0.3",
31
31
  "h11>=0.16",
32
32
  "h2>=4.1",
33
33
  "wsproto>=1.2",
@@ -1,7 +1,8 @@
1
- from without_http.client import ClientExchange
1
+ from without_http.client import Client
2
2
  from without_http.client import ClientMiddleware
3
3
  from without_http.client import ClientRequest
4
4
  from without_http.client import ClientResponse
5
+ from without_http.client import Connect
5
6
  from without_http.client import ConnectionPool
6
7
  from without_http.client import CookieJar
7
8
  from without_http.client import ResponseBody
@@ -9,7 +10,9 @@ from without_http.client import ResponseHead
9
10
  from without_http.client import ResponseTrailers
10
11
  from without_http.client import add_headers
11
12
  from without_http.client import cookies
13
+ from without_http.client import deadline
12
14
  from without_http.client import follow_redirects
15
+ from without_http.client import request
13
16
  from without_http.client import stack
14
17
  from without_http.client import wrap
15
18
  from without_http.h2_wire import early_hint_headers
@@ -42,10 +45,11 @@ from without_http.ws_wire import ws_events_from_outbound
42
45
 
43
46
  __all__ = [
44
47
  "ALPN_PROTOCOLS",
45
- "ClientExchange",
48
+ "Client",
46
49
  "ClientMiddleware",
47
50
  "ClientRequest",
48
51
  "ClientResponse",
52
+ "Connect",
49
53
  "ConnectTimeout",
50
54
  "ConnectionPool",
51
55
  "CookieJar",
@@ -62,12 +66,14 @@ __all__ = [
62
66
  "WriteTimeout",
63
67
  "add_headers",
64
68
  "cookies",
69
+ "deadline",
65
70
  "early_hint_headers",
66
71
  "follow_redirects",
67
72
  "h11_events_from_outbound",
68
73
  "inbound_from_event",
69
74
  "is_websocket_upgrade",
70
75
  "receive_buffer_size",
76
+ "request",
71
77
  "request_headers",
72
78
  "response_headers",
73
79
  "response_status_and_headers",
@@ -16,6 +16,7 @@ from datetime import UTC
16
16
  from datetime import datetime
17
17
  from email.utils import parsedate_to_datetime
18
18
  from typing import NamedTuple
19
+ from typing import Protocol
19
20
  from typing import Self
20
21
  from urllib.parse import SplitResult
21
22
  from urllib.parse import urljoin
@@ -30,7 +31,9 @@ from without import Endo
30
31
  from without import Stream
31
32
  from without import cancel_futures
32
33
  from without import stack
34
+ from without_asgi import Content
33
35
  from without_asgi import RawHeaders
36
+ from without_asgi.headers import merge
34
37
 
35
38
  from without_http.h2_wire import request_headers
36
39
  from without_http.h2_wire import response_status_and_headers
@@ -86,18 +89,26 @@ class Origin:
86
89
  @dataclass(frozen=True, slots=True)
87
90
  class ClientRequest:
88
91
  """
89
- A client request as a value: the head plus a streaming body.
92
+ A client request as a value: the head, a streaming body, and its deadline.
90
93
 
91
94
  The body is a `Stream[bytes]` (an async iterable of chunks), so a request can be
92
95
  buffered (one chunk) or streamed (many), the upload half of the buffered/streaming
93
- matrix. Because the whole request is the value a `ClientExchange` transforms,
94
- middleware can rewrite it: add headers, change the URL, wrap the body.
96
+ matrix. Because the whole request is the value a `Client` transforms, middleware can
97
+ rewrite it: add headers, change the URL, wrap the body, extend the deadline.
98
+
99
+ `timeout` bounds each phase of *this* request (see `Timeout`), and defaults to no
100
+ bounds at all. It rides on the request rather than on the transport because a
101
+ deadline is the caller's policy, not the connection's: it is the caller's time
102
+ budget that decides when slow progress is worse than failure. Carrying it here is
103
+ what lets a `Client` be a plain one-argument function, and what lets middleware
104
+ (`deadline`, or a retry that shortens each attempt) set it like any other field.
95
105
  """
96
106
 
97
107
  method: str
98
108
  url: str
99
109
  headers: RawHeaders = ()
100
110
  body: Stream[bytes] = field(default_factory=_empty_body)
111
+ timeout: Timeout = _NO_TIMEOUT
101
112
 
102
113
 
103
114
  @dataclass(frozen=True, slots=True)
@@ -185,16 +196,16 @@ class ClientResponse(NamedTuple):
185
196
  A client response as a value: the head paired with the body.
186
197
 
187
198
  `head` is the parsed `ResponseHead` (status + headers), available the instant
188
- `await exchange(request)` returns. `body` is a `ResponseBody`, a once-consumable
199
+ `await client(request)` returns. `body` is a `ResponseBody`, a once-consumable
189
200
  stream that releases its connection when it ends or is closed.
190
201
 
191
202
  A `NamedTuple` so a caller can take it whole (`response.head`, `response.body`) or
192
203
  unpack it (`head, body = response`) with each field keeping its precise type, which a
193
204
  `__iter__` on a dataclass could not give. The two halves are independent, the
194
205
  consumer split that mirrors how a server consumes a request (a `scope` value plus a
195
- body stream): branch on `head` without touching `body`. `ConnectionPool.request`
196
- yields this value and closes `body` on exit; it is also what a `ClientExchange`
197
- rewrites (by constructing a new one, since a `NamedTuple` has no `dataclasses.replace`).
206
+ body stream): branch on `head` without touching `body`. `request` yields this value
207
+ and closes `body` on exit; it is also what a `ClientMiddleware` rewrites (by
208
+ constructing a new one, since a `NamedTuple` has no `dataclasses.replace`).
198
209
  """
199
210
 
200
211
  head: ResponseHead
@@ -242,17 +253,21 @@ async def _releasing(
242
253
  return armed
243
254
 
244
255
 
245
- # A client exchange is the dual of a server handler: where a handler maps a request to
246
- # a response over streams, an exchange maps a whole `ClientRequest` to a
247
- # `ClientResponse`. A `ClientMiddleware` wraps an exchange into an exchange (`Endo`):
248
- # it can rewrite the request before, or the response after, the inner exchange runs.
249
- # This is the zero-context case of the shared `stack` vocabulary: a server middleware
250
- # is `(handler, state, scope)`, a client one needs no context (the request is the value
251
- # it transforms, not a fixed scope), so it is simply `(exchange) -> exchange`, and the
252
- # same `stack` composes them. State a middleware must keep lives in a closure (see
253
- # `cookies`), as it does server-side.
254
- type ClientExchange = Callable[[ClientRequest], Awaitable[ClientResponse]]
255
- type ClientMiddleware = Endo[ClientExchange]
256
+ # A client *is* a function from a request to a response, the dual of a server handler:
257
+ # where a handler maps a request to a response over streams, a client maps a whole
258
+ # `ClientRequest` to a `ClientResponse`. Everything that answers a request is one, and
259
+ # they are interchangeable by construction: a `ConnectionPool` over the network, a
260
+ # canned response table in a test, an ASGI app driven in memory.
261
+ #
262
+ # A `ClientMiddleware` wraps a client into a client (`Endo`): it can rewrite the request
263
+ # before, or the response after, the inner client runs. This is the zero-context case of
264
+ # the shared `stack` vocabulary: a server middleware is `(handler, state, scope)`, a
265
+ # client one needs no context (the request is the value it transforms, not a fixed
266
+ # scope), so it is simply `(client) -> client`, and the same `stack` composes them.
267
+ # State a middleware must keep lives in a closure (see `cookies`), as it does
268
+ # server-side.
269
+ type Client = Callable[[ClientRequest], Awaitable[ClientResponse]]
270
+ type ClientMiddleware = Endo[Client]
256
271
 
257
272
  _PASSTHROUGH: ClientMiddleware = stack()
258
273
 
@@ -275,23 +290,34 @@ def _target(parts: SplitResult) -> str:
275
290
  return target
276
291
 
277
292
 
278
- def _build_request(method: str, url: str, headers: RawHeaders, content: bytes | Stream[bytes]) -> ClientRequest:
293
+ def _build_request(
294
+ method: str, url: str, headers: RawHeaders, content: bytes | Stream[bytes] | Content, timeout: Timeout
295
+ ) -> ClientRequest:
279
296
  """
280
297
  Assemble a `ClientRequest`, picking the body framing from `content`.
281
298
 
282
299
  Buffered `bytes` get a `content-length`; a streaming body whose length is unknown
283
300
  gets `transfer-encoding: chunked` (HTTP/1.1 frames it as chunks; over HTTP/2 the
284
301
  framing headers are dropped and the body rides DATA frames either way).
302
+
303
+ A `Content` is bytes that already know what they are, so its headers go *under* the
304
+ caller's: an explicit `content-type` on the request wins over the one the encoding
305
+ supplied, and everything after this point sees plain bytes.
285
306
  """
307
+ if isinstance(content, Content):
308
+ headers = merge(content.headers, headers)
309
+ content = content.body
286
310
  if isinstance(content, bytes):
287
311
  if not content:
288
- return ClientRequest(method, url, headers, _empty_body()) # pragma: no mutate - equals _empty_body()
312
+ return ClientRequest(
313
+ method, url, headers, _empty_body(), timeout
314
+ ) # pragma: no mutate - equals _empty_body()
289
315
  if not _has(headers, b"content-length"):
290
316
  headers = (*headers, (b"content-length", str(len(content)).encode(_ASCII)))
291
- return ClientRequest(method, url, headers, _single(content))
317
+ return ClientRequest(method, url, headers, _single(content), timeout)
292
318
  if not _has(headers, b"content-length") and not _has(headers, b"transfer-encoding"):
293
319
  headers = (*headers, (b"transfer-encoding", b"chunked"))
294
- return ClientRequest(method, url, headers, content)
320
+ return ClientRequest(method, url, headers, content, timeout)
295
321
 
296
322
 
297
323
  _ALPN_H2 = ("h2", "http/1.1")
@@ -329,6 +355,28 @@ async def _open(
329
355
  return reader, writer, "h2" if negotiated == "h2" else "http/1.1"
330
356
 
331
357
 
358
+ class Connect(Protocol):
359
+ """
360
+ How a pool reaches an origin: the one step that touches the network.
361
+
362
+ Injected into `ConnectionPool` so the rest of it (reuse, bounds, protocol selection)
363
+ stays independent of how a connection is made. `_open` is the default and speaks
364
+ TCP; a test dials an in-memory pipe, and a unix-socket or proxy connector would slot
365
+ in the same way. The negotiated wire protocol comes back alongside the streams
366
+ because only the connector can know it: ALPN is read off the finished handshake.
367
+ """
368
+
369
+ async def __call__(
370
+ self,
371
+ host: str,
372
+ port: int,
373
+ *,
374
+ ssl_context: ssl.SSLContext | None,
375
+ timeout: Timeout = ...,
376
+ socket_options: SocketOptions = (),
377
+ ) -> tuple[asyncio.StreamReader, asyncio.StreamWriter, str]: ...
378
+
379
+
332
380
  @dataclass(slots=True, eq=False)
333
381
  class _Http11Connection:
334
382
  """
@@ -933,12 +981,16 @@ class _HostPool:
933
981
  @dataclass(slots=True)
934
982
  class ConnectionPool:
935
983
  """
936
- Connections keyed by origin, and the entrypoint for making requests.
984
+ Connections keyed by origin: the `Client` that answers a request over the network.
985
+
986
+ Calling it *is* the request (`await pool(request)`), so a pool is interchangeable
987
+ with any other `Client` and composes with `ClientMiddleware` through `stack`. Most
988
+ callers go through the `request` context manager rather than calling it directly,
989
+ since that builds the `ClientRequest` and closes the response body for them.
937
990
 
938
991
  Open it as an async context manager (`async with ConnectionPool(...) as pool`) so
939
992
  its connections are closed on exit; a directly-constructed pool works for
940
993
  short-lived use but does not manage the long-lived connections keep-alive retains.
941
- Make requests through `async with pool.request(...) as response`.
942
994
 
943
995
  HTTP/2 connections are kept and reused: many concurrent requests to one origin
944
996
  multiplex over a single connection, which is the point of h2. HTTP/1.1
@@ -957,13 +1009,14 @@ class ConnectionPool:
957
1009
  live context, precisely because ALPN can only be set context-wide: a shared context
958
1010
  would be mutated out from under other pools or libraries using it.
959
1011
 
960
- `middleware` decorates every request through the pool; per-request `middleware` on
961
- `request` composes inside it. Keep pool-level middleware to *pure* decoration
962
- (default headers, redirect following, retry): things that are values, not state.
963
- Anything carrying mutable, request-spanning identity (a `CookieJar`, an auth
964
- session) belongs in a value you own and pass per request, so that connection reuse
965
- (a transport concern) and application identity stay independent rather than both
966
- hiding in the pool.
1012
+ `connect` is how the pool reaches an origin, defaulting to a TCP connect (see
1013
+ `Connect`). It is the only part of the pool that touches the network, so replacing
1014
+ it points the same pooling, protocol selection, and keep-alive at somewhere else.
1015
+
1016
+ Decoration (default headers, redirect following, cookies, a deadline) is *not* a
1017
+ pool concern: compose it around the pool with `stack`, which yields another `Client`.
1018
+ That keeps connection reuse (a transport concern) and application identity
1019
+ independent, rather than both hiding in the pool.
967
1020
 
968
1021
  `max_connections_per_host` bounds the number of concurrent HTTP/1.1 connections to
969
1022
  one origin: at the bound, a checkout *waits* for one to be returned (the wait a
@@ -980,9 +1033,9 @@ class ConnectionPool:
980
1033
  connections cannot outnumber concurrent checkouts. Both knobs, when set, MUST be `>=
981
1034
  1`.
982
1035
 
983
- `timeout` bounds each phase of a request (see `Timeout`); it defaults to no timeouts,
984
- since a deadline is the caller's policy, not the transport's. A per-request `timeout`
985
- on `request` replaces it wholesale for that call.
1036
+ Deadlines ride on the request (`ClientRequest.timeout`), not on the pool: the same
1037
+ pool serves callers with different time budgets, and a bound stored here would be a
1038
+ property of the connection rather than of the caller that wanted it.
986
1039
 
987
1040
  `socket_options` is applied to every socket the pool opens, as `(level, option, value)`
988
1041
  triples. Build it by concatenating the pure producers in `without_http.socket_options`
@@ -996,10 +1049,9 @@ class ConnectionPool:
996
1049
  allow_http2: bool = True
997
1050
  force_http2_cleartext: bool = False
998
1051
  ssl_context_factory: Callable[[], ssl.SSLContext] = ssl.create_default_context
999
- middleware: ClientMiddleware = _PASSTHROUGH
1052
+ connect: Connect = _open
1000
1053
  max_connections_per_host: int | None = None
1001
1054
  max_keepalive_per_host: int | None = None
1002
- timeout: Timeout = _NO_TIMEOUT
1003
1055
  socket_options: SocketOptions = _DEFAULT_SOCKET_OPTIONS
1004
1056
  _h2: dict[Origin, _Http2Connection] = field(default_factory=dict)
1005
1057
  _h11: dict[Origin, _HostPool] = field(default_factory=dict)
@@ -1021,61 +1073,16 @@ class ConnectionPool:
1021
1073
  async def __aexit__(self, *exc: object) -> None:
1022
1074
  await self.aclose()
1023
1075
 
1024
- @asynccontextmanager
1025
- async def request(
1026
- self,
1027
- method: str,
1028
- url: str,
1029
- *,
1030
- headers: RawHeaders = (),
1031
- body: bytes | Stream[bytes] = b"",
1032
- middleware: ClientMiddleware = _PASSTHROUGH,
1033
- timeout: Timeout | None = None,
1034
- ) -> AsyncIterator[ClientResponse]:
1035
- """
1036
- Send a request and yield its `ClientResponse` for the block, then release the connection.
1037
-
1038
- `body` is the request body: `bytes` to buffer it, or a `Stream[bytes]` to stream
1039
- it. The yielded `ClientResponse` can be taken whole (`response.head`,
1040
- `response.body`) or unpacked (`head, body = ...`); read the response body with
1041
- `async for chunk in body` / `await body.read()`, or `body.read_with_trailers()`
1042
- when the endpoint carries trailers. On exit any unread body is drained or aborted
1043
- so the connection is never stranded.
1044
-
1045
- `middleware` is composed inside the pool's own `middleware`, so a single request
1046
- can add decoration (a `CookieJar` via `cookies`, an extra header) on top of the
1047
- pool-wide stack for this call alone.
1048
-
1049
- `timeout` replaces the pool's own `timeout` for this call (`None` inherits it).
1050
- It is captured as a value in the transport exchange, so it does not compose through
1051
- `middleware` the way decoration does; a deadline overrides, it does not layer.
1076
+ async def __call__(self, request: ClientRequest) -> ClientResponse:
1052
1077
  """
1053
- outgoing = _build_request(method, url, headers, body)
1054
- effective = self.timeout if timeout is None else timeout
1055
-
1056
- async def bound(outgoing: ClientRequest) -> ClientResponse:
1057
- return await self._exchange(outgoing, effective)
1078
+ Answer one request over the network, which is what makes a pool a `Client`.
1058
1079
 
1059
- exchange = stack(self.middleware, middleware)(bound)
1060
- response = await exchange(outgoing)
1061
- try:
1062
- yield response
1063
- except BaseException:
1064
- # An error is already in flight; still close the body to release the connection,
1065
- # but suppress any close error (e.g. a surfaced request-body send failure) so it
1066
- # cannot mask the original exception the caller is trying to debug.
1067
- with suppress(Exception):
1068
- await response.body.aclose()
1069
- raise
1070
- else:
1071
- await response.body.aclose()
1072
-
1073
- async def _exchange(self, request: ClientRequest, timeout: Timeout) -> ClientResponse:
1074
- # The bare transport exchange: the inner `ClientExchange` that `request` wraps
1075
- # with `stack(self.middleware, ...)`. Private so callers go through `request`
1076
- # and never accidentally bypass the pool's configured middleware. The effective
1077
- # `timeout` is threaded in as a value (captured by `bound` above), never stored on
1078
- # the shared connection objects, so concurrent requests keep their own deadlines.
1080
+ Picks the origin's wire protocol, checks out (or opens) a connection, and returns
1081
+ the response as soon as its head arrives, with the body still streaming. The
1082
+ request's own `timeout` is threaded through as a value, never stored on the
1083
+ shared connection objects, so concurrent requests keep their own deadlines.
1084
+ """
1085
+ timeout = request.timeout
1079
1086
  parts = urlsplit(request.url)
1080
1087
  origin = _origin(parts)
1081
1088
  if origin.secure and self.allow_http2:
@@ -1110,7 +1117,7 @@ class ConnectionPool:
1110
1117
  async with self._lock_for(origin):
1111
1118
  connection = self._reusable_h2(origin)
1112
1119
  if connection is None and origin not in self._h11_only:
1113
- reader, writer, protocol = await _open(
1120
+ reader, writer, protocol = await self.connect(
1114
1121
  origin.host,
1115
1122
  origin.port,
1116
1123
  ssl_context=self._context_for_connection(http2=True),
@@ -1140,7 +1147,7 @@ class ConnectionPool:
1140
1147
  async with self._lock_for(origin):
1141
1148
  connection = self._reusable_h2(origin)
1142
1149
  if connection is None:
1143
- reader, writer, _ = await _open(
1150
+ reader, writer, _ = await self.connect(
1144
1151
  origin.host,
1145
1152
  origin.port,
1146
1153
  ssl_context=None,
@@ -1233,7 +1240,7 @@ class ConnectionPool:
1233
1240
 
1234
1241
  async def _open_h11(self, origin: Origin, timeout: Timeout) -> _Http11Connection:
1235
1242
  ssl_context = self._context_for_connection(http2=False) if origin.secure else None
1236
- reader, writer, _ = await _open(
1243
+ reader, writer, _ = await self.connect(
1237
1244
  origin.host, origin.port, ssl_context=ssl_context, timeout=timeout, socket_options=self.socket_options
1238
1245
  )
1239
1246
  return _Http11Connection.new(reader, writer)
@@ -1263,6 +1270,53 @@ class ConnectionPool:
1263
1270
  await host_pool.aclose()
1264
1271
 
1265
1272
 
1273
+ @asynccontextmanager
1274
+ async def request(
1275
+ client: Client,
1276
+ method: str,
1277
+ url: str,
1278
+ *,
1279
+ headers: RawHeaders = (),
1280
+ body: bytes | Stream[bytes] | Content = b"",
1281
+ timeout: Timeout = _NO_TIMEOUT,
1282
+ ) -> AsyncIterator[ClientResponse]:
1283
+ """
1284
+ Send a request through `client` and yield its `ClientResponse` for the block.
1285
+
1286
+ The one request surface, over *any* `Client`: a `ConnectionPool`, a pool wrapped in
1287
+ middleware (`stack(add_headers(...), cookies(jar))(pool)`), or an in-memory one from
1288
+ `without_http.testing`. It owns the two things a caller would otherwise repeat: the
1289
+ body framing (`bytes` gets a `content-length`, a `Stream[bytes]` gets
1290
+ `transfer-encoding: chunked`) and closing the response body on the way out, so a
1291
+ connection is never stranded by a body nobody read.
1292
+
1293
+ `body` takes bytes, a `Stream[bytes]` to stream them, or a `Content` when the caller
1294
+ holds a *value* rather than bytes: `body=json_content(order)` sends the encoding and
1295
+ the `content-type` describing it together, since neither is any use without the other.
1296
+
1297
+ The yielded `ClientResponse` can be taken whole (`response.head`, `response.body`) or
1298
+ unpacked (`head, body = ...`); read the response body with `async for chunk in body`
1299
+ / `await body.read()`, or `body.read_with_trailers()` when the endpoint carries
1300
+ trailers. On exit any unread body is drained or aborted.
1301
+
1302
+ `timeout` bounds this request's phases (see `Timeout`), defaulting to no bounds. It
1303
+ lands on the `ClientRequest`, so middleware sees and can rewrite it like any other
1304
+ field; `deadline` sets the same field for every request through a client.
1305
+ """
1306
+ response = await client(_build_request(method, url, headers, body, timeout))
1307
+ try:
1308
+ yield response
1309
+ except BaseException:
1310
+ # An error is already in flight; still close the body to release the connection,
1311
+ # but suppress any close error (e.g. a surfaced request-body send failure) so it
1312
+ # cannot mask the original exception the caller is trying to debug.
1313
+ with suppress(Exception):
1314
+ await response.body.aclose()
1315
+ raise
1316
+ else:
1317
+ await response.body.aclose()
1318
+
1319
+
1266
1320
  def wrap(
1267
1321
  *,
1268
1322
  request: Endo[ClientRequest] | None = None,
@@ -1280,10 +1334,10 @@ def wrap(
1280
1334
  This is the easy path for the *independent* before/after case (the dual of why
1281
1335
  `add_headers`, below, is a one-liner over it). A middleware whose two sides share
1282
1336
  state, like `cookies` needing the request URL when it stores the response, or that
1283
- loops, like `follow_redirects`, is written directly as a `ClientExchange` wrapper.
1337
+ loops, like `follow_redirects`, is written directly as a `Client` wrapper.
1284
1338
  """
1285
1339
 
1286
- def middleware(inner: ClientExchange) -> ClientExchange:
1340
+ def middleware(inner: Client) -> Client:
1287
1341
  async def exchange(outgoing: ClientRequest) -> ClientResponse:
1288
1342
  if request is not None:
1289
1343
  outgoing = request(outgoing)
@@ -1302,14 +1356,35 @@ def add_headers(*headers: tuple[bytes, bytes]) -> ClientMiddleware:
1302
1356
  Client middleware that adds headers to every request.
1303
1357
 
1304
1358
  The mirror of a server's request-decorating middleware: it sits in the same
1305
- `stack` and rewrites the request before the inner exchange runs. This is how a
1306
- pool sends default headers (auth tokens, a user agent) on every request, or a
1359
+ `stack` and rewrites the request before the inner client runs. This is how a
1360
+ caller sends default headers (auth tokens, a user agent) on every request, or a
1307
1361
  single request adds its own.
1308
1362
  """
1309
1363
  extra: RawHeaders = tuple(headers)
1310
1364
  return wrap(request=lambda request: replace(request, headers=extra + request.headers))
1311
1365
 
1312
1366
 
1367
+ def deadline(timeout: Timeout) -> ClientMiddleware:
1368
+ """
1369
+ Client middleware that applies `timeout` to every request that bounds nothing itself.
1370
+
1371
+ A default time budget for everything sent through the composed client, in the same
1372
+ `stack` as any other decoration. A request that bounds any phase of its own keeps its
1373
+ own `timeout` whole, so a caller with a tighter budget for one call is not overridden
1374
+ by the default; that is the difference between a default and a policy imposed from
1375
+ above. A request bounding *nothing* (the default `Timeout()`) reads as "no budget
1376
+ stated" rather than "no budget wanted", so it takes the default: a caller who wants
1377
+ one request exempt composes it against a client without this middleware.
1378
+ """
1379
+
1380
+ def apply(request: ClientRequest) -> ClientRequest:
1381
+ if request.timeout != _NO_TIMEOUT:
1382
+ return request
1383
+ return replace(request, timeout=timeout)
1384
+
1385
+ return wrap(request=apply)
1386
+
1387
+
1313
1388
  _SENSITIVE_REDIRECT_HEADERS = frozenset({b"authorization", b"cookie", b"proxy-authorization"})
1314
1389
  _BODY_FRAMING_HEADERS = frozenset({b"content-length", b"content-type", b"transfer-encoding"})
1315
1390
 
@@ -1334,7 +1409,7 @@ def follow_redirects(max_hops: int = 5) -> ClientMiddleware:
1334
1409
  unfollowed) so nothing is replayed over cleartext.
1335
1410
  """
1336
1411
 
1337
- def middleware(inner: ClientExchange) -> ClientExchange:
1412
+ def middleware(inner: Client) -> Client:
1338
1413
  async def exchange(request: ClientRequest) -> ClientResponse:
1339
1414
  response = await inner(request)
1340
1415
  for _ in range(max_hops):
@@ -1622,7 +1697,7 @@ def cookies(jar: CookieJar) -> ClientMiddleware:
1622
1697
  the hop sets.
1623
1698
  """
1624
1699
 
1625
- def middleware(inner: ClientExchange) -> ClientExchange:
1700
+ def middleware(inner: Client) -> Client:
1626
1701
  async def exchange(request: ClientRequest) -> ClientResponse:
1627
1702
  header = jar.header_for(request.url)
1628
1703
  if header is not None:
@@ -86,13 +86,18 @@ class _Limits:
86
86
  Bundled into one value so `serving` threads a single argument down through the
87
87
  protocol handlers, and a new bound snaps in as a field rather than another
88
88
  parameter on every signature. `serving` exposes each field as an explicit keyword
89
- argument, keeping the public surface flat while the plumbing stays terse.
89
+ argument, keeping the public surface flat while the plumbing stays terse. The
90
+ defaults live here, and the exposing signatures (`serving`, `loopback_client`,
91
+ `served_pipe`) read them off `_DEFAULT_LIMITS`, so the three cannot disagree.
90
92
  """
91
93
 
92
- max_concurrent_streams: int
93
- max_stream_resets: int
94
- idle_timeout: timedelta | None
95
- max_websocket_message_bytes: int | None
94
+ max_concurrent_streams: int = 100
95
+ max_stream_resets: int = 200
96
+ idle_timeout: timedelta | None = None
97
+ max_websocket_message_bytes: int | None = None
98
+
99
+
100
+ _DEFAULT_LIMITS = _Limits()
96
101
 
97
102
 
98
103
  def _address(info: object) -> tuple[str, int] | None:
@@ -845,10 +850,10 @@ async def serving(
845
850
  host: str = "127.0.0.1",
846
851
  port: int = 0,
847
852
  max_pending_connections: int = 100,
848
- max_concurrent_streams: int = 100,
849
- max_stream_resets: int = 200,
850
- idle_timeout: timedelta | None = None,
851
- max_websocket_message_bytes: int | None = None,
853
+ max_concurrent_streams: int = _DEFAULT_LIMITS.max_concurrent_streams,
854
+ max_stream_resets: int = _DEFAULT_LIMITS.max_stream_resets,
855
+ idle_timeout: timedelta | None = _DEFAULT_LIMITS.idle_timeout,
856
+ max_websocket_message_bytes: int | None = _DEFAULT_LIMITS.max_websocket_message_bytes,
852
857
  ssl_context: ssl.SSLContext | None = None,
853
858
  ssl_handshake_timeout: float | None = None,
854
859
  ssl_shutdown_timeout: float | None = None,
@@ -0,0 +1,587 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import ssl
5
+ from collections.abc import AsyncGenerator
6
+ from collections.abc import AsyncIterator
7
+ from collections.abc import Awaitable
8
+ from collections.abc import Buffer
9
+ from collections.abc import Callable
10
+ from collections.abc import Mapping
11
+ from contextlib import asynccontextmanager
12
+ from dataclasses import replace
13
+ from datetime import timedelta
14
+ from types import MappingProxyType
15
+ from typing import assert_never
16
+ from urllib.parse import unquote
17
+ from urllib.parse import urlsplit
18
+
19
+ from without import cancel_futures
20
+ from without_asgi import Asgi
21
+ from without_asgi import ASGIApp
22
+ from without_asgi import Disconnect
23
+ from without_asgi import EarlyHint
24
+ from without_asgi import HttpScope
25
+ from without_asgi import PathSend
26
+ from without_asgi import RawHeaders
27
+ from without_asgi import RawMessage
28
+ from without_asgi import RequestBody
29
+ from without_asgi import ResponseDebug
30
+ from without_asgi import ResponseStart
31
+ from without_asgi import ServerPush
32
+ from without_asgi import ZeroCopySend
33
+ from without_asgi import encode_http_scope
34
+ from without_asgi import encode_inbound
35
+ from without_asgi import parse_outbound
36
+ from without_asgi.outbound import ResponseBody as OutboundBody
37
+ from without_asgi.outbound import ResponseTrailers as OutboundTrailers
38
+
39
+ from without_http.client import _NO_TIMEOUT
40
+ from without_http.client import Client
41
+ from without_http.client import ClientMiddleware
42
+ from without_http.client import ClientRequest
43
+ from without_http.client import ClientResponse
44
+ from without_http.client import ConnectionPool
45
+ from without_http.client import ResponseBody
46
+ from without_http.client import ResponseHead
47
+ from without_http.client import ResponseTrailers
48
+ from without_http.client import _releasing
49
+ from without_http.client import wrap
50
+ from without_http.lifespan import _wait_for
51
+ from without_http.lifespan import run_lifespan
52
+ from without_http.server import _DEFAULT_LIMITS
53
+ from without_http.server import _Limits
54
+ from without_http.server import _serve_connection
55
+ from without_http.socket_options import SocketOptions
56
+ from without_http.timeouts import Timeout
57
+
58
+ # What an in-memory client tells an app about itself. It presents as HTTP/1.1, so an app
59
+ # that runs against it faces the same surface it would over a socket.
60
+ _ASGI = Asgi(version="3.0", spec_version="2.4")
61
+
62
+ # The one extension this transport can honestly offer: a `ClientResponse` carries trailer
63
+ # blocks through to `read_with_trailers`, so an app's trailers reach the caller here
64
+ # rather than being dropped. The server-offload extensions (server push, zero-copy and
65
+ # path send) have a kernel or a proxy to offload to and nothing in memory does, so they
66
+ # stay unadvertised, as they are over HTTP/1.1.
67
+ _EXTENSIONS: Mapping[str, Mapping[str, object]] = MappingProxyType({"http.response.trailers": {}})
68
+ _ASCII = "ascii"
69
+ _CLIENT_ADDRESS = ("127.0.0.1", 51234)
70
+ _BUFFER = 65536
71
+
72
+ # The address an in-memory server presents as, which nothing resolves: a `pipe` has no
73
+ # port to report, so the server reads this back from `sockname` and a client names it in
74
+ # the URL (or, over HTTP/2, in `:authority`). Public because a test that writes frames by
75
+ # hand has to spell the authority itself, which is what `AUTHORITY` spells for it.
76
+ SERVER_ADDRESS = ("testserver", 80)
77
+ AUTHORITY = f"{SERVER_ADDRESS[0]}:{SERVER_ADDRESS[1]}".encode()
78
+
79
+
80
+ def mock_client(handler: Callable[[ClientRequest], ClientResponse | Awaitable[ClientResponse]]) -> Client:
81
+ """
82
+ A `Client` that answers every request from `handler`, reaching nothing at all.
83
+
84
+ The whole of mocking, because a client is already a function: `handler` takes the
85
+ `ClientRequest` a caller built and returns the `ClientResponse` it should see, so no
86
+ pool, socket, or app exists underneath. Use it to test code that *sends* requests.
87
+
88
+ ```python
89
+ def answer(request: ClientRequest) -> ClientResponse:
90
+ if request.url == "https://api.test/items":
91
+ return respond(200, body=b'[]')
92
+ raise AssertionError(f"unexpected request to {request.url}")
93
+
94
+ stats = await summarize(mock_client(answer))
95
+ ```
96
+
97
+ `handler` may be sync or async, and is called once per request, which is what keeps
98
+ a canned body usable more than once: a `ClientResponse` body is a *stream*, consumed
99
+ exactly once, so build it inside `handler` (as above) rather than holding one
100
+ response value and returning it twice.
101
+ """
102
+
103
+ async def client(request: ClientRequest) -> ClientResponse:
104
+ answered = handler(request)
105
+ if isinstance(answered, ClientResponse):
106
+ return answered
107
+ return await answered
108
+
109
+ return client
110
+
111
+
112
+ def respond(
113
+ status: int = 200,
114
+ *,
115
+ headers: RawHeaders = (),
116
+ body: bytes = b"",
117
+ trailers: RawHeaders | None = None,
118
+ ) -> ClientResponse:
119
+ """
120
+ Build a canned `ClientResponse` for a `mock_client` handler to return.
121
+
122
+ The body is a one-shot stream over `body`, so call this per request rather than
123
+ reusing one value (see `mock_client`). `trailers`, when given, is a single trailing
124
+ header block a `read_with_trailers` caller will see after the body.
125
+ """
126
+
127
+ async def events() -> AsyncGenerator[bytes | ResponseTrailers]:
128
+ if body:
129
+ yield body
130
+ if trailers is not None:
131
+ yield ResponseTrailers(trailers)
132
+
133
+ return ClientResponse(ResponseHead(status, headers), ResponseBody(events()))
134
+
135
+
136
+ def base_url(base: str) -> ClientMiddleware:
137
+ """
138
+ Client middleware that resolves each request's URL against `base`.
139
+
140
+ A test client needs absolute URLs for the same reason the network one does (the URL
141
+ names the origin), so this is how `"/items"` becomes `"http://testserver/items"`
142
+ without every call site repeating the host. An already-absolute URL is left alone.
143
+ """
144
+ prefix = base.rstrip("/")
145
+
146
+ def resolve(request: ClientRequest) -> ClientRequest:
147
+ if urlsplit(request.url).scheme:
148
+ return request
149
+ return replace(request, url=prefix + request.url)
150
+
151
+ return wrap(request=resolve)
152
+
153
+
154
+ def scope_from_client_request(request: ClientRequest, *, root_path: str = "") -> HttpScope:
155
+ """
156
+ Build the `HttpScope` an ASGI app expects directly from a `ClientRequest`.
157
+
158
+ The in-memory counterpart of `scope_from_request`, which does the same job from an
159
+ `h11.Request`: pure, and reading only the request itself. The URL supplies what the
160
+ wire would have (`scheme`, `server`, the raw path and query string), and a `host`
161
+ header is synthesized when the caller did not set one, matching what the HTTP/1.1
162
+ transport puts on the wire.
163
+
164
+ The scope advertises `http.response.trailers`, since trailers do reach the caller in
165
+ memory, and nothing else: an app that negotiates the extension takes its trailer path
166
+ here.
167
+ """
168
+ parts = urlsplit(request.url)
169
+ if parts.hostname is None:
170
+ raise ValueError(f"client request URL must be absolute, got {request.url!r}")
171
+ raw_path = (parts.path or "/").encode(_ASCII)
172
+ headers = request.headers
173
+ if not any(name.lower() == b"host" for name, _ in headers):
174
+ headers = ((b"host", parts.netloc.encode(_ASCII)), *headers)
175
+ return HttpScope(
176
+ asgi=_ASGI,
177
+ http_version="1.1",
178
+ method=request.method,
179
+ scheme=parts.scheme,
180
+ path=unquote(raw_path.decode(_ASCII)),
181
+ raw_path=raw_path,
182
+ query_string=parts.query.encode(_ASCII),
183
+ root_path=root_path,
184
+ headers=headers,
185
+ client=_CLIENT_ADDRESS,
186
+ server=(parts.hostname, parts.port or (443 if parts.scheme == "https" else 80)),
187
+ extensions=_EXTENSIONS,
188
+ )
189
+
190
+
191
+ @asynccontextmanager
192
+ async def asgi_client(app: ASGIApp, *, root_path: str = "") -> AsyncIterator[Client]:
193
+ """
194
+ A `Client` that drives `app` in memory, with no wire and no server underneath.
195
+
196
+ The app's lifespan runs for the block (through the same `run_lifespan` a real server
197
+ uses), so startup state is in place before the first request and torn down after the
198
+ last, and each request calls `app(scope, receive, send)` directly on a task of its
199
+ own. Nothing is encoded, no socket is opened, and the whole exchange is one process.
200
+
201
+ ```python
202
+ async with asgi_client(app) as client:
203
+ async with request(client, "GET", "http://testserver/items") as (head, body):
204
+ assert head.status == 200
205
+ ```
206
+
207
+ It speaks only ASGI, so it drives *any* ASGI app, not just a `without` one. The
208
+ response streams: the head is returned the instant the app sends
209
+ `http.response.start`, and each body chunk crosses a one-slot queue, so an app that
210
+ reads the request body while writing its response behaves as it would on the wire. An
211
+ exception from the app surfaces to the caller rather than becoming a `500`, since
212
+ there is no server here to convert it; reach for `loopback_client` to exercise the
213
+ server's own error path.
214
+
215
+ URLs are absolute, as they are for a `ConnectionPool`, so the same test body runs
216
+ against a real server by swapping the client. Compose `base_url` for relative ones.
217
+ """
218
+ async with run_lifespan(app):
219
+
220
+ async def client(request: ClientRequest) -> ClientResponse:
221
+ return await _drive(app, request, root_path=root_path)
222
+
223
+ yield client
224
+
225
+
226
+ async def _drive(app: ASGIApp, request: ClientRequest, *, root_path: str) -> ClientResponse:
227
+ """
228
+ Run one request through `app`, returning as soon as its head is sent.
229
+
230
+ The in-memory sibling of the server's `_run_request`: it closes a `receive` and a
231
+ `send` over local state and hands them to the app. Where that one encodes through
232
+ `h11`, this one resolves a future for the head and pushes body chunks onto a
233
+ one-slot queue, which is the in-memory stand-in for a socket buffer: an app that
234
+ runs ahead of a slow reader blocks in `send`, exactly as it would on the wire.
235
+ """
236
+ scope = encode_http_scope(scope_from_client_request(request, root_path=root_path))
237
+ started = asyncio.Event()
238
+ head: list[ResponseHead] = []
239
+ chunks: asyncio.Queue[bytes | ResponseTrailers] = asyncio.Queue(maxsize=1)
240
+ body = aiter(request.body)
241
+ request_done = False # pragma: no mutate - initial sentinel, read only as a bool
242
+ response_done = False # pragma: no mutate - initial sentinel, read only as a bool
243
+ sends_trailers = False # pragma: no mutate - `http.response.start` always assigns it before a body event
244
+
245
+ async def receive() -> RawMessage:
246
+ nonlocal request_done
247
+ if request_done:
248
+ return encode_inbound(Disconnect())
249
+ try:
250
+ chunk = await anext(body)
251
+ except StopAsyncIteration:
252
+ request_done = True
253
+ return encode_inbound(RequestBody(body=b"", more_body=False))
254
+ return encode_inbound(RequestBody(body=chunk, more_body=True))
255
+
256
+ def end_response() -> None:
257
+ nonlocal response_done
258
+ response_done = True
259
+ chunks.shutdown() # drains what is queued, then ends the body stream
260
+
261
+ async def send(message: RawMessage) -> None:
262
+ nonlocal sends_trailers
263
+ match parse_outbound(message):
264
+ case ResponseStart(status, headers, trailers):
265
+ sends_trailers = trailers
266
+ head.append(ResponseHead(status, headers))
267
+ started.set()
268
+ case OutboundBody(chunk, more_body):
269
+ if chunk and request.method != "HEAD":
270
+ await chunks.put(chunk)
271
+ if not more_body and not sends_trailers:
272
+ end_response()
273
+ case OutboundTrailers(headers, more_trailers):
274
+ # The extension puts the trailing blocks *after* the final body message,
275
+ # so for an app that declared them at `http.response.start` it is the
276
+ # last block, not the last body chunk, that ends the response.
277
+ await chunks.put(ResponseTrailers(headers))
278
+ if not more_trailers:
279
+ end_response()
280
+ case EarlyHint():
281
+ pass # a client discards informational responses, as the h11 one does
282
+ case ServerPush() | ZeroCopySend() | PathSend() | ResponseDebug() as unsupported:
283
+ # Trailers are the only extension this transport advertises, so an app
284
+ # sending one of these events is misusing the scope it was handed.
285
+ raise NotImplementedError(f"{type(unsupported).__name__} is not supported in memory")
286
+ case _ as unreachable:
287
+ assert_never(unreachable)
288
+
289
+ async def drive() -> None:
290
+ try:
291
+ await app(scope, receive, send)
292
+ finally:
293
+ # However the app ends, the body stream ends with it: shutting the queue down
294
+ # is synchronous and non-blocking, so this is safe even under cancellation,
295
+ # and it drains what is already queued before ending the stream.
296
+ chunks.shutdown()
297
+
298
+ task = asyncio.create_task(drive())
299
+ if not await _wait_for(started, task):
300
+ await task # re-raises whatever the app failed with, if anything
301
+ raise RuntimeError("the application returned without starting a response")
302
+
303
+ async def events() -> AsyncGenerator[bytes | ResponseTrailers]:
304
+ while True:
305
+ try:
306
+ item = await chunks.get()
307
+ except asyncio.QueueShutDown:
308
+ break
309
+ yield item
310
+ if response_done:
311
+ return
312
+ # The app ended mid-response: short of the final body chunk, or short of the
313
+ # trailing block it declared. Surface why, rather than handing back a truncated
314
+ # response as though it were the whole thing.
315
+ await task # re-raises the app's failure, when it had one
316
+ raise RuntimeError("the application ended before finishing its response")
317
+
318
+ async def release(fully_read: bool) -> None:
319
+ # Shutting the queue down is what unblocks a `send` parked on it, immediate or not;
320
+ # `immediate` additionally drops what is still queued, which nothing will read now
321
+ # that the body generator is closed.
322
+ chunks.shutdown(immediate=True) # pragma: no mutate - nothing reads the queue after this
323
+ await cancel_futures([task])
324
+
325
+ return ClientResponse(head[0], ResponseBody(await _releasing(events(), release)))
326
+
327
+
328
+ class _PipeTransport(asyncio.Transport):
329
+ """
330
+ One direction of an in-memory connection: writes land in the peer's `StreamReader`.
331
+
332
+ The stand-in for the socket transport `asyncio.open_connection` would hand back, so
333
+ the reader/writer pair above it is the ordinary `asyncio` one and every consumer
334
+ (`h11`, `h2`, the server's connection loop) is unchanged. Three behaviours make it a
335
+ connection rather than a buffer:
336
+
337
+ - **EOF and close.** `write_eof` and `close` feed the peer EOF, which is the half-
338
+ close the wire protocols read as "the peer is done sending"; `close` also completes
339
+ this side's `wait_closed` by delivering `connection_lost` to its own protocol. Once
340
+ either end has closed, a `write` is dropped rather than delivered, since a socket
341
+ also accepts it and reports the failure on a later read.
342
+ - **Backpressure.** When a reader's buffer fills, `asyncio` pauses *its* transport;
343
+ here that is translated into pausing the peer's writing, so the peer's `drain()`
344
+ blocks until the reader catches up. Without that wiring an in-memory writer would
345
+ run arbitrarily far ahead of its reader, which no socket does.
346
+ - **Connection facts.** `get_extra_info` answers `sockname`/`peername` from the
347
+ addresses it was built with and `None` for `socket`/`ssl_object`, so the server
348
+ reads a cleartext connection with a peer address, as it would off a real socket.
349
+ """
350
+
351
+ def __init__(self, extra: dict[str, object]) -> None:
352
+ super().__init__(extra)
353
+ # Three placeholders until `link` runs, which it always does before any use.
354
+ self._peer_reader: asyncio.StreamReader | None = None # pragma: no mutate
355
+ self._peer: _PipeTransport | None = None # pragma: no mutate
356
+ self._protocol: asyncio.StreamReaderProtocol | None = None # pragma: no mutate
357
+ self._closing = False
358
+ self._paused = False
359
+ self._eof_sent = False
360
+
361
+ def link(
362
+ self, peer_reader: asyncio.StreamReader, peer: _PipeTransport, protocol: asyncio.StreamReaderProtocol
363
+ ) -> None:
364
+ self._peer_reader = peer_reader
365
+ self._peer = peer
366
+ self._protocol = protocol
367
+
368
+ def write(self, data: Buffer) -> None:
369
+ # A write lands in the peer's `StreamReader`, which refuses data once it has been
370
+ # fed EOF: by this side's own `write_eof`, or by the peer's `connection_lost` when
371
+ # the peer closed. A socket takes such a write and surfaces the failure on a later
372
+ # read, so the bytes are dropped here rather than raising out of the reader.
373
+ if self._eof_sent or (self._peer is not None and self._peer.is_closing()):
374
+ return
375
+ if self._peer_reader is not None: # pragma: no branch - always linked before use
376
+ self._peer_reader.feed_data(bytes(data))
377
+
378
+ def can_write_eof(self) -> bool:
379
+ return True
380
+
381
+ def write_eof(self) -> None:
382
+ self._eof_sent = True
383
+ if self._peer_reader is not None: # pragma: no branch - always linked before use
384
+ self._peer_reader.feed_eof()
385
+
386
+ def is_closing(self) -> bool:
387
+ return self._closing
388
+
389
+ def close(self) -> None:
390
+ if self._closing:
391
+ return
392
+ self._closing = True
393
+ self.write_eof()
394
+ if self._protocol is not None: # pragma: no branch - always linked before use
395
+ asyncio.get_running_loop().call_soon(self._protocol.connection_lost, None)
396
+
397
+ def abort(self) -> None:
398
+ self.close()
399
+
400
+ def pause_reading(self) -> None:
401
+ # asyncio pauses the transport a full reader belongs to; here that means telling
402
+ # the *peer* to stop writing, which is what a socket's receive window would do.
403
+ if self._peer is not None: # pragma: no branch - always linked before use
404
+ self._peer.stall_writes()
405
+
406
+ def resume_reading(self) -> None:
407
+ if self._peer is not None: # pragma: no branch - always linked before use
408
+ self._peer.resume_writes()
409
+
410
+ def stall_writes(self) -> None:
411
+ """Park this side's `drain()` until the peer's reader has caught up."""
412
+ if not self._paused and self._protocol is not None: # pragma: no branch
413
+ self._paused = True
414
+ self._protocol.pause_writing()
415
+
416
+ def resume_writes(self) -> None:
417
+ """Let this side's `drain()` proceed again."""
418
+ if self._paused and self._protocol is not None: # pragma: no branch
419
+ self._paused = False
420
+ self._protocol.resume_writing()
421
+
422
+
423
+ type Endpoint = tuple[asyncio.StreamReader, asyncio.StreamWriter]
424
+
425
+
426
+ def pipe(
427
+ *,
428
+ server: tuple[str, int] = SERVER_ADDRESS,
429
+ client: tuple[str, int] = _CLIENT_ADDRESS,
430
+ limit: int = _BUFFER,
431
+ ) -> tuple[Endpoint, Endpoint]:
432
+ """
433
+ Two connected `(reader, writer)` endpoints, wired to each other and to nothing else.
434
+
435
+ The in-memory equivalent of a connected socket pair, `(client_side, server_side)`,
436
+ with no file descriptor, no port, and no kernel involved. `limit` is the reader
437
+ buffer at which backpressure kicks in, the analogue of a socket receive buffer.
438
+
439
+ What it cannot reproduce is what only a kernel provides: TLS, and the difference
440
+ between an orderly `FIN` and an abortive `RST`. Tests that turn on those stay on a
441
+ real socket.
442
+ """
443
+ loop = asyncio.get_running_loop()
444
+
445
+ def endpoint(
446
+ sockname: tuple[str, int], peername: tuple[str, int]
447
+ ) -> tuple[Endpoint, _PipeTransport, asyncio.StreamReaderProtocol]:
448
+ reader = asyncio.StreamReader(limit=limit, loop=loop)
449
+ protocol = asyncio.StreamReaderProtocol(reader, loop=loop)
450
+ transport = _PipeTransport({"sockname": sockname, "peername": peername})
451
+ writer = asyncio.StreamWriter(transport, protocol, reader, loop)
452
+ protocol.connection_made(transport) # sets the reader's transport, for flow control
453
+ return (reader, writer), transport, protocol
454
+
455
+ near, near_transport, near_protocol = endpoint(client, server)
456
+ far, far_transport, far_protocol = endpoint(server, client)
457
+ near_transport.link(far[0], far_transport, near_protocol)
458
+ far_transport.link(near[0], near_transport, far_protocol)
459
+ return near, far
460
+
461
+
462
+ @asynccontextmanager
463
+ async def served_pipe(
464
+ app: ASGIApp,
465
+ *,
466
+ max_concurrent_streams: int = _DEFAULT_LIMITS.max_concurrent_streams,
467
+ max_stream_resets: int = _DEFAULT_LIMITS.max_stream_resets,
468
+ idle_timeout: timedelta | None = _DEFAULT_LIMITS.idle_timeout,
469
+ max_websocket_message_bytes: int | None = _DEFAULT_LIMITS.max_websocket_message_bytes,
470
+ ) -> AsyncIterator[Endpoint]:
471
+ """
472
+ The client end of a `pipe` with `app` served on the other, for a test that writes bytes.
473
+
474
+ `serving` minus `asyncio.start_server`, and minus a client: where `loopback_client`
475
+ puts a `ConnectionPool` on this end, this hands the raw `(reader, writer)` over, so a
476
+ test can drive the exact frames and (half-)close timing a protocol conformance test
477
+ needs. The server reads `SERVER_ADDRESS` back as its `sockname`, which is the
478
+ authority such a test names.
479
+
480
+ ```python
481
+ async with served_pipe(app, max_stream_resets=2) as (reader, writer):
482
+ writer.write(connection.data_to_send())
483
+ await writer.drain()
484
+ ```
485
+
486
+ The app's lifespan runs for the block and the connection is cancelled on exit, both
487
+ as `serving` does, so a test can assert what leaving the block does to work still in
488
+ flight. Both ends are closed on the way out, and a test may `write_eof()` its own
489
+ end early to send the half-close a protocol reads as "done sending" while still
490
+ reading the response. `close()` is full teardown, not a half-close: it also ends
491
+ this end's own reader and drops the server's subsequent writes, though closing
492
+ early is safe since closing twice is a no-op. The keyword arguments are `serving`'s
493
+ per-connection bounds, with the same defaults.
494
+ """
495
+ limits = _Limits(
496
+ max_concurrent_streams=max_concurrent_streams,
497
+ max_stream_resets=max_stream_resets,
498
+ idle_timeout=idle_timeout,
499
+ max_websocket_message_bytes=max_websocket_message_bytes,
500
+ )
501
+ async with run_lifespan(app):
502
+ near, far = pipe()
503
+ connection = asyncio.create_task(_serve_connection(app, *far, limits))
504
+ try:
505
+ yield near
506
+ finally:
507
+ # Cancelling first is what makes the shutdown, rather than the client's EOF,
508
+ # the thing that ends work still in flight. The server's end is closed here
509
+ # rather than left to `_serve_connection`, since a block that exits without
510
+ # ever awaiting never lets the connection task run at all. `close()` alone,
511
+ # not `wait_closed()`: a pipe has nothing to flush, and the close waiter is
512
+ # shared with whoever closed the endpoint first, so it may already have been
513
+ # cancelled with them. The closes run even when the connection task crashed
514
+ # (`cancel_futures` re-raises such a failure), so the endpoints never leak.
515
+ try:
516
+ await cancel_futures([connection])
517
+ finally:
518
+ for _reader, writer in (near, far):
519
+ writer.close()
520
+
521
+
522
+ @asynccontextmanager
523
+ async def loopback_client(
524
+ app: ASGIApp,
525
+ *,
526
+ http2: bool = False,
527
+ max_concurrent_streams: int = _DEFAULT_LIMITS.max_concurrent_streams,
528
+ max_stream_resets: int = _DEFAULT_LIMITS.max_stream_resets,
529
+ idle_timeout: timedelta | None = _DEFAULT_LIMITS.idle_timeout,
530
+ max_websocket_message_bytes: int | None = _DEFAULT_LIMITS.max_websocket_message_bytes,
531
+ ) -> AsyncIterator[Client]:
532
+ """
533
+ A `Client` that reaches `app` through the real wire protocols, over no socket at all.
534
+
535
+ This is `serving` minus `asyncio.start_server`: the same `ConnectionPool` encodes the
536
+ request, the same server code decodes and drives the app, and the bytes cross a
537
+ `pipe` instead of the kernel. So it exercises what `asgi_client` skips (framing,
538
+ chunking, keep-alive and connection reuse, the server turning a crashing handler into
539
+ a `500`) while still opening no port and holding no file descriptor.
540
+
541
+ ```python
542
+ async with loopback_client(app) as client:
543
+ async with request(client, "GET", "http://testserver/items") as (head, body):
544
+ assert head.status == 200
545
+ ```
546
+
547
+ `http2` sends the h2 connection preface instead, which the server recognizes by prior
548
+ knowledge, so one flag runs the same test over HTTP/2. The remaining arguments are
549
+ `serving`'s per-connection bounds, with the same defaults.
550
+
551
+ URLs must be `http`, since a pipe has no TLS to negotiate: an `https` URL is a loud
552
+ failure rather than a silent downgrade. Nor can it reproduce an abortive close, so
553
+ tests that turn on `RST` versus `FIN` semantics belong on `serving` and a real socket.
554
+ """
555
+ limits = _Limits(
556
+ max_concurrent_streams=max_concurrent_streams,
557
+ max_stream_resets=max_stream_resets,
558
+ idle_timeout=idle_timeout,
559
+ max_websocket_message_bytes=max_websocket_message_bytes,
560
+ )
561
+ connections: set[asyncio.Task[None]] = set()
562
+
563
+ async def connect(
564
+ host: str,
565
+ port: int,
566
+ *,
567
+ ssl_context: ssl.SSLContext | None,
568
+ timeout: Timeout = _NO_TIMEOUT,
569
+ socket_options: SocketOptions = (),
570
+ ) -> tuple[asyncio.StreamReader, asyncio.StreamWriter, str]:
571
+ if ssl_context is not None:
572
+ raise ValueError("loopback_client has no TLS; request an http:// URL")
573
+ near, far = pipe(server=(host, port))
574
+ task = asyncio.create_task(_serve_connection(app, *far, limits))
575
+ connections.add(task)
576
+ task.add_done_callback(connections.discard)
577
+ return (*near, "http/1.1")
578
+
579
+ async with run_lifespan(app), ConnectionPool(connect=connect, force_http2_cleartext=http2) as pool:
580
+ try:
581
+ yield pool
582
+ finally:
583
+ # Close the pooled connections first, so each server task sees the EOF and
584
+ # ends on its own; whatever is still in flight after that is cancelled, which
585
+ # is what `serving` does at shutdown too.
586
+ await pool.aclose()
587
+ await cancel_futures(connections)
@@ -12,7 +12,7 @@ from without import timeout
12
12
  @dataclass(frozen=True, slots=True)
13
13
  class Timeout:
14
14
  """
15
- Per-phase inactivity bounds for a client request, each disabled (`None`) by default.
15
+ Per-phase inactivity bounds for one client request, each disabled (`None`) by default.
16
16
 
17
17
  Four axes, following httpx, each bounding one phase of a request that fails for its
18
18
  own reason (see the `without-http` guide's request-lifecycle table):
@@ -31,8 +31,10 @@ class Timeout:
31
31
  budget ("fail rather than make slow progress so my upstream can react"), which the
32
32
  transport cannot know, so a caller opts in per axis (`Timeout(connect=timedelta(
33
33
  seconds=10), read=timedelta(seconds=30))`). There is deliberately no shared-default
34
- scalar: one duration across four unrelated phases carries no meaning. For an overall
35
- wall-clock cap, compose `async with asyncio.timeout(t): pool.request(...)`.
34
+ scalar: one duration across four unrelated phases carries no meaning. It rides on the
35
+ `ClientRequest` it bounds (`deadline` fills it in for a whole client), so it is the
36
+ caller's value rather than the connection's. For an overall wall-clock cap, compose
37
+ `async with asyncio.timeout(t): request(...)`.
36
38
 
37
39
  Each axis is applied through its own bound: `connecting()`, `reading()`, `writing()`,
38
40
  and `pooling()` each return a context manager that bounds the wrapped await(s) by that