diff --git a/pyproject.toml b/pyproject.toml index f253bbf..48d641a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,6 +117,10 @@ isort.no-lines-before = ["standard-library", "local-folder"] include = ["scripts/**"] rules = { unresolved-import = "ignore" } +[[tool.ty.overrides]] +include = ["tests/test_client_typing.py"] +rules = { unused-ignore-comment = "error" } + [tool.pytest.ini_options] testpaths = ["tests"] addopts = "" diff --git a/src/httpware/client.py b/src/httpware/client.py index c0ace04..abb76a9 100644 --- a/src/httpware/client.py +++ b/src/httpware/client.py @@ -348,38 +348,52 @@ def _stream_capped( response.close() -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, +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): + """Per-request keyword arguments of the methods that send no body.""" + + +class _BodyRequestOptions(_RequestOptionsBase, total=False, closed=True): + """Per-request keyword arguments of the methods that may send a body.""" + + json: typing.Any | None + content: typing.Any | None + data: typing.Any | None + files: typing.Any | None + + +def _option_defaults(names: frozenset[str]) -> dict[str, object]: + """Map each option name to the value that means "not given": `USE_CLIENT_DEFAULT` for `timeout`, else `None`.""" + return {name: httpx2.USE_CLIENT_DEFAULT if name == "timeout" else None for name in names} + + +_REQUEST_OPTION_DEFAULTS = _option_defaults(_RequestOptions.__optional_keys__) +_BODY_REQUEST_OPTION_DEFAULTS = _option_defaults(_BodyRequestOptions.__optional_keys__) +_UNKNOWN_OPTION = object() + + +def _request_kwargs( + client: object, caller: str, options: Mapping[str, typing.Any], defaults: Mapping[str, object] ) -> dict[str, typing.Any]: - """Build the kwargs dict for a per-request httpx2 call (build_request/stream).""" + """Return the `options` to pass to httpx2, dropping those left at their default. + + Raise TypeError naming the first option that `defaults` does not know. + """ 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 + for name, value in options.items(): + default = defaults.get(name, _UNKNOWN_OPTION) + if default is _UNKNOWN_OPTION: + msg = f"{type(client).__name__}.{caller}() got an unexpected keyword argument {name!r}" + raise TypeError(msg) + if value is not default: + kwargs[name] = value return kwargs @@ -514,113 +528,34 @@ def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.R """Delegate request construction to the wrapped httpx2.AsyncClient, keeping the URL's own query.""" return _build_request(self._httpx2_client, method, url, kwargs) - def _prepare_request( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural + def _prepare_request( self, + caller: str, method: str, 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, + options: Mapping[str, typing.Any], + defaults: Mapping[str, object], ) -> httpx2.Request: - kwargs = _assemble_request_kwargs( - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - ) + kwargs = _request_kwargs(self, caller, options, defaults) try: request = _build_request(self._httpx2_client, method, url, kwargs) except httpx2.InvalidURL as exc: raise map_httpx2_exception(exc) from exc - if _is_streaming_body_async(content) or _is_streaming_body_async(data) or _is_streaming_body_async(files): + if ("content" in kwargs or "data" in kwargs or "files" in kwargs) and ( + _is_streaming_body_async(kwargs.get("content")) + or _is_streaming_body_async(kwargs.get("data")) + or _is_streaming_body_async(kwargs.get("files")) + ): request.extensions[STREAMING_BODY_MARKER] = True return request - async def _request_with_body( # noqa: PLR0913 — mirrors httpx2 per-method signatures - self, - method: str, - 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, - response_model: type[T] | None = None, - ) -> httpx2.Response | T: - request = self._prepare_request( - method, - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - ) - return await self._send(request, response_model) - - async def _request_with_body_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures - self, - method: str, - 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, - response_model: type[T], - ) -> tuple[httpx2.Response, T]: - request = self._prepare_request( - method, - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - ) - return await self._send_with_response(request, response_model) - @typing.overload async def get( 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: None = None, + **kwargs: typing.Unpack[_RequestOptions], ) -> httpx2.Response: ... @typing.overload @@ -628,75 +563,39 @@ async def get( 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], + **kwargs: typing.Unpack[_RequestOptions], ) -> T: ... - async def get( # noqa: PLR0913 — mirrors httpx2 per-method signatures + async def get( 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, + **kwargs: typing.Unpack[_RequestOptions], ) -> 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 = self._prepare_request("get", "GET", url, kwargs, _REQUEST_OPTION_DEFAULTS) + return await self._send(request, response_model) - async def get_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + async def get_with_response( 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], + **kwargs: typing.Unpack[_RequestOptions], ) -> tuple[httpx2.Response, T]: """Send a GET request; return (response, decoded body).""" - return await self._request_with_body_with_response( - "GET", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - response_model=response_model, - ) + request = self._prepare_request("get_with_response", "GET", url, kwargs, _REQUEST_OPTION_DEFAULTS) + return await self._send_with_response(request, response_model) @typing.overload async def post( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: None = None, + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> httpx2.Response: ... @typing.overload @@ -704,95 +603,39 @@ async def post( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T], + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> T: ... - async def post( # noqa: PLR0913 — mirrors httpx2 per-method signatures + async def post( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T] | None = None, + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> httpx2.Response | T: """Send a POST request.""" - return await self._request_with_body( - "POST", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - response_model=response_model, - ) + request = self._prepare_request("post", "POST", url, kwargs, _BODY_REQUEST_OPTION_DEFAULTS) + return await self._send(request, response_model) - async def post_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + async def post_with_response( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T], + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> tuple[httpx2.Response, T]: """Send a POST request; return (response, decoded body).""" - return await self._request_with_body_with_response( - "POST", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - response_model=response_model, - ) + request = self._prepare_request("post_with_response", "POST", url, kwargs, _BODY_REQUEST_OPTION_DEFAULTS) + return await self._send_with_response(request, response_model) @typing.overload async def put( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: None = None, + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> httpx2.Response: ... @typing.overload @@ -800,95 +643,39 @@ async def put( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T], + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> T: ... - async def put( # noqa: PLR0913 — mirrors httpx2 per-method signatures + async def put( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T] | None = None, + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> httpx2.Response | T: """Send a PUT request.""" - return await self._request_with_body( - "PUT", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - response_model=response_model, - ) + request = self._prepare_request("put", "PUT", url, kwargs, _BODY_REQUEST_OPTION_DEFAULTS) + return await self._send(request, response_model) - async def put_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + async def put_with_response( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T], + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> tuple[httpx2.Response, T]: """Send a PUT request; return (response, decoded body).""" - return await self._request_with_body_with_response( - "PUT", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - response_model=response_model, - ) + request = self._prepare_request("put_with_response", "PUT", url, kwargs, _BODY_REQUEST_OPTION_DEFAULTS) + return await self._send_with_response(request, response_model) @typing.overload async def patch( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: None = None, + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> httpx2.Response: ... @typing.overload @@ -896,95 +683,39 @@ async def patch( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T], + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> T: ... - async def patch( # noqa: PLR0913 — mirrors httpx2 per-method signatures + async def patch( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T] | None = None, + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> httpx2.Response | T: """Send a PATCH request.""" - return await self._request_with_body( - "PATCH", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - response_model=response_model, - ) + request = self._prepare_request("patch", "PATCH", url, kwargs, _BODY_REQUEST_OPTION_DEFAULTS) + return await self._send(request, response_model) - async def patch_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + async def patch_with_response( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T], + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> tuple[httpx2.Response, T]: """Send a PATCH request; return (response, decoded body).""" - return await self._request_with_body_with_response( - "PATCH", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - response_model=response_model, - ) + request = self._prepare_request("patch_with_response", "PATCH", url, kwargs, _BODY_REQUEST_OPTION_DEFAULTS) + return await self._send_with_response(request, response_model) @typing.overload async def delete( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: None = None, + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> httpx2.Response: ... @typing.overload @@ -992,91 +723,39 @@ async def delete( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T], + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> T: ... - async def delete( # noqa: PLR0913 — mirrors httpx2 per-method signatures + async def delete( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T] | None = None, + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> httpx2.Response | T: """Send a DELETE request.""" - return await self._request_with_body( - "DELETE", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - response_model=response_model, - ) + request = self._prepare_request("delete", "DELETE", url, kwargs, _BODY_REQUEST_OPTION_DEFAULTS) + return await self._send(request, response_model) - async def delete_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + async def delete_with_response( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T], + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> tuple[httpx2.Response, T]: """Send a DELETE request; return (response, decoded body).""" - return await self._request_with_body_with_response( - "DELETE", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - response_model=response_model, - ) + request = self._prepare_request("delete_with_response", "DELETE", url, kwargs, _BODY_REQUEST_OPTION_DEFAULTS) + return await self._send_with_response(request, response_model) @typing.overload async def head( 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: None = None, + **kwargs: typing.Unpack[_RequestOptions], ) -> httpx2.Response: ... @typing.overload @@ -1084,48 +763,28 @@ async def head( 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], + **kwargs: typing.Unpack[_RequestOptions], ) -> T: ... - async def head( # noqa: PLR0913 — mirrors httpx2 per-method signatures + async def head( 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, + **kwargs: typing.Unpack[_RequestOptions], ) -> httpx2.Response | T: """Send a HEAD request.""" - return await self._request_with_body( - "HEAD", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - response_model=response_model, - ) + request = self._prepare_request("head", "HEAD", url, kwargs, _REQUEST_OPTION_DEFAULTS) + return await self._send(request, response_model) @typing.overload async def options( 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: None = None, + **kwargs: typing.Unpack[_RequestOptions], ) -> httpx2.Response: ... @typing.overload @@ -1133,36 +792,20 @@ async def options( 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], + **kwargs: typing.Unpack[_RequestOptions], ) -> T: ... - async def options( # noqa: PLR0913 — mirrors httpx2 per-method signatures + async def options( 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, + **kwargs: typing.Unpack[_RequestOptions], ) -> httpx2.Response | T: """Send an OPTIONS request.""" - return await self._request_with_body( - "OPTIONS", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - response_model=response_model, - ) + request = self._prepare_request("options", "OPTIONS", url, kwargs, _REQUEST_OPTION_DEFAULTS) + return await self._send(request, response_model) @typing.overload async def request( @@ -1170,16 +813,8 @@ async def request( method: str, 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: None = None, + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> httpx2.Response: ... @typing.overload @@ -1188,97 +823,40 @@ async def request( method: str, 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T], + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> T: ... - async def request( # noqa: PLR0913 — mirrors httpx2 per-method signatures + async def request( self, method: str, 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T] | None = None, + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> httpx2.Response | T: """Send a request with an arbitrary HTTP method.""" - return await self._request_with_body( - method, - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - response_model=response_model, - ) + request = self._prepare_request("request", method, url, kwargs, _BODY_REQUEST_OPTION_DEFAULTS) + return await self._send(request, response_model) - async def request_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + async def request_with_response( self, method: str, 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T], + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> tuple[httpx2.Response, T]: """Send a request with an explicit method; return (response, decoded body).""" - return await self._request_with_body_with_response( - method, - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - response_model=response_model, - ) + request = self._prepare_request("request_with_response", method, url, kwargs, _BODY_REQUEST_OPTION_DEFAULTS) + return await self._send_with_response(request, response_model) @contextlib.asynccontextmanager - async def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural + async def stream( self, method: str, 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> AsyncGenerator[httpx2.Response]: """Stream an HTTP response. Bypasses the middleware chain. @@ -1299,23 +877,13 @@ async def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwa Maps httpx2 exceptions raised during the request OR body consumption to httpware exceptions. """ - kwargs = _assemble_request_kwargs( - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - ) + forwarded = _request_kwargs(self, "stream", kwargs, _BODY_REQUEST_OPTION_DEFAULTS) cap = self._max_response_body_bytes opened = ( - _stream_async(self._httpx2_client, method, url, kwargs) + _stream_async(self._httpx2_client, method, url, forwarded) if cap is None - else _stream_capped_async(self._httpx2_client, method, url, kwargs, cap) + else _stream_capped_async(self._httpx2_client, method, url, forwarded, cap) ) try: async with opened as response: @@ -1496,113 +1064,34 @@ def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.R """Delegate request construction to the wrapped httpx2.Client, keeping the URL's own query.""" return _build_request(self._httpx2_client, method, url, kwargs) - def _prepare_request( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural + def _prepare_request( self, + caller: str, method: str, 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, + options: Mapping[str, typing.Any], + defaults: Mapping[str, object], ) -> httpx2.Request: - kwargs = _assemble_request_kwargs( - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - ) + kwargs = _request_kwargs(self, caller, options, defaults) try: request = _build_request(self._httpx2_client, method, url, kwargs) except httpx2.InvalidURL as exc: raise map_httpx2_exception(exc) from exc - if _is_streaming_body_sync(content) or _is_streaming_body_sync(data) or _is_streaming_body_sync(files): + if ("content" in kwargs or "data" in kwargs or "files" in kwargs) and ( + _is_streaming_body_sync(kwargs.get("content")) + or _is_streaming_body_sync(kwargs.get("data")) + or _is_streaming_body_sync(kwargs.get("files")) + ): request.extensions[STREAMING_BODY_MARKER] = True return request - def _request_with_body( # noqa: PLR0913 — mirrors httpx2 per-method signatures - self, - method: str, - 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, - response_model: type[T] | None = None, - ) -> httpx2.Response | T: - request = self._prepare_request( - method, - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - ) - return self._send(request, response_model) - - def _request_with_body_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures - self, - method: str, - 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, - response_model: type[T], - ) -> tuple[httpx2.Response, T]: - request = self._prepare_request( - method, - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - ) - return self._send_with_response(request, response_model) - @typing.overload def get( 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: None = None, + **kwargs: typing.Unpack[_RequestOptions], ) -> httpx2.Response: ... @typing.overload @@ -1610,75 +1099,39 @@ def get( 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], + **kwargs: typing.Unpack[_RequestOptions], ) -> T: ... - def get( # noqa: PLR0913 — mirrors httpx2 per-method signatures + def get( 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, + **kwargs: typing.Unpack[_RequestOptions], ) -> httpx2.Response | T: """Send a GET request.""" - return self._request_with_body( - "GET", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - response_model=response_model, - ) + request = self._prepare_request("get", "GET", url, kwargs, _REQUEST_OPTION_DEFAULTS) + return self._send(request, response_model) - def get_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + def get_with_response( 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], + **kwargs: typing.Unpack[_RequestOptions], ) -> tuple[httpx2.Response, T]: """Send a GET request; return (response, decoded body).""" - return self._request_with_body_with_response( - "GET", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - response_model=response_model, - ) + request = self._prepare_request("get_with_response", "GET", url, kwargs, _REQUEST_OPTION_DEFAULTS) + return self._send_with_response(request, response_model) @typing.overload def post( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: None = None, + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> httpx2.Response: ... @typing.overload @@ -1686,95 +1139,39 @@ def post( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T], + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> T: ... - def post( # noqa: PLR0913 — mirrors httpx2 per-method signatures + def post( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T] | None = None, + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> httpx2.Response | T: """Send a POST request.""" - return self._request_with_body( - "POST", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - response_model=response_model, - ) + request = self._prepare_request("post", "POST", url, kwargs, _BODY_REQUEST_OPTION_DEFAULTS) + return self._send(request, response_model) - def post_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + def post_with_response( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T], + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> tuple[httpx2.Response, T]: """Send a POST request; return (response, decoded body).""" - return self._request_with_body_with_response( - "POST", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - response_model=response_model, - ) + request = self._prepare_request("post_with_response", "POST", url, kwargs, _BODY_REQUEST_OPTION_DEFAULTS) + return self._send_with_response(request, response_model) @typing.overload def put( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: None = None, + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> httpx2.Response: ... @typing.overload @@ -1782,95 +1179,39 @@ def put( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T], + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> T: ... - def put( # noqa: PLR0913 — mirrors httpx2 per-method signatures + def put( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T] | None = None, + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> httpx2.Response | T: """Send a PUT request.""" - return self._request_with_body( - "PUT", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - response_model=response_model, - ) + request = self._prepare_request("put", "PUT", url, kwargs, _BODY_REQUEST_OPTION_DEFAULTS) + return self._send(request, response_model) - def put_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + def put_with_response( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T], + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> tuple[httpx2.Response, T]: """Send a PUT request; return (response, decoded body).""" - return self._request_with_body_with_response( - "PUT", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - response_model=response_model, - ) + request = self._prepare_request("put_with_response", "PUT", url, kwargs, _BODY_REQUEST_OPTION_DEFAULTS) + return self._send_with_response(request, response_model) @typing.overload def patch( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: None = None, + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> httpx2.Response: ... @typing.overload @@ -1878,95 +1219,39 @@ def patch( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T], + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> T: ... - def patch( # noqa: PLR0913 — mirrors httpx2 per-method signatures + def patch( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T] | None = None, + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> httpx2.Response | T: """Send a PATCH request.""" - return self._request_with_body( - "PATCH", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - response_model=response_model, - ) + request = self._prepare_request("patch", "PATCH", url, kwargs, _BODY_REQUEST_OPTION_DEFAULTS) + return self._send(request, response_model) - def patch_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + def patch_with_response( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T], + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> tuple[httpx2.Response, T]: """Send a PATCH request; return (response, decoded body).""" - return self._request_with_body_with_response( - "PATCH", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - response_model=response_model, - ) + request = self._prepare_request("patch_with_response", "PATCH", url, kwargs, _BODY_REQUEST_OPTION_DEFAULTS) + return self._send_with_response(request, response_model) @typing.overload def delete( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: None = None, + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> httpx2.Response: ... @typing.overload @@ -1974,91 +1259,39 @@ def delete( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T], + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> T: ... - def delete( # noqa: PLR0913 — mirrors httpx2 per-method signatures + def delete( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T] | None = None, + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> httpx2.Response | T: """Send a DELETE request.""" - return self._request_with_body( - "DELETE", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - response_model=response_model, - ) + request = self._prepare_request("delete", "DELETE", url, kwargs, _BODY_REQUEST_OPTION_DEFAULTS) + return self._send(request, response_model) - def delete_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + def delete_with_response( 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T], + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> tuple[httpx2.Response, T]: """Send a DELETE request; return (response, decoded body).""" - return self._request_with_body_with_response( - "DELETE", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - response_model=response_model, - ) + request = self._prepare_request("delete_with_response", "DELETE", url, kwargs, _BODY_REQUEST_OPTION_DEFAULTS) + return self._send_with_response(request, response_model) @typing.overload def head( 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: None = None, + **kwargs: typing.Unpack[_RequestOptions], ) -> httpx2.Response: ... @typing.overload @@ -2066,48 +1299,28 @@ def head( 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], + **kwargs: typing.Unpack[_RequestOptions], ) -> T: ... - def head( # noqa: PLR0913 — mirrors httpx2 per-method signatures + def head( 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, + **kwargs: typing.Unpack[_RequestOptions], ) -> httpx2.Response | T: """Send a HEAD request.""" - return self._request_with_body( - "HEAD", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - response_model=response_model, - ) + request = self._prepare_request("head", "HEAD", url, kwargs, _REQUEST_OPTION_DEFAULTS) + return self._send(request, response_model) @typing.overload def options( 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: None = None, + **kwargs: typing.Unpack[_RequestOptions], ) -> httpx2.Response: ... @typing.overload @@ -2115,36 +1328,20 @@ def options( 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], + **kwargs: typing.Unpack[_RequestOptions], ) -> T: ... - def options( # noqa: PLR0913 — mirrors httpx2 per-method signatures + def options( 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, + **kwargs: typing.Unpack[_RequestOptions], ) -> httpx2.Response | T: """Send an OPTIONS request.""" - return self._request_with_body( - "OPTIONS", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - response_model=response_model, - ) + request = self._prepare_request("options", "OPTIONS", url, kwargs, _REQUEST_OPTION_DEFAULTS) + return self._send(request, response_model) @typing.overload def request( @@ -2152,16 +1349,8 @@ def request( method: str, 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: None = None, + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> httpx2.Response: ... @typing.overload @@ -2170,97 +1359,40 @@ def request( method: str, 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T], + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> T: ... - def request( # noqa: PLR0913 — mirrors httpx2 per-method signatures + def request( self, method: str, 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T] | None = None, + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> httpx2.Response | T: """Send a request with an arbitrary HTTP method.""" - return self._request_with_body( - method, - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - response_model=response_model, - ) + request = self._prepare_request("request", method, url, kwargs, _BODY_REQUEST_OPTION_DEFAULTS) + return self._send(request, response_model) - def request_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + def request_with_response( self, method: str, 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, response_model: type[T], + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> tuple[httpx2.Response, T]: """Send a request with an explicit method; return (response, decoded body).""" - return self._request_with_body_with_response( - method, - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - response_model=response_model, - ) + request = self._prepare_request("request_with_response", method, url, kwargs, _BODY_REQUEST_OPTION_DEFAULTS) + return self._send_with_response(request, response_model) @contextlib.contextmanager - def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural + def stream( self, method: str, 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, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, + **kwargs: typing.Unpack[_BodyRequestOptions], ) -> Generator[httpx2.Response]: """Stream an HTTP response. Bypasses the middleware chain. @@ -2278,23 +1410,13 @@ def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwargs-fo Maps httpx2 exceptions raised during the request OR body consumption to httpware exceptions. """ - kwargs = _assemble_request_kwargs( - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - ) + forwarded = _request_kwargs(self, "stream", kwargs, _BODY_REQUEST_OPTION_DEFAULTS) cap = self._max_response_body_bytes opened = ( - _stream(self._httpx2_client, method, url, kwargs) + _stream(self._httpx2_client, method, url, forwarded) if cap is None - else _stream_capped(self._httpx2_client, method, url, kwargs, cap) + else _stream_capped(self._httpx2_client, method, url, forwarded, cap) ) try: with opened as response: diff --git a/tests/test_client_methods.py b/tests/test_client_methods.py index 05298b6..af397bf 100644 --- a/tests/test_client_methods.py +++ b/tests/test_client_methods.py @@ -103,7 +103,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: async def test_get_with_cookies_forwarded() -> None: - """Exercises the cookies branch in _request_with_body.""" + """Exercises the cookies branch in _request_kwargs.""" captured: list[httpx2.Request] = [] def handler(request: httpx2.Request) -> httpx2.Response: @@ -116,35 +116,35 @@ def handler(request: httpx2.Request) -> httpx2.Response: async def test_get_with_explicit_timeout() -> None: - """Exercises the timeout branch in _request_with_body.""" + """Exercises the timeout branch in _request_kwargs.""" client = _client_with_handler(_echo_handler) response = await client.get("https://example.test/x", timeout=5.0) assert response.status_code == HTTPStatus.OK async def test_get_with_extensions() -> None: - """Exercises the extensions branch in _request_with_body.""" + """Exercises the extensions branch in _request_kwargs.""" client = _client_with_handler(_echo_handler) response = await client.get("https://example.test/x", extensions={"trace": True}) assert response.status_code == HTTPStatus.OK async def test_post_with_content_body() -> None: - """Exercises the content branch in _request_with_body.""" + """Exercises the content branch in _request_kwargs.""" client = _client_with_handler(_echo_handler) response = await client.post("https://example.test/x", content=b"raw-bytes") assert response.json()["content"] == "raw-bytes" async def test_post_with_data_body() -> None: - """Exercises the data branch in _request_with_body.""" + """Exercises the data branch in _request_kwargs.""" client = _client_with_handler(_echo_handler) response = await client.post("https://example.test/x", data={"field": "value"}) assert response.status_code == HTTPStatus.OK async def test_post_with_files_body() -> None: - """Exercises the files branch in _request_with_body.""" + """Exercises the files branch in _request_kwargs.""" client = _client_with_handler(_echo_handler) response = await client.post("https://example.test/x", files={"upload": b"file-content"}) assert response.status_code == HTTPStatus.OK diff --git a/tests/test_client_parity.py b/tests/test_client_parity.py index 64448ed..42d4f65 100644 --- a/tests/test_client_parity.py +++ b/tests/test_client_parity.py @@ -1,6 +1,7 @@ """Sync/async feature parity across the two client surfaces.""" import inspect +import typing import pytest @@ -21,7 +22,14 @@ def _public_methods(cls: type) -> dict[str, inspect.Signature]: def _parameter_shape(signature: inspect.Signature) -> list[tuple[str, str]]: - return [(p.name, p.kind.name) for p in signature.parameters.values()] + shape = [] + for parameter in signature.parameters.values(): + if typing.get_origin(parameter.annotation) is typing.Unpack: + (options,) = typing.get_args(parameter.annotation) + shape += [(name, inspect.Parameter.KEYWORD_ONLY.name) for name in options.__annotations__] + else: + shape.append((parameter.name, parameter.kind.name)) + return shape def test_client_and_asyncclient_expose_the_same_public_methods() -> None: @@ -56,9 +64,11 @@ def test_mirrored_client_methods_take_the_same_parameters(name: str) -> None: Matching method names are the cheap half of parity and the half that drifts least. The expensive half is the signature: a keyword added to `AsyncClient.post` and forgotten on `Client.post` leaves both surfaces looking complete while one of them silently cannot - express the option. Only names, order and kind are compared — the two worlds legitimately - annotate `middleware` and every return type differently, and pinning those here would - make the test fail on changes that preserve the parity it exists to protect. + express the option. A `**options: Unpack[...]` parameter is expanded into the keywords of its + TypedDict, so the comparison sees each keyword a caller can pass. + Only names, order and kind are compared: the two worlds legitimately annotate `middleware` + and every return type differently, and pinning those here would make the test fail on + changes that preserve the parity it exists to protect. """ sync = inspect.signature(getattr(httpware.Client, name)) asynchronous = inspect.signature(getattr(httpware.AsyncClient, name)) diff --git a/tests/test_client_sync.py b/tests/test_client_sync.py index 0c29076..ff87ca4 100644 --- a/tests/test_client_sync.py +++ b/tests/test_client_sync.py @@ -193,7 +193,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: def test_get_with_cookies_forwarded() -> None: - """Exercises the cookies branch in _request_with_body.""" + """Exercises the cookies branch in _request_kwargs.""" captured: list[httpx2.Request] = [] def handler(request: httpx2.Request) -> httpx2.Response: @@ -206,35 +206,35 @@ def handler(request: httpx2.Request) -> httpx2.Response: def test_get_with_explicit_timeout() -> None: - """Exercises the timeout branch in _request_with_body.""" + """Exercises the timeout branch in _request_kwargs.""" client = _client_with_handler(_echo_handler) response = client.get("https://example.test/x", timeout=5.0) assert response.status_code == HTTPStatus.OK def test_get_with_extensions() -> None: - """Exercises the extensions branch in _request_with_body.""" + """Exercises the extensions branch in _request_kwargs.""" client = _client_with_handler(_echo_handler) response = client.get("https://example.test/x", extensions={"trace": True}) assert response.status_code == HTTPStatus.OK def test_post_with_content_body() -> None: - """Exercises the content branch in _request_with_body.""" + """Exercises the content branch in _request_kwargs.""" client = _client_with_handler(_echo_handler) response = client.post("https://example.test/x", content=b"raw-bytes") assert response.json()["content"] == "raw-bytes" def test_post_with_data_body() -> None: - """Exercises the data branch in _request_with_body.""" + """Exercises the data branch in _request_kwargs.""" client = _client_with_handler(_echo_handler) response = client.post("https://example.test/x", data={"field": "value"}) assert response.status_code == HTTPStatus.OK def test_post_with_files_body() -> None: - """Exercises the files branch in _request_with_body.""" + """Exercises the files branch in _request_kwargs.""" client = _client_with_handler(_echo_handler) response = client.post("https://example.test/x", files={"upload": b"file-content"}) assert response.status_code == HTTPStatus.OK diff --git a/tests/test_client_typing.py b/tests/test_client_typing.py index 6f5d654..aa5546f 100644 --- a/tests/test_client_typing.py +++ b/tests/test_client_typing.py @@ -12,6 +12,7 @@ import httpx2 import pydantic +import pytest from httpware import AsyncClient, Client @@ -125,3 +126,94 @@ def test_sync_get_with_response_return_type() -> None: typing.assert_type(user, _User) assert isinstance(response, httpx2.Response) assert isinstance(user, _User) + + +# --------------------------------------------------------------------------- +# Unknown keywords — rejected by the type checker and at runtime, in both worlds +# --------------------------------------------------------------------------- + +_URL = "https://example.test/x" +_VERBS = ("get", "head", "options", "post", "put", "patch", "delete") +_WITH_RESPONSE_VERBS = ("get", "post", "put", "patch", "delete") +_METHOD_NAMES = (*_VERBS, *(f"{verb}_with_response" for verb in _WITH_RESPONSE_VERBS)) + + +def _sync_client() -> Client: + transport = httpx2.MockTransport( + lambda req: httpx2.Response(HTTPStatus.OK, request=req, json={"id": 1, "name": "a"}) + ) + return Client(httpx2_client=httpx2.Client(transport=transport)) + + +def _async_client() -> AsyncClient: + transport = httpx2.MockTransport( + lambda req: httpx2.Response(HTTPStatus.OK, request=req, json={"id": 1, "name": "a"}) + ) + return AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) + + +def _unknown_keyword(owner: str, keyword: str = "foo") -> str: + return rf"^{owner}\(\) got an unexpected keyword argument '{keyword}'$" + + +def test_sync_options_keep_the_overload_return_types() -> None: + client = _sync_client() + typing.assert_type(client.get(_URL, params={"q": "1"}, timeout=None), httpx2.Response) + typing.assert_type(client.post(_URL, json={"a": 1}, response_model=_User), _User) + typing.assert_type( + client.request_with_response("PUT", _URL, content=b"x", response_model=_User), tuple[httpx2.Response, _User] + ) + + +async def test_options_keep_the_overload_return_types() -> None: + client = _async_client() + typing.assert_type(await client.get(_URL, params={"q": "1"}, timeout=None), httpx2.Response) + typing.assert_type(await client.post(_URL, json={"a": 1}, response_model=_User), _User) + typing.assert_type( + await client.request_with_response("PUT", _URL, content=b"x", response_model=_User), + tuple[httpx2.Response, _User], + ) + + +def test_sync_unknown_keyword_is_rejected_statically_and_at_runtime() -> None: + client = _sync_client() + with pytest.raises(TypeError, match=_unknown_keyword("Client.get")): + client.get(_URL, foo=1) # ty: ignore[no-matching-overload] + with pytest.raises(TypeError, match=_unknown_keyword("Client.get", "json")): + client.get(_URL, json={"a": 1}) # ty: ignore[no-matching-overload] + with pytest.raises(TypeError, match=_unknown_keyword("Client.post_with_response")): + client.post_with_response(_URL, response_model=_User, foo=1) # ty: ignore[unknown-argument] + with pytest.raises(TypeError, match=_unknown_keyword("Client.request")): + client.request("GET", _URL, foo=1) # ty: ignore[no-matching-overload] + with pytest.raises(TypeError, match=_unknown_keyword("Client.request_with_response")): + client.request_with_response("GET", _URL, response_model=_User, foo=1) # ty: ignore[unknown-argument] + with pytest.raises(TypeError, match=_unknown_keyword("Client.stream")): + client.stream("GET", _URL, foo=1).__enter__() # ty: ignore[unknown-argument] + + +async def test_unknown_keyword_is_rejected_statically_and_at_runtime() -> None: + client = _async_client() + with pytest.raises(TypeError, match=_unknown_keyword("AsyncClient.get")): + await client.get(_URL, foo=1) # ty: ignore[no-matching-overload] + with pytest.raises(TypeError, match=_unknown_keyword("AsyncClient.get", "json")): + await client.get(_URL, json={"a": 1}) # ty: ignore[no-matching-overload] + with pytest.raises(TypeError, match=_unknown_keyword("AsyncClient.post_with_response")): + await client.post_with_response(_URL, response_model=_User, foo=1) # ty: ignore[unknown-argument] + with pytest.raises(TypeError, match=_unknown_keyword("AsyncClient.request")): + await client.request("GET", _URL, foo=1) # ty: ignore[no-matching-overload] + with pytest.raises(TypeError, match=_unknown_keyword("AsyncClient.request_with_response")): + await client.request_with_response("GET", _URL, response_model=_User, foo=1) # ty: ignore[unknown-argument] + with pytest.raises(TypeError, match=_unknown_keyword("AsyncClient.stream")): + await client.stream("GET", _URL, foo=1).__aenter__() # ty: ignore[unknown-argument] + + +@pytest.mark.parametrize("name", _METHOD_NAMES) +def test_sync_every_verb_rejects_an_unknown_keyword_at_runtime(name: str) -> None: + with pytest.raises(TypeError, match=_unknown_keyword(f"Client.{name}")): + getattr(_sync_client(), name)(_URL, response_model=_User, foo=1) + + +@pytest.mark.parametrize("name", _METHOD_NAMES) +async def test_every_verb_rejects_an_unknown_keyword_at_runtime(name: str) -> None: + with pytest.raises(TypeError, match=_unknown_keyword(f"AsyncClient.{name}")): + await getattr(_async_client(), name)(_URL, response_model=_User, foo=1) diff --git a/tests/test_request_assembly.py b/tests/test_request_assembly.py index 6b859ed..0767383 100644 --- a/tests/test_request_assembly.py +++ b/tests/test_request_assembly.py @@ -1,109 +1,56 @@ """Tests for request assembly functions in httpware.client module.""" import httpx2 +import pytest -from httpware.client import _assemble_request_kwargs +from httpware import Client +from httpware.client import _BODY_REQUEST_OPTION_DEFAULTS, _REQUEST_OPTION_DEFAULTS, _request_kwargs -class TestAssembleRequestKwargs: - """Tests for _assemble_request_kwargs.""" +_CLIENT = Client.__new__(Client) - 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"))] +class TestRequestKwargs: + """Tests for _request_kwargs.""" + + def test_no_options_returns_empty_dict(self) -> None: + assert _request_kwargs(_CLIENT, "post", {}, _BODY_REQUEST_OPTION_DEFAULTS) == {} - 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, - ) + def test_all_unset_returns_empty_dict(self) -> None: + """When every option is passed at its unset value, return empty dict.""" + options = dict.fromkeys(_BODY_REQUEST_OPTION_DEFAULTS) | {"timeout": httpx2.USE_CLIENT_DEFAULT} + assert _request_kwargs(_CLIENT, "post", options, _BODY_REQUEST_OPTION_DEFAULTS) == {} - 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_all_set_returns_full_dict(self) -> None: + """When all options are set to real values, return dict with all keys.""" + options = { + "params": {"key": "value"}, + "headers": {"X-Test": "header"}, + "cookies": {"session": "123"}, + "timeout": 5.0, + "extensions": {"timeout": 10.0}, + "json": {"data": "json"}, + "content": b"binary content", + "data": {"form": "data"}, + "files": [("file", ("name.txt", b"content"))], } + assert _request_kwargs(_CLIENT, "post", options, _BODY_REQUEST_OPTION_DEFAULTS) == options 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 == {} + options = {"timeout": httpx2.USE_CLIENT_DEFAULT} + assert _request_kwargs(_CLIENT, "get", options, _REQUEST_OPTION_DEFAULTS) == {} 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 + assert _request_kwargs(_CLIENT, "get", {"timeout": None}, _REQUEST_OPTION_DEFAULTS) == {"timeout": 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 + result = _request_kwargs(_CLIENT, "get", {"timeout": timeout_value}, _REQUEST_OPTION_DEFAULTS) + assert result == {"timeout": timeout_value} + + def test_unknown_option_raises_type_error_naming_it(self) -> None: + with pytest.raises(TypeError, match=r"^Client\.get\(\) got an unexpected keyword argument 'json'$"): + _request_kwargs(_CLIENT, "get", {"json": {"a": 1}}, _REQUEST_OPTION_DEFAULTS)