From be772b38e21a76d5b4c39e1cd2faa634d1e720ae Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Mon, 7 Sep 2026 16:24:12 +0100 Subject: [PATCH] Add HTTPX2 transports --- README.rst | 32 ++++++ docs/source/conf.py | 6 +- docs/source/index.rst | 32 ++++++ newsfragments/385.change.rst | 2 + pyproject.toml | 2 + spelling_private_dict.txt | 1 + src/coderpad/async_client.py | 9 +- src/coderpad/client.py | 7 +- src/coderpad/transports.py | 206 +++++++++++++++++++++++++++++++++++ tests/test_async_client.py | 80 ++++++++++++++ tests/test_client.py | 75 +++++++++++++ 11 files changed, 445 insertions(+), 7 deletions(-) create mode 100644 newsfragments/385.change.rst diff --git a/README.rst b/README.rst index a3442c4..3507d2a 100644 --- a/README.rst +++ b/README.rst @@ -49,6 +49,38 @@ The organization users endpoint also supports server-side email filtering: The equivalent asynchronous operation is ``await client.organization.users.list(email="person@example.com")``. +HTTPX2 transports +----------------- + +HTTPX remains the default client family. To opt in to HTTPX2, construct the +HTTPX2 transport explicitly and pass it to the client. The standard +``coderpad-py`` installation includes both client families. + +.. code-block:: python + + """Configure CoderPad to use HTTPX2.""" + + import sys + + import httpx2 + + from coderpad.client import CoderPad + from coderpad.transports import HTTPX2Transport + + transport = HTTPX2Transport(timeout=httpx2.Timeout(timeout=10)) + with CoderPad(api_key="your-api-key", transport=transport) as client: + sys.stdout.write(client.base_url) + +Use ``AsyncHTTPX2Transport`` with ``AsyncCoderPad`` for asynchronous calls. +The ``limits``, ``proxy`` and ``timeout`` arguments passed to these transports +accept HTTPX2 configuration objects. Do not pass equivalent ``httpx`` objects +across the package boundary. If the Screen API should also use HTTPX2, pass a +separate HTTPX2 transport as ``screen_transport``. + +Existing HTTPX users do not need to change anything. Migrating to HTTPX2 only +requires selecting the new transport; the CoderPad client methods and returned +models are unchanged. + CoderPad Screen --------------- diff --git a/docs/source/conf.py b/docs/source/conf.py index 3680bf0..06c48a0 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -79,10 +79,14 @@ nitpicky = True nitpick_ignore: list[tuple[str, str]] = [ ("py:class", "coderpad.types._T"), - # httpx is not in intersphinx_mapping; keep typed annotations. + # The HTTP client inventories do not expose these configuration classes; + # keep the typed annotations without making the docs build fail. ("py:class", "httpx.Limits"), ("py:class", "httpx.Proxy"), ("py:class", "httpx.Timeout"), + ("py:class", "httpx2.Limits"), + ("py:class", "httpx2.Proxy"), + ("py:class", "httpx2.Timeout"), ] nitpick_ignore_regex = [ (r"py:class", r"coderpad\._dict_types\..*"), diff --git a/docs/source/index.rst b/docs/source/index.rst index 2080b24..582685a 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -29,6 +29,38 @@ Usage org = client.organization.get() sys.stdout.write(org.organization_name) +HTTPX2 transports +----------------- + +HTTPX remains the default client family. To opt in to HTTPX2, construct the +HTTPX2 transport explicitly and pass it to the client. The standard +``coderpad-py`` installation includes both client families. + +.. code-block:: python + + """Configure CoderPad to use HTTPX2.""" + + import sys + + import httpx2 + + from coderpad.client import CoderPad + from coderpad.transports import HTTPX2Transport + + transport = HTTPX2Transport(timeout=httpx2.Timeout(timeout=10)) + with CoderPad(api_key="your-api-key", transport=transport) as client: + sys.stdout.write(client.base_url) + +Use ``AsyncHTTPX2Transport`` with ``AsyncCoderPad`` for asynchronous calls. +The ``limits``, ``proxy`` and ``timeout`` arguments passed to these transports +accept HTTPX2 configuration objects. Do not pass equivalent ``httpx`` objects +across the package boundary. If the Screen API should also use HTTPX2, pass a +separate HTTPX2 transport as ``screen_transport``. + +Existing HTTPX users do not need to change anything. Migrating to HTTPX2 only +requires selecting the new transport; the CoderPad client methods and returned +models are unchanged. + See the :doc:`api-reference` for full usage details, including the exception hierarchy. Reference diff --git a/newsfragments/385.change.rst b/newsfragments/385.change.rst new file mode 100644 index 0000000..02a8710 --- /dev/null +++ b/newsfragments/385.change.rst @@ -0,0 +1,2 @@ +Add synchronous and asynchronous HTTPX2 transports while retaining HTTPX as +the default client family. diff --git a/pyproject.toml b/pyproject.toml index 5c70755..7fb63b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dynamic = [ dependencies = [ "beartype>=0.22.9", "httpx>=0.28.0", + "httpx2>=2.12.0", "pydantic>=2.12.0", ] optional-dependencies.dev = [ @@ -64,6 +65,7 @@ optional-dependencies.dev = [ "pytest-asyncio==1.4.0", "pytest-beartype-tests==2026.8.16", "pytest-cov==7.1.0", + "pytest-httpx2==1.0.0", "pyyaml==6.0.3", "respx==0.23.1", "ruff==0.16.6", diff --git a/spelling_private_dict.txt b/spelling_private_dict.txt index 78e5943..413db2d 100644 --- a/spelling_private_dict.txt +++ b/spelling_private_dict.txt @@ -18,6 +18,7 @@ html http https httpx +httpx2 io iterable json diff --git a/src/coderpad/async_client.py b/src/coderpad/async_client.py index 2344283..c3caa0d 100644 --- a/src/coderpad/async_client.py +++ b/src/coderpad/async_client.py @@ -18,6 +18,7 @@ from coderpad.exceptions import CoderPadError from coderpad.screen import SCREEN_US_BASE_URL from coderpad.transports import ( + AsyncHTTPX2Transport, AsyncHTTPXTransport, AsyncJSONTransport, AsyncTransport, @@ -924,12 +925,14 @@ async def _noop() -> None: if isinstance( resolved_transport, - AsyncHTTPXTransport, + (AsyncHTTPXTransport, AsyncHTTPX2Transport), ): self._aclose = resolved_transport.aclose else: self._aclose = _noop - if isinstance(screen_transport, AsyncHTTPXTransport): + if isinstance( + screen_transport, (AsyncHTTPXTransport, AsyncHTTPX2Transport) + ): self._screen_aclose = screen_transport.aclose else: self._screen_aclose = _noop @@ -962,7 +965,7 @@ def screen(self) -> AsyncScreenNamespace: ) if self._screen_transport is None and isinstance( resolved_screen_transport, - AsyncHTTPXTransport, + (AsyncHTTPXTransport, AsyncHTTPX2Transport), ): self._screen_aclose = resolved_screen_transport.aclose self._screen_transport = resolved_screen_transport diff --git a/src/coderpad/client.py b/src/coderpad/client.py index 254cb08..198d349 100644 --- a/src/coderpad/client.py +++ b/src/coderpad/client.py @@ -17,6 +17,7 @@ from coderpad.exceptions import CoderPadError from coderpad.screen import SCREEN_US_BASE_URL, ScreenNamespace from coderpad.transports import ( + HTTPX2Transport, HTTPXTransport, JSONTransport, Transport, @@ -903,11 +904,11 @@ def __init__( self._screen_transport = screen_transport self._default_headers = default_headers self._screen: ScreenNamespace | None = None - if isinstance(resolved_transport, HTTPXTransport): + if isinstance(resolved_transport, (HTTPXTransport, HTTPX2Transport)): self._close = resolved_transport.close else: self._close = lambda: None - if isinstance(screen_transport, HTTPXTransport): + if isinstance(screen_transport, (HTTPXTransport, HTTPX2Transport)): self._screen_close = screen_transport.close else: self._screen_close = lambda: None @@ -938,7 +939,7 @@ def screen(self) -> ScreenNamespace: ) if self._screen_transport is None and isinstance( resolved_screen_transport, - HTTPXTransport, + (HTTPXTransport, HTTPX2Transport), ): self._screen_close = resolved_screen_transport.close self._screen_transport = resolved_screen_transport diff --git a/src/coderpad/transports.py b/src/coderpad/transports.py index 790696c..2cd982f 100644 --- a/src/coderpad/transports.py +++ b/src/coderpad/transports.py @@ -6,6 +6,7 @@ from typing import Any, Protocol, Self, TypedDict, runtime_checkable import httpx +import httpx2 from beartype import beartype @@ -17,6 +18,16 @@ class _HTTPXClientKwargs(TypedDict, total=False): timeout: httpx.Timeout | float +class _HTTPX2ClientKwargs(TypedDict, total=False): + """Optional kwargs forwarded to ``httpx2.Client`` / + ``AsyncClient``. + """ + + limits: httpx2.Limits + proxy: str | httpx2.Proxy + timeout: httpx2.Timeout | float + + class HTTPStatusError(Exception): """Raised when an HTTP response has an error status code.""" @@ -222,6 +233,103 @@ def __call__( ) +@beartype +class HTTPX2Transport: + """HTTP transport using the ``httpx2`` library. + + This transport uses a shared ``httpx2.Client`` for connection pooling. + Configuration objects must come from ``httpx2``, not ``httpx``. + """ + + def __init__( + self, + *, + limits: httpx2.Limits | None = None, + proxy: str | httpx2.Proxy | None = None, + timeout: httpx2.Timeout | float | None = None, + ) -> None: + """Create a new HTTPX2 transport. + + Args: + limits: Optional connection pool limits for ``httpx2.Client``. + proxy: Optional proxy passed to ``httpx2.Client``. + timeout: Optional timeout passed to ``httpx2.Client``. + When omitted, the default ``httpx2`` timeout is used. + """ + self.limits = limits + self.proxy = proxy + self.timeout = timeout + client_kwargs: _HTTPX2ClientKwargs = {} + if limits is not None: + client_kwargs["limits"] = limits + if proxy is not None: + client_kwargs["proxy"] = proxy + if timeout is not None: + client_kwargs["timeout"] = timeout + self._client = httpx2.Client(**client_kwargs) + + def close(self) -> None: + """Close the underlying HTTP client.""" + self._client.close() + + def __enter__(self) -> Self: + """Enter the context manager. + + Returns: + This transport instance. + """ + return self + + def __exit__( + self, + _exc_type: type[BaseException] | None, + _exc_val: BaseException | None, + _exc_tb: object, + ) -> None: + """Exit the context manager and close the client.""" + self.close() + + def __call__( + self, + *, + method: str, + url: str, + headers: dict[str, str], + params: dict[str, str | int] | None, + data: dict[str, str] | None, + files: (dict[str, tuple[str, bytes, str]] | None), + json: object | None = None, + ) -> TransportResponse: + """Make an HTTP request using ``httpx2``. + + Args: + method: The HTTP method. + url: The full URL. + headers: Request headers. + params: Query parameters. + data: Form data to send in the request body. + files: Files to upload as multipart form data. + json: A JSON-compatible request body. + + Returns: + A ``TransportResponse`` populated from the HTTPX2 response. + """ + response = self._client.request( + method=method, + url=url, + headers=headers, + params=params, + data=data, + files=files, + json=json, + ) + return TransportResponse( + status_code=response.status_code, + headers=dict(response.headers), + content=response.content, + ) + + @runtime_checkable class AsyncTransport(Protocol): """Protocol for async HTTP transports. @@ -375,3 +483,101 @@ async def __call__( headers=dict(response.headers), content=response.content, ) + + +@beartype +class AsyncHTTPX2Transport: + """Async HTTP transport using the ``httpx2`` library. + + This transport uses a shared ``httpx2.AsyncClient`` for connection + pooling. Configuration objects must come from ``httpx2``, not ``httpx``. + """ + + def __init__( + self, + *, + limits: httpx2.Limits | None = None, + proxy: str | httpx2.Proxy | None = None, + timeout: httpx2.Timeout | float | None = None, + ) -> None: + """Create a new asynchronous HTTPX2 transport. + + Args: + limits: Optional connection pool limits for ``httpx2.AsyncClient``. + proxy: Optional proxy passed to ``httpx2.AsyncClient``. + timeout: Optional timeout passed to ``httpx2.AsyncClient``. + When omitted, the default ``httpx2`` timeout is used. + """ + self.limits = limits + self.proxy = proxy + self.timeout = timeout + client_kwargs: _HTTPX2ClientKwargs = {} + if limits is not None: + client_kwargs["limits"] = limits + if proxy is not None: + client_kwargs["proxy"] = proxy + if timeout is not None: + client_kwargs["timeout"] = timeout + self._client = httpx2.AsyncClient(**client_kwargs) + + async def aclose(self) -> None: + """Close the underlying asynchronous HTTP client.""" + await self._client.aclose() + + async def __aenter__(self) -> Self: + """Enter the async context manager. + + Returns: + This transport instance. + """ + return self + + async def __aexit__( + self, + _exc_type: type[BaseException] | None, + _exc_val: BaseException | None, + _exc_tb: object, + /, + ) -> None: + """Exit the async context manager and close.""" + await self.aclose() + + async def __call__( + self, + *, + method: str, + url: str, + headers: dict[str, str], + params: dict[str, str | int] | None, + data: dict[str, str] | None, + files: (dict[str, tuple[str, bytes, str]] | None), + json: object | None = None, + ) -> TransportResponse: + """Make an asynchronous HTTP request using ``httpx2``. + + Args: + method: The HTTP method. + url: The full URL. + headers: Request headers. + params: Query parameters. + data: Form data to send in the request body. + files: Files to upload as multipart form data. + json: A JSON-compatible request body. + + Returns: + A ``TransportResponse`` populated from the HTTPX2 response. + """ + response = await self._client.request( + method=method, + url=url, + headers=headers, + params=params, + data=data, + files=files, + json=json, + ) + return TransportResponse( + status_code=response.status_code, + headers=dict(response.headers), + content=response.content, + ) diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 2606134..04a8af4 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -7,12 +7,14 @@ from urllib.parse import parse_qs import httpx +import httpx2 import pytest import respx from coderpad.async_client import AsyncCoderPad from coderpad.exceptions import NotFoundError from coderpad.transports import ( + AsyncHTTPX2Transport, AsyncHTTPXTransport, AsyncTransport, TransportResponse, @@ -332,6 +334,84 @@ async def test_limits_and_timeout_passed_to_httpx_client() -> None: await transport.aclose() +class TestAsyncHTTPX2Transport: + """Tests for ``AsyncHTTPX2Transport``.""" + + @staticmethod + @pytest.mark.asyncio + async def test_is_async_transport() -> None: + """AsyncHTTPX2Transport satisfies AsyncTransport.""" + async with AsyncHTTPX2Transport() as transport: + assert isinstance(transport, AsyncTransport) + + @staticmethod + @pytest.mark.asyncio + async def test_httpx2_configuration_types() -> None: + """HTTPX2 configuration objects are stored on the transport.""" + limits = httpx2.Limits(max_connections=5) + proxy = httpx2.Proxy(url="http://proxy.example:8080") + timeout = httpx2.Timeout(timeout=12.5) + async with AsyncHTTPX2Transport( + limits=limits, proxy=proxy, timeout=timeout + ) as transport: + assert transport.limits is limits + assert transport.proxy is proxy + assert transport.timeout is timeout + + @staticmethod + @pytest.mark.asyncio + async def test_real_httpx2_request(httpx2_mock: respx.Router) -> None: + """The transport makes a request through an HTTPX2 async + client. + """ + httpx2_mock.get(url="https://api.example/items").respond( + status_code=HTTPStatus.OK, + headers={"X-Family": "httpx2"}, + content=b"listed", + ) + + async with AsyncHTTPX2Transport() as transport: + response = await transport( + method="GET", + url="https://api.example/items", + headers={"Authorization": "Token"}, + params={"page": 2}, + data=None, + files=None, + ) + + assert response == TransportResponse( + status_code=HTTPStatus.OK, + headers={ + "x-family": "httpx2", + "content-length": "6", + }, + content=b"listed", + ) + + @staticmethod + @pytest.mark.asyncio + async def test_httpx2_exception_family( + httpx2_mock: respx.Router, + ) -> None: + """HTTPX2 transport exceptions propagate without conversion.""" + error = httpx2.ConnectError(message="HTTPX2 failed") + httpx2_mock.get(url="https://api.example/failure").mock( + side_effect=error + ) + + with pytest.raises(expected_exception=httpx2.ConnectError): + async with AsyncHTTPX2Transport() as transport: + await transport( + method="GET", + url="https://api.example/failure", + headers={}, + params=None, + data=None, + files=None, + ) + + class TestAsyncListPads: """Tests for ``AsyncCoderPad.pads.list``.""" diff --git a/tests/test_client.py b/tests/test_client.py index 3584b4d..240c9d0 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -7,6 +7,7 @@ from urllib.parse import parse_qs import httpx +import httpx2 import pytest import respx @@ -25,6 +26,7 @@ ) from coderpad.transports import ( HTTPStatusError, + HTTPX2Transport, HTTPXTransport, Transport, TransportResponse, @@ -316,6 +318,79 @@ def test_limits_and_timeout_passed_to_httpx_client() -> None: transport.close() +class TestHTTPX2Transport: + """Tests for ``HTTPX2Transport``.""" + + @staticmethod + def test_is_transport() -> None: + """HTTPX2Transport satisfies the Transport protocol.""" + with HTTPX2Transport() as transport: + assert isinstance(transport, Transport) + + @staticmethod + def test_httpx2_configuration_types() -> None: + """HTTPX2 configuration objects are stored on the transport.""" + limits = httpx2.Limits(max_connections=5) + proxy = httpx2.Proxy(url="http://proxy.example:8080") + timeout = httpx2.Timeout(timeout=12.5) + with HTTPX2Transport( + limits=limits, proxy=proxy, timeout=timeout + ) as transport: + assert transport.limits is limits + assert transport.proxy is proxy + assert transport.timeout is timeout + + @staticmethod + def test_real_httpx2_request(httpx2_mock: respx.Router) -> None: + """The transport makes a request through an HTTPX2 client.""" + httpx2_mock.post(url="https://api.example/items").respond( + status_code=HTTPStatus.CREATED, + headers={"X-Family": "httpx2"}, + content=b"created", + ) + + with HTTPX2Transport() as transport: + response = transport( + method="POST", + url="https://api.example/items", + headers={"Authorization": "Token"}, + params={"page": 2}, + data=None, + files=None, + json={"name": "example"}, + ) + + assert response == TransportResponse( + status_code=HTTPStatus.CREATED, + headers={ + "x-family": "httpx2", + "content-length": "7", + }, + content=b"created", + ) + + @staticmethod + def test_httpx2_exception_family(httpx2_mock: respx.Router) -> None: + """HTTPX2 transport exceptions propagate without conversion.""" + error = httpx2.ConnectError(message="HTTPX2 failed") + httpx2_mock.get(url="https://api.example/failure").mock( + side_effect=error + ) + + with ( + HTTPX2Transport() as transport, + pytest.raises(expected_exception=httpx2.ConnectError), + ): + transport( + method="GET", + url="https://api.example/failure", + headers={}, + params=None, + data=None, + files=None, + ) + + class TestTransportResponse: """Tests for ``TransportResponse``."""