Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions src/hip/_client.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
70 changes: 51 additions & 19 deletions src/hip/_parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ 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
RECV_BODYLESS = 10
DONE = 11
CLOSED = 12


class Mode(enum.Enum):
Expand Down Expand Up @@ -92,6 +94,8 @@ 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:
"""
Expand All @@ -114,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

Expand Down Expand Up @@ -142,11 +149,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}"
Expand Down Expand Up @@ -175,16 +183,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)
Expand Down Expand Up @@ -245,6 +265,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
Expand Down Expand Up @@ -283,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}"
Expand Down Expand Up @@ -322,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 server will switch to DONE.
The receive state switches to DONE once an empty byte syting is returned.

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)
Expand Down Expand Up @@ -398,6 +428,8 @@ def complete(self):
self.recv_keep_alive = True
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:
Expand Down
15 changes: 9 additions & 6 deletions src/hip/_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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()


Expand Down
5 changes: 3 additions & 2 deletions src/hip/transports/base.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
5 changes: 3 additions & 2 deletions src/hip/transports/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<html><body>¡Hola, mundo!</body></html>")
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"
23 changes: 23 additions & 0 deletions tests/test_parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
18 changes: 18 additions & 0 deletions tests/test_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -90,6 +99,15 @@ def test_connection_request(server):
assert repr(conn) == f"<Connection [{server.url} idle]>"


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"<Connection [{server.url} idle]>"
Expand Down
19 changes: 19 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "<html><body>¡Hola, mundo!</body></html>".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):
Expand Down
Loading