Merge branch 'master' into feature/allow-multipart-without-files

This commit is contained in:
-LAN- 2024-12-05 17:08:10 +08:00 committed by GitHub
commit d543147c8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 165 additions and 64 deletions

View File

@ -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=<ssl_context>` 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)

View File

@ -13,9 +13,7 @@
</a>
</p>
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**.
---

View File

@ -1,3 +1,3 @@
__title__ = "httpx"
__description__ = "A next generation HTTP client, for Python 3."
__version__ = "0.27.2"
__version__ = "0.28.0"

View File

@ -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:
"""

View File

@ -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,

View File

@ -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=<str>` 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=<ssl_context>` 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:

View File

@ -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

View File

@ -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

View File

@ -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(

View File

@ -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"]

View File

@ -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

View File

@ -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"

View File

@ -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 = (