diff --git a/AGENTS.md b/AGENTS.md index 34c299b..5c584ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ Every module under `src/httpware/` is named for what it does; read it. What a si ### Testing patterns -Transport mocking is `httpx2.MockTransport` passed as `httpx2_client=`, never `respx` — `respx` +Transport mocking is `httpx2.MockTransport` passed as `transport=`, never `respx` — `respx` targets `httpx`, not `httpx2`, and patches its internals. Concurrency-sensitive components carry Hypothesis property tests in `test_*_props.py`, and `stress`-marked tests drive real thread parallelism: they run under the GIL for coverage, but the proof comes from the free-threaded diff --git a/docs/errors.md b/docs/errors.md index 5269e93..fdf7850 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -190,7 +190,7 @@ Unlike `DecodeError`, this error fires *before* the HTTP request — no traffic ## `ResponseTooLargeError` -Both `Client` and `AsyncClient` accept a `max_response_body_bytes: int | None = None` constructor argument. It's an opt-in cap — the default `None` means unbounded, matching current behavior. When set, a response body that exceeds the cap raises `ResponseTooLargeError` instead of being returned. The check is status-agnostic (a `200` can trip it just as easily as a `4xx`/`5xx`), and it counts **decoded** bytes. It fires from the non-streaming terminal (`send()` / verb methods) and from `stream()`'s internal error pre-read; bytes you pull yourself via `stream()` iteration are never capped. +Both `Client` and `AsyncClient` accept a `max_response_body_bytes: int | None = None` constructor argument. It's an opt-in cap — the default `None` means unbounded, matching current behavior. When set, a response body that exceeds the cap raises `ResponseTooLargeError` instead of being returned. The check is status-agnostic (a `200` can trip it just as easily as a `4xx`/`5xx`), and it counts **decoded** bytes. It fires from the non-streaming terminal (`send()` / verb methods) and from `stream()`'s internal error pre-read; bytes you pull yourself via `stream()` iteration are never capped. Combining the cap with `follow_redirects=True`, on the client or on a passed `httpx2_client`, raises `ValueError`: `httpx2` reads every intermediate redirect body without it. `ResponseTooLargeError` carries: diff --git a/docs/index.md b/docs/index.md index 87584bb..28b157c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -64,6 +64,21 @@ with Client(base_url="https://jsonplaceholder.typicode.com") as client: `base_url` must not contain a query string: constructing a client with one raises `ValueError`. Put query parameters shared by every request in `params=` instead. +Every other keyword of `httpx2.AsyncClient`/`httpx2.Client` (`verify`, `proxy`, `http2`, `transport`, `follow_redirects`, ...) is forwarded to the `httpx2` client that `httpware` builds and closes. Two are refused: `cert`, deprecated by `httpx2` in favour of an `ssl.SSLContext` passed as `verify`, and `event_hooks`, which run below the middleware chain; use [middleware](middleware.md) instead. + +```python +import ssl + +from httpware import AsyncClient + +client = AsyncClient( + base_url="https://internal.example", + verify=ssl.create_default_context(cafile="/etc/ssl/internal-ca.pem"), +) +``` + +To share one connection pool between several clients, build the `httpx2` client yourself and pass it as `httpx2_client=`. It is then yours to close, and none of the options above can be combined with it. + Typed decoding via `response_model=` works the same way in both worlds: ```python diff --git a/docs/testing.md b/docs/testing.md index eaeabd5..b67d58a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,8 +1,8 @@ # Testing guide -`httpware`'s test seam is `httpx2`. Pass any `httpx2.AsyncClient` (including one built on `httpx2.MockTransport`) to `AsyncClient(httpx2_client=...)` — the middleware chain still runs end-to-end, only the wire is mocked. No special test mode, no monkey-patching, no `respx`. +`httpware`'s test seam is `httpx2`. Pass an `httpx2.MockTransport` as `AsyncClient(transport=...)` — the middleware chain still runs end-to-end, only the wire is mocked, and the client still owns and closes its `httpx2` client. No special test mode, no monkey-patching, no `respx`. -`httpx2_client=` is mutually exclusive with `base_url`, `headers`, `params`, `cookies`, `timeout`, `limits`, and `auth`: passing any of those alongside a pre-built `httpx2_client=` raises `TypeError`. Configure the `httpx2.AsyncClient`/`httpx2.Client` you pass instead. +A pre-built `httpx2.AsyncClient`/`httpx2.Client` can be passed as `httpx2_client=` instead. It is mutually exclusive with every `httpx2` client option (`base_url`, `headers`, `transport`, `verify`, ...): passing any of them alongside it raises `TypeError`. Configure the client you pass instead; `httpware` will not close it. ## The basic pattern @@ -19,8 +19,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: async def test_get_user() -> None: - transport = httpx2.MockTransport(handler) - async with AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) as client: + async with AsyncClient(transport=httpx2.MockTransport(handler)) as client: response = await client.get("https://api.example.test/users/1") assert response.status_code == HTTPStatus.OK assert response.json()["name"] == "Alice" @@ -32,7 +31,7 @@ If you use `pytest-asyncio` in auto-mode (`asyncio_mode = "auto"` under `[tool.p ### Sync `Client` -The same pattern works for the sync `Client` — pass an `httpx2.Client` (not `httpx2.AsyncClient`) built on `httpx2.MockTransport`: +The same pattern works for the sync `Client`; `httpx2.MockTransport` serves both worlds: ```python from http import HTTPStatus @@ -46,7 +45,7 @@ def test_get_returns_typed_response() -> None: def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(HTTPStatus.OK, request=request, json={"ok": True}) - with Client(httpx2_client=httpx2.Client(transport=httpx2.MockTransport(handler))) as client: + with Client(transport=httpx2.MockTransport(handler)) as client: response = client.get("https://example.test/x") assert response.status_code == HTTPStatus.OK @@ -76,9 +75,8 @@ class _ResponseSequence: async def test_retry_succeeds_after_503() -> None: handler = _ResponseSequence([HTTPStatus.SERVICE_UNAVAILABLE, HTTPStatus.OK]) - transport = httpx2.MockTransport(handler) async with AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=transport), + transport=httpx2.MockTransport(handler), middleware=[AsyncRetry(base_delay=0.001, max_delay=0.002)], ) as client: response = await client.get("https://example.test/x") @@ -96,7 +94,7 @@ Compose your middleware with the mock transport to exercise the chain end-to-end async def test_my_middleware_adds_header() -> None: handler = _ResponseSequence([HTTPStatus.OK]) async with AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), + transport=httpx2.MockTransport(handler), middleware=[MyHeaderMiddleware()], ) as client: await client.get("https://example.test/x") diff --git a/src/httpware/client.py b/src/httpware/client.py index 222f2a3..807070a 100644 --- a/src/httpware/client.py +++ b/src/httpware/client.py @@ -1,8 +1,9 @@ """Client + AsyncClient — thin httpx2 wrappers with typed decoding and middleware.""" import contextlib +import ssl import typing -from collections.abc import AsyncIterator, Iterator, Sequence +from collections.abc import AsyncIterator, Callable, Iterator, Mapping, Sequence from http import HTTPStatus import httpx2 @@ -29,10 +30,17 @@ T = typing.TypeVar("T") -_FORWARDED_KWARG_NAMES = ("base_url", "headers", "params", "cookies", "timeout", "limits", "auth") _HTTPX2_CLIENT_CONFLICT_MESSAGE = ( - "httpx2_client=... cannot be combined with any of " - f"{_FORWARDED_KWARG_NAMES}; configure the httpx2 client you pass instead." + "httpx2_client=... cannot be combined with httpx2 client options {names}; " + "configure the httpx2 client you pass instead." +) +_UNSUPPORTED_OPTION_HINTS = { + "cert": "cert=... is deprecated by httpx2; pass verify= configured with .load_cert_chain().", + "event_hooks": "event_hooks=... is not supported; use middleware=... instead.", +} +_FOLLOW_REDIRECTS_WITH_BODY_CAP_MESSAGE = ( + "follow_redirects=True cannot be combined with max_response_body_bytes: httpx2 reads every " + "intermediate redirect body without the cap." ) _BASE_URL_QUERY_MESSAGE = ( "base_url must not contain a query string: httpx2 appends request paths after it, " @@ -61,63 +69,75 @@ def _build_default_decoders() -> tuple[ResponseDecoder, ...]: return tuple(decoders) -def _validate_httpx2_client_conflict( # noqa: PLR0913 — 7 forwarded kwargs from caller's constructor - *, - base_url: str, - headers: dict[str, str] | None, - params: dict[str, str] | None, - cookies: dict[str, str] | None, - timeout: httpx2.Timeout | float | None, - limits: httpx2.Limits | None, - auth: httpx2.Auth | None, -) -> None: - """Raise TypeError if httpx2_client=... is combined with a forwarded kwarg.""" - forwarded = { - "base_url": base_url, - "headers": headers, - "params": params, - "cookies": cookies, - "timeout": timeout, - "limits": limits, - "auth": auth, - } - if any(value not in (None, "") for value in forwarded.values()): - raise TypeError(_HTTPX2_CLIENT_CONFLICT_MESSAGE) - - def _reject_base_url_query(base_url: httpx2.URL | str) -> None: """Raise ValueError if base_url carries a query string.""" if httpx2.URL(base_url).query: raise ValueError(_BASE_URL_QUERY_MESSAGE) -def _assemble_httpx2_client_kwargs( # noqa: PLR0913 — 7 forwarded kwargs from caller's constructor +class _ClientOptionsBase(typing.TypedDict, total=False): + base_url: str + headers: dict[str, str] | None + params: dict[str, str] | None + cookies: dict[str, str] | None + timeout: httpx2.Timeout | float | None + limits: httpx2.Limits | None + auth: httpx2.Auth | None + verify: ssl.SSLContext | bool + trust_env: bool + http1: bool + http2: bool + proxy: httpx2.URL | str | httpx2.Proxy | None + follow_redirects: bool + max_redirects: int + default_encoding: str | Callable[[bytes], str | None] + + +class _AsyncClientOptions(_ClientOptionsBase, total=False): + """Keyword arguments `AsyncClient` forwards to the `httpx2.AsyncClient` it owns.""" + + transport: httpx2.AsyncBaseTransport | None + mounts: Mapping[str, httpx2.AsyncBaseTransport | None] | None + + +class _ClientOptions(_ClientOptionsBase, total=False): + """Keyword arguments `Client` forwards to the `httpx2.Client` it owns.""" + + transport: httpx2.BaseTransport | None + mounts: Mapping[str, httpx2.BaseTransport | None] | None + + +def _is_unset(value: object) -> bool: + return value is None or (isinstance(value, str) and not value) + + +def _select_httpx2_options( + owner: str, + options: Mapping[str, typing.Any], + supported: frozenset[str], *, - base_url: str, - headers: dict[str, str] | None, - params: dict[str, str] | None, - cookies: dict[str, str] | None, - timeout: httpx2.Timeout | float | None, - limits: httpx2.Limits | None, - auth: httpx2.Auth | None, + httpx2_client: httpx2.Client | httpx2.AsyncClient | None, + max_response_body_bytes: int | None, ) -> dict[str, typing.Any]: - """Build the kwargs dict for constructing the owned httpx2 client.""" - kwargs: dict[str, typing.Any] = {} - if base_url: - kwargs["base_url"] = base_url - if headers is not None: - kwargs["headers"] = headers - if params is not None: - kwargs["params"] = params - if cookies is not None: - kwargs["cookies"] = cookies - if timeout is not None: - kwargs["timeout"] = timeout - if limits is not None: - kwargs["limits"] = limits - if auth is not None: - kwargs["auth"] = auth - return kwargs + """Return the options to forward to the owned httpx2 client, dropping unset ones. + + Raise TypeError for unsupported options or options combined with `httpx2_client`, and + ValueError when the client would follow redirects under a body cap. + """ + unsupported = sorted(options.keys() - supported) + if unsupported: + hints = "".join( + f" {_UNSUPPORTED_OPTION_HINTS[name]}" for name in unsupported if name in _UNSUPPORTED_OPTION_HINTS + ) + msg = f"{owner}() got unexpected keyword arguments {unsupported}.{hints}" + raise TypeError(msg) + forwarded = {name: value for name, value in options.items() if not _is_unset(value)} + if httpx2_client is not None and forwarded: + raise TypeError(_HTTPX2_CLIENT_CONFLICT_MESSAGE.format(names=sorted(forwarded))) + follows = httpx2_client.follow_redirects if httpx2_client is not None else forwarded.get("follow_redirects") + if follows and max_response_body_bytes is not None: + raise ValueError(_FOLLOW_REDIRECTS_WITH_BODY_CAP_MESSAGE) + return forwarded def _assemble_request_kwargs( # noqa: PLR0913 — 9 per-request kwargs from httpx2 call signatures @@ -175,47 +195,30 @@ class AsyncClient: _dispatch: AsyncNext _max_response_body_bytes: int | None - def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call API + def __init__( self, *, - base_url: str = "", - headers: dict[str, str] | None = None, - params: dict[str, str] | None = None, - cookies: dict[str, str] | None = None, - timeout: httpx2.Timeout | float | None = None, - limits: httpx2.Limits | None = None, - auth: httpx2.Auth | None = None, httpx2_client: httpx2.AsyncClient | None = None, decoders: Sequence[ResponseDecoder] | None = None, middleware: Sequence[AsyncMiddleware] = (), max_response_body_bytes: int | None = None, + **httpx2_options: typing.Unpack[_AsyncClientOptions], ) -> None: _validate_max_response_body_bytes(max_response_body_bytes) + forwarded = _select_httpx2_options( + type(self).__name__, + httpx2_options, + _AsyncClientOptions.__optional_keys__, + httpx2_client=httpx2_client, + max_response_body_bytes=max_response_body_bytes, + ) if httpx2_client is not None: - _validate_httpx2_client_conflict( - base_url=base_url, - headers=headers, - params=params, - cookies=cookies, - timeout=timeout, - limits=limits, - auth=auth, - ) _reject_base_url_query(httpx2_client.base_url) self._httpx2_client = httpx2_client self._owns_client = False else: - _reject_base_url_query(base_url) - kwargs = _assemble_httpx2_client_kwargs( - base_url=base_url, - headers=headers, - params=params, - cookies=cookies, - timeout=timeout, - limits=limits, - auth=auth, - ) - self._httpx2_client = httpx2.AsyncClient(**kwargs) + _reject_base_url_query(forwarded.get("base_url", "")) + self._httpx2_client = httpx2.AsyncClient(**forwarded) self._owns_client = True self._decoders = tuple(decoders) if decoders is not None else _build_default_decoders() @@ -1129,47 +1132,30 @@ class Client: _dispatch: Next _max_response_body_bytes: int | None - def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call API + def __init__( self, *, - base_url: str = "", - headers: dict[str, str] | None = None, - params: dict[str, str] | None = None, - cookies: dict[str, str] | None = None, - timeout: httpx2.Timeout | float | None = None, - limits: httpx2.Limits | None = None, - auth: httpx2.Auth | None = None, httpx2_client: httpx2.Client | None = None, decoders: Sequence[ResponseDecoder] | None = None, middleware: Sequence[Middleware] = (), max_response_body_bytes: int | None = None, + **httpx2_options: typing.Unpack[_ClientOptions], ) -> None: _validate_max_response_body_bytes(max_response_body_bytes) + forwarded = _select_httpx2_options( + type(self).__name__, + httpx2_options, + _ClientOptions.__optional_keys__, + httpx2_client=httpx2_client, + max_response_body_bytes=max_response_body_bytes, + ) if httpx2_client is not None: - _validate_httpx2_client_conflict( - base_url=base_url, - headers=headers, - params=params, - cookies=cookies, - timeout=timeout, - limits=limits, - auth=auth, - ) _reject_base_url_query(httpx2_client.base_url) self._httpx2_client = httpx2_client self._owns_client = False else: - _reject_base_url_query(base_url) - kwargs = _assemble_httpx2_client_kwargs( - base_url=base_url, - headers=headers, - params=params, - cookies=cookies, - timeout=timeout, - limits=limits, - auth=auth, - ) - self._httpx2_client = httpx2.Client(**kwargs) + _reject_base_url_query(forwarded.get("base_url", "")) + self._httpx2_client = httpx2.Client(**forwarded) self._owns_client = True self._decoders = tuple(decoders) if decoders is not None else _build_default_decoders() diff --git a/tests/test_client_construction.py b/tests/test_client_construction.py index f9a809b..a80907e 100644 --- a/tests/test_client_construction.py +++ b/tests/test_client_construction.py @@ -33,25 +33,6 @@ def test_construction_with_caller_owned_httpx2_client() -> None: assert isinstance(client, AsyncClient) -@pytest.mark.parametrize( - "kwargs", - [ - {"base_url": "https://example.test"}, - {"headers": {"x": "1"}}, - {"params": {"x": "1"}}, - {"cookies": {"x": "1"}}, - {"timeout": 5.0}, - {"limits": httpx2.Limits(max_connections=10)}, - {"auth": httpx2.BasicAuth("u", "p")}, - ], -) -def test_caller_owned_client_with_forwarded_kwargs_is_typeerror(kwargs: dict) -> None: - transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req)) - caller = httpx2.AsyncClient(transport=transport) - with pytest.raises(TypeError, match="httpx2_client"): - AsyncClient(httpx2_client=caller, **kwargs) - - def test_default_decoders_includes_pydantic_when_installed() -> None: client = AsyncClient() assert any(isinstance(d, PydanticDecoder) for d in client._decoders) # noqa: SLF001 @@ -96,20 +77,6 @@ class _Foo: assert exc_info.value.registered_names == () -@pytest.mark.parametrize( - "kwargs", - [ - {"cookies": {"session": "abc"}}, - {"limits": httpx2.Limits(max_connections=5)}, - {"auth": httpx2.BasicAuth("user", "pass")}, - ], -) -def test_construction_with_optional_forwarded_kwargs(kwargs: dict) -> None: - """Exercises cookies/limits/auth branches in __init__ when no httpx2_client is supplied.""" - client = AsyncClient(**kwargs) - assert isinstance(client, AsyncClient) - - def test_explicit_middleware_is_honored() -> None: captured: list[str] = [] diff --git a/tests/test_client_options.py b/tests/test_client_options.py new file mode 100644 index 0000000..d93dc00 --- /dev/null +++ b/tests/test_client_options.py @@ -0,0 +1,145 @@ +"""httpx2 client options are forwarded to the owned httpx2 client by both worlds.""" + +import inspect +import ssl +import typing +from http import HTTPStatus +from unittest.mock import patch + +import httpx2 +import pytest + +from httpware import AsyncClient, Client +from httpware.client import _AsyncClientOptions, _ClientOptions + + +_EXCLUDED = {"cert", "event_hooks"} + + +def _ok(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(HTTPStatus.OK, request=request) + + +def _samples() -> dict[str, typing.Any]: + transport = httpx2.MockTransport(_ok) + return { + "base_url": "https://example.test", + "headers": {"x": "1"}, + "params": {"x": "1"}, + "cookies": {"x": "1"}, + "timeout": 5.0, + "limits": httpx2.Limits(max_connections=10), + "auth": httpx2.BasicAuth("u", "p"), + "verify": ssl.create_default_context(), + "trust_env": False, + "http1": False, + "http2": True, + "proxy": "http://proxy.test:8080", + "follow_redirects": True, + "max_redirects": 3, + "default_encoding": "latin-1", + "mounts": {"http://": transport}, + "transport": transport, + } + + +_KEYS = sorted(_samples()) + + +_WORLDS = [ + pytest.param(AsyncClient, "AsyncClient", id="async"), + pytest.param(Client, "Client", id="sync"), +] + + +@pytest.mark.parametrize( + ("httpx2_name", "option_keys"), + [ + pytest.param("AsyncClient", _AsyncClientOptions.__optional_keys__, id="async"), + pytest.param("Client", _ClientOptions.__optional_keys__, id="sync"), + ], +) +def test_options_cover_every_httpx2_client_kwarg_except_excluded(httpx2_name: str, option_keys: frozenset[str]) -> None: + httpx2_kwargs = set(inspect.signature(getattr(httpx2, httpx2_name).__init__).parameters) - {"self"} + + assert option_keys == httpx2_kwargs - _EXCLUDED + assert set(_KEYS) == option_keys + + +@pytest.mark.parametrize("key", _KEYS) +@pytest.mark.parametrize(("client_cls", "httpx2_name"), _WORLDS) +def test_option_is_forwarded_to_owned_httpx2_client( + client_cls: type, + httpx2_name: str, + key: str, +) -> None: + value = _samples()[key] + with patch.object(httpx2, httpx2_name) as httpx2_cls: + client_cls(**{key: value}) + + httpx2_cls.assert_called_once_with(**{key: value}) + + +@pytest.mark.parametrize(("client_cls", "httpx2_name"), _WORLDS) +def test_unset_options_are_not_forwarded( + client_cls: type, + httpx2_name: str, +) -> None: + with patch.object(httpx2, httpx2_name) as httpx2_cls: + client_cls(base_url="", headers=None, timeout=None, proxy=None, transport=None) + + httpx2_cls.assert_called_once_with() + + +@pytest.mark.parametrize("key", _KEYS) +@pytest.mark.parametrize(("client_cls", "httpx2_name"), _WORLDS) +def test_option_with_caller_owned_httpx2_client_is_typeerror( + client_cls: type, + httpx2_name: str, + key: str, +) -> None: + caller = getattr(httpx2, httpx2_name)(transport=httpx2.MockTransport(_ok)) + with pytest.raises(TypeError, match=f"httpx2_client.*{key}"): + client_cls(httpx2_client=caller, **{key: _samples()[key]}) + + +@pytest.mark.parametrize("key", ["cert", "event_hooks", "verfy"]) +@pytest.mark.parametrize("client_cls", [AsyncClient, Client]) +def test_unsupported_option_is_typeerror(client_cls: type, key: str) -> None: + with pytest.raises(TypeError, match=key): + client_cls(**{key: object()}) + + +@pytest.mark.parametrize("client_cls", [AsyncClient, Client]) +def test_follow_redirects_with_body_cap_is_valueerror(client_cls: type) -> None: + with pytest.raises(ValueError, match="follow_redirects"): + client_cls(follow_redirects=True, max_response_body_bytes=1024) + + +@pytest.mark.parametrize(("client_cls", "httpx2_name"), _WORLDS) +def test_caller_owned_client_following_redirects_with_body_cap_is_valueerror( + client_cls: type, + httpx2_name: str, +) -> None: + caller = getattr(httpx2, httpx2_name)(follow_redirects=True) + with pytest.raises(ValueError, match="follow_redirects"): + client_cls(httpx2_client=caller, max_response_body_bytes=1024) + + +@pytest.mark.parametrize("client_cls", [AsyncClient, Client]) +def test_body_cap_without_following_redirects_is_accepted(client_cls: type) -> None: + client_cls(follow_redirects=False, max_response_body_bytes=1024) + + +async def test_async_transport_option_keeps_the_httpx2_client_owned() -> None: + async with AsyncClient(transport=httpx2.MockTransport(_ok)) as client: + response = await client.get("https://example.test/") + assert response.status_code == HTTPStatus.OK + assert client._httpx2_client.is_closed # noqa: SLF001 + + +def test_sync_transport_option_keeps_the_httpx2_client_owned() -> None: + with Client(transport=httpx2.MockTransport(_ok)) as client: + response = client.get("https://example.test/") + assert response.status_code == HTTPStatus.OK + assert client._httpx2_client.is_closed # noqa: SLF001 diff --git a/tests/test_client_sync.py b/tests/test_client_sync.py index 3f79415..0c29076 100644 --- a/tests/test_client_sync.py +++ b/tests/test_client_sync.py @@ -39,26 +39,6 @@ def test_construction_with_caller_owned_httpx2_client() -> None: caller.close() -@pytest.mark.parametrize( - "kwargs", - [ - {"base_url": "https://example.test"}, - {"headers": {"x": "1"}}, - {"params": {"x": "1"}}, - {"cookies": {"x": "1"}}, - {"timeout": 5.0}, - {"limits": httpx2.Limits(max_connections=10)}, - {"auth": httpx2.BasicAuth("u", "p")}, - ], -) -def test_caller_owned_client_with_forwarded_kwargs_is_typeerror(kwargs: dict) -> None: - transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req)) - caller = httpx2.Client(transport=transport) - with pytest.raises(TypeError, match="httpx2_client"): - Client(httpx2_client=caller, **kwargs) - caller.close() - - def test_default_decoders_includes_pydantic_when_installed() -> None: client = Client() assert any(isinstance(d, PydanticDecoder) for d in client._decoders) # noqa: SLF001 @@ -105,21 +85,6 @@ class _Foo: client.close() -@pytest.mark.parametrize( - "kwargs", - [ - {"cookies": {"session": "abc"}}, - {"limits": httpx2.Limits(max_connections=5)}, - {"auth": httpx2.BasicAuth("user", "pass")}, - ], -) -def test_construction_with_optional_forwarded_kwargs(kwargs: dict) -> None: - """Exercises cookies/limits/auth branches in __init__ when no httpx2_client is supplied.""" - client = Client(**kwargs) - assert isinstance(client, Client) - client.close() - - def test_explicit_middleware_is_honored() -> None: class _Tag: def __call__(self, request, next) -> httpx2.Response: # noqa: A002, ANN001 # pragma: no cover — never invoked; the test only checks the stored middleware tuple diff --git a/tests/test_request_assembly.py b/tests/test_request_assembly.py index 12974af..6b859ed 100644 --- a/tests/test_request_assembly.py +++ b/tests/test_request_assembly.py @@ -2,82 +2,7 @@ import httpx2 -from httpware.client import _assemble_httpx2_client_kwargs, _assemble_request_kwargs - - -class TestAssembleHttpx2ClientKwargs: - """Tests for _assemble_httpx2_client_kwargs.""" - - def test_all_unset_returns_empty_dict(self) -> None: - """When all arguments are at their unset values, return empty dict.""" - result = _assemble_httpx2_client_kwargs( - base_url="", - headers=None, - params=None, - cookies=None, - timeout=None, - limits=None, - auth=None, - ) - assert result == {} - - def test_all_set_returns_full_dict(self) -> None: - """When all arguments are set to real values, return dict with all keys.""" - timeout_val = 10.0 - limits_val = httpx2.Limits(max_connections=100) - auth_val = httpx2.BasicAuth("user", "pass") - headers_val = {"X-Test": "value"} - params_val = {"key": "value"} - cookies_val = {"session": "abc123"} - base_url_val = "https://example.com" - - result = _assemble_httpx2_client_kwargs( - base_url=base_url_val, - headers=headers_val, - params=params_val, - cookies=cookies_val, - timeout=timeout_val, - limits=limits_val, - auth=auth_val, - ) - - assert result == { - "base_url": base_url_val, - "headers": headers_val, - "params": params_val, - "cookies": cookies_val, - "timeout": timeout_val, - "limits": limits_val, - "auth": auth_val, - } - - def test_base_url_empty_string_omitted(self) -> None: - """When base_url is an empty string, it is omitted (falsy string check).""" - result = _assemble_httpx2_client_kwargs( - base_url="", - headers=None, - params=None, - cookies=None, - timeout=None, - limits=None, - auth=None, - ) - assert "base_url" not in result - assert result == {} - - def test_base_url_non_empty_string_included(self) -> None: - """When base_url is a non-empty string, it is included.""" - result = _assemble_httpx2_client_kwargs( - base_url="https://example.com", - headers=None, - params=None, - cookies=None, - timeout=None, - limits=None, - auth=None, - ) - assert "base_url" in result - assert result["base_url"] == "https://example.com" +from httpware.client import _assemble_request_kwargs class TestAssembleRequestKwargs: