From 8ebadd8052fd54ad4350558e3bc0d682c2e02860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87a=C4=9F=C4=B1l=20Ulu=C5=9Fahin?= Date: Thu, 5 Nov 2026 15:21:53 +0000 Subject: [PATCH 1/3] Support HEAD responses in server --- src/hip/_parsers.py | 40 +++++++++++++++++++++++++++++----------- src/hip/_server.py | 15 +++++++++------ tests/test_parsers.py | 23 +++++++++++++++++++++++ tests/test_server.py | 19 +++++++++++++++++++ 4 files changed, 80 insertions(+), 17 deletions(-) diff --git a/src/hip/_parsers.py b/src/hip/_parsers.py index 64391c0..1b9a53c 100644 --- a/src/hip/_parsers.py +++ b/src/hip/_parsers.py @@ -12,12 +12,13 @@ class State(enum.Enum): SEND_STATUS_LINE = 2 SEND_HEADERS = 3 SEND_BODY = 4 - RECV_METHOD_LINE = 5 - RECV_STATUS_LINE = 6 - RECV_HEADERS = 7 - RECV_BODY = 8 - DONE = 9 - CLOSED = 10 + SEND_BODYLESS = 5 + RECV_METHOD_LINE = 6 + RECV_STATUS_LINE = 7 + RECV_HEADERS = 8 + RECV_BODY = 9 + DONE = 10 + CLOSED = 11 class Mode(enum.Enum): @@ -92,6 +93,7 @@ def __init__(self, stream: Stream, mode: str) -> None: # Special states... self.processing_1xx = False + self.send_bodyless = False def send_method_line(self, method: bytes, target: bytes, protocol: bytes) -> None: """ @@ -142,11 +144,12 @@ def send_status_line( def send_headers(self, headers: list[tuple[bytes, bytes]]) -> None: """ - Send the request headers: + Send the message headers: >>> p.send_headers([(b'Host', b'www.example.com')]) - Sending state will switch to SEND_BODY state. + Sending state switches to SEND_BODY state by default, or SEND_BODYLESS + when sending a response for a HEAD request. """ if self.send_state != State.SEND_HEADERS: msg = f"Called 'send_headers' in invalid state {self.send_state}" @@ -175,16 +178,28 @@ def send_headers(self, headers: list[tuple[bytes, bytes]]) -> None: data = b"".join(lines) + b"\r\n" self.stream.write(data) - self.send_state = State.SEND_BODY + self.send_state = ( + State.SEND_BODYLESS + if self.mode == Mode.SERVER and self.send_bodyless + else State.SEND_BODY + ) def send_body(self, body: bytes) -> None: """ - Send the request body. An empty bytes argument indicates the end of the stream: + Send the message body. An empty bytes argument indicates the end of the stream: >>> p.send_body(b'') - Sending state will switch to DONE. + Sending state will switch to DONE. For a response to HEAD, only an empty + body is accepted; it switches the state to DONE without writing body bytes. """ + if self.send_state == State.SEND_BODYLESS: + if body != b"": + raise ProtocolError("Cannot send body for a bodyless response") + + self.send_state = State.DONE + return + if self.send_state != State.SEND_BODY: msg = f"Called 'send_body' in invalid state {self.send_state}" raise ProtocolError(msg) @@ -245,6 +260,8 @@ def recv_method_line(self) -> tuple[bytes, bytes, bytes]: if protocol != b"HTTP/1.1": raise ProtocolError("Received unsupported protocol version") + self.send_bodyless = method == b"HEAD" + self.recv_state = State.RECV_HEADERS self.send_state = State.SEND_STATUS_LINE return method, target, protocol @@ -398,6 +415,7 @@ def complete(self): self.recv_keep_alive = True self.processing_1xx = False self.keepalive_until = time.monotonic() + self.keepalive_duration + self.send_bodyless = False def close(self): if self.send_state != State.CLOSED: diff --git a/src/hip/_server.py b/src/hip/_server.py index baf09d7..631973e 100644 --- a/src/hip/_server.py +++ b/src/hip/_server.py @@ -47,10 +47,10 @@ def handle_requests(self): content = Text("Internal Server Error") err = Response(500, content=content) self._send_head(err) - self._send_body(err) + self._send_body(err, method) else: self._send_head(response) - self._send_body(response) + self._send_body(response, method) except BaseException: logger.exception("Internal Server Error") @@ -77,11 +77,14 @@ def _send_head(self, response: Response): headers = headers.copy_update(response.headers) self._parser.send_headers(headers.as_byte_pairs()) - def _send_body(self, response: Response): - with response.content.open() as stream: - while data := stream.read(64 * 1024): - self._parser.send_body(data) + def _send_body(self, response: Response, method: str) -> None: + if method == "HEAD": self._parser.send_body(b"") + else: + with response.content.open() as stream: + while data := stream.read(64 * 1024): + self._parser.send_body(data) + self._parser.send_body(b"") self._parser.complete() diff --git a/tests/test_parsers.py b/tests/test_parsers.py index b6a608f..0355beb 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -145,6 +145,29 @@ def test_parser_server(): assert p.is_idle() +def test_parser_server_head_bodyless(): + stream = DuplexStream(b"HEAD / HTTP/1.1\r\nHost: example.com\r\n\r\n") + parser = hip.parsers.HTTPParser(stream, mode="SERVER") + + assert parser.recv_method_line()[0] == b"HEAD" + parser.recv_headers() + assert parser.recv_body() == b"" + + parser.send_status_line(b"HTTP/1.1", 200, b"OK") + parser.send_headers([(b"Content-Length", b"23")]) + assert parser.send_state.name == "SEND_BODYLESS" + + with pytest.raises( + hip.parsers.ProtocolError, match="Cannot send body for a bodyless response" + ): + parser.send_body(b'{"msg": "hello, world"}') + + parser.send_body(b"") + assert stream.output_bytes() == b"HTTP/1.1 200 OK\r\nContent-Length: 23\r\n\r\n" + parser.complete() + assert parser.is_idle() + + def test_empty_request_line_raises_eof_error(): parser = hip.parsers.HTTPParser(DuplexStream(b""), mode="SERVER") diff --git a/tests/test_server.py b/tests/test_server.py index b43b67d..e67a899 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -30,6 +30,25 @@ def test_server_defaults_headers(): assert response.headers["Server"] == "hip/dev" assert response.headers["Connection"] == "keep-alive" assert response.headers["Date"].endswith("GMT") + assert response.body == "¡Hola, mundo!".encode() + + +def test_server_head(): + with ( + hip.Server(app) as server, + socket.create_connection((server.host, server.port)) as client, + ): + client.sendall(b"HEAD / HTTP/1.1\r\nHost: localhost\r\n\r\n") + client.shutdown(socket.SHUT_WR) + response = bytearray() + while chunk := client.recv(4096): + response.extend(chunk) + + head, _, body = response.partition(b"\r\n\r\n") + assert head.startswith(b"HTTP/1.1 200 OK\r\n") + assert b"Server: hip/dev\r\n" in head + assert b"Content-Length: 40" in head.split(b"\r\n") + assert body == b"" def test_server_ignores_clean_client_disconnect(caplog): From 5bf0a40fab72eeac9191a5daeb86c45e745b761c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87a=C4=9F=C4=B1l=20Ulu=C5=9Fahin?= Date: Thu, 5 Nov 2026 20:26:48 +0000 Subject: [PATCH 2/3] Support HEAD responses in client --- src/hip/_client.py | 19 +++++++++++++++---- src/hip/_parsers.py | 34 ++++++++++++++++++++++++---------- tests/test_client.py | 12 ++++++++++++ 3 files changed, 51 insertions(+), 14 deletions(-) diff --git a/src/hip/_client.py b/src/hip/_client.py index cbd355e..8b04734 100644 --- a/src/hip/_client.py +++ b/src/hip/_client.py @@ -1,7 +1,7 @@ import types import typing -from ._content import HTML, JSON, Binary, Content, Text, select_media +from ._content import HTML, JSON, Binary, Content, Empty, Text, select_media from ._headers import Headers from ._request import Method, Request from ._response import Response @@ -58,9 +58,13 @@ def request( ) -> Response: request = self.build_request(method, url, headers=headers, content=content) 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) + if request.method == "HEAD": + stream.read() # reaches recv_body(), which switches the receive state to DONE + content = Empty() + else: + ct = resp_headers.get("Content-Type", "application/octet-stream") + cls = select_media(self.media, ct) + content = cls.parse(stream) return Response( status_code=status_code, headers=resp_headers, @@ -74,6 +78,13 @@ def get( ): return self.request("GET", url, headers=headers) + def head( + self, + url: URL | str, + headers: Headers | dict[str, str] | None = None, + ): + return self.request("HEAD", url, headers=headers) + def post( self, url: URL | str, diff --git a/src/hip/_parsers.py b/src/hip/_parsers.py index 1b9a53c..fc15033 100644 --- a/src/hip/_parsers.py +++ b/src/hip/_parsers.py @@ -17,8 +17,9 @@ class State(enum.Enum): RECV_STATUS_LINE = 7 RECV_HEADERS = 8 RECV_BODY = 9 - DONE = 10 - CLOSED = 11 + RECV_BODYLESS = 10 + DONE = 11 + CLOSED = 12 class Mode(enum.Enum): @@ -94,6 +95,7 @@ def __init__(self, stream: Stream, mode: str) -> None: # Special states... self.processing_1xx = False self.send_bodyless = False + self.response_bodyless = False def send_method_line(self, method: bytes, target: bytes, protocol: bytes) -> None: """ @@ -116,6 +118,9 @@ def send_method_line(self, method: bytes, target: bytes, protocol: bytes) -> Non data = b" ".join([method, target, protocol]) + b"\r\n" self.stream.write(data) + # Track whether the response to this request has no body. + self.response_bodyless = method == b"HEAD" + self.send_state = State.SEND_HEADERS self.recv_state = State.RECV_STATUS_LINE @@ -300,10 +305,10 @@ def recv_headers(self) -> list[tuple[bytes, bytes]]: """ Receive the response headers: - >>> headers = p.recv_status_line() + >>> headers = p.recv_headers() - Receive state will switch to RECV_BODY by default. - Receive state will revert to RECV_STATUS_CODE for interim 1xx responses. + Receive state will switch to RECV_BODY by default and RECV_BODYLESS for a + HEAD response, and RECV_STATUS_LINE for interim 1xx responses. """ if self.recv_state != State.RECV_HEADERS: msg = f"Called 'recv_headers' in invalid state {self.recv_state}" @@ -339,20 +344,28 @@ def recv_headers(self) -> list[tuple[bytes, bytes]]: # 1xx status codes preceed the final response status code self.processing_1xx = False self.recv_state = State.RECV_STATUS_LINE + elif self.response_bodyless: + self.recv_state = State.RECV_BODYLESS else: self.recv_state = State.RECV_BODY return headers def recv_body(self) -> bytes: """ - Receive the response body. An empty byte string indicates the end of the stream: + Receive the message body. An empty byte string indicates the end of the stream: + + buffer = bytearray() + while body := p.recv_body(): + buffer.extend(body) - >>> buffer = bytearray() - >>> while body := p.recv_body() - >>> buffer.extend(body) + The receive state switches to DONE once an empty byte syting is returned. - The server will switch to DONE. + For a response to HEAD, return b"" without reading from the stream, even + when Content-Length is nonzero. """ + if self.recv_state == State.RECV_BODYLESS: + self.recv_state = State.DONE + return b"" if self.recv_state != State.RECV_BODY: msg = f"Called 'recv_body' in invalid state {self.recv_state}" raise ProtocolError(msg) @@ -416,6 +429,7 @@ def complete(self): self.processing_1xx = False self.keepalive_until = time.monotonic() + self.keepalive_duration self.send_bodyless = False + self.response_bodyless = False def close(self): if self.send_state != State.CLOSED: diff --git a/tests/test_client.py b/tests/test_client.py index 1554641..12d4c82 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -340,3 +340,15 @@ def app(request): with client.transport.send(request) as (status, _, stream): assert status == 200 assert stream.read() == source + + +def test_server_head(): + def app(request): + content = hip.HTML("¡Hola, mundo!") + return hip.Response(200, content=content) + + with hip.Server(app) as server, hip.Client() as client: + response = client.head(server.url) + assert response.headers["Server"] == "hip/dev" + assert response.body == b"" + assert response.headers["Content-Length"] == "40" From 26dd04075e411d092d767cd45bbb19bd1ab10ad2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87a=C4=9F=C4=B1l=20Ulu=C5=9Fahin?= Date: Thu, 5 Nov 2026 20:33:28 +0000 Subject: [PATCH 3/3] Support HEAD responses in transport APIs --- src/hip/transports/base.py | 5 +++-- src/hip/transports/connection.py | 5 +++-- tests/test_pool.py | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/hip/transports/base.py b/src/hip/transports/base.py index a6feff2..80a1f7d 100644 --- a/src/hip/transports/base.py +++ b/src/hip/transports/base.py @@ -1,6 +1,6 @@ import types -from .._content import Content +from .._content import Content, Empty from .._headers import Headers from .._request import Method, Request from .._response import Response, StatusCode @@ -41,4 +41,5 @@ def request( request = Request(method, url, headers=headers, content=content) with self.send(request) as (status, resp_headers, stream): body = stream.read() - return Response(status, headers=resp_headers, content=body) + response_content = Empty() if request.method == "HEAD" else body + return Response(status, headers=resp_headers, content=response_content) diff --git a/src/hip/transports/connection.py b/src/hip/transports/connection.py index 58680f6..cc9d0f1 100644 --- a/src/hip/transports/connection.py +++ b/src/hip/transports/connection.py @@ -6,7 +6,7 @@ from typing_extensions import Self from .._body import GZipStream, HTTPStream -from .._content import Content +from .._content import Content, Empty from .._headers import Headers from .._parsers import HTTPParser from .._request import Method, Request @@ -68,7 +68,8 @@ def request( request = Request(method, url, headers=headers, content=content) with self.send(request) as (status_code, resp_headers, stream): body = stream.read() - return Response(status_code, headers=resp_headers, content=body) + response_content = Empty() if request.method == "HEAD" else body + return Response(status_code, headers=resp_headers, content=response_content) # Send the request... def _send_head(self, request: Request) -> None: diff --git a/tests/test_pool.py b/tests/test_pool.py index 0dc4e93..a1630d2 100644 --- a/tests/test_pool.py +++ b/tests/test_pool.py @@ -26,6 +26,15 @@ def test_connection_pool_request(server): assert len(pool.connections) == 1 +def test_connection_pool_head(server): + with hip.transports.ConnectionPool() as pool: + response = pool.request("HEAD", server.url) + + assert response.status_code == 200 + assert response.headers["Content-Length"] == "13" + assert response.body == b"" + + def test_transport_request_not_implemented(): transport = hip.transports.Transport() @@ -90,6 +99,15 @@ def test_connection_request(server): assert repr(conn) == f"" +def test_connection_head(server): + with hip.transports.open_connection(server.url) as conn: + response = conn.request("HEAD", "/") + + assert response.status_code == 200 + assert response.headers["Content-Length"] == "13" + assert response.body == b"" + + def test_connection_stream(server): with hip.transports.open_connection(server.url) as conn: assert repr(conn) == f""