What changed
client.py spelled out the same per-request keywords (params, headers, cookies, timeout,
extensions, plus json, content, data, files for methods that can send a body) on every
overload, every implementation and every private layer they were forwarded through:
verb → _request_with_body / _request_with_body_with_response → _prepare_request →
_assemble_request_kwargs. Both clients carried a copy.
They are now declared once:
class _RequestOptionsBase(typing_extensions.TypedDict, total=False):
params: typing.Any | None
headers: typing.Any | None
cookies: typing.Any | None
timeout: typing.Any
extensions: typing.Any | None
class _RequestOptions(_RequestOptionsBase, total=False, closed=True): ...
class _BodyRequestOptions(_RequestOptionsBase, total=False, closed=True):
json: typing.Any | None
content: typing.Any | None
data: typing.Any | None
files: typing.Any | None
get, head, options and get_with_response take **kwargs: Unpack[_RequestOptions]. post,
put, patch, delete, request, their *_with_response siblings and stream() take
**kwargs: Unpack[_BodyRequestOptions]. The base is open and the two leaves are closed because a
closed TypedDict cannot be extended, and the type checker only rejects unknown keywords when the
TypedDict is closed.
_request_with_body, _request_with_body_with_response and _assemble_request_kwargs are gone.
Each verb now calls _prepare_request and then _send or _send_with_response. stream() shares
the body options and goes through the same _request_kwargs helper. send(),
send_with_response() and build_request() are untouched. All the # noqa: PLR0913 comments in
client.py are gone; none is needed any more.
Before and after: AsyncClient.get
Before (the two overloads repeat the same five keywords, with response_model: None = None and
response_model: type[T]):
async def get( # noqa: PLR0913 — mirrors httpx2 per-method signatures
self,
url: str,
*,
params: typing.Any | None = None,
headers: typing.Any | None = None,
cookies: typing.Any | None = None,
timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
extensions: typing.Any | None = None,
response_model: type[T] | None = None,
) -> httpx2.Response | T:
"""Send a GET request."""
return await self._request_with_body(
"GET",
url,
params=params,
headers=headers,
cookies=cookies,
timeout=timeout,
extensions=extensions,
response_model=response_model,
)
_request_with_body then forwarded all nine keywords to _prepare_request, which forwarded them
to _assemble_request_kwargs.
After:
@typing.overload
async def get(
self, url: str, *, response_model: None = None, **kwargs: typing.Unpack[_RequestOptions]
) -> httpx2.Response: ...
@typing.overload
async def get(
self, url: str, *, response_model: type[T], **kwargs: typing.Unpack[_RequestOptions]
) -> T: ...
async def get(
self, url: str, *, response_model: type[T] | None = None, **kwargs: typing.Unpack[_RequestOptions]
) -> httpx2.Response | T:
"""Send a GET request."""
request = self._prepare_request("get", "GET", url, kwargs, _REQUEST_OPTION_DEFAULTS)
return await self._send(request, response_model)
(ruff format puts one parameter per line in the file.)
Line counts
src/httpware/client.py: 2,314 → 1,436 lines (1,096 deleted, 218 added, net −878). Across the
whole PR, tests included: 1,200 deleted, 375 added.
Caller-facing API
Unchanged. Method names are the same, url (and method for request/stream) is still
positional, every option is still keyword-only, and the keyword names are the same per method.
"Not given" means the same thing as before:
- a key that is absent, or set to
None, is not passed to httpx2;
timeout is the exception: absent or httpx2.USE_CLIENT_DEFAULT means the client default, and
an explicit timeout=None means no timeout (the 0.22.0 rule).
_request_kwargs encodes this with one map per TypedDict from option name to its "not given"
value (None, or USE_CLIENT_DEFAULT for timeout) and drops a value that is that sentinel. The
old tests for _assemble_request_kwargs were ported to it.
Hover trade-off: an IDE hover on client.get now shows **kwargs: Unpack[_RequestOptions] instead
of the five named parameters, though completion and type checking still list and check each
keyword (there is no generated API reference to worry about, ADR-0007).
Unknown keywords
With a type checker, ty treats **kwargs: Unpack[<closed TypedDict>] as exactly those keywords, so
client.get(url, foo=1) and client.get(url, json=...) fail with no-matching-overload, and
client.post_with_response(url, response_model=M, foo=1) fails with unknown-argument.
tests/test_client_typing.py now has those calls (plus request, request_with_response and
stream, sync and async), each with a # ty: ignore[<code>]. A new ty override in
pyproject.toml makes unused-ignore-comment an error for that file only, so if a TypedDict ever
stops rejecting unknown keywords, the ignore becomes unused and just lint fails. I checked this by
removing closed=True from _RequestOptions: ty reported four unused-ignore errors. The file also
gained assert_type checks that get(..., params=..., timeout=None) still returns Response,
post(..., json=..., response_model=M) returns M, and request_with_response returns
tuple[Response, M].
At runtime, Python no longer rejects the keyword while binding arguments, so _request_kwargs does
it. It already walks the given options to drop the "not given" ones, and the same dict lookup that
finds an option's sentinel also finds an unknown name, so the check adds no work per request. The
error mirrors Python's own:
TypeError: AsyncClient.get() got an unexpected keyword argument 'foo'
Tests cover every verb and *_with_response method in both clients, plus request,
request_with_response and stream.
One small difference: stream() is a context manager, so the TypeError now comes from entering it
(with client.stream(..., foo=1):) rather than from the call expression. In normal use both are
the same line.
Parity test
_parameter_shape in tests/test_client_parity.py expands a **kwargs: Unpack[TD] parameter into
the keyword-only names of TD. Sync/async drift in the options (or one world pointing at a
TypedDict with different keys) is still caught, and the comparison lists each keyword a caller can
pass. The constructor check gains the same expansion for _ClientOptions and
_AsyncClientOptions. The close/aclose and AsyncTimeout exceptions are unchanged.
Performance
In-process A/B: the origin/main package was imported under another name next to this branch and
the two were timed alternately, best of 3 per round, 16 rounds, then the paired new/base ratio per
round. The machine was heavily loaded (load average 13 to 55), so absolute numbers moved a lot
between runs; the ratios were stable.
"Forwarding" stubs out httpx2.Client.build_request and _send, which isolates the code this PR
touches. "Full" is a real call on httpx2.MockTransport.
| case |
new/base, paired median (two runs) |
sync get, forwarding |
0.59 to 0.63 (quietest run: 483 → 285 ns) |
async get, forwarding |
0.66 to 0.69 (739 → 492 ns) |
sync post(json=...), forwarding |
0.78 to 0.89 (594 → 533 ns) |
async post(json=...), forwarding |
0.79 to 0.87 (871 → 757 ns) |
sync get(params=..., timeout=...), forwarding |
0.93 to 0.95 |
| full calls on MockTransport (sync/async get and post) |
0.95 to 1.01, within noise |
The first cut checked for streaming bodies with three dict.get calls on every request, which made
get(params=..., timeout=...) about 5% slower than main (ratio 1.05). It now tests whether
content, data or files is present before looking any of them up.
Behaviour change for the 0.22.0 notes
None for callers. The one visible difference is where an unknown keyword to stream() raises (on
entering the context manager instead of at the call). The message text is unchanged for Client
and AsyncClient instances; a subclass instance now gets its own class name in the message where
Python used to print the defining class.