|
1 | 1 | """Client + AsyncClient — thin httpx2 wrappers with typed decoding and middleware.""" |
2 | 2 |
|
3 | 3 | import contextlib |
| 4 | +import ssl |
4 | 5 | import typing |
5 | | -from collections.abc import AsyncIterator, Iterator, Sequence |
| 6 | +from collections.abc import AsyncIterator, Callable, Iterator, Mapping, Sequence |
6 | 7 | from http import HTTPStatus |
7 | 8 |
|
8 | 9 | import httpx2 |
|
29 | 30 | T = typing.TypeVar("T") |
30 | 31 |
|
31 | 32 |
|
32 | | -_FORWARDED_KWARG_NAMES = ("base_url", "headers", "params", "cookies", "timeout", "limits", "auth") |
33 | 33 | _HTTPX2_CLIENT_CONFLICT_MESSAGE = ( |
34 | | - "httpx2_client=... cannot be combined with any of " |
35 | | - f"{_FORWARDED_KWARG_NAMES}; configure the httpx2 client you pass instead." |
| 34 | + "httpx2_client=... cannot be combined with httpx2 client options {names}; " |
| 35 | + "configure the httpx2 client you pass instead." |
| 36 | +) |
| 37 | +_UNSUPPORTED_OPTION_HINTS = { |
| 38 | + "cert": "cert=... is deprecated by httpx2; pass verify=<ssl.SSLContext> configured with .load_cert_chain().", |
| 39 | + "event_hooks": "event_hooks=... is not supported; use middleware=... instead.", |
| 40 | +} |
| 41 | +_FOLLOW_REDIRECTS_WITH_BODY_CAP_MESSAGE = ( |
| 42 | + "follow_redirects=True cannot be combined with max_response_body_bytes: httpx2 reads every " |
| 43 | + "intermediate redirect body without the cap." |
36 | 44 | ) |
37 | 45 | _BASE_URL_QUERY_MESSAGE = ( |
38 | 46 | "base_url must not contain a query string: httpx2 appends request paths after it, " |
@@ -61,63 +69,75 @@ def _build_default_decoders() -> tuple[ResponseDecoder, ...]: |
61 | 69 | return tuple(decoders) |
62 | 70 |
|
63 | 71 |
|
64 | | -def _validate_httpx2_client_conflict( # noqa: PLR0913 — 7 forwarded kwargs from caller's constructor |
65 | | - *, |
66 | | - base_url: str, |
67 | | - headers: dict[str, str] | None, |
68 | | - params: dict[str, str] | None, |
69 | | - cookies: dict[str, str] | None, |
70 | | - timeout: httpx2.Timeout | float | None, |
71 | | - limits: httpx2.Limits | None, |
72 | | - auth: httpx2.Auth | None, |
73 | | -) -> None: |
74 | | - """Raise TypeError if httpx2_client=... is combined with a forwarded kwarg.""" |
75 | | - forwarded = { |
76 | | - "base_url": base_url, |
77 | | - "headers": headers, |
78 | | - "params": params, |
79 | | - "cookies": cookies, |
80 | | - "timeout": timeout, |
81 | | - "limits": limits, |
82 | | - "auth": auth, |
83 | | - } |
84 | | - if any(value not in (None, "") for value in forwarded.values()): |
85 | | - raise TypeError(_HTTPX2_CLIENT_CONFLICT_MESSAGE) |
86 | | - |
87 | | - |
88 | 72 | def _reject_base_url_query(base_url: httpx2.URL | str) -> None: |
89 | 73 | """Raise ValueError if base_url carries a query string.""" |
90 | 74 | if httpx2.URL(base_url).query: |
91 | 75 | raise ValueError(_BASE_URL_QUERY_MESSAGE) |
92 | 76 |
|
93 | 77 |
|
94 | | -def _assemble_httpx2_client_kwargs( # noqa: PLR0913 — 7 forwarded kwargs from caller's constructor |
| 78 | +class _ClientOptionsBase(typing.TypedDict, total=False): |
| 79 | + base_url: str |
| 80 | + headers: dict[str, str] | None |
| 81 | + params: dict[str, str] | None |
| 82 | + cookies: dict[str, str] | None |
| 83 | + timeout: httpx2.Timeout | float | None |
| 84 | + limits: httpx2.Limits | None |
| 85 | + auth: httpx2.Auth | None |
| 86 | + verify: ssl.SSLContext | bool |
| 87 | + trust_env: bool |
| 88 | + http1: bool |
| 89 | + http2: bool |
| 90 | + proxy: httpx2.URL | str | httpx2.Proxy | None |
| 91 | + follow_redirects: bool |
| 92 | + max_redirects: int |
| 93 | + default_encoding: str | Callable[[bytes], str | None] |
| 94 | + |
| 95 | + |
| 96 | +class _AsyncClientOptions(_ClientOptionsBase, total=False): |
| 97 | + """Keyword arguments `AsyncClient` forwards to the `httpx2.AsyncClient` it owns.""" |
| 98 | + |
| 99 | + transport: httpx2.AsyncBaseTransport | None |
| 100 | + mounts: Mapping[str, httpx2.AsyncBaseTransport | None] | None |
| 101 | + |
| 102 | + |
| 103 | +class _ClientOptions(_ClientOptionsBase, total=False): |
| 104 | + """Keyword arguments `Client` forwards to the `httpx2.Client` it owns.""" |
| 105 | + |
| 106 | + transport: httpx2.BaseTransport | None |
| 107 | + mounts: Mapping[str, httpx2.BaseTransport | None] | None |
| 108 | + |
| 109 | + |
| 110 | +def _is_unset(value: object) -> bool: |
| 111 | + return value is None or (isinstance(value, str) and not value) |
| 112 | + |
| 113 | + |
| 114 | +def _select_httpx2_options( |
| 115 | + owner: str, |
| 116 | + options: Mapping[str, typing.Any], |
| 117 | + supported: frozenset[str], |
95 | 118 | *, |
96 | | - base_url: str, |
97 | | - headers: dict[str, str] | None, |
98 | | - params: dict[str, str] | None, |
99 | | - cookies: dict[str, str] | None, |
100 | | - timeout: httpx2.Timeout | float | None, |
101 | | - limits: httpx2.Limits | None, |
102 | | - auth: httpx2.Auth | None, |
| 119 | + httpx2_client: httpx2.Client | httpx2.AsyncClient | None, |
| 120 | + max_response_body_bytes: int | None, |
103 | 121 | ) -> dict[str, typing.Any]: |
104 | | - """Build the kwargs dict for constructing the owned httpx2 client.""" |
105 | | - kwargs: dict[str, typing.Any] = {} |
106 | | - if base_url: |
107 | | - kwargs["base_url"] = base_url |
108 | | - if headers is not None: |
109 | | - kwargs["headers"] = headers |
110 | | - if params is not None: |
111 | | - kwargs["params"] = params |
112 | | - if cookies is not None: |
113 | | - kwargs["cookies"] = cookies |
114 | | - if timeout is not None: |
115 | | - kwargs["timeout"] = timeout |
116 | | - if limits is not None: |
117 | | - kwargs["limits"] = limits |
118 | | - if auth is not None: |
119 | | - kwargs["auth"] = auth |
120 | | - return kwargs |
| 122 | + """Return the options to forward to the owned httpx2 client, dropping unset ones. |
| 123 | +
|
| 124 | + Raise TypeError for unsupported options or options combined with `httpx2_client`, and |
| 125 | + ValueError when the client would follow redirects under a body cap. |
| 126 | + """ |
| 127 | + unsupported = sorted(options.keys() - supported) |
| 128 | + if unsupported: |
| 129 | + hints = "".join( |
| 130 | + f" {_UNSUPPORTED_OPTION_HINTS[name]}" for name in unsupported if name in _UNSUPPORTED_OPTION_HINTS |
| 131 | + ) |
| 132 | + msg = f"{owner}() got unexpected keyword arguments {unsupported}.{hints}" |
| 133 | + raise TypeError(msg) |
| 134 | + forwarded = {name: value for name, value in options.items() if not _is_unset(value)} |
| 135 | + if httpx2_client is not None and forwarded: |
| 136 | + raise TypeError(_HTTPX2_CLIENT_CONFLICT_MESSAGE.format(names=sorted(forwarded))) |
| 137 | + follows = httpx2_client.follow_redirects if httpx2_client is not None else forwarded.get("follow_redirects") |
| 138 | + if follows and max_response_body_bytes is not None: |
| 139 | + raise ValueError(_FOLLOW_REDIRECTS_WITH_BODY_CAP_MESSAGE) |
| 140 | + return forwarded |
121 | 141 |
|
122 | 142 |
|
123 | 143 | def _assemble_request_kwargs( # noqa: PLR0913 — 9 per-request kwargs from httpx2 call signatures |
@@ -175,47 +195,30 @@ class AsyncClient: |
175 | 195 | _dispatch: AsyncNext |
176 | 196 | _max_response_body_bytes: int | None |
177 | 197 |
|
178 | | - def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call API |
| 198 | + def __init__( |
179 | 199 | self, |
180 | 200 | *, |
181 | | - base_url: str = "", |
182 | | - headers: dict[str, str] | None = None, |
183 | | - params: dict[str, str] | None = None, |
184 | | - cookies: dict[str, str] | None = None, |
185 | | - timeout: httpx2.Timeout | float | None = None, |
186 | | - limits: httpx2.Limits | None = None, |
187 | | - auth: httpx2.Auth | None = None, |
188 | 201 | httpx2_client: httpx2.AsyncClient | None = None, |
189 | 202 | decoders: Sequence[ResponseDecoder] | None = None, |
190 | 203 | middleware: Sequence[AsyncMiddleware] = (), |
191 | 204 | max_response_body_bytes: int | None = None, |
| 205 | + **httpx2_options: typing.Unpack[_AsyncClientOptions], |
192 | 206 | ) -> None: |
193 | 207 | _validate_max_response_body_bytes(max_response_body_bytes) |
| 208 | + forwarded = _select_httpx2_options( |
| 209 | + type(self).__name__, |
| 210 | + httpx2_options, |
| 211 | + _AsyncClientOptions.__optional_keys__, |
| 212 | + httpx2_client=httpx2_client, |
| 213 | + max_response_body_bytes=max_response_body_bytes, |
| 214 | + ) |
194 | 215 | if httpx2_client is not None: |
195 | | - _validate_httpx2_client_conflict( |
196 | | - base_url=base_url, |
197 | | - headers=headers, |
198 | | - params=params, |
199 | | - cookies=cookies, |
200 | | - timeout=timeout, |
201 | | - limits=limits, |
202 | | - auth=auth, |
203 | | - ) |
204 | 216 | _reject_base_url_query(httpx2_client.base_url) |
205 | 217 | self._httpx2_client = httpx2_client |
206 | 218 | self._owns_client = False |
207 | 219 | else: |
208 | | - _reject_base_url_query(base_url) |
209 | | - kwargs = _assemble_httpx2_client_kwargs( |
210 | | - base_url=base_url, |
211 | | - headers=headers, |
212 | | - params=params, |
213 | | - cookies=cookies, |
214 | | - timeout=timeout, |
215 | | - limits=limits, |
216 | | - auth=auth, |
217 | | - ) |
218 | | - self._httpx2_client = httpx2.AsyncClient(**kwargs) |
| 220 | + _reject_base_url_query(forwarded.get("base_url", "")) |
| 221 | + self._httpx2_client = httpx2.AsyncClient(**forwarded) |
219 | 222 | self._owns_client = True |
220 | 223 |
|
221 | 224 | self._decoders = tuple(decoders) if decoders is not None else _build_default_decoders() |
@@ -1129,47 +1132,30 @@ class Client: |
1129 | 1132 | _dispatch: Next |
1130 | 1133 | _max_response_body_bytes: int | None |
1131 | 1134 |
|
1132 | | - def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call API |
| 1135 | + def __init__( |
1133 | 1136 | self, |
1134 | 1137 | *, |
1135 | | - base_url: str = "", |
1136 | | - headers: dict[str, str] | None = None, |
1137 | | - params: dict[str, str] | None = None, |
1138 | | - cookies: dict[str, str] | None = None, |
1139 | | - timeout: httpx2.Timeout | float | None = None, |
1140 | | - limits: httpx2.Limits | None = None, |
1141 | | - auth: httpx2.Auth | None = None, |
1142 | 1138 | httpx2_client: httpx2.Client | None = None, |
1143 | 1139 | decoders: Sequence[ResponseDecoder] | None = None, |
1144 | 1140 | middleware: Sequence[Middleware] = (), |
1145 | 1141 | max_response_body_bytes: int | None = None, |
| 1142 | + **httpx2_options: typing.Unpack[_ClientOptions], |
1146 | 1143 | ) -> None: |
1147 | 1144 | _validate_max_response_body_bytes(max_response_body_bytes) |
| 1145 | + forwarded = _select_httpx2_options( |
| 1146 | + type(self).__name__, |
| 1147 | + httpx2_options, |
| 1148 | + _ClientOptions.__optional_keys__, |
| 1149 | + httpx2_client=httpx2_client, |
| 1150 | + max_response_body_bytes=max_response_body_bytes, |
| 1151 | + ) |
1148 | 1152 | if httpx2_client is not None: |
1149 | | - _validate_httpx2_client_conflict( |
1150 | | - base_url=base_url, |
1151 | | - headers=headers, |
1152 | | - params=params, |
1153 | | - cookies=cookies, |
1154 | | - timeout=timeout, |
1155 | | - limits=limits, |
1156 | | - auth=auth, |
1157 | | - ) |
1158 | 1153 | _reject_base_url_query(httpx2_client.base_url) |
1159 | 1154 | self._httpx2_client = httpx2_client |
1160 | 1155 | self._owns_client = False |
1161 | 1156 | else: |
1162 | | - _reject_base_url_query(base_url) |
1163 | | - kwargs = _assemble_httpx2_client_kwargs( |
1164 | | - base_url=base_url, |
1165 | | - headers=headers, |
1166 | | - params=params, |
1167 | | - cookies=cookies, |
1168 | | - timeout=timeout, |
1169 | | - limits=limits, |
1170 | | - auth=auth, |
1171 | | - ) |
1172 | | - self._httpx2_client = httpx2.Client(**kwargs) |
| 1157 | + _reject_base_url_query(forwarded.get("base_url", "")) |
| 1158 | + self._httpx2_client = httpx2.Client(**forwarded) |
1173 | 1159 | self._owns_client = True |
1174 | 1160 |
|
1175 | 1161 | self._decoders = tuple(decoders) if decoders is not None else _build_default_decoders() |
|
0 commit comments