diff --git a/planning/changes/2026-07-13.02-client-request-assembly-extraction.md b/planning/changes/2026-07-13.02-client-request-assembly-extraction.md new file mode 100644 index 0000000..293b9cd --- /dev/null +++ b/planning/changes/2026-07-13.02-client-request-assembly-extraction.md @@ -0,0 +1,251 @@ +--- +summary: Extract the non-I/O construction-validation and kwargs-assembly logic hand-duplicated between Client and AsyncClient into shared module-level functions in client.py. +--- + +# Design: Extract shared request-assembly functions from `Client`/`AsyncClient` + +## Summary + +`AsyncClient` (`client.py:60-1031`, ~972 lines) and `Client` (`client.py:1032-1999`, +~968 lines) are structurally identical, byte-for-byte duplicated except for +`async`/`await` and the sync-vs-async I/O calls. This change extracts the +purely non-I/O logic — `__init__`'s `httpx2_client=...` conflict validation, +`__init__`'s httpx2-client-constructor kwargs assembly, and the per-request +kwargs assembly shared by `_prepare_request` and `stream()` — into three +module-level private functions in `client.py`, called by both classes. The +public per-verb surface (`get`/`post`/`put`/.../`request` and their +`_with_response` siblings, each `async`/sync and separately overloaded) and +the actual I/O call sites (`_terminal`, `_prepare_request`'s +`build_request` call, `stream()`'s `self._httpx2_client.stream(...)` call) +stay exactly as they are — mirroring httpx2's own per-method API is a +deliberate design choice (see the `# noqa: PLR0913 — mirrors httpx2 +per-method signatures` comments throughout), and the `await`/non-`await` +split at the I/O boundary is irreducible without source-to-source codegen, +which this change deliberately does not introduce. No behavior change. + +## Motivation + +- The package already solved this exact duplication shape twice: + `middleware/resilience/circuit_breaker.py`'s `_CircuitBreakerState` and + `middleware/resilience/retry.py`'s `_RetryPolicy` are both stateless/ + lock-free decision logic shared by a sync and an async wrapper that + otherwise differ only in `await`/locking. `client.py` never received the + same treatment — its non-I/O logic is hand-copied instead of shared. +- **Depth:** three blocks of pure, input-to-output logic are duplicated + verbatim: the `httpx2_client=...` conflict check (~11 lines ×2), the + httpx2-client-constructor kwargs dict-building (~14 lines ×2), and the + per-request kwargs dict-building (~19 lines — duplicated not just across + `AsyncClient`/`Client` but a second time within each class, between + `_prepare_request` and `stream()`, for 4 call sites total). None of this + logic touches `httpx2.Client` vs `httpx2.AsyncClient` differently. +- **Deletion test:** delete the three shared functions after this change + and the same four dict-building/validation blocks reappear at their + six call sites — real, load-bearing logic, not a pass-through. +- **Scope discipline:** the per-verb methods (`get`/`post`/etc.) are + call-site boilerplate whose complexity is the sheer *count* of methods + (8 verbs × 2 overloads + impl + `_with_response` sibling × 2 classes), + not hidden logic — each body is already just "forward these named kwargs + to `_request_with_body`". There is nothing further to extract there + without codegen, and the type overloads are part of the public interface + (caller ergonomics), not internal duplication. This change does not touch + them. + +## Non-goals + +- No behavior change. Every existing test must pass with the same + assertions; only the internal call path changes. +- Not collapsing the per-verb methods, `_request_with_body`/ + `_request_with_body_with_response`, `_terminal`, or `stream()`'s I/O + call — these stay duplicated (thin as they already are), because the + `await`/non-`await` split there is structural, not incidental. +- Not introducing codegen (unasync-style generation of `Client` from + `AsyncClient`). Considered and explicitly rejected for this change: no + precedent in this codebase for that kind of build/lint tooling, and the + bulk of the actual duplication cost is concentrated in the non-I/O logic + this change already collapses. +- Not moving the new functions to `_internal/`. Precedent + (`_CircuitBreakerState` in `circuit_breaker.py`, `_RetryPolicy` in + `retry.py`) keeps logic private to one file's two classes in that same + file; `_internal/` is reserved for logic shared *across* otherwise- + unrelated modules (`status.py`, `exception_mapping.py`). +- Not a class. `_CircuitBreakerState`/`_RetryPolicy` are classes because + they hold config plus evolving/shared state reused across many calls. + The three functions extracted here hold no state between calls — each is + a pure function of its arguments — so a class would be a namespace with + no benefit. + +## Design + +### 1. `_validate_httpx2_client_conflict` + +```python +def _validate_httpx2_client_conflict( + *, + 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) +``` + +Replaces `client.py:86-97` (async) and the equivalent sync block verbatim. +Called only from inside the `if httpx2_client is not None:` branch of both +`__init__`s, immediately before `self._httpx2_client = httpx2_client`. + +### 2. `_assemble_httpx2_client_kwargs` + +```python +def _assemble_httpx2_client_kwargs( + *, + 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, +) -> 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 +``` + +Replaces `client.py:101-115` (async) and the equivalent sync block. Each +`__init__`'s `else:` branch becomes: + +```python +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) # or httpx2.Client(**kwargs) +self._owns_client = True +``` + +Uses `timeout is not None` — client-construction semantics. Distinct from +function 3's `timeout is not httpx2.USE_CLIENT_DEFAULT` check; these are +different semantics for a same-named parameter and must stay two separate +functions (confirmed during design — a merged function would have silently +conflated them). + +### 3. `_assemble_request_kwargs` + +```python +def _assemble_request_kwargs( + *, + params: typing.Any | None, + headers: typing.Any | None, + cookies: typing.Any | None, + timeout: typing.Any, + extensions: typing.Any | None, + json: typing.Any | None, + content: typing.Any | None, + data: typing.Any | None, + files: typing.Any | None, +) -> dict[str, typing.Any]: + """Build the kwargs dict for a per-request httpx2 call (build_request/stream).""" + kwargs: dict[str, typing.Any] = {} + if params is not None: + kwargs["params"] = params + if headers is not None: + kwargs["headers"] = headers + if cookies is not None: + kwargs["cookies"] = cookies + if timeout is not httpx2.USE_CLIENT_DEFAULT: + kwargs["timeout"] = timeout + if extensions is not None: + kwargs["extensions"] = extensions + if json is not None: + kwargs["json"] = json + if content is not None: + kwargs["content"] = content + if data is not None: + kwargs["data"] = data + if files is not None: + kwargs["files"] = files + return kwargs +``` + +Replaces `client.py:202-220` (async `_prepare_request`) and the +byte-identical block at `client.py:976-994` (async `stream()`), plus both +sync equivalents — **4 call sites total**, not 2. `_prepare_request` keeps +its own `build_request(...)` call and the streaming-body-marker check +(`_is_streaming_body_async`/`_sync` — this predicate genuinely differs +between worlds and stays inline); `stream()` keeps its own +`self._httpx2_client.stream(...)` call. Only the kwargs-dict construction +moves. + +### 4. Placement + +All three functions go in `client.py`, module level, alongside the +existing `_build_default_decoders` (before `class AsyncClient:`) — +matching where `_CircuitBreakerState`/`_RetryPolicy` sit relative to their +wrapper classes: private to the file, not moved to `_internal/`. + +## Testing + +- **Parity net:** all existing suites stay green unchanged — + `test_client_construction.py`, `test_client_sync.py`, + `test_client_dispatch.py`, `test_client_methods.py`, + `test_client_stream.py`, `test_client_stream_sync.py`, and the rest. + Byte-identical behavior is the bar. +- **New seam tests:** `tests/test_request_assembly.py` drives + `_assemble_httpx2_client_kwargs` and `_assemble_request_kwargs` directly + (no client, no `MockTransport`) — which keys appear/are omitted for + `None`/falsy/`USE_CLIENT_DEFAULT` inputs, and that a fully-populated call + includes every key. `_validate_httpx2_client_conflict` gets no separate + direct test: it's a single boolean check already directly asserted via + `TypeError` through the public constructor in + `test_client_construction.py`, and a duplicate direct test would add no + leverage. +- `just lint && just test` both clean; 100% coverage maintained. + +## Risk + +- **Behavioral drift during extraction** (unlikely × medium): a subtle + reordering changes which kwarg wins or silently drops a falsy-but-valid + value (e.g. `base_url=""` vs `base_url` unset — the existing code already + treats empty string as "unset" via the truthy check, and this must be + preserved exactly). *Mitigation:* extract verbatim under the existing + green suites, which construct clients with every combination of + set/unset kwargs; do not edit those suites in this change. +- **Conflating the two `timeout` semantics** (unlikely × high, if it + happened): function 2 must keep `is not None`, function 3 must keep + `is not httpx2.USE_CLIENT_DEFAULT` — merging them into one function was + considered and rejected specifically to avoid this. *Mitigation:* keep + them as two functions per this design; `test_request_assembly.py` + asserts each function's actual sentinel/None behavior directly. +- **Per-verb layer accidentally touched** (unlikely × low): scope creep + into the per-verb methods would reopen the codegen-vs-hand-duplication + question this change deliberately defers. *Mitigation:* this design's + Non-goals section is explicit; the task brief will name the exact + functions and line ranges to touch and nothing else. diff --git a/src/httpware/client.py b/src/httpware/client.py index f04f5e1..35e876b 100644 --- a/src/httpware/client.py +++ b/src/httpware/client.py @@ -57,6 +57,94 @@ 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 _assemble_httpx2_client_kwargs( # 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, +) -> 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 + + +def _assemble_request_kwargs( # noqa: PLR0913 — 9 per-request kwargs from httpx2 call signatures + *, + params: typing.Any | None, + headers: typing.Any | None, + cookies: typing.Any | None, + timeout: typing.Any, + extensions: typing.Any | None, + json: typing.Any | None, + content: typing.Any | None, + data: typing.Any | None, + files: typing.Any | None, +) -> dict[str, typing.Any]: + """Build the kwargs dict for a per-request httpx2 call (build_request/stream).""" + kwargs: dict[str, typing.Any] = {} + if params is not None: + kwargs["params"] = params + if headers is not None: + kwargs["headers"] = headers + if cookies is not None: + kwargs["cookies"] = cookies + if timeout is not httpx2.USE_CLIENT_DEFAULT: + kwargs["timeout"] = timeout + if extensions is not None: + kwargs["extensions"] = extensions + if json is not None: + kwargs["json"] = json + if content is not None: + kwargs["content"] = content + if data is not None: + kwargs["data"] = data + if files is not None: + kwargs["files"] = files + return kwargs + + class AsyncClient: """Async HTTP client: thin wrapper around httpx2 with typed decoding and middleware.""" @@ -84,35 +172,27 @@ def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call ) -> None: _validate_max_response_body_bytes(max_response_body_bytes) if httpx2_client is not None: - 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) + _validate_httpx2_client_conflict( + base_url=base_url, + headers=headers, + params=params, + cookies=cookies, + timeout=timeout, + limits=limits, + auth=auth, + ) self._httpx2_client = httpx2_client self._owns_client = False else: - 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 + 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) self._owns_client = True @@ -184,7 +264,7 @@ def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.R """Delegate request construction to the wrapped httpx2.AsyncClient.""" return self._httpx2_client.build_request(method, url, **kwargs) - def _prepare_request( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural + def _prepare_request( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural self, method: str, url: str, @@ -199,25 +279,17 @@ def _prepare_request( # noqa: PLR0913, C901 — mirrors httpx2 per-method signa data: typing.Any | None = None, files: typing.Any | None = None, ) -> httpx2.Request: - kwargs: dict[str, typing.Any] = {} - if params is not None: - kwargs["params"] = params - if headers is not None: - kwargs["headers"] = headers - if cookies is not None: - kwargs["cookies"] = cookies - if timeout is not httpx2.USE_CLIENT_DEFAULT: - kwargs["timeout"] = timeout - if extensions is not None: - kwargs["extensions"] = extensions - if json is not None: - kwargs["json"] = json - if content is not None: - kwargs["content"] = content - if data is not None: - kwargs["data"] = data - if files is not None: - kwargs["files"] = files + kwargs = _assemble_request_kwargs( + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + extensions=extensions, + json=json, + content=content, + data=data, + files=files, + ) request = self._httpx2_client.build_request(method, url, **kwargs) if _is_streaming_body_async(content) or _is_streaming_body_async(data) or _is_streaming_body_async(files): request.extensions[STREAMING_BODY_MARKER] = True @@ -940,7 +1012,7 @@ async def request_with_response( # noqa: PLR0913 — mirrors httpx2 per-method ) @contextlib.asynccontextmanager - async def stream( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural + async def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural self, method: str, url: str, @@ -973,25 +1045,17 @@ async def stream( # noqa: PLR0913, C901 — mirrors httpx2 per-method signature Maps httpx2 exceptions raised during the request OR body consumption to httpware exceptions via _httpx2_exception_mapper. """ - kwargs: dict[str, typing.Any] = {} - if params is not None: - kwargs["params"] = params - if headers is not None: - kwargs["headers"] = headers - if cookies is not None: - kwargs["cookies"] = cookies - if timeout is not httpx2.USE_CLIENT_DEFAULT: - kwargs["timeout"] = timeout - if extensions is not None: - kwargs["extensions"] = extensions - if json is not None: - kwargs["json"] = json - if content is not None: - kwargs["content"] = content - if data is not None: - kwargs["data"] = data - if files is not None: - kwargs["files"] = files + kwargs = _assemble_request_kwargs( + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + extensions=extensions, + json=json, + content=content, + data=data, + files=files, + ) async with _httpx2_exception_mapper(), self._httpx2_client.stream(method, url, **kwargs) as response: if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx @@ -1056,35 +1120,27 @@ def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call ) -> None: _validate_max_response_body_bytes(max_response_body_bytes) if httpx2_client is not None: - 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) + _validate_httpx2_client_conflict( + base_url=base_url, + headers=headers, + params=params, + cookies=cookies, + timeout=timeout, + limits=limits, + auth=auth, + ) self._httpx2_client = httpx2_client self._owns_client = False else: - 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 + 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) self._owns_client = True @@ -1180,7 +1236,7 @@ def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.R """Delegate request construction to the wrapped httpx2.Client.""" return self._httpx2_client.build_request(method, url, **kwargs) - def _prepare_request( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural + def _prepare_request( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural self, method: str, url: str, @@ -1195,25 +1251,17 @@ def _prepare_request( # noqa: PLR0913, C901 — mirrors httpx2 per-method signa data: typing.Any | None = None, files: typing.Any | None = None, ) -> httpx2.Request: - kwargs: dict[str, typing.Any] = {} - if params is not None: - kwargs["params"] = params - if headers is not None: - kwargs["headers"] = headers - if cookies is not None: - kwargs["cookies"] = cookies - if timeout is not httpx2.USE_CLIENT_DEFAULT: - kwargs["timeout"] = timeout - if extensions is not None: - kwargs["extensions"] = extensions - if json is not None: - kwargs["json"] = json - if content is not None: - kwargs["content"] = content - if data is not None: - kwargs["data"] = data - if files is not None: - kwargs["files"] = files + kwargs = _assemble_request_kwargs( + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + extensions=extensions, + json=json, + content=content, + data=data, + files=files, + ) request = self._httpx2_client.build_request(method, url, **kwargs) if _is_streaming_body_sync(content) or _is_streaming_body_sync(data) or _is_streaming_body_sync(files): request.extensions[STREAMING_BODY_MARKER] = True @@ -1936,7 +1984,7 @@ def request_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signat ) @contextlib.contextmanager - def stream( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural + def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural self, method: str, url: str, @@ -1967,25 +2015,17 @@ def stream( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwa Maps httpx2 exceptions raised during the request OR body consumption to httpware exceptions via _httpx2_exception_mapper_sync. """ - kwargs: dict[str, typing.Any] = {} - if params is not None: - kwargs["params"] = params - if headers is not None: - kwargs["headers"] = headers - if cookies is not None: - kwargs["cookies"] = cookies - if timeout is not httpx2.USE_CLIENT_DEFAULT: - kwargs["timeout"] = timeout - if extensions is not None: - kwargs["extensions"] = extensions - if json is not None: - kwargs["json"] = json - if content is not None: - kwargs["content"] = content - if data is not None: - kwargs["data"] = data - if files is not None: - kwargs["files"] = files + kwargs = _assemble_request_kwargs( + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + extensions=extensions, + json=json, + content=content, + data=data, + files=files, + ) with _httpx2_exception_mapper_sync(), self._httpx2_client.stream(method, url, **kwargs) as response: if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx diff --git a/tests/test_request_assembly.py b/tests/test_request_assembly.py new file mode 100644 index 0000000..12974af --- /dev/null +++ b/tests/test_request_assembly.py @@ -0,0 +1,184 @@ +"""Tests for request assembly functions in httpware.client module.""" + +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" + + +class TestAssembleRequestKwargs: + """Tests for _assemble_request_kwargs.""" + + def test_all_unset_returns_empty_dict(self) -> None: + """When all arguments are at their unset values, return empty dict.""" + result = _assemble_request_kwargs( + params=None, + headers=None, + cookies=None, + timeout=httpx2.USE_CLIENT_DEFAULT, + extensions=None, + json=None, + content=None, + data=None, + files=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.""" + params_val = {"key": "value"} + headers_val = {"X-Test": "header"} + cookies_val = {"session": "123"} + timeout_val = 5.0 + extensions_val = {"timeout": 10.0} + json_val = {"data": "json"} + content_val = b"binary content" + data_val = {"form": "data"} + files_val = [("file", ("name.txt", b"content"))] + + result = _assemble_request_kwargs( + params=params_val, + headers=headers_val, + cookies=cookies_val, + timeout=timeout_val, + extensions=extensions_val, + json=json_val, + content=content_val, + data=data_val, + files=files_val, + ) + + assert result == { + "params": params_val, + "headers": headers_val, + "cookies": cookies_val, + "timeout": timeout_val, + "extensions": extensions_val, + "json": json_val, + "content": content_val, + "data": data_val, + "files": files_val, + } + + def test_timeout_use_client_default_omitted(self) -> None: + """When timeout is httpx2.USE_CLIENT_DEFAULT, it is omitted.""" + result = _assemble_request_kwargs( + params=None, + headers=None, + cookies=None, + timeout=httpx2.USE_CLIENT_DEFAULT, + extensions=None, + json=None, + content=None, + data=None, + files=None, + ) + assert "timeout" not in result + assert result == {} + + def test_timeout_none_is_included(self) -> None: + """When timeout is None (not USE_CLIENT_DEFAULT), it is included.""" + result = _assemble_request_kwargs( + params=None, + headers=None, + cookies=None, + timeout=None, + extensions=None, + json=None, + content=None, + data=None, + files=None, + ) + assert "timeout" in result + assert result["timeout"] is None + + def test_timeout_numeric_value_is_included(self) -> None: + """When timeout is a numeric value, it is included.""" + timeout_value = 10.5 + result = _assemble_request_kwargs( + params=None, + headers=None, + cookies=None, + timeout=timeout_value, + extensions=None, + json=None, + content=None, + data=None, + files=None, + ) + assert "timeout" in result + assert result["timeout"] == timeout_value