diff --git a/CHANGELOG.md b/CHANGELOG.md index e7ad03c0..4f1233ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,32 +4,35 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## [Unreleased] +## Dev -The 0.28 release includes a limited set of backwards incompatible changes. +* Fix SSL case where `verify=False` together with client side certificates. + +## 0.28.0 (28th November, 2024) -**Backwards incompatible changes**: +The 0.28 release includes a limited set of deprecations. -SSL configuration has been significantly simplified. +**Deprecations**: -* The `verify` argument no longer accepts string arguments. -* The `cert` argument has now been removed. -* The `SSL_CERT_FILE` and `SSL_CERT_DIR` environment variables are no longer automatically used. +We are working towards a simplified SSL configuration API. -For users of the standard `verify=True` or `verify=False` cases this should require no changes. +*For users of the standard `verify=True` or `verify=False` cases, or `verify=` case this should require no changes. The following cases have been deprecated...* -For information on configuring more complex SSL cases, please see the [SSL documentation](docs/advanced/ssl.md). +* The `verify` argument as a string argument is now deprecated and will raise warnings. +* The `cert` argument is now deprecated and will raise warnings. + +Our revised [SSL documentation](docs/advanced/ssl.md) covers how to implement the same behaviour with a more constrained API. **The following changes are also included**: -* The undocumented `URL.raw` property has now been deprecated, and will raise warnings. * The deprecated `proxies` argument has now been removed. * The deprecated `app` argument has now been removed. -* Ensure JSON request bodies are compact. (#3363) +* JSON request bodies use a compact representation. (#3363) * Review URL percent escape sets, based on WHATWG spec. (#3371, #3373) * Ensure `certifi` and `httpcore` are only imported if required. (#3377) * Treat `socks5h` as a valid proxy scheme. (#3178) * Cleanup `Request()` method signature in line with `client.request()` and `httpx.request()`. (#3378) +* Bugfix: When passing `params={}`, always strictly update rather than merge with an existing querystring. (#3364) ## 0.27.2 (27th August, 2024) @@ -617,7 +620,7 @@ See pull requests #1057, #1058. * Added dedicated exception class `httpx.HTTPStatusError` for `.raise_for_status()` exceptions. (Pull #1072) * Added `httpx.create_ssl_context()` helper function. (Pull #996) -* Support for proxy exlcusions like `proxies={"https://www.example.com": None}`. (Pull #1099) +* Support for proxy exclusions like `proxies={"https://www.example.com": None}`. (Pull #1099) * Support `QueryParams(None)` and `client.params = None`. (Pull #1060) ### Changed @@ -845,7 +848,7 @@ We believe the API is now pretty much stable, and are aiming for a 1.0 release s ### Fixed -- Fix issue with concurrent connection acquiry. (Pull #700) +- Fix issue with concurrent connection acquisition. (Pull #700) - Fix write error on closing HTTP/2 connections. (Pull #699) ## 0.10.0 (December 29th, 2019) @@ -1094,7 +1097,7 @@ importing modules within the package. ## 0.6.7 (July 8, 2019) -- Check for connection aliveness on re-acquiry (Pull #111) +- Check for connection aliveness on re-acquisition (Pull #111) ## 0.6.6 (July 3, 2019) diff --git a/README.md b/README.md index d5d21487..23992d9c 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,7 @@

-HTTPX is a fully featured HTTP client library for Python 3. It includes **an integrated -command line client**, has support for both **HTTP/1.1 and HTTP/2**, and provides both **sync -and async APIs**. +HTTPX is a fully featured HTTP client library for Python 3. It includes **an integrated command line client**, has support for both **HTTP/1.1 and HTTP/2**, and provides both **sync and async APIs**. --- diff --git a/httpx/__version__.py b/httpx/__version__.py index 5eaaddba..0a684ac3 100644 --- a/httpx/__version__.py +++ b/httpx/__version__.py @@ -1,3 +1,3 @@ __title__ = "httpx" __description__ = "A next generation HTTP client, for Python 3." -__version__ = "0.27.2" +__version__ = "0.28.0" diff --git a/httpx/_api.py b/httpx/_api.py index ab1be081..c3cda1ec 100644 --- a/httpx/_api.py +++ b/httpx/_api.py @@ -51,7 +51,7 @@ def request( proxy: ProxyTypes | None = None, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, follow_redirects: bool = False, - verify: ssl.SSLContext | bool = True, + verify: ssl.SSLContext | str | bool = True, trust_env: bool = True, ) -> Response: """ @@ -136,7 +136,7 @@ def stream( proxy: ProxyTypes | None = None, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, follow_redirects: bool = False, - verify: ssl.SSLContext | bool = True, + verify: ssl.SSLContext | str | bool = True, trust_env: bool = True, ) -> typing.Iterator[Response]: """ @@ -180,7 +180,7 @@ def get( auth: AuthTypes | None = None, proxy: ProxyTypes | None = None, follow_redirects: bool = False, - verify: ssl.SSLContext | bool = True, + verify: ssl.SSLContext | str | bool = True, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, trust_env: bool = True, ) -> Response: @@ -216,7 +216,7 @@ def options( auth: AuthTypes | None = None, proxy: ProxyTypes | None = None, follow_redirects: bool = False, - verify: ssl.SSLContext | bool = True, + verify: ssl.SSLContext | str | bool = True, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, trust_env: bool = True, ) -> Response: @@ -252,7 +252,7 @@ def head( auth: AuthTypes | None = None, proxy: ProxyTypes | None = None, follow_redirects: bool = False, - verify: ssl.SSLContext | bool = True, + verify: ssl.SSLContext | str | bool = True, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, trust_env: bool = True, ) -> Response: @@ -292,7 +292,7 @@ def post( auth: AuthTypes | None = None, proxy: ProxyTypes | None = None, follow_redirects: bool = False, - verify: ssl.SSLContext | bool = True, + verify: ssl.SSLContext | str | bool = True, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, trust_env: bool = True, ) -> Response: @@ -333,7 +333,7 @@ def put( auth: AuthTypes | None = None, proxy: ProxyTypes | None = None, follow_redirects: bool = False, - verify: ssl.SSLContext | bool = True, + verify: ssl.SSLContext | str | bool = True, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, trust_env: bool = True, ) -> Response: @@ -374,7 +374,7 @@ def patch( auth: AuthTypes | None = None, proxy: ProxyTypes | None = None, follow_redirects: bool = False, - verify: ssl.SSLContext | bool = True, + verify: ssl.SSLContext | str | bool = True, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, trust_env: bool = True, ) -> Response: @@ -412,7 +412,7 @@ def delete( proxy: ProxyTypes | None = None, follow_redirects: bool = False, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, - verify: ssl.SSLContext | bool = True, + verify: ssl.SSLContext | str | bool = True, trust_env: bool = True, ) -> Response: """ diff --git a/httpx/_client.py b/httpx/_client.py index 76325c14..2249231f 100644 --- a/httpx/_client.py +++ b/httpx/_client.py @@ -33,6 +33,7 @@ from ._transports.default import AsyncHTTPTransport, HTTPTransport from ._types import ( AsyncByteStream, AuthTypes, + CertTypes, CookieTypes, HeaderTypes, ProxyTypes, @@ -619,8 +620,6 @@ class Client(BaseClient): * **http2** - *(optional)* A boolean indicating if HTTP/2 support should be enabled. Defaults to `False`. * **proxy** - *(optional)* A proxy URL where all the traffic should be routed. - * **proxies** - *(optional)* A dictionary mapping proxy keys to proxy - URLs. * **timeout** - *(optional)* The timeout configuration to use when sending requests. * **limits** - *(optional)* The limits configuration to use. @@ -644,7 +643,9 @@ class Client(BaseClient): params: QueryParamTypes | None = None, headers: HeaderTypes | None = None, cookies: CookieTypes | None = None, - verify: ssl.SSLContext | bool = True, + verify: ssl.SSLContext | str | bool = True, + cert: CertTypes | None = None, + trust_env: bool = True, http1: bool = True, http2: bool = False, proxy: ProxyTypes | None = None, @@ -656,7 +657,6 @@ class Client(BaseClient): event_hooks: None | (typing.Mapping[str, list[EventHook]]) = None, base_url: URL | str = "", transport: BaseTransport | None = None, - trust_env: bool = True, default_encoding: str | typing.Callable[[bytes], str] = "utf-8", ) -> None: super().__init__( @@ -687,6 +687,8 @@ class Client(BaseClient): self._transport = self._init_transport( verify=verify, + cert=cert, + trust_env=trust_env, http1=http1, http2=http2, limits=limits, @@ -698,6 +700,8 @@ class Client(BaseClient): else self._init_proxy_transport( proxy, verify=verify, + cert=cert, + trust_env=trust_env, http1=http1, http2=http2, limits=limits, @@ -713,7 +717,9 @@ class Client(BaseClient): def _init_transport( self, - verify: ssl.SSLContext | bool = True, + verify: ssl.SSLContext | str | bool = True, + cert: CertTypes | None = None, + trust_env: bool = True, http1: bool = True, http2: bool = False, limits: Limits = DEFAULT_LIMITS, @@ -724,6 +730,8 @@ class Client(BaseClient): return HTTPTransport( verify=verify, + cert=cert, + trust_env=trust_env, http1=http1, http2=http2, limits=limits, @@ -732,13 +740,17 @@ class Client(BaseClient): def _init_proxy_transport( self, proxy: Proxy, - verify: ssl.SSLContext | bool = True, + verify: ssl.SSLContext | str | bool = True, + cert: CertTypes | None = None, + trust_env: bool = True, http1: bool = True, http2: bool = False, limits: Limits = DEFAULT_LIMITS, ) -> BaseTransport: return HTTPTransport( verify=verify, + cert=cert, + trust_env=trust_env, http1=http1, http2=http2, limits=limits, @@ -1345,7 +1357,8 @@ class AsyncClient(BaseClient): params: QueryParamTypes | None = None, headers: HeaderTypes | None = None, cookies: CookieTypes | None = None, - verify: ssl.SSLContext | bool = True, + verify: ssl.SSLContext | str | bool = True, + cert: CertTypes | None = None, http1: bool = True, http2: bool = False, proxy: ProxyTypes | None = None, @@ -1388,6 +1401,8 @@ class AsyncClient(BaseClient): self._transport = self._init_transport( verify=verify, + cert=cert, + trust_env=trust_env, http1=http1, http2=http2, limits=limits, @@ -1400,6 +1415,8 @@ class AsyncClient(BaseClient): else self._init_proxy_transport( proxy, verify=verify, + cert=cert, + trust_env=trust_env, http1=http1, http2=http2, limits=limits, @@ -1414,7 +1431,9 @@ class AsyncClient(BaseClient): def _init_transport( self, - verify: ssl.SSLContext | bool = True, + verify: ssl.SSLContext | str | bool = True, + cert: CertTypes | None = None, + trust_env: bool = True, http1: bool = True, http2: bool = False, limits: Limits = DEFAULT_LIMITS, @@ -1425,6 +1444,8 @@ class AsyncClient(BaseClient): return AsyncHTTPTransport( verify=verify, + cert=cert, + trust_env=trust_env, http1=http1, http2=http2, limits=limits, @@ -1433,13 +1454,17 @@ class AsyncClient(BaseClient): def _init_proxy_transport( self, proxy: Proxy, - verify: ssl.SSLContext | bool = True, + verify: ssl.SSLContext | str | bool = True, + cert: CertTypes | None = None, + trust_env: bool = True, http1: bool = True, http2: bool = False, limits: Limits = DEFAULT_LIMITS, ) -> AsyncBaseTransport: return AsyncHTTPTransport( verify=verify, + cert=cert, + trust_env=trust_env, http1=http1, http2=http2, limits=limits, diff --git a/httpx/_config.py b/httpx/_config.py index 9318de3c..467a6c90 100644 --- a/httpx/_config.py +++ b/httpx/_config.py @@ -1,9 +1,10 @@ from __future__ import annotations +import os import typing from ._models import Headers -from ._types import HeaderTypes, TimeoutTypes +from ._types import CertTypes, HeaderTypes, TimeoutTypes from ._urls import URL if typing.TYPE_CHECKING: @@ -19,20 +20,53 @@ class UnsetType: UNSET = UnsetType() -def create_ssl_context(verify: ssl.SSLContext | bool = True) -> ssl.SSLContext: +def create_ssl_context( + verify: ssl.SSLContext | str | bool = True, + cert: CertTypes | None = None, + trust_env: bool = True, +) -> ssl.SSLContext: import ssl + import warnings import certifi if verify is True: - return ssl.create_default_context(cafile=certifi.where()) + if trust_env and os.environ.get("SSL_CERT_FILE"): # pragma: nocover + ctx = ssl.create_default_context(cafile=os.environ["SSL_CERT_FILE"]) + elif trust_env and os.environ.get("SSL_CERT_DIR"): # pragma: nocover + ctx = ssl.create_default_context(capath=os.environ["SSL_CERT_DIR"]) + else: + # Default case... + ctx = ssl.create_default_context(cafile=certifi.where()) elif verify is False: - ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - ssl_context.check_hostname = False - ssl_context.verify_mode = ssl.CERT_NONE - return ssl_context + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + elif isinstance(verify, str): # pragma: nocover + message = ( + "`verify=` is deprecated. " + "Use `verify=ssl.create_default_context(cafile=...)` " + "or `verify=ssl.create_default_context(capath=...)` instead." + ) + warnings.warn(message, DeprecationWarning) + if os.path.isdir(verify): + return ssl.create_default_context(capath=verify) + return ssl.create_default_context(cafile=verify) + else: + ctx = verify - return verify + if cert: # pragma: nocover + message = ( + "`cert=...` is deprecated. Use `verify=` instead," + "with `.load_cert_chain()` to configure the certificate chain." + ) + warnings.warn(message, DeprecationWarning) + if isinstance(cert, str): + ctx.load_cert_chain(cert) + else: + ctx.load_cert_chain(*cert) + + return ctx class Timeout: diff --git a/httpx/_decoders.py b/httpx/_decoders.py index 180898c5..899dfada 100644 --- a/httpx/_decoders.py +++ b/httpx/_decoders.py @@ -175,9 +175,11 @@ class ZStandardDecoder(ContentDecoder): ) from None self.decompressor = zstandard.ZstdDecompressor().decompressobj() + self.seen_data = False def decode(self, data: bytes) -> bytes: assert zstandard is not None + self.seen_data = True output = io.BytesIO() try: output.write(self.decompressor.decompress(data)) @@ -190,6 +192,8 @@ class ZStandardDecoder(ContentDecoder): return output.getvalue() def flush(self) -> bytes: + if not self.seen_data: + return b"" ret = self.decompressor.flush() # note: this is a no-op if not self.decompressor.eof: raise DecodingError("Zstandard data is incomplete") # pragma: no cover diff --git a/httpx/_models.py b/httpx/_models.py index e7c992f7..67d74bf8 100644 --- a/httpx/_models.py +++ b/httpx/_models.py @@ -398,7 +398,7 @@ class Request: self.method = method.upper() self.url = URL(url) if params is None else URL(url, params=params) self.headers = Headers(headers) - self.extensions = {} if extensions is None else extensions + self.extensions = {} if extensions is None else dict(extensions) if cookies: Cookies(cookies).set_cookie_header(self) @@ -537,7 +537,7 @@ class Response: # the client will set `response.next_request`. self.next_request: Request | None = None - self.extensions: ResponseExtensions = {} if extensions is None else extensions + self.extensions = {} if extensions is None else dict(extensions) self.history = [] if history is None else list(history) self.is_closed = False diff --git a/httpx/_transports/default.py b/httpx/_transports/default.py index ccc19af4..d5aa05ff 100644 --- a/httpx/_transports/default.py +++ b/httpx/_transports/default.py @@ -53,7 +53,7 @@ from .._exceptions import ( WriteTimeout, ) from .._models import Request, Response -from .._types import AsyncByteStream, ProxyTypes, SyncByteStream +from .._types import AsyncByteStream, CertTypes, ProxyTypes, SyncByteStream from .._urls import URL from .base import AsyncBaseTransport, BaseTransport @@ -135,7 +135,9 @@ class ResponseStream(SyncByteStream): class HTTPTransport(BaseTransport): def __init__( self, - verify: ssl.SSLContext | bool = True, + verify: ssl.SSLContext | str | bool = True, + cert: CertTypes | None = None, + trust_env: bool = True, http1: bool = True, http2: bool = False, limits: Limits = DEFAULT_LIMITS, @@ -148,7 +150,7 @@ class HTTPTransport(BaseTransport): import httpcore proxy = Proxy(url=proxy) if isinstance(proxy, (str, URL)) else proxy - ssl_context = create_ssl_context(verify=verify) + ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env) if proxy is None: self._pool = httpcore.ConnectionPool( @@ -277,7 +279,9 @@ class AsyncResponseStream(AsyncByteStream): class AsyncHTTPTransport(AsyncBaseTransport): def __init__( self, - verify: ssl.SSLContext | bool = True, + verify: ssl.SSLContext | str | bool = True, + cert: CertTypes | None = None, + trust_env: bool = True, http1: bool = True, http2: bool = False, limits: Limits = DEFAULT_LIMITS, @@ -290,7 +294,7 @@ class AsyncHTTPTransport(AsyncBaseTransport): import httpcore proxy = Proxy(url=proxy) if isinstance(proxy, (str, URL)) else proxy - ssl_context = create_ssl_context(verify=verify) + ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env) if proxy is None: self._pool = httpcore.AsyncConnectionPool( diff --git a/httpx/_types.py b/httpx/_types.py index edd00da1..704dfdff 100644 --- a/httpx/_types.py +++ b/httpx/_types.py @@ -15,7 +15,6 @@ from typing import ( Iterator, List, Mapping, - MutableMapping, Optional, Sequence, Tuple, @@ -58,6 +57,7 @@ TimeoutTypes = Union[ "Timeout", ] ProxyTypes = Union["URL", str, "Proxy"] +CertTypes = Union[str, Tuple[str, str], Tuple[str, str, str]] AuthTypes = Union[ Tuple[Union[str, bytes], Union[str, bytes]], @@ -67,7 +67,7 @@ AuthTypes = Union[ RequestContent = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]] ResponseContent = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]] -ResponseExtensions = MutableMapping[str, Any] +ResponseExtensions = Mapping[str, Any] RequestData = Mapping[str, Any] @@ -84,7 +84,7 @@ FileTypes = Union[ ] RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]] -RequestExtensions = MutableMapping[str, Any] +RequestExtensions = Mapping[str, Any] __all__ = ["AsyncByteStream", "SyncByteStream"] diff --git a/requirements.txt b/requirements.txt index 53fd0a6c..0d8ba2ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,19 +11,20 @@ chardet==5.2.0 # Documentation mkdocs==1.6.1 mkautodoc==0.2.0 -mkdocs-material==9.5.39 +mkdocs-material==9.5.47 # Packaging -build==1.2.2 -twine==5.1.1 +build==1.2.2.post1 +twine==6.0.1 # Tests & Linting coverage[toml]==7.6.1 -cryptography==43.0.1 -mypy==1.11.2 -pytest==8.3.3 -ruff==0.6.8 -trio==0.26.2 +cryptography==44.0.0 +mypy==1.13.0 +pytest==8.3.4 +ruff==0.8.1 +trio==0.27.0 trio-typing==0.10.0 -trustme==1.1.0 -uvicorn==0.31.0 +trustme==1.1.0; python_version < '3.9' +trustme==1.2.0; python_version >= '3.9' +uvicorn==0.32.1 diff --git a/tests/models/test_requests.py b/tests/models/test_requests.py index d2a458d5..b31fe007 100644 --- a/tests/models/test_requests.py +++ b/tests/models/test_requests.py @@ -226,3 +226,16 @@ def test_request_generator_content_picklable(): request.read() pickle_request = pickle.loads(pickle.dumps(request)) assert pickle_request.content == b"test 123" + + +def test_request_params(): + request = httpx.Request("GET", "http://example.com", params={}) + assert str(request.url) == "http://example.com" + + request = httpx.Request( + "GET", "http://example.com?c=3", params={"a": "1", "b": "2"} + ) + assert str(request.url) == "http://example.com?a=1&b=2" + + request = httpx.Request("GET", "http://example.com?a=1", params={}) + assert str(request.url) == "http://example.com" diff --git a/tests/test_decoders.py b/tests/test_decoders.py index bcbb18bb..9ffaba18 100644 --- a/tests/test_decoders.py +++ b/tests/test_decoders.py @@ -100,6 +100,25 @@ def test_zstd_decoding_error(): ) +def test_zstd_empty(): + headers = [(b"Content-Encoding", b"zstd")] + response = httpx.Response(200, headers=headers, content=b"") + assert response.content == b"" + + +def test_zstd_truncated(): + body = b"test 123" + compressed_body = zstd.compress(body) + + headers = [(b"Content-Encoding", b"zstd")] + with pytest.raises(httpx.DecodingError): + httpx.Response( + 200, + headers=headers, + content=compressed_body[1:3], + ) + + def test_zstd_multiframe(): # test inspired by urllib3 test suite data = (