From 28c74218d3513bf70de06e2d790842c8552719b4 Mon Sep 17 00:00:00 2001 From: Kim Christie Date: Wed, 21 Oct 2026 14:36:56 +0100 Subject: [PATCH 1/4] File.name is a property, not a method --- src/hip/_content.py | 1 + tests/test_content.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hip/_content.py b/src/hip/_content.py index 2a3948d..94457b6 100644 --- a/src/hip/_content.py +++ b/src/hip/_content.py @@ -195,6 +195,7 @@ class File(Content): def __init__(self, path: str): self._path = path + @property def name(self) -> str: return os.path.basename(self._path) diff --git a/tests/test_content.py b/tests/test_content.py index 36d9540..1f0b3c0 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -176,7 +176,7 @@ def test_file(): "Content-Type": "text/plain; charset='utf-8'", "Content-Length": "12", } - assert file.name() == os.path.basename(temp.name) + assert file.name == os.path.basename(temp.name) with file.open() as stream: b = stream.read() assert b == b"Hello, world" From 0e721a4c6b72363d69568e40bfc764b748261c30 Mon Sep 17 00:00:00 2001 From: Kim Christie Date: Thu, 22 Oct 2026 14:27:07 +0100 Subject: [PATCH 2/4] Simplify content parsing --- src/hip/_client.py | 17 ++++++------- src/hip/_content.py | 57 +++++++++++++++++++++++++------------------ src/hip/_headers.py | 48 ------------------------------------ src/hip/_timeouts.py | 2 +- tests/test_content.py | 33 +++++++++++++++++++++++++ 5 files changed, 75 insertions(+), 82 deletions(-) diff --git a/src/hip/_client.py b/src/hip/_client.py index 740f5bf..51c3383 100644 --- a/src/hip/_client.py +++ b/src/hip/_client.py @@ -2,7 +2,7 @@ import typing from ._connection import ResponseStream, Transport -from ._content import HTML, JSON, Binary, Content, Text, select_media +from ._content import HTML, JSON, Binary, Content, Text from ._headers import Headers from ._pool import ConnectionPool from ._request import Method, Request @@ -29,12 +29,6 @@ def __init__( self.transport = ConnectionPool() self.url = URL(url) self.via = RedirectMiddleware(self.transport) - self.media = { - "application/json": JSON, - "text/html": HTML, - "text/*": Text, - "*/*": Binary, - } def build_request( self, @@ -60,8 +54,13 @@ def request( 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") - cls = select_media(self.media, ct) - content = cls.parse(stream) + ct = ct.split(';')[0].strip() + for cls in [JSON, HTML, Text]: + if cls.can_handle(ct): + content = cls.parse(stream) + break + else: + content = Binary.parse(stream) return Response( status_code=stream.status_code, headers=stream.headers, diff --git a/src/hip/_content.py b/src/hip/_content.py index 94457b6..3f8dd4f 100644 --- a/src/hip/_content.py +++ b/src/hip/_content.py @@ -3,7 +3,7 @@ import os import typing -from ._headers import Headers, parse_opts_header +from ._headers import Headers from ._streams import ByteStream, FileStream, MultiPartStream, Stream from ._urlencode import urldecode, urlencode @@ -15,10 +15,8 @@ "Empty", "File", "Files", - # Request Content Types "Form", "MultiPart", - # Response Content Types "Text", ] @@ -47,6 +45,10 @@ def headers(self) -> Headers: def parse(self, stream: Stream) -> "Content": raise NotImplementedError() + @classmethod + def can_handle(self, content_type: str) -> bool: + raise NotImplementedError() + def __bytes__(self): raise TypeError( f"Content {self.__class__.__name__} does not support a bytes interface." @@ -352,6 +354,13 @@ def parse(self, stream: Stream) -> "Content": data = json.loads(source) return JSON(data, source) + @classmethod + def can_handle(self, content_type: str) -> bool: + return ( + content_type == 'application/json' or + (content_type.startswith('application/') and content_type.endswith('+json')) + ) + # Return the underlying data. Copied to ensure immutability. @property def value(self) -> typing.Any: @@ -407,6 +416,10 @@ def parse(self, stream: Stream) -> Content: text = source.decode("utf-8") return Text(text, source) + @classmethod + def can_handle(self, content_type: str) -> bool: + return content_type.startswith('text/') + def __str__(self) -> str: return self._text @@ -441,6 +454,10 @@ def parse(self, stream: Stream) -> Content: text = source.decode("utf-8") return HTML(text, source) + @classmethod + def can_handle(self, content_type: str) -> bool: + return content_type == 'text/html' + def __str__(self) -> str: return self._text @@ -467,6 +484,19 @@ def parse(self, stream: Stream) -> Content: source = stream.read() return Binary(source) + @classmethod + def can_handle(self, content_type: str) -> bool: + return True + + # Built-ins. + def __eq__(self, other: object) -> bool: + if isinstance(other, Binary): + return self._content == bytes(other) + return self._content == other + + def __len__(self) -> int: + return len(self._content) + def __bytes__(self) -> bytes: return self._content @@ -536,24 +566,3 @@ def headers(self) -> Headers: def __repr__(self) -> str: return f"" - - -def select_media(choices: dict[str, type[Content]], content_type: str) -> type[Content]: - # Eg. multipart/form-data; boundary=398hjiun98jhhi87g98h76g8 - ct, _ = parse_opts_header(content_type) - # Eg. application/json - main, _, subtype = ct.partition("/") - - if "+" in subtype: - # RFC 6839. Eg. application/schema+json - _, _, subtype = subtype.partition("+") - - primary = choices.get(f"{main}/{subtype}") - if primary is not None: - return primary - - secondary = choices.get(f"{main}/*") - if secondary is not None: - return secondary - - return choices["*/*"] diff --git a/src/hip/_headers.py b/src/hip/_headers.py index ab87c8d..87487be 100644 --- a/src/hip/_headers.py +++ b/src/hip/_headers.py @@ -201,51 +201,3 @@ def __eq__(self, other: object) -> bool: def __repr__(self) -> str: return f"" - - -def parse_opts_header(header: str) -> tuple[str, dict[str, str]]: - # The Content-Type header is described in RFC 2616 'Content-Type' - # https://datatracker.ietf.org/doc/html/rfc2616#section-14.17 - - # The 'type/subtype; parameter' format is described in RFC 2616 'Media Types' - # https://datatracker.ietf.org/doc/html/rfc2616#section-3.7 - - # Parameter quoting is described in RFC 2616 'Transfer Codings' - # https://datatracker.ietf.org/doc/html/rfc2616#section-3.6 - - header = header.strip() - content_type = "" - params = {} - - # Match the content type (up to the first semicolon or end) - match = re.match(r"^([^;]+)", header) - if match: - content_type = match.group(1).strip().lower() - rest = header[match.end() :] - else: - return "", {} - - # Parse parameters, accounting for quoted strings - param_pattern = re.compile( - r""" - ;\s* # Semicolon + optional whitespace - (?P[^=;\s]+) # Parameter key - = # Equal sign - (?P # Parameter value: - "(?:[^"\\]|\\.)*" # Quoted string with escapes - | # OR - [^;]* # Unquoted string (until semicolon) - ) - """, - re.VERBOSE, - ) - - for match in param_pattern.finditer(rest): - key = match.group("key").lower() - value = match.group("value").strip() - if value.startswith('"') and value.endswith('"'): - # Remove surrounding quotes and unescape - value = re.sub(r"\\(.)", r"\1", value[1:-1]) - params[key] = value - - return content_type, params diff --git a/src/hip/_timeouts.py b/src/hip/_timeouts.py index 7a6662d..ec909a7 100644 --- a/src/hip/_timeouts.py +++ b/src/hip/_timeouts.py @@ -41,7 +41,7 @@ def get_current_timeout() -> float | None: return remaining -def sleep(dur: float): +def sleep(dur: float) -> None: """ A `sleep()` implementation that is aware of the current timeout context. """ diff --git a/tests/test_content.py b/tests/test_content.py index 1f0b3c0..0c3c3cd 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -75,6 +75,36 @@ def test_json_unicode_serialization(): assert bytes(data) == '{"name":"Çağdaş"}'.encode() +def test_json_parsing(): + assert hip.JSON.can_handle('application/json') + assert hip.JSON.can_handle('application/openapi+json') + assert not hip.JSON.can_handle('text/plain') + + with hip.Binary(b'{"text": "hello, world"}').open() as stream: + data = hip.JSON.parse(stream) + assert data == hip.JSON({"text": "hello, world"}) + + +# Binary + + +def test_binary(): + data = hip.Binary(b'...') + + assert repr(data) == "" + assert bytes(data) == b'...' + assert len(data) == 3 + + +def test_binary_parsing(): + assert hip.Binary.can_handle('literally/anything') + + with hip.Binary(b'{"text": "hello, world"}').open() as stream: + data = hip.Binary.parse(stream) + assert data == hip.Binary(b'{"text": "hello, world"}') + assert data == b'{"text": "hello, world"}' + + # Form @@ -383,5 +413,8 @@ def test_content(): with pytest.raises(NotImplementedError): content.parse(hip.Stream()) + with pytest.raises(NotImplementedError): + content.can_handle("text/plain") + with pytest.raises(TypeError): bytes(content) From 75f3cf5b76af2db423e93f15f1a8633a9b7d76d7 Mon Sep 17 00:00:00 2001 From: Kim Christie Date: Thu, 22 Oct 2026 15:32:43 +0100 Subject: [PATCH 3/4] Drop unused import --- src/hip/_headers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hip/_headers.py b/src/hip/_headers.py index 87487be..3ec3b70 100644 --- a/src/hip/_headers.py +++ b/src/hip/_headers.py @@ -1,6 +1,5 @@ from __future__ import annotations -import re import typing __all__ = ["Headers"] From 6afa780a3f5a3bc153a2b1ed849c330141e9b0bd Mon Sep 17 00:00:00 2001 From: Kim Christie Date: Thu, 22 Oct 2026 15:58:33 +0100 Subject: [PATCH 4/4] Fix formatting --- src/hip/_client.py | 2 +- src/hip/_content.py | 9 ++++----- tests/test_content.py | 12 ++++++------ 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/hip/_client.py b/src/hip/_client.py index 51c3383..3825d98 100644 --- a/src/hip/_client.py +++ b/src/hip/_client.py @@ -54,7 +54,7 @@ def request( 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") - ct = ct.split(';')[0].strip() + ct = ct.split(";")[0].strip() for cls in [JSON, HTML, Text]: if cls.can_handle(ct): content = cls.parse(stream) diff --git a/src/hip/_content.py b/src/hip/_content.py index 3f8dd4f..bc4fc33 100644 --- a/src/hip/_content.py +++ b/src/hip/_content.py @@ -356,9 +356,8 @@ def parse(self, stream: Stream) -> "Content": @classmethod def can_handle(self, content_type: str) -> bool: - return ( - content_type == 'application/json' or - (content_type.startswith('application/') and content_type.endswith('+json')) + return content_type == "application/json" or ( + content_type.startswith("application/") and content_type.endswith("+json") ) # Return the underlying data. Copied to ensure immutability. @@ -418,7 +417,7 @@ def parse(self, stream: Stream) -> Content: @classmethod def can_handle(self, content_type: str) -> bool: - return content_type.startswith('text/') + return content_type.startswith("text/") def __str__(self) -> str: return self._text @@ -456,7 +455,7 @@ def parse(self, stream: Stream) -> Content: @classmethod def can_handle(self, content_type: str) -> bool: - return content_type == 'text/html' + return content_type == "text/html" def __str__(self) -> str: return self._text diff --git a/tests/test_content.py b/tests/test_content.py index 0c3c3cd..5033f21 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -76,9 +76,9 @@ def test_json_unicode_serialization(): def test_json_parsing(): - assert hip.JSON.can_handle('application/json') - assert hip.JSON.can_handle('application/openapi+json') - assert not hip.JSON.can_handle('text/plain') + assert hip.JSON.can_handle("application/json") + assert hip.JSON.can_handle("application/openapi+json") + assert not hip.JSON.can_handle("text/plain") with hip.Binary(b'{"text": "hello, world"}').open() as stream: data = hip.JSON.parse(stream) @@ -89,15 +89,15 @@ def test_json_parsing(): def test_binary(): - data = hip.Binary(b'...') + data = hip.Binary(b"...") assert repr(data) == "" - assert bytes(data) == b'...' + assert bytes(data) == b"..." assert len(data) == 3 def test_binary_parsing(): - assert hip.Binary.can_handle('literally/anything') + assert hip.Binary.can_handle("literally/anything") with hip.Binary(b'{"text": "hello, world"}').open() as stream: data = hip.Binary.parse(stream)