httpx2 2.1.0__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {httpx2-2.1.0 → httpx2-2.3.0}/.gitignore +1 -0
  2. {httpx2-2.1.0 → httpx2-2.3.0}/CHANGELOG.md +25 -2
  3. {httpx2-2.1.0 → httpx2-2.3.0}/PKG-INFO +12 -13
  4. {httpx2-2.1.0 → httpx2-2.3.0}/README.md +2 -4
  5. {httpx2-2.1.0 → httpx2-2.3.0}/httpx2/__init__.py +17 -16
  6. {httpx2-2.1.0 → httpx2-2.3.0}/httpx2/_api.py +22 -35
  7. {httpx2-2.1.0 → httpx2-2.3.0}/httpx2/_auth.py +1 -1
  8. {httpx2-2.1.0 → httpx2-2.3.0}/httpx2/_client.py +17 -17
  9. {httpx2-2.1.0 → httpx2-2.3.0}/httpx2/_config.py +14 -8
  10. {httpx2-2.1.0 → httpx2-2.3.0}/httpx2/_decoders.py +16 -10
  11. {httpx2-2.1.0 → httpx2-2.3.0}/httpx2/_main.py +1 -2
  12. {httpx2-2.1.0 → httpx2-2.3.0}/httpx2/_models.py +4 -1
  13. {httpx2-2.1.0 → httpx2-2.3.0}/httpx2/_multipart.py +1 -1
  14. httpx2-2.3.0/httpx2/_transports/__init__.py +15 -0
  15. {httpx2-2.1.0 → httpx2-2.3.0}/httpx2/_transports/asgi.py +10 -15
  16. {httpx2-2.1.0 → httpx2-2.3.0}/httpx2/_transports/base.py +1 -0
  17. {httpx2-2.1.0 → httpx2-2.3.0}/httpx2/_transports/mock.py +1 -4
  18. {httpx2-2.1.0 → httpx2-2.3.0}/httpx2/_transports/wsgi.py +6 -8
  19. {httpx2-2.1.0 → httpx2-2.3.0}/httpx2/_types.py +1 -1
  20. {httpx2-2.1.0 → httpx2-2.3.0}/httpx2/_urlparse.py +2 -2
  21. {httpx2-2.1.0 → httpx2-2.3.0}/httpx2/_urls.py +1 -1
  22. {httpx2-2.1.0 → httpx2-2.3.0}/httpx2/_utils.py +1 -1
  23. {httpx2-2.1.0 → httpx2-2.3.0}/pyproject.toml +4 -3
  24. httpx2-2.1.0/httpx2/_transports/__init__.py +0 -15
  25. {httpx2-2.1.0 → httpx2-2.3.0}/LICENSE.md +0 -0
  26. {httpx2-2.1.0 → httpx2-2.3.0}/httpx2/__version__.py +0 -0
  27. {httpx2-2.1.0 → httpx2-2.3.0}/httpx2/_content.py +0 -0
  28. {httpx2-2.1.0 → httpx2-2.3.0}/httpx2/_exceptions.py +0 -0
  29. {httpx2-2.1.0 → httpx2-2.3.0}/httpx2/_status_codes.py +0 -0
  30. {httpx2-2.1.0 → httpx2-2.3.0}/httpx2/_transports/default.py +0 -0
  31. {httpx2-2.1.0 → httpx2-2.3.0}/httpx2/py.typed +0 -0
@@ -5,6 +5,7 @@
5
5
  __pycache__/
6
6
  htmlcov/
7
7
  site/
8
+ .wrangler/
8
9
  *.egg-info/
9
10
  venv*/
10
11
  .python-version
@@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
6
6
 
7
+ ## 2.3.0 (June 1st, 2026)
8
+
9
+ ### Changed
10
+
11
+ * Use `truststore` instead of `certifi` for default SSL verification, loading the operating system's trust store. ([#209](https://github.com/pydantic/httpx2/pull/209))
12
+
13
+ ### Fixed
14
+
15
+ * Raise when `verify` is a string path and a client-side `cert` is also provided to `create_ssl_context`. ([#990](https://github.com/pydantic/httpx2/pull/990))
16
+ * Allow a `default_encoding` callable to return `None`. ([#951](https://github.com/pydantic/httpx2/pull/951))
17
+ * Make the `zstd` import optional on Python 3.14. ([#1000](https://github.com/pydantic/httpx2/pull/1000))
18
+ * Store elapsed time on the stream wrapper to avoid reference cycles. ([#948](https://github.com/pydantic/httpx2/pull/948))
19
+
20
+ ## 2.2.0 (May 16th, 2026)
21
+
22
+ ### Fixed
23
+
24
+ * Handle multi-frame zstd streams split across chunks. ([#946](https://github.com/pydantic/httpx2/pull/946))
25
+
26
+ ### Changed
27
+
28
+ * Lazily import the `_main` CLI module to speed up `import httpx2`. ([#947](https://github.com/pydantic/httpx2/pull/947))
29
+
7
30
  ## 2.1.0 (May 15th, 2026)
8
31
 
9
32
  ### Removed
@@ -47,7 +70,7 @@ Historical entries below are from upstream `encode/httpx`.
47
70
 
48
71
  ## 0.28.0 (28th November, 2024)
49
72
 
50
- Be aware that the default *JSON request bodies now use a more compact representation*. This is generally considered a prefered style, tho may require updates to test suites.
73
+ Be aware that the default *JSON request bodies now use a more compact representation*. This is generally considered a preferred style, tho may require updates to test suites.
51
74
 
52
75
  The 0.28 release includes a limited set of deprecations...
53
76
 
@@ -182,7 +205,7 @@ Our revised [SSL documentation](docs/advanced/ssl.md) covers how to implement th
182
205
 
183
206
  ### Removed
184
207
 
185
- * The `rfc3986` dependancy has been removed. (#2252)
208
+ * The `rfc3986` dependency has been removed. (#2252)
186
209
 
187
210
  ## 0.23.3 (4th January, 2023)
188
211
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpx2
3
- Version: 2.1.0
3
+ Version: 2.3.0
4
4
  Summary: The next generation HTTP client.
5
5
  Project-URL: Changelog, https://github.com/pydantic/httpx2/blob/main/src/httpx2/CHANGELOG.md
6
6
  Project-URL: Homepage, https://github.com/pydantic/httpx2
@@ -25,9 +25,9 @@ Classifier: Programming Language :: Python :: 3.14
25
25
  Classifier: Topic :: Internet :: WWW/HTTP
26
26
  Requires-Python: >=3.10
27
27
  Requires-Dist: anyio
28
- Requires-Dist: certifi
29
- Requires-Dist: httpcore2==2.1.0
28
+ Requires-Dist: httpcore2==2.3.0
30
29
  Requires-Dist: idna
30
+ Requires-Dist: truststore>=0.10
31
31
  Provides-Extra: brotli
32
32
  Requires-Dist: brotli; (platform_python_implementation == 'CPython') and extra == 'brotli'
33
33
  Requires-Dist: brotlicffi; (platform_python_implementation != 'CPython') and extra == 'brotli'
@@ -139,8 +139,6 @@ Or, to include the optional HTTP/2 support, use:
139
139
  pip install httpx2[http2]
140
140
  ```
141
141
 
142
- HTTPX2 requires Python 3.10+.
143
-
144
142
  ## Documentation
145
143
 
146
144
  Project documentation is available at [https://httpx2.pydantic.dev/](https://httpx2.pydantic.dev/).
@@ -164,7 +162,7 @@ The HTTPX2 project relies on these excellent libraries:
164
162
  * `httpcore2` - The underlying transport implementation for `httpx2`.
165
163
  * `h11` - HTTP/1.1 support.
166
164
  * `anyio` - Structured concurrency primitives, used to support both `asyncio` and `trio`.
167
- * `certifi` - SSL certificates.
165
+ * `truststore` - SSL verification using the operating system's trust store.
168
166
  * `idna` - Internationalized domain name support.
169
167
 
170
168
  As well as these optional installs:
@@ -174,7 +172,7 @@ As well as these optional installs:
174
172
  * `rich` - Rich terminal support. *(Optional, with `httpx2[cli]`)*
175
173
  * `click` - Command line client support. *(Optional, with `httpx2[cli]`)*
176
174
  * `brotli` or `brotlicffi` - Decoding for "brotli" compressed responses. *(Optional, with `httpx2[brotli]`)*
177
- * `zstandard` - Decoding for "zstd" compressed responses on Python 3.13 and below. *(Optional, with `httpx2[zstd]`. On Python 3.14+, `zstd` is supported natively via the stdlib [`compression.zstd`](https://docs.python.org/3/library/compression.zstd.html) module.)*
175
+ * `zstandard` - Decoding for "zstd" compressed responses on Python 3.13 and below. *(Optional, with `httpx2[zstd]`. On Python 3.14+, `zstd` is supported via the stdlib [`compression.zstd`](https://docs.python.org/3/library/compression.zstd.html) module when it is available; the `httpx2[zstd]` extra installs nothing there, so decoding falls back to `zstandard` only on 3.13 and below.)*
178
176
 
179
177
  A huge amount of credit is due to `requests` for the API layout that
180
178
  much of this work follows, as well as to `urllib3` for plenty of design
@@ -186,15 +184,16 @@ inspiration around the lower-level networking details.
186
184
 
187
185
  ## Release Information
188
186
 
189
- ### Removed
187
+ ### Changed
190
188
 
191
- * Drop support for Python 3.9. ([#208](https://github.com/pydantic/httpx2/pull/208))
189
+ * Use `truststore` instead of `certifi` for default SSL verification, loading the operating system's trust store. ([#209](https://github.com/pydantic/httpx2/pull/209))
192
190
 
193
- ### Added
191
+ ### Fixed
194
192
 
195
- * Add support for Python 3.14. ([#208](https://github.com/pydantic/httpx2/pull/208))
196
- * Use stdlib `compression.zstd` for Zstd decompression on Python 3.14+; fall back to the `zstandard` package on older versions. ([#932](https://github.com/pydantic/httpx2/pull/932))
197
- * Bundle `LICENSE.md` in the sdist. ([#938](https://github.com/pydantic/httpx2/pull/938))
193
+ * Raise when `verify` is a string path and a client-side `cert` is also provided to `create_ssl_context`. ([#990](https://github.com/pydantic/httpx2/pull/990))
194
+ * Allow a `default_encoding` callable to return `None`. ([#951](https://github.com/pydantic/httpx2/pull/951))
195
+ * Make the `zstd` import optional on Python 3.14. ([#1000](https://github.com/pydantic/httpx2/pull/1000))
196
+ * Store elapsed time on the stream wrapper to avoid reference cycles. ([#948](https://github.com/pydantic/httpx2/pull/948))
198
197
 
199
198
 
200
199
  ---
@@ -94,8 +94,6 @@ Or, to include the optional HTTP/2 support, use:
94
94
  pip install httpx2[http2]
95
95
  ```
96
96
 
97
- HTTPX2 requires Python 3.10+.
98
-
99
97
  ## Documentation
100
98
 
101
99
  Project documentation is available at [https://httpx2.pydantic.dev/](https://httpx2.pydantic.dev/).
@@ -119,7 +117,7 @@ The HTTPX2 project relies on these excellent libraries:
119
117
  * `httpcore2` - The underlying transport implementation for `httpx2`.
120
118
  * `h11` - HTTP/1.1 support.
121
119
  * `anyio` - Structured concurrency primitives, used to support both `asyncio` and `trio`.
122
- * `certifi` - SSL certificates.
120
+ * `truststore` - SSL verification using the operating system's trust store.
123
121
  * `idna` - Internationalized domain name support.
124
122
 
125
123
  As well as these optional installs:
@@ -129,7 +127,7 @@ As well as these optional installs:
129
127
  * `rich` - Rich terminal support. *(Optional, with `httpx2[cli]`)*
130
128
  * `click` - Command line client support. *(Optional, with `httpx2[cli]`)*
131
129
  * `brotli` or `brotlicffi` - Decoding for "brotli" compressed responses. *(Optional, with `httpx2[brotli]`)*
132
- * `zstandard` - Decoding for "zstd" compressed responses on Python 3.13 and below. *(Optional, with `httpx2[zstd]`. On Python 3.14+, `zstd` is supported natively via the stdlib [`compression.zstd`](https://docs.python.org/3/library/compression.zstd.html) module.)*
130
+ * `zstandard` - Decoding for "zstd" compressed responses on Python 3.13 and below. *(Optional, with `httpx2[zstd]`. On Python 3.14+, `zstd` is supported via the stdlib [`compression.zstd`](https://docs.python.org/3/library/compression.zstd.html) module when it is available; the `httpx2[zstd]` extra installs nothing there, so decoding falls back to `zstandard` only on 3.13 and below.)*
133
131
 
134
132
  A huge amount of credit is due to `requests` for the API layout that
135
133
  much of this work follows, as well as to `urllib3` for plenty of design
@@ -11,21 +11,6 @@ from ._transports import *
11
11
  from ._types import *
12
12
  from ._urls import *
13
13
 
14
- try:
15
- from ._main import main
16
- except ImportError: # pragma: no cover
17
-
18
- def main() -> None: # type: ignore
19
- import sys
20
-
21
- print(
22
- "The httpx command line client could not run because the required "
23
- "dependencies were not installed.\nMake sure you've installed "
24
- "everything with: pip install 'httpx[cli]'"
25
- )
26
- sys.exit(1)
27
-
28
-
29
14
  __all__ = [
30
15
  "__description__",
31
16
  "__title__",
@@ -60,7 +45,6 @@ __all__ = [
60
45
  "InvalidURL",
61
46
  "Limits",
62
47
  "LocalProtocolError",
63
- "main",
64
48
  "MockTransport",
65
49
  "NetRCAuth",
66
50
  "NetworkError",
@@ -104,3 +88,20 @@ __locals = locals()
104
88
  for __name in __all__:
105
89
  if not __name.startswith("__"):
106
90
  setattr(__locals[__name], "__module__", "httpx2") # noqa
91
+
92
+
93
+ def __getattr__(name: str) -> object: # pragma: no cover
94
+ if name == "main":
95
+ import warnings
96
+
97
+ warnings.warn(
98
+ "`httpx2.main` is deprecated and will be removed in a future release. "
99
+ "Use the `httpx2` CLI entry point instead.",
100
+ DeprecationWarning,
101
+ stacklevel=2,
102
+ )
103
+ from ._main import main
104
+
105
+ return main
106
+
107
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -54,41 +54,28 @@ def request(
54
54
  verify: ssl.SSLContext | str | bool = True,
55
55
  trust_env: bool = True,
56
56
  ) -> Response:
57
- """
58
- Sends an HTTP request.
59
-
60
- **Parameters:**
61
-
62
- * **method** - HTTP method for the new `Request` object: `GET`, `OPTIONS`,
63
- `HEAD`, `POST`, `PUT`, `PATCH`, or `DELETE`.
64
- * **url** - URL for the new `Request` object.
65
- * **params** - *(optional)* Query parameters to include in the URL, as a
66
- string, dictionary, or sequence of two-tuples.
67
- * **content** - *(optional)* Binary content to include in the body of the
68
- request, as bytes or a byte iterator.
69
- * **data** - *(optional)* Form data to include in the body of the request,
70
- as a dictionary.
71
- * **files** - *(optional)* A dictionary of upload files to include in the
72
- body of the request.
73
- * **json** - *(optional)* A JSON serializable object to include in the body
74
- of the request.
75
- * **headers** - *(optional)* Dictionary of HTTP headers to include in the
76
- request.
77
- * **cookies** - *(optional)* Dictionary of Cookie items to include in the
78
- request.
79
- * **auth** - *(optional)* An authentication class to use when sending the
80
- request.
81
- * **proxy** - *(optional)* A proxy URL where all the traffic should be routed.
82
- * **timeout** - *(optional)* The timeout configuration to use when sending
83
- the request.
84
- * **follow_redirects** - *(optional)* Enables or disables HTTP redirects.
85
- * **verify** - *(optional)* Either `True` to use an SSL context with the
86
- default CA bundle, `False` to disable verification, or an instance of
87
- `ssl.SSLContext` to use a custom context.
88
- * **trust_env** - *(optional)* Enables or disables usage of environment
89
- variables for configuration.
90
-
91
- **Returns:** `Response`
57
+ """Sends an HTTP request.
58
+
59
+ Parameters:
60
+ method: HTTP method for the new `Request` object: `GET`, `OPTIONS`, `HEAD`, `POST`, `PUT`, `PATCH`, or `DELETE`.
61
+ url: URL for the new `Request` object.
62
+ params: *(optional)* Query parameters to include in the URL, as a string, dictionary, or sequence of two-tuples.
63
+ content: *(optional)* Binary content to include in the body of the request, as bytes or a byte iterator.
64
+ data: *(optional)* Form data to include in the body of the request, as a dictionary.
65
+ files: *(optional)* A dictionary of upload files to include in the body of the request.
66
+ json: *(optional)* A JSON serializable object to include in the body of the request.
67
+ headers: *(optional)* Dictionary of HTTP headers to include in the request.
68
+ cookies: *(optional)* Dictionary of Cookie items to include in the request.
69
+ auth: *(optional)* An authentication class to use when sending the request.
70
+ proxy: *(optional)* A proxy URL where all the traffic should be routed.
71
+ timeout: *(optional)* The timeout configuration to use when sending the request.
72
+ follow_redirects: *(optional)* Enables or disables HTTP redirects.
73
+ verify: *(optional)* Either `True` to use an SSL context with the default CA bundle, `False` to disable
74
+ verification, or an instance of `ssl.SSLContext` to use a custom context.
75
+ trust_env: *(optional)* Enables or disables usage of environment variables for configuration.
76
+
77
+ Returns:
78
+ The `Response` object.
92
79
 
93
80
  Usage:
94
81
 
@@ -12,7 +12,7 @@ from ._exceptions import ProtocolError
12
12
  from ._models import Cookies, Request, Response
13
13
  from ._utils import to_bytes, to_str, unquote
14
14
 
15
- if typing.TYPE_CHECKING: # pragma: no cover
15
+ if typing.TYPE_CHECKING:
16
16
  from hashlib import _Hash
17
17
 
18
18
 
@@ -132,43 +132,43 @@ class ClientState(enum.Enum):
132
132
 
133
133
  class BoundSyncStream(SyncByteStream):
134
134
  """
135
- A byte stream that is bound to a given response instance, and that
136
- ensures the `response.elapsed` is set once the response is closed.
135
+ A byte stream that tracks elapsed time for a response. Once closed, the
136
+ elapsed time is available via the `elapsed` attribute, and the response
137
+ can read it back from `response.stream.elapsed`.
137
138
  """
138
139
 
139
- def __init__(self, stream: SyncByteStream, response: Response, start: float) -> None:
140
+ def __init__(self, stream: SyncByteStream, start: float) -> None:
140
141
  self._stream = stream
141
- self._response = response
142
142
  self._start = start
143
+ self.elapsed: datetime.timedelta | None = None
143
144
 
144
145
  def __iter__(self) -> typing.Iterator[bytes]:
145
146
  for chunk in self._stream:
146
147
  yield chunk
147
148
 
148
149
  def close(self) -> None:
149
- elapsed = time.perf_counter() - self._start
150
- self._response.elapsed = datetime.timedelta(seconds=elapsed)
150
+ self.elapsed = datetime.timedelta(seconds=time.perf_counter() - self._start)
151
151
  self._stream.close()
152
152
 
153
153
 
154
154
  class BoundAsyncStream(AsyncByteStream):
155
155
  """
156
- An async byte stream that is bound to a given response instance, and that
157
- ensures the `response.elapsed` is set once the response is closed.
156
+ An async byte stream that tracks elapsed time for a response. Once closed,
157
+ the elapsed time is available via the `elapsed` attribute, and the response
158
+ can read it back from `response.stream.elapsed`.
158
159
  """
159
160
 
160
- def __init__(self, stream: AsyncByteStream, response: Response, start: float) -> None:
161
+ def __init__(self, stream: AsyncByteStream, start: float) -> None:
161
162
  self._stream = stream
162
- self._response = response
163
163
  self._start = start
164
+ self.elapsed: datetime.timedelta | None = None
164
165
 
165
166
  async def __aiter__(self) -> typing.AsyncIterator[bytes]:
166
167
  async for chunk in self._stream:
167
168
  yield chunk
168
169
 
169
170
  async def aclose(self) -> None:
170
- elapsed = time.perf_counter() - self._start
171
- self._response.elapsed = datetime.timedelta(seconds=elapsed)
171
+ self.elapsed = datetime.timedelta(seconds=time.perf_counter() - self._start)
172
172
  await self._stream.aclose()
173
173
 
174
174
 
@@ -189,7 +189,7 @@ class BaseClient:
189
189
  event_hooks: None | (typing.Mapping[str, list[EventHook]]) = None,
190
190
  base_url: URL | str = "",
191
191
  trust_env: bool = True,
192
- default_encoding: str | typing.Callable[[bytes], str] = "utf-8",
192
+ default_encoding: str | typing.Callable[[bytes], str | None] = "utf-8",
193
193
  ) -> None:
194
194
  event_hooks = {} if event_hooks is None else event_hooks
195
195
 
@@ -628,7 +628,7 @@ class Client(BaseClient):
628
628
  event_hooks: None | (typing.Mapping[str, list[EventHook]]) = None,
629
629
  base_url: URL | str = "",
630
630
  transport: BaseTransport | None = None,
631
- default_encoding: str | typing.Callable[[bytes], str] = "utf-8",
631
+ default_encoding: str | typing.Callable[[bytes], str | None] = "utf-8",
632
632
  ) -> None:
633
633
  super().__init__(
634
634
  auth=auth,
@@ -977,7 +977,7 @@ class Client(BaseClient):
977
977
  assert isinstance(response.stream, SyncByteStream)
978
978
 
979
979
  response.request = request
980
- response.stream = BoundSyncStream(response.stream, response=response, start=start)
980
+ response.stream = BoundSyncStream(response.stream, start=start)
981
981
  self.cookies.extract_cookies(response)
982
982
  response.default_encoding = self._default_encoding
983
983
 
@@ -1330,7 +1330,7 @@ class AsyncClient(BaseClient):
1330
1330
  base_url: URL | str = "",
1331
1331
  transport: AsyncBaseTransport | None = None,
1332
1332
  trust_env: bool = True,
1333
- default_encoding: str | typing.Callable[[bytes], str] = "utf-8",
1333
+ default_encoding: str | typing.Callable[[bytes], str | None] = "utf-8",
1334
1334
  ) -> None:
1335
1335
  super().__init__(
1336
1336
  auth=auth,
@@ -1680,7 +1680,7 @@ class AsyncClient(BaseClient):
1680
1680
 
1681
1681
  assert isinstance(response.stream, AsyncByteStream)
1682
1682
  response.request = request
1683
- response.stream = BoundAsyncStream(response.stream, response=response, start=start)
1683
+ response.stream = BoundAsyncStream(response.stream, start=start)
1684
1684
  self.cookies.extract_cookies(response)
1685
1685
  response.default_encoding = self._default_encoding
1686
1686
 
@@ -28,34 +28,40 @@ def create_ssl_context(
28
28
  import ssl
29
29
  import warnings
30
30
 
31
- import certifi
31
+ import truststore
32
32
 
33
33
  if verify is True:
34
- if trust_env and os.environ.get("SSL_CERT_FILE"): # pragma: nocover
34
+ if trust_env and os.environ.get("SSL_CERT_FILE"): # pragma: no cover
35
35
  ctx = ssl.create_default_context(cafile=os.environ["SSL_CERT_FILE"])
36
- elif trust_env and os.environ.get("SSL_CERT_DIR"): # pragma: nocover
36
+ elif trust_env and os.environ.get("SSL_CERT_DIR"): # pragma: no cover
37
37
  ctx = ssl.create_default_context(capath=os.environ["SSL_CERT_DIR"])
38
38
  else:
39
- # Default case...
40
- ctx = ssl.create_default_context(cafile=certifi.where())
39
+ # Default case: rely on the system trust store via `truststore`.
40
+ ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
41
41
  elif verify is False:
42
42
  ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
43
43
  ctx.check_hostname = False
44
44
  ctx.verify_mode = ssl.CERT_NONE
45
- elif isinstance(verify, str): # pragma: nocover
45
+ elif isinstance(verify, str):
46
+ if cert:
47
+ raise TypeError(
48
+ "`verify=<str>` cannot be combined with `cert=...`. "
49
+ "Build an `ssl.SSLContext` and pass it as `verify=<ctx>`, "
50
+ "using `.load_cert_chain()` to configure the certificate chain."
51
+ )
46
52
  message = (
47
53
  "`verify=<str>` is deprecated. "
48
54
  "Use `verify=ssl.create_default_context(cafile=...)` "
49
55
  "or `verify=ssl.create_default_context(capath=...)` instead."
50
56
  )
51
57
  warnings.warn(message, DeprecationWarning)
52
- if os.path.isdir(verify):
58
+ if os.path.isdir(verify): # pragma: no cover
53
59
  return ssl.create_default_context(capath=verify)
54
60
  return ssl.create_default_context(cafile=verify)
55
61
  else:
56
62
  ctx = verify
57
63
 
58
- if cert: # pragma: nocover
64
+ if cert: # pragma: no cover
59
65
  message = (
60
66
  "`cert=...` is deprecated. Use `verify=<ssl_context>` instead,"
61
67
  "with `.load_cert_chain()` to configure the certificate chain."
@@ -29,9 +29,9 @@ except ImportError: # pragma: no cover
29
29
 
30
30
 
31
31
  # Zstandard support is optional on Python <= 3.13.
32
- # On Python 3.14, the stdlib includes a built-in zstd implementation, so we can support
33
- # it without an extra dependency.
32
+ # On Python 3.14+, the stdlib includes an optional built-in zstd implementation.
34
33
  if typing.TYPE_CHECKING:
34
+ # We keep checking Python version in the type checker path because try..except doesn't help type checkers.
35
35
  if sys.version_info >= (3, 14):
36
36
  from compression.zstd import ZstdDecompressor, ZstdError
37
37
  else:
@@ -39,20 +39,22 @@ if typing.TYPE_CHECKING:
39
39
 
40
40
  ZstdDecompressor = functools.partial(_ZstdDecompressor().decompressobj)
41
41
 
42
- _zstandard_installed: bool = True
42
+ _zstandard_installed: bool
43
43
  else: # pragma: no cover
44
- if sys.version_info >= (3, 14):
44
+ _zstandard_installed = False
45
+ try:
45
46
  from compression.zstd import ZstdDecompressor, ZstdError
46
47
 
47
48
  _zstandard_installed = True
48
- else:
49
+ # Either Python <3.14 or the distro doesn't have `compression.zstd`.
50
+ except ImportError:
49
51
  try:
50
52
  from zstandard import ZstdDecompressor as _ZstdDecompressor, ZstdError
51
53
 
52
54
  ZstdDecompressor = functools.partial(_ZstdDecompressor().decompressobj)
53
55
  _zstandard_installed = True
54
56
  except ImportError:
55
- _zstandard_installed = False
57
+ pass
56
58
 
57
59
 
58
60
  class ContentDecoder:
@@ -181,11 +183,10 @@ class BrotliDecoder(ContentDecoder):
181
183
 
182
184
 
183
185
  class ZStandardDecoder(ContentDecoder):
184
- """
185
- Handle 'zstd' RFC 8878 decoding.
186
+ """Handle 'zstd' RFC 8878 decoding.
186
187
 
187
- Requires `pip install zstandard`.
188
- Can be installed as a dependency of httpx using `pip install httpx[zstd]`.
188
+ If running on Python 3.14+ or a distro that doesn't have the `compression.zstd` stdlib module, requires either:
189
+ `pip install zstandard` or `pip install httpx2[zstd]`.
189
190
  """
190
191
 
191
192
  # inspired by the ZstdDecoder implementation in urllib3
@@ -199,9 +200,14 @@ class ZStandardDecoder(ContentDecoder):
199
200
  self.seen_data = False
200
201
 
201
202
  def decode(self, data: bytes) -> bytes:
203
+ if not data:
204
+ return b""
202
205
  self.seen_data = True
203
206
  output = io.BytesIO()
204
207
  try:
208
+ if self.decompressor.eof:
209
+ data = self.decompressor.unused_data + data
210
+ self.decompressor = ZstdDecompressor()
205
211
  output.write(self.decompressor.decompress(data))
206
212
  while self.decompressor.eof and self.decompressor.unused_data:
207
213
  unused_data = self.decompressor.unused_data
@@ -180,8 +180,7 @@ def format_certificate(cert: _PeerCertRetDictType) -> str: # pragma: no cover
180
180
  lines.append(f"* {key}:")
181
181
  for item in value:
182
182
  if key in ("subject", "issuer"):
183
- for sub_item in item:
184
- lines.append(f"* {sub_item[0]}: {sub_item[1]!r}")
183
+ lines.extend(f"* {sub_item[0]}: {sub_item[1]!r}" for sub_item in item)
185
184
  elif isinstance(item, tuple) and len(item) == 2:
186
185
  lines.append(f"* {item[0]}: {item[1]!r}")
187
186
  else:
@@ -507,7 +507,7 @@ class Response:
507
507
  request: Request | None = None,
508
508
  extensions: ResponseExtensions | None = None,
509
509
  history: list[Response] | None = None,
510
- default_encoding: str | typing.Callable[[bytes], str] = "utf-8",
510
+ default_encoding: str | typing.Callable[[bytes], str | None] = "utf-8",
511
511
  ) -> None:
512
512
  self.status_code = status_code
513
513
  self.headers = Headers(headers)
@@ -563,6 +563,9 @@ class Response:
563
563
  cycle to complete.
564
564
  """
565
565
  if not hasattr(self, "_elapsed"):
566
+ stream_elapsed: datetime.timedelta | None = getattr(self.stream, "elapsed", None)
567
+ if stream_elapsed is not None:
568
+ return stream_elapsed
566
569
  raise RuntimeError("'.elapsed' may only be accessed after the response has been read or closed.")
567
570
  return self._elapsed
568
571
 
@@ -117,7 +117,7 @@ class FileField:
117
117
  # This large tuple based API largely mirror's requests' API
118
118
  # It would be good to think of better APIs for this that we could
119
119
  # include in httpx 2.0 since variable length tuples(especially of 4 elements)
120
- # are quite unwieldly
120
+ # are quite unwieldy
121
121
  if isinstance(value, tuple):
122
122
  if len(value) == 2:
123
123
  # neither the 3rd parameter (content_type) nor the 4th (headers)
@@ -0,0 +1,15 @@
1
+ from .asgi import ASGITransport
2
+ from .base import AsyncBaseTransport, BaseTransport
3
+ from .default import AsyncHTTPTransport, HTTPTransport
4
+ from .mock import MockTransport
5
+ from .wsgi import WSGITransport
6
+
7
+ __all__ = [
8
+ "ASGITransport",
9
+ "AsyncBaseTransport",
10
+ "BaseTransport",
11
+ "AsyncHTTPTransport",
12
+ "HTTPTransport",
13
+ "MockTransport",
14
+ "WSGITransport",
15
+ ]
@@ -6,7 +6,7 @@ from .._models import Request, Response
6
6
  from .._types import AsyncByteStream
7
7
  from .base import AsyncBaseTransport
8
8
 
9
- if typing.TYPE_CHECKING: # pragma: no cover
9
+ if typing.TYPE_CHECKING:
10
10
  import asyncio
11
11
 
12
12
  import trio
@@ -31,7 +31,7 @@ def is_running_trio() -> bool:
31
31
 
32
32
  if sniffio.current_async_library() == "trio":
33
33
  return True
34
- except ImportError: # pragma: nocover
34
+ except ImportError: # pragma: no cover
35
35
  pass
36
36
 
37
37
  return False
@@ -70,14 +70,12 @@ class ASGITransport(AsyncBaseTransport):
70
70
  ```
71
71
 
72
72
  Arguments:
73
-
74
- * `app` - The ASGI application.
75
- * `raise_app_exceptions` - Boolean indicating if exceptions in the application
76
- should be raised. Default to `True`. Can be set to `False` for use cases
77
- such as testing the content of a client 500 response.
78
- * `root_path` - The root path on which the ASGI application should be mounted.
79
- * `client` - A two-tuple indicating the client IP and port of incoming requests.
80
- ```
73
+ app: The ASGI application.
74
+ raise_app_exceptions: Boolean indicating if exceptions in the application
75
+ should be raised. Default to `True`. Can be set to `False` for use cases
76
+ such as testing the content of a client 500 response.
77
+ root_path: The root path on which the ASGI application should be mounted.
78
+ client: A two-tuple indicating the client IP and port of incoming requests.
81
79
  """
82
80
 
83
81
  def __init__(
@@ -92,10 +90,7 @@ class ASGITransport(AsyncBaseTransport):
92
90
  self.root_path = root_path
93
91
  self.client = client
94
92
 
95
- async def handle_async_request(
96
- self,
97
- request: Request,
98
- ) -> Response:
93
+ async def handle_async_request(self, request: Request) -> Response:
99
94
  assert isinstance(request.stream, AsyncByteStream)
100
95
 
101
96
  # ASGI scope.
@@ -121,7 +116,7 @@ class ASGITransport(AsyncBaseTransport):
121
116
  # Response.
122
117
  status_code = None
123
118
  response_headers = None
124
- body_parts = []
119
+ body_parts: list[bytes] = []
125
120
  response_started = False
126
121
  response_complete = create_event()
127
122
 
@@ -5,6 +5,7 @@ from types import TracebackType
5
5
 
6
6
  from .._models import Request, Response
7
7
 
8
+ # TODO(Marcelo): When Python 3.10 reaches EOF, we can use `typing.Self` instead of defining those two.
8
9
  T = typing.TypeVar("T", bound="BaseTransport")
9
10
  A = typing.TypeVar("A", bound="AsyncBaseTransport")
10
11
 
@@ -26,10 +26,7 @@ class MockTransport(AsyncBaseTransport, BaseTransport):
26
26
  raise TypeError("Cannot use an async handler in a sync Client")
27
27
  return response
28
28
 
29
- async def handle_async_request(
30
- self,
31
- request: Request,
32
- ) -> Response:
29
+ async def handle_async_request(self, request: Request) -> Response:
33
30
  await request.aread()
34
31
  response = self.handler(request)
35
32
 
@@ -64,14 +64,12 @@ class WSGITransport(BaseTransport):
64
64
  ```
65
65
 
66
66
  Arguments:
67
-
68
- * `app` - The WSGI application.
69
- * `raise_app_exceptions` - Boolean indicating if exceptions in the application
70
- should be raised. Default to `True`. Can be set to `False` for use cases
71
- such as testing the content of a client 500 response.
72
- * `script_name` - The root path on which the WSGI application should be mounted.
73
- * `remote_addr` - A string indicating the client IP of incoming requests.
74
- ```
67
+ app: The WSGI application.
68
+ raise_app_exceptions: Boolean indicating if exceptions in the application
69
+ should be raised. Default to `True`. Can be set to `False` for use cases
70
+ such as testing the content of a client 500 response.
71
+ script_name: The root path on which the WSGI application should be mounted.
72
+ remote_addr: A string indicating the client IP of incoming requests.
75
73
  """
76
74
 
77
75
  def __init__(
@@ -21,7 +21,7 @@ from typing import (
21
21
  Union,
22
22
  )
23
23
 
24
- if TYPE_CHECKING: # pragma: no cover
24
+ if TYPE_CHECKING:
25
25
  from ._auth import Auth # noqa: F401
26
26
  from ._config import Proxy, Timeout # noqa: F401
27
27
  from ._models import Cookies, Headers, Request # noqa: F401
@@ -236,8 +236,8 @@ def urlparse(url: str = "", **kwargs: str | None) -> ParseResult:
236
236
  # Replace "raw_path" with "path" and "query".
237
237
  if "raw_path" in kwargs:
238
238
  raw_path = kwargs.pop("raw_path") or ""
239
- kwargs["path"], seperator, kwargs["query"] = raw_path.partition("?")
240
- if not seperator:
239
+ kwargs["path"], separator, kwargs["query"] = raw_path.partition("?")
240
+ if not separator:
241
241
  kwargs["query"] = None
242
242
 
243
243
  # Ensure that IPv6 "host" addresses are always escaped with "[...]".
@@ -398,7 +398,7 @@ class URL:
398
398
  return f"{self.__class__.__name__}({url!r})"
399
399
 
400
400
  @property
401
- def raw(self) -> tuple[bytes, bytes, int, bytes]: # pragma: nocover
401
+ def raw(self) -> tuple[bytes, bytes, int, bytes]: # pragma: no cover
402
402
  import collections
403
403
  import warnings
404
404
 
@@ -8,7 +8,7 @@ from urllib.request import getproxies
8
8
 
9
9
  from ._types import PrimitiveData
10
10
 
11
- if typing.TYPE_CHECKING: # pragma: no cover
11
+ if typing.TYPE_CHECKING:
12
12
  from ._urls import URL
13
13
 
14
14
 
@@ -1,5 +1,5 @@
1
1
  [build-system]
2
- requires = ["hatchling", "hatch-fancy-pypi-readme", "uv-dynamic-versioning>=0.7.0"]
2
+ requires = ["hatchling", "hatch-fancy-pypi-readme", "uv-dynamic-versioning>=0.8.0"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
5
  [tool.hatch.version]
@@ -9,6 +9,7 @@ source = "uv-dynamic-versioning"
9
9
  vcs = "git"
10
10
  style = "pep440"
11
11
  bump = true
12
+ fallback-version = "0.0.0"
12
13
 
13
14
  [project]
14
15
  name = "httpx2"
@@ -42,7 +43,7 @@ dynamic = ["readme", "version", "dependencies"]
42
43
 
43
44
  [tool.hatch.metadata.hooks.uv-dynamic-versioning]
44
45
  dependencies = [
45
- "certifi",
46
+ "truststore>=0.10",
46
47
  "httpcore2=={{ version }}",
47
48
  "anyio",
48
49
  "idna",
@@ -70,7 +71,7 @@ zstd = [
70
71
  ]
71
72
 
72
73
  [project.scripts]
73
- httpx2 = "httpx2:main"
74
+ httpx2 = "httpx2._main:main"
74
75
 
75
76
  [project.urls]
76
77
  Changelog = "https://github.com/pydantic/httpx2/blob/main/src/httpx2/CHANGELOG.md"
@@ -1,15 +0,0 @@
1
- from .asgi import *
2
- from .base import *
3
- from .default import *
4
- from .mock import *
5
- from .wsgi import *
6
-
7
- __all__ = [
8
- "ASGITransport",
9
- "AsyncBaseTransport",
10
- "BaseTransport",
11
- "AsyncHTTPTransport",
12
- "HTTPTransport",
13
- "MockTransport",
14
- "WSGITransport",
15
- ]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes