From ad8f103c51b62c58b37d217a671216f2e7849b50 Mon Sep 17 00:00:00 2001 From: Kim Christie Date: Wed, 4 Nov 2026 10:21:44 +0100 Subject: [PATCH 1/4] Transport API --- src/hip/_client.py | 30 ++++++++++++++--------------- src/hip/transports/__init__.py | 4 ++-- src/hip/transports/base.py | 21 +++++++++----------- src/hip/transports/connection.py | 12 ++++++------ src/hip/transports/pool.py | 4 ++-- tests/test_client.py | 33 +++++++++++++++++--------------- tests/test_pool.py | 21 ++++++++++---------- 7 files changed, 63 insertions(+), 62 deletions(-) diff --git a/src/hip/_client.py b/src/hip/_client.py index 127eeff..672ee22 100644 --- a/src/hip/_client.py +++ b/src/hip/_client.py @@ -6,7 +6,7 @@ from ._request import Method, Request from ._response import Response from ._urls import URL -from .transports import ConnectionPool, ResponseStream, Transport +from .transports import ConnectionPool, SendContext, Transport __all__ = ["Client"] @@ -57,13 +57,13 @@ def request( content: Content | bytes | None = None, ) -> Response: request = self.build_request(method, url, headers=headers, content=content) - with self.via.send(request) as stream: - ct = stream.headers.get("Content-Type", "application/octet-stream") + with self.via.send(request) as (status_code, resp_headers, stream): + ct = resp_headers.get("Content-Type", "application/octet-stream") cls = select_media(self.media, ct) content = cls.parse(stream) return Response( - status_code=stream.status_code, - headers=stream.headers, + status_code=status_code, + headers=resp_headers, content=content, ) @@ -73,7 +73,7 @@ def stream( url: URL | str, headers: Headers | dict[str, str] | None = None, content: Content | bytes | None = None, - ) -> ResponseStream: + ) -> SendContext: request = self.build_request(method, url, headers=headers, content=content) return self.via.send(request) @@ -138,15 +138,15 @@ def __init__(self, transport: Transport) -> None: self._transport = transport def build_redirect_request( - self, request: Request, response: ResponseStream + self, request: Request, ctx: SendContext ) -> Request | None: # Redirect status codes... - if response.status_code not in (301, 302, 303, 307, 308): + if ctx.status_code not in (301, 302, 303, 307, 308): return None # Redirects need a valid location header... try: - location = URL(response.headers["Location"]) + location = URL(ctx.headers["Location"]) except (KeyError, ValueError): return None @@ -158,22 +158,22 @@ def build_redirect_request( return Request(method, url, headers, content) - def send(self, request: Request) -> ResponseStream: + def send(self, request: Request) -> SendContext: while True: - response = self._transport.send(request) + ctx = self._transport.send(request) # Determine if we have a redirect or not. - redirect = self.build_redirect_request(request, response) + redirect = self.build_redirect_request(request, ctx) # If we don't have a redirect, we're done. if redirect is None: - return response + return ctx # If we have a redirect, then we read the body of the response. # Ensures that the HTTP connection is available for a new # request/response cycle. - with response: - response.read() + with ctx: + ctx.stream.read() # Make the next request request = redirect diff --git a/src/hip/transports/__init__.py b/src/hip/transports/__init__.py index cd54fdf..a0b09a7 100644 --- a/src/hip/transports/__init__.py +++ b/src/hip/transports/__init__.py @@ -1,11 +1,11 @@ -from .base import ResponseStream, Transport +from .base import SendContext, Transport from .connection import Connection, open_connection from .pool import ConnectionPool __all__ = [ "Connection", "ConnectionPool", - "ResponseStream", + "SendContext", "Transport", "open_connection", ] diff --git a/src/hip/transports/base.py b/src/hip/transports/base.py index fb23780..fb5ec32 100644 --- a/src/hip/transports/base.py +++ b/src/hip/transports/base.py @@ -7,10 +7,10 @@ from .._streams import Stream from .._urls import URL -__all__ = ["ResponseStream", "Transport"] +__all__ = ["SendContext", "Transport"] -class ResponseStream(Stream): +class SendContext: def __init__( self, status_code: StatusCode, headers: Headers, stream: Stream ) -> None: @@ -18,11 +18,8 @@ def __init__( self.headers = headers self.stream = stream - def read(self, size: int = -1) -> bytes: - return self.stream.read(size) - - def close(self): - self.stream.close() + def __iter__(self): + return iter((self.status_code, self.headers, self.stream)) def __enter__(self): return self @@ -33,11 +30,11 @@ def __exit__( exc_value: BaseException | None = None, traceback: types.TracebackType | None = None, ) -> None: - self.close() + self.stream.close() class Transport: - def send(self, request: Request) -> ResponseStream: + def send(self, request: Request) -> SendContext: raise NotImplementedError() def close(self): @@ -51,9 +48,9 @@ def request( content: Content | bytes | None = None, ) -> Response: request = Request(method, url, headers=headers, content=content) - with self.send(request) as stream: + with self.send(request) as (status_code, resp_headers, stream): body = stream.read() - return Response(stream.status_code, headers=stream.headers, content=body) + return Response(status_code, headers=resp_headers, content=body) def stream( self, @@ -61,6 +58,6 @@ def stream( url: URL | str, headers: Headers | dict[str, str] | None = None, content: Content | bytes | None = None, - ) -> ResponseStream: + ) -> SendContext: request = Request(method, url, headers=headers, content=content) return self.send(request) diff --git a/src/hip/transports/connection.py b/src/hip/transports/connection.py index 8d0d9bf..24d3a17 100644 --- a/src/hip/transports/connection.py +++ b/src/hip/transports/connection.py @@ -14,7 +14,7 @@ from .._streams import Stream from .._urls import URL from ..backends.sync import Lock, NetworkBackend -from .base import ResponseStream, Transport +from .base import SendContext, Transport __all__ = ["Connection", "open_connection"] @@ -43,14 +43,14 @@ def description(self) -> str: return self._parser.description() # API entry points... - def send(self, request: Request) -> ResponseStream: + def send(self, request: Request) -> SendContext: # async with self._request_lock: # try: self._send_head(request) self._send_body(request) code, headers = self._recv_head() stream = self._recv_body(headers) - return ResponseStream(code, headers, stream) + return SendContext(code, headers, stream) def close(self) -> None: with self._request_lock: @@ -66,9 +66,9 @@ def request( ) -> Response: url = self._origin.join(url) request = Request(method, url, headers=headers, content=content) - with self.send(request) as stream: + with self.send(request) as (status_code, resp_headers, stream): body = stream.read() - return Response(stream.status_code, headers=stream.headers, content=body) + return Response(status_code, headers=resp_headers, content=body) def stream( self, @@ -76,7 +76,7 @@ def stream( url: URL | str, headers: Headers | dict[str, str] | None = None, content: Content | bytes | None = None, - ) -> ResponseStream: + ) -> SendContext: url = self._origin.join(url) request = Request(method, url, headers=headers, content=content) return self.send(request) diff --git a/src/hip/transports/pool.py b/src/hip/transports/pool.py index d55cc51..2d2cabb 100644 --- a/src/hip/transports/pool.py +++ b/src/hip/transports/pool.py @@ -9,7 +9,7 @@ from .._request import Request from .._urls import URL from ..backends.sync import NetworkBackend, Semaphore -from .base import ResponseStream, Transport +from .base import SendContext, Transport from .connection import ( Connection, open_connection, @@ -31,7 +31,7 @@ def __init__(self, backend: NetworkBackend | None = None): self._closed = False # Public API... - def send(self, request: Request) -> ResponseStream: + def send(self, request: Request) -> SendContext: if self._closed: raise RuntimeError("ConnectionPool is closed.") diff --git a/tests/test_client.py b/tests/test_client.py index 781f9b8..69fe788 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -149,9 +149,9 @@ def test_request(client, server): def test_stream(client, server): - with client.stream("GET", server.url) as r: - assert r.status_code == 200 - body = r.read() + with client.stream("GET", server.url) as (status, _, stream): + assert status == 200 + body = stream.read() assert json.loads(body) == { "method": "GET", "query-params": {}, @@ -161,10 +161,11 @@ def test_stream(client, server): def test_stream_read_after_eof(client, server): - with client.stream("GET", server.url) as response: - assert response.read() - assert response.read() == b"" - assert response.read() == b"" + with client.stream("GET", server.url) as (status, _, stream): + assert status == 200 + assert stream.read() + assert stream.read() == b"" + assert stream.read() == b"" def test_keepalive(client, server): @@ -271,13 +272,14 @@ def app(request): def test_client_streams_gzip_response(client): with ( hip.Server(gzip_response) as server, - client.stream("GET", server.url) as response, + client.stream("GET", server.url) as (status, _, stream), ): - assert response.read(5) == b"Hello" - assert response.read(5) == b", wor" - assert response.read(5) == b"ld." - assert response.read(5) == b"" - assert response.read(5) == b"" + assert status == 200 + assert stream.read(5) == b"Hello" + assert stream.read(5) == b", wor" + assert stream.read(5) == b"ld." + assert stream.read(5) == b"" + assert stream.read(5) == b"" def test_client_rejects_truncated_gzip_response(client): @@ -334,6 +336,7 @@ def app(request): with ( hip.Server(app) as server, - client.stream("GET", server.url) as response, + client.stream("GET", server.url) as (status, _, stream), ): - assert response.read() == source + assert status == 200 + assert stream.read() == source diff --git a/tests/test_pool.py b/tests/test_pool.py index b48f71e..33574de 100644 --- a/tests/test_pool.py +++ b/tests/test_pool.py @@ -55,11 +55,11 @@ def test_connection_pool_stream(server): assert repr(pool) == "" assert len(pool.connections) == 0 - with pool.stream("GET", server.url) as r: - assert r.status_code == 200 + with pool.stream("GET", server.url) as (status, _, stream): + assert status == 200 assert repr(pool) == "" assert len(pool.connections) == 1 - r.read() + stream.read() assert repr(pool) == "" assert len(pool.connections) == 1 @@ -92,21 +92,22 @@ def test_connection_request(server): def test_connection_stream(server): with hip.transports.open_connection(server.url) as conn: assert repr(conn) == f"" - with conn.stream("GET", "/") as r: - assert r.status_code == 200 + with conn.stream("GET", "/") as (status, _, stream): + assert status == 200 assert repr(conn) == f"" - r.read() + stream.read() assert repr(conn) == f"" def test_connection_stream_read_in_chunks(server): with ( hip.transports.open_connection(server.url) as conn, - conn.stream("GET", "/") as response, + conn.stream("GET", "/") as (status, _, stream), ): - assert response.read(5) == b"Hello" - assert response.read(5) == b", wor" - assert response.read() == b"ld." + status == 200 + assert stream.read(5) == b"Hello" + assert stream.read(5) == b", wor" + assert stream.read() == b"ld." class TLSBackend: From d0ba24783fcce65176cb1480830a2cd9945f064a Mon Sep 17 00:00:00 2001 From: Kim Christie Date: Wed, 4 Nov 2026 11:34:40 +0100 Subject: [PATCH 2/4] Linting --- tests/test_pool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_pool.py b/tests/test_pool.py index 33574de..f15ef8f 100644 --- a/tests/test_pool.py +++ b/tests/test_pool.py @@ -104,7 +104,7 @@ def test_connection_stream_read_in_chunks(server): hip.transports.open_connection(server.url) as conn, conn.stream("GET", "/") as (status, _, stream), ): - status == 200 + assert status == 200 assert stream.read(5) == b"Hello" assert stream.read(5) == b", wor" assert stream.read() == b"ld." From eeda00d858e1923d8b1724f5b9ba0baed8a66e06 Mon Sep 17 00:00:00 2001 From: Kim Christie Date: Wed, 4 Nov 2026 14:47:52 +0100 Subject: [PATCH 3/4] SendContext is a context managed tuple. This encompasses proper typing. --- src/hip/_client.py | 9 ++++++--- src/hip/transports/base.py | 19 +++++-------------- src/hip/transports/connection.py | 2 +- 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/src/hip/_client.py b/src/hip/_client.py index 672ee22..76a7fc2 100644 --- a/src/hip/_client.py +++ b/src/hip/_client.py @@ -140,13 +140,15 @@ def __init__(self, transport: Transport) -> None: def build_redirect_request( self, request: Request, ctx: SendContext ) -> Request | None: + status, headers, _ = ctx + # Redirect status codes... - if ctx.status_code not in (301, 302, 303, 307, 308): + if status not in (301, 302, 303, 307, 308): return None # Redirects need a valid location header... try: - location = URL(ctx.headers["Location"]) + location = URL(headers["Location"]) except (KeyError, ValueError): return None @@ -173,7 +175,8 @@ def send(self, request: Request) -> SendContext: # Ensures that the HTTP connection is available for a new # request/response cycle. with ctx: - ctx.stream.read() + _, _, stream = ctx + stream.read() # Make the next request request = redirect diff --git a/src/hip/transports/base.py b/src/hip/transports/base.py index fb5ec32..d0bfaaf 100644 --- a/src/hip/transports/base.py +++ b/src/hip/transports/base.py @@ -10,17 +10,7 @@ __all__ = ["SendContext", "Transport"] -class SendContext: - def __init__( - self, status_code: StatusCode, headers: Headers, stream: Stream - ) -> None: - self.status_code = status_code - self.headers = headers - self.stream = stream - - def __iter__(self): - return iter((self.status_code, self.headers, self.stream)) - +class SendContext(tuple[StatusCode, Headers, Stream]): def __enter__(self): return self @@ -30,7 +20,8 @@ def __exit__( exc_value: BaseException | None = None, traceback: types.TracebackType | None = None, ) -> None: - self.stream.close() + _, _, stream = self + stream.close() class Transport: @@ -48,9 +39,9 @@ def request( content: Content | bytes | None = None, ) -> Response: request = Request(method, url, headers=headers, content=content) - with self.send(request) as (status_code, resp_headers, stream): + with self.send(request) as (status, resp_headers, stream): body = stream.read() - return Response(status_code, headers=resp_headers, content=body) + return Response(status, headers=resp_headers, content=body) def stream( self, diff --git a/src/hip/transports/connection.py b/src/hip/transports/connection.py index 24d3a17..6700479 100644 --- a/src/hip/transports/connection.py +++ b/src/hip/transports/connection.py @@ -50,7 +50,7 @@ def send(self, request: Request) -> SendContext: self._send_body(request) code, headers = self._recv_head() stream = self._recv_body(headers) - return SendContext(code, headers, stream) + return SendContext((code, headers, stream)) def close(self) -> None: with self._request_lock: From 33ed439d4e4a8ad85ec08841567e58d22d594701 Mon Sep 17 00:00:00 2001 From: Kim Christie Date: Wed, 4 Nov 2026 16:19:55 +0100 Subject: [PATCH 4/4] Remove high-level .stream() API --- src/hip/_client.py | 10 --------- src/hip/transports/base.py | 10 --------- src/hip/transports/connection.py | 11 ---------- tests/test_client.py | 36 ++++++++++++++++---------------- tests/test_pool.py | 21 ++++++++++--------- 5 files changed, 29 insertions(+), 59 deletions(-) diff --git a/src/hip/_client.py b/src/hip/_client.py index 76a7fc2..cbd355e 100644 --- a/src/hip/_client.py +++ b/src/hip/_client.py @@ -67,16 +67,6 @@ def request( content=content, ) - def stream( - self, - method: Method | str, - url: URL | str, - headers: Headers | dict[str, str] | None = None, - content: Content | bytes | None = None, - ) -> SendContext: - request = self.build_request(method, url, headers=headers, content=content) - return self.via.send(request) - def get( self, url: URL | str, diff --git a/src/hip/transports/base.py b/src/hip/transports/base.py index d0bfaaf..a6feff2 100644 --- a/src/hip/transports/base.py +++ b/src/hip/transports/base.py @@ -42,13 +42,3 @@ def request( with self.send(request) as (status, resp_headers, stream): body = stream.read() return Response(status, headers=resp_headers, content=body) - - def stream( - self, - method: Method | str, - url: URL | str, - headers: Headers | dict[str, str] | None = None, - content: Content | bytes | None = None, - ) -> SendContext: - request = Request(method, url, headers=headers, content=content) - return self.send(request) diff --git a/src/hip/transports/connection.py b/src/hip/transports/connection.py index 6700479..58680f6 100644 --- a/src/hip/transports/connection.py +++ b/src/hip/transports/connection.py @@ -70,17 +70,6 @@ def request( body = stream.read() return Response(status_code, headers=resp_headers, content=body) - def stream( - self, - method: Method | str, - url: URL | str, - headers: Headers | dict[str, str] | None = None, - content: Content | bytes | None = None, - ) -> SendContext: - url = self._origin.join(url) - request = Request(method, url, headers=headers, content=content) - return self.send(request) - # Send the request... def _send_head(self, request: Request) -> None: method = bytes(request.method) diff --git a/tests/test_client.py b/tests/test_client.py index 69fe788..1554641 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -149,7 +149,8 @@ def test_request(client, server): def test_stream(client, server): - with client.stream("GET", server.url) as (status, _, stream): + request = hip.Request("GET", server.url) + with client.transport.send(request) as (status, _, stream): assert status == 200 body = stream.read() assert json.loads(body) == { @@ -161,7 +162,8 @@ def test_stream(client, server): def test_stream_read_after_eof(client, server): - with client.stream("GET", server.url) as (status, _, stream): + request = hip.Request("GET", server.url) + with client.transport.send(request) as (status, _, stream): assert status == 200 assert stream.read() assert stream.read() == b"" @@ -270,16 +272,15 @@ def app(request): def test_client_streams_gzip_response(client): - with ( - hip.Server(gzip_response) as server, - client.stream("GET", server.url) as (status, _, stream), - ): - assert status == 200 - assert stream.read(5) == b"Hello" - assert stream.read(5) == b", wor" - assert stream.read(5) == b"ld." - assert stream.read(5) == b"" - assert stream.read(5) == b"" + with hip.Server(gzip_response) as server: + request = hip.Request("GET", server.url, {"Accept-Encoding": "gzip"}) + with client.transport.send(request) as (status, _, stream): + assert status == 200 + assert stream.read(5) == b"Hello" + assert stream.read(5) == b", wor" + assert stream.read(5) == b"ld." + assert stream.read(5) == b"" + assert stream.read(5) == b"" def test_client_rejects_truncated_gzip_response(client): @@ -334,9 +335,8 @@ def app(request): content=gzipped, ) - with ( - hip.Server(app) as server, - client.stream("GET", server.url) as (status, _, stream), - ): - assert status == 200 - assert stream.read() == source + with hip.Server(app) as server: + request = hip.Request("GET", server.url, {"Accept-Encoding": "gzip"}) + with client.transport.send(request) as (status, _, stream): + assert status == 200 + assert stream.read() == source diff --git a/tests/test_pool.py b/tests/test_pool.py index f15ef8f..0dc4e93 100644 --- a/tests/test_pool.py +++ b/tests/test_pool.py @@ -55,7 +55,8 @@ def test_connection_pool_stream(server): assert repr(pool) == "" assert len(pool.connections) == 0 - with pool.stream("GET", server.url) as (status, _, stream): + request = hip.Request("GET", server.url) + with pool.send(request) as (status, _, stream): assert status == 200 assert repr(pool) == "" assert len(pool.connections) == 1 @@ -92,7 +93,8 @@ def test_connection_request(server): def test_connection_stream(server): with hip.transports.open_connection(server.url) as conn: assert repr(conn) == f"" - with conn.stream("GET", "/") as (status, _, stream): + request = hip.Request("GET", server.url) + with conn.send(request) as (status, _, stream): assert status == 200 assert repr(conn) == f"" stream.read() @@ -100,14 +102,13 @@ def test_connection_stream(server): def test_connection_stream_read_in_chunks(server): - with ( - hip.transports.open_connection(server.url) as conn, - conn.stream("GET", "/") as (status, _, stream), - ): - assert status == 200 - assert stream.read(5) == b"Hello" - assert stream.read(5) == b", wor" - assert stream.read() == b"ld." + with hip.transports.open_connection(server.url) as conn: + request = hip.Request("GET", server.url) + with conn.send(request) as (status, _, stream): + assert status == 200 + assert stream.read(5) == b"Hello" + assert stream.read(5) == b", wor" + assert stream.read() == b"ld." class TLSBackend: