diff --git a/src/hip/_client.py b/src/hip/_client.py index 740f5bf..3825d98 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 2a3948d..bc4fc33 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." @@ -195,6 +197,7 @@ class File(Content): def __init__(self, path: str): self._path = path + @property def name(self) -> str: return os.path.basename(self._path) @@ -351,6 +354,12 @@ 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: @@ -406,6 +415,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 @@ -440,6 +453,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 @@ -466,6 +483,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 @@ -535,24 +565,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..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"] @@ -201,51 +200,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 36d9540..5033f21 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 @@ -176,7 +206,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" @@ -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)