diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index e1a953dc97..2cbd58004b 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -211,9 +211,10 @@ this is where our previously generated `client.pem` comes in: ``` import httpx -proxies = {"all": "http://127.0.0.1:8080/"} +ssl_context = httpx.SSLContext() +ssl_context.load_verify_locations("/path/to/client.pem") -with httpx.Client(proxies=proxies, verify="/path/to/client.pem") as client: +with httpx.Client(proxy="http://127.0.0.1:8080/", ssl_context=ssl_context) as client: response = client.get("https://example.org") print(response.status_code) # should print 200 ``` diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 0bb570cedb..ce3df5db81 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -5,7 +5,7 @@ on: push: branches: ["master"] pull_request: - branches: ["master"] + branches: ["master", "version-*"] jobs: tests: diff --git a/CHANGELOG.md b/CHANGELOG.md index f3aba3cc03..bc3fa411f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,31 @@ 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/). +## 0.28.0 (28th November, 2024) + +The 0.28 release includes a limited set of deprecations. + +**Deprecations**: + +We are working towards a simplified SSL configuration API. + +*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...* + +* 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 deprecated `proxies` argument has now been removed. +* The deprecated `app` argument has now been removed. +* 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) + ## 0.27.2 (27th August, 2024) ### Fixed @@ -590,7 +615,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 @@ -818,7 +843,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) @@ -1067,7 +1092,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 5e459a2814..23992d9c24 100644 --- a/README.md +++ b/README.md @@ -13,16 +13,14 @@

-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**. --- Install HTTPX using pip: ```shell -pip install httpx +$ pip install httpx ``` Now, let's get started: @@ -43,7 +41,7 @@ Now, let's get started: Or, using the command-line client. ```shell -pip install 'httpx[cli]' # The command line client is an optional dependency. +$ pip install 'httpx[cli]' # The command line client is an optional dependency. ``` Which now allows us to use HTTPX directly from the command-line... @@ -94,13 +92,13 @@ Plus all the standard features of `requests`... Install with pip: ```shell -pip install httpx +$ pip install httpx ``` Or, to include the optional HTTP/2 support, use: ```shell -pip install httpx[http2] +$ pip install httpx[http2] ``` HTTPX requires Python 3.8+. diff --git a/docs/advanced/proxies.md b/docs/advanced/proxies.md index f1ee3ec8d3..2a6b7d5f36 100644 --- a/docs/advanced/proxies.md +++ b/docs/advanced/proxies.md @@ -73,7 +73,7 @@ This is an optional feature that requires an additional third-party library be i You can install SOCKS support using `pip`: ```shell -pip install httpx[socks] +$ pip install httpx[socks] ``` You can now configure a client to make requests via a proxy using the SOCKS protocol: diff --git a/docs/advanced/ssl.md b/docs/advanced/ssl.md index d96bbe1979..da40ed2843 100644 --- a/docs/advanced/ssl.md +++ b/docs/advanced/ssl.md @@ -1,100 +1,101 @@ When making a request over HTTPS, HTTPX needs to verify the identity of the requested host. To do this, it uses a bundle of SSL certificates (a.k.a. CA bundle) delivered by a trusted certificate authority (CA). -## Changing the verification defaults +### Enabling and disabling verification -By default, HTTPX uses the CA bundle provided by [Certifi](https://pypi.org/project/certifi/). This is what you want in most cases, even though some advanced situations may require you to use a different set of certificates. +By default httpx will verify HTTPS connections, and raise an error for invalid SSL cases... -If you'd like to use a custom CA bundle, you can use the `verify` parameter. - -```python -import httpx - -r = httpx.get("https://example.org", verify="path/to/client.pem") +```pycon +>>> httpx.get("https://expired.badssl.com/") +httpx.ConnectError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:997) ``` -Alternatively, you can pass a standard library `ssl.SSLContext`. +You can disable SSL verification completely and allow insecure requests... ```pycon ->>> import ssl ->>> import httpx ->>> context = ssl.create_default_context() ->>> context.load_verify_locations(cafile="/tmp/client.pem") ->>> httpx.get('https://example.org', verify=context) +>>> httpx.get("https://expired.badssl.com/", verify=False) ``` -We also include a helper function for creating properly configured `SSLContext` instances. +### Configuring client instances -```pycon ->>> context = httpx.create_ssl_context() -``` +If you're using a `Client()` instance you should pass any `verify=<...>` configuration when instantiating the client. -The `create_ssl_context` function accepts the same set of SSL configuration arguments -(`trust_env`, `verify`, `cert` and `http2` arguments) -as `httpx.Client` or `httpx.AsyncClient` +By default the [certifi CA bundle](https://certifiio.readthedocs.io/en/latest/) is used for SSL verification. -```pycon ->>> import httpx ->>> context = httpx.create_ssl_context(verify="/tmp/client.pem") ->>> httpx.get('https://example.org', verify=context) - +For more complex configurations you can pass an [SSL Context](https://docs.python.org/3/library/ssl.html) instance... + +```python +import certifi +import httpx +import ssl + +# This SSL context is equivelent to the default `verify=True`. +ctx = ssl.create_default_context(cafile=certifi.where()) +client = httpx.Client(verify=ctx) ``` -Or you can also disable the SSL verification entirely, which is _not_ recommended. +Using [the `truststore` package](https://truststore.readthedocs.io/) to support system certificate stores... ```python +import ssl +import truststore import httpx -r = httpx.get("https://example.org", verify=False) +# Use system certificate stores. +ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) +client = httpx.Client(verify=ctx) ``` -## SSL configuration on client instances - -If you're using a `Client()` instance, then you should pass any SSL settings when instantiating the client. +Loding an alternative certificate verification store using [the standard SSL context API](https://docs.python.org/3/library/ssl.html)... ```python -client = httpx.Client(verify=False) +import httpx +import ssl + +# Use an explicitly configured certificate store. +ctx = ssl.create_default_context(cafile="path/to/certs.pem") # Either cafile or capath. +client = httpx.Client(verify=ctx) ``` -The `client.get(...)` method and other request methods *do not* support changing the SSL settings on a per-request basis. If you need different SSL settings in different cases you should use more that one client instance, with different settings on each. Each client will then be using an isolated connection pool with a specific fixed SSL configuration on all connections within that pool. +### Client side certificates -## Client Side Certificates +Client side certificates allow a remote server to verify the client. They tend to be used within private organizations to authenticate requests to remote servers. -You can also specify a local cert to use as a client-side certificate, either a path to an SSL certificate file, or two-tuple of (certificate file, key file), or a three-tuple of (certificate file, key file, password) +You can specify client-side certificates, using the [`.load_cert_chain()`](https://docs.python.org/3/library/ssl.html#ssl.SSLContext.load_cert_chain) API... ```python -cert = "path/to/client.pem" -client = httpx.Client(cert=cert) -response = client.get("https://example.org") +ctx = ssl.create_default_context() +ctx.load_cert_chain(certfile="path/to/client.pem") # Optionally also keyfile or password. +client = httpx.Client(verify=ctx) ``` -Alternatively... +### Working with `SSL_CERT_FILE` and `SSL_CERT_DIR` -```python -cert = ("path/to/client.pem", "path/to/client.key") -client = httpx.Client(cert=cert) -response = client.get("https://example.org") -``` +Unlike `requests`, the `httpx` package does not automatically pull in [the environment variables `SSL_CERT_FILE` or `SSL_CERT_DIR`](https://www.openssl.org/docs/manmaster/man3/SSL_CTX_set_default_verify_paths.html). If you want to use these they need to be enabled explicitly. -Or... +For example... ```python -cert = ("path/to/client.pem", "path/to/client.key", "password") -client = httpx.Client(cert=cert) -response = client.get("https://example.org") +# Use `SSL_CERT_FILE` or `SSL_CERT_DIR` if configured. +# Otherwise default to certifi. +ctx = ssl.create_default_context( + cafile=os.environ.get("SSL_CERT_FILE", certifi.where()), + capath=os.environ.get("SSL_CERT_DIR"), +) +client = httpx.Client(verify=ctx) ``` -## Making HTTPS requests to a local server +### Making HTTPS requests to a local server When making requests to local servers, such as a development server running on `localhost`, you will typically be using unencrypted HTTP connections. -If you do need to make HTTPS connections to a local server, for example to test an HTTPS-only service, you will need to create and use your own certificates. Here's one way to do it: +If you do need to make HTTPS connections to a local server, for example to test an HTTPS-only service, you will need to create and use your own certificates. Here's one way to do it... 1. Use [trustme](https://github.com/python-trio/trustme) to generate a pair of server key/cert files, and a client cert file. -1. Pass the server key/cert files when starting your local server. (This depends on the particular web server you're using. For example, [Uvicorn](https://www.uvicorn.org) provides the `--ssl-keyfile` and `--ssl-certfile` options.) -1. Tell HTTPX to use the certificates stored in `client.pem`: +2. Pass the server key/cert files when starting your local server. (This depends on the particular web server you're using. For example, [Uvicorn](https://www.uvicorn.org) provides the `--ssl-keyfile` and `--ssl-certfile` options.) +3. Configure `httpx` to use the certificates stored in `client.pem`. ```python -client = httpx.Client(verify="/tmp/client.pem") -response = client.get("https://localhost:8000") +ctx = ssl.create_default_context(cafile="client.pem") +client = httpx.Client(verify=ctx) ``` diff --git a/docs/compatibility.md b/docs/compatibility.md index e820a67b07..52e9389a79 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -143,7 +143,7 @@ Within a `stream()` block request data is made available with: * `.iter_text()` - Instead of `response.iter_content(decode_unicode=True)` * `.iter_lines()` - Corresponding to `response.iter_lines()` * `.iter_raw()` - Use this instead of `response.raw` -* `.read()` - Read the entire response body, making `request.text` and `response.content` available. +* `.read()` - Read the entire response body, making `response.text` and `response.content` available. ## Timeouts @@ -171,12 +171,10 @@ Also note that `requests.Session.request(...)` allows a `proxies=...` parameter, ## SSL configuration -When using a `Client` instance, the `trust_env`, `verify`, and `cert` arguments should always be passed on client instantiation, rather than passed to the request method. +When using a `Client` instance, the ssl configurations should always be passed on client instantiation, rather than passed to the request method. If you need more than one different SSL configuration, you should use different client instances for each SSL configuration. -Requests supports `REQUESTS_CA_BUNDLE` which points to either a file or a directory. HTTPX supports the `SSL_CERT_FILE` (for a file) and `SSL_CERT_DIR` (for a directory) OpenSSL variables instead. - ## Request body on HTTP methods The HTTP `GET`, `DELETE`, `HEAD`, and `OPTIONS` methods are specified as not supporting a request body. To stay in line with this, the `.get`, `.delete`, `.head` and `.options` functions do not support `content`, `files`, `data`, or `json` arguments. diff --git a/docs/contributing.md b/docs/contributing.md index 110a127c74..2759019b2f 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -46,14 +46,14 @@ Then clone your fork with the following command replacing `YOUR-USERNAME` with your GitHub username: ```shell -git clone https://github.com/YOUR-USERNAME/httpx +$ git clone https://github.com/YOUR-USERNAME/httpx ``` You can now install the project and its dependencies using: ```shell -cd httpx -scripts/install +$ cd httpx +$ scripts/install ``` ## Testing and Linting @@ -64,7 +64,7 @@ and documentation building workflow. To run the tests, use: ```shell -scripts/test +$ scripts/test ``` !!! warning @@ -76,19 +76,19 @@ Any additional arguments will be passed to `pytest`. See the [pytest documentati For example, to run a single test script: ```shell -scripts/test tests/test_multipart.py +$ scripts/test tests/test_multipart.py ``` To run the code auto-formatting: ```shell -scripts/lint +$ scripts/lint ``` Lastly, to run code checks separately (they are also run as part of `scripts/test`), run: ```shell -scripts/check +$ scripts/check ``` ## Documenting @@ -98,7 +98,7 @@ Documentation pages are located under the `docs/` folder. To run the documentation site locally (useful for previewing changes), use: ```shell -scripts/docs +$ scripts/docs ``` ## Resolving Build / CI Failures @@ -122,7 +122,7 @@ This job failing means there is either a code formatting issue or type-annotatio You can look at the job output to figure out why it's failed or within a shell run: ```shell -scripts/check +$ scripts/check ``` It may be worth it to run `$ scripts/lint` to attempt auto-formatting the code @@ -210,12 +210,9 @@ configure HTTPX as described in the the [SSL certificates section](https://www.python-httpx.org/advanced/ssl/), this is where our previously generated `client.pem` comes in: -``` -import httpx - -with httpx.Client(proxy="http://127.0.0.1:8080/", verify="/path/to/client.pem") as client: - response = client.get("https://example.org") - print(response.status_code) # should print 200 +```python +ctx = ssl.create_default_context(cafile="/path/to/client.pem") +client = httpx.Client(proxy="http://127.0.0.1:8080/", verify=ctx) ``` Note, however, that HTTPS requests will only succeed to the host specified diff --git a/docs/environment_variables.md b/docs/environment_variables.md index 28fdc5e8af..4f7a9f5284 100644 --- a/docs/environment_variables.md +++ b/docs/environment_variables.md @@ -8,66 +8,6 @@ Environment variables are used by default. To ignore environment variables, `tru Here is a list of environment variables that HTTPX recognizes and what function they serve: -## `SSLKEYLOGFILE` - -Valid values: a filename - -If this environment variable is set, TLS keys will be appended to the specified file, creating it if it doesn't exist, whenever key material is generated or received. The keylog file is designed for debugging purposes only. - -Support for `SSLKEYLOGFILE` requires Python 3.8 and OpenSSL 1.1.1 or newer. - -Example: - -```python -# test_script.py -import httpx - -with httpx.AsyncClient() as client: - r = client.get("https://google.com") -``` - -```console -SSLKEYLOGFILE=test.log python test_script.py -cat test.log -# TLS secrets log file, generated by OpenSSL / Python -SERVER_HANDSHAKE_TRAFFIC_SECRET XXXX -EXPORTER_SECRET XXXX -SERVER_TRAFFIC_SECRET_0 XXXX -CLIENT_HANDSHAKE_TRAFFIC_SECRET XXXX -CLIENT_TRAFFIC_SECRET_0 XXXX -SERVER_HANDSHAKE_TRAFFIC_SECRET XXXX -EXPORTER_SECRET XXXX -SERVER_TRAFFIC_SECRET_0 XXXX -CLIENT_HANDSHAKE_TRAFFIC_SECRET XXXX -CLIENT_TRAFFIC_SECRET_0 XXXX -``` - -## `SSL_CERT_FILE` - -Valid values: a filename - -If this environment variable is set then HTTPX will load -CA certificate from the specified file instead of the default -location. - -Example: - -```console -SSL_CERT_FILE=/path/to/ca-certs/ca-bundle.crt python -c "import httpx; httpx.get('https://example.com')" -``` - -## `SSL_CERT_DIR` - -Valid values: a directory following an [OpenSSL specific layout](https://www.openssl.org/docs/manmaster/man3/SSL_CTX_load_verify_locations.html). - -If this environment variable is set and the directory follows an [OpenSSL specific layout](https://www.openssl.org/docs/manmaster/man3/SSL_CTX_load_verify_locations.html) (ie. you ran `c_rehash`) then HTTPX will load CA certificates from this directory instead of the default location. - -Example: - -```console -SSL_CERT_DIR=/path/to/ca-certs/ python -c "import httpx; httpx.get('https://example.com')" -``` - ## Proxies The environment variables documented below are used as a convention by various HTTP tooling, including: diff --git a/docs/http2.md b/docs/http2.md index 434606c411..3cab09d912 100644 --- a/docs/http2.md +++ b/docs/http2.md @@ -28,7 +28,7 @@ trying out our HTTP/2 support. You can do so by first making sure to install the optional HTTP/2 dependencies... ```shell -pip install httpx[http2] +$ pip install httpx[http2] ``` And then instantiating a client with HTTP/2 support enabled: diff --git a/docs/img/speakeasy.png b/docs/img/speakeasy.png new file mode 100644 index 0000000000..f8a22cca4a Binary files /dev/null and b/docs/img/speakeasy.png differ diff --git a/docs/index.md b/docs/index.md index 98bf0fd6a0..c2210bc74f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,7 +28,7 @@ HTTPX is a fully featured HTTP client for Python 3, which provides sync and asyn Install HTTPX using pip: ```shell -pip install httpx +$ pip install httpx ``` Now, let's get started: @@ -50,7 +50,7 @@ Or, using the command-line client. ```shell # The command line client is an optional dependency. -pip install 'httpx[cli]' +$ pip install 'httpx[cli]' ``` Which now allows us to use HTTPX directly from the command-line... @@ -130,19 +130,19 @@ inspiration around the lower-level networking details. Install with pip: ```shell -pip install httpx +$ pip install httpx ``` Or, to include the optional HTTP/2 support, use: ```shell -pip install httpx[http2] +$ pip install httpx[http2] ``` To include the optional brotli and zstandard decoders support, use: ```shell -pip install httpx[brotli,zstd] +$ pip install httpx[brotli,zstd] ``` HTTPX requires Python 3.8+ diff --git a/docs/logging.md b/docs/logging.md index 53ae74990d..90c21e2563 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -20,25 +20,25 @@ httpx.get("https://www.example.com") Will send debug level output to the console, or wherever `stdout` is directed too... ``` -DEBUG [2023-03-16 14:36:20] httpx - load_ssl_context verify=True cert=None trust_env=True http2=False -DEBUG [2023-03-16 14:36:20] httpx - load_verify_locations cafile='/Users/tomchristie/GitHub/encode/httpx/venv/lib/python3.10/site-packages/certifi/cacert.pem' -DEBUG [2023-03-16 14:36:20] httpcore - connection.connect_tcp.started host='www.example.com' port=443 local_address=None timeout=5.0 -DEBUG [2023-03-16 14:36:20] httpcore - connection.connect_tcp.complete return_value= -DEBUG [2023-03-16 14:36:20] httpcore - connection.start_tls.started ssl_context= server_hostname='www.example.com' timeout=5.0 -DEBUG [2023-03-16 14:36:20] httpcore - connection.start_tls.complete return_value= -DEBUG [2023-03-16 14:36:20] httpcore - http11.send_request_headers.started request= -DEBUG [2023-03-16 14:36:20] httpcore - http11.send_request_headers.complete -DEBUG [2023-03-16 14:36:20] httpcore - http11.send_request_body.started request= -DEBUG [2023-03-16 14:36:20] httpcore - http11.send_request_body.complete -DEBUG [2023-03-16 14:36:20] httpcore - http11.receive_response_headers.started request= -DEBUG [2023-03-16 14:36:21] httpcore - http11.receive_response_headers.complete return_value=(b'HTTP/1.1', 200, b'OK', [(b'Content-Encoding', b'gzip'), (b'Accept-Ranges', b'bytes'), (b'Age', b'507675'), (b'Cache-Control', b'max-age=604800'), (b'Content-Type', b'text/html; charset=UTF-8'), (b'Date', b'Thu, 16 Mar 2023 14:36:21 GMT'), (b'Etag', b'"3147526947+ident"'), (b'Expires', b'Thu, 23 Mar 2023 14:36:21 GMT'), (b'Last-Modified', b'Thu, 17 Oct 2019 07:18:26 GMT'), (b'Server', b'ECS (nyb/1D2E)'), (b'Vary', b'Accept-Encoding'), (b'X-Cache', b'HIT'), (b'Content-Length', b'648')]) -INFO [2023-03-16 14:36:21] httpx - HTTP Request: GET https://www.example.com "HTTP/1.1 200 OK" -DEBUG [2023-03-16 14:36:21] httpcore - http11.receive_response_body.started request= -DEBUG [2023-03-16 14:36:21] httpcore - http11.receive_response_body.complete -DEBUG [2023-03-16 14:36:21] httpcore - http11.response_closed.started -DEBUG [2023-03-16 14:36:21] httpcore - http11.response_closed.complete -DEBUG [2023-03-16 14:36:21] httpcore - connection.close.started -DEBUG [2023-03-16 14:36:21] httpcore - connection.close.complete +DEBUG [2024-09-28 17:27:40] httpx - load_ssl_context verify=True cert=None +DEBUG [2024-09-28 17:27:40] httpx - load_verify_locations cafile='/Users/karenpetrosyan/oss/karhttpx/.venv/lib/python3.9/site-packages/certifi/cacert.pem' +DEBUG [2024-09-28 17:27:40] httpcore.connection - connect_tcp.started host='www.example.com' port=443 local_address=None timeout=5.0 socket_options=None +DEBUG [2024-09-28 17:27:41] httpcore.connection - connect_tcp.complete return_value= +DEBUG [2024-09-28 17:27:41] httpcore.connection - start_tls.started ssl_context=SSLContext(verify=True) server_hostname='www.example.com' timeout=5.0 +DEBUG [2024-09-28 17:27:41] httpcore.connection - start_tls.complete return_value= +DEBUG [2024-09-28 17:27:41] httpcore.http11 - send_request_headers.started request= +DEBUG [2024-09-28 17:27:41] httpcore.http11 - send_request_headers.complete +DEBUG [2024-09-28 17:27:41] httpcore.http11 - send_request_body.started request= +DEBUG [2024-09-28 17:27:41] httpcore.http11 - send_request_body.complete +DEBUG [2024-09-28 17:27:41] httpcore.http11 - receive_response_headers.started request= +DEBUG [2024-09-28 17:27:41] httpcore.http11 - receive_response_headers.complete return_value=(b'HTTP/1.1', 200, b'OK', [(b'Content-Encoding', b'gzip'), (b'Accept-Ranges', b'bytes'), (b'Age', b'407727'), (b'Cache-Control', b'max-age=604800'), (b'Content-Type', b'text/html; charset=UTF-8'), (b'Date', b'Sat, 28 Sep 2024 13:27:42 GMT'), (b'Etag', b'"3147526947+gzip"'), (b'Expires', b'Sat, 05 Oct 2024 13:27:42 GMT'), (b'Last-Modified', b'Thu, 17 Oct 2019 07:18:26 GMT'), (b'Server', b'ECAcc (dcd/7D43)'), (b'Vary', b'Accept-Encoding'), (b'X-Cache', b'HIT'), (b'Content-Length', b'648')]) +INFO [2024-09-28 17:27:41] httpx - HTTP Request: GET https://www.example.com "HTTP/1.1 200 OK" +DEBUG [2024-09-28 17:27:41] httpcore.http11 - receive_response_body.started request= +DEBUG [2024-09-28 17:27:41] httpcore.http11 - receive_response_body.complete +DEBUG [2024-09-28 17:27:41] httpcore.http11 - response_closed.started +DEBUG [2024-09-28 17:27:41] httpcore.http11 - response_closed.complete +DEBUG [2024-09-28 17:27:41] httpcore.connection - close.started +DEBUG [2024-09-28 17:27:41] httpcore.connection - close.complete ``` Logging output includes information from both the high-level `httpx` logger, and the network-level `httpcore` logger, which can be configured separately. diff --git a/docs/overrides/partials/nav.html b/docs/overrides/partials/nav.html new file mode 100644 index 0000000000..d5a413f013 --- /dev/null +++ b/docs/overrides/partials/nav.html @@ -0,0 +1,54 @@ +{% import "partials/nav-item.html" as item with context %} + + + {% set class = "md-nav md-nav--primary" %} + {% if "navigation.tabs" in features %} + {% set class = class ~ " md-nav--lifted" %} + {% endif %} + {% if "toc.integrate" in features %} + {% set class = class ~ " md-nav--integrated" %} + {% endif %} + + + + \ No newline at end of file diff --git a/docs/third_party_packages.md b/docs/third_party_packages.md index f6ce96d702..78ecc5a7fa 100644 --- a/docs/third_party_packages.md +++ b/docs/third_party_packages.md @@ -4,7 +4,17 @@ As HTTPX usage grows, there is an expanding community of developers building too ## Plugins - +### httpx-ws + +[GitHub](https://github.com/frankie567/httpx-ws) - [Documentation](https://frankie567.github.io/httpx-ws/) + +WebSocket support for HTTPX. + +### httpx-socks + +[GitHub](https://github.com/romis2012/httpx-socks) + +Proxy (HTTP, SOCKS) transports for httpx. ### Hishel diff --git a/httpx/__version__.py b/httpx/__version__.py index 5eaaddbac9..0a684ac3a9 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 4e98b60694..c3cda1ecda 100644 --- a/httpx/_api.py +++ b/httpx/_api.py @@ -8,20 +8,21 @@ from ._models import Response from ._types import ( AuthTypes, - CertTypes, CookieTypes, HeaderTypes, - ProxiesTypes, ProxyTypes, QueryParamTypes, RequestContent, RequestData, RequestFiles, TimeoutTypes, - VerifyTypes, ) from ._urls import URL +if typing.TYPE_CHECKING: + import ssl # pragma: no cover + + __all__ = [ "delete", "get", @@ -48,11 +49,9 @@ def request( cookies: CookieTypes | None = None, auth: AuthTypes | None = None, proxy: ProxyTypes | None = None, - proxies: ProxiesTypes | None = None, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, follow_redirects: bool = False, - verify: VerifyTypes = True, - cert: CertTypes | None = None, + verify: ssl.SSLContext | str | bool = True, trust_env: bool = True, ) -> Response: """ @@ -80,18 +79,12 @@ def request( * **auth** - *(optional)* An authentication class to use when sending the request. * **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 the request. * **follow_redirects** - *(optional)* Enables or disables HTTP redirects. - * **verify** - *(optional)* SSL certificates (a.k.a CA bundle) used to - verify the identity of requested hosts. Either `True` (default CA bundle), - a path to an SSL certificate file, an `ssl.SSLContext`, or `False` - (which will disable verification). - * **cert** - *(optional)* An SSL certificate used by the requested host - to authenticate the client. Either a path to an SSL certificate file, or - two-tuple of (certificate file, key file), or a three-tuple of (certificate - file, key file, password). + * **verify** - *(optional)* Either `True` to use an SSL context with the + default CA bundle, `False` to disable verification, or an instance of + `ssl.SSLContext` to use a custom context. * **trust_env** - *(optional)* Enables or disables usage of environment variables for configuration. @@ -109,8 +102,6 @@ def request( with Client( cookies=cookies, proxy=proxy, - proxies=proxies, - cert=cert, verify=verify, timeout=timeout, trust_env=trust_env, @@ -143,11 +134,9 @@ def stream( cookies: CookieTypes | None = None, auth: AuthTypes | None = None, proxy: ProxyTypes | None = None, - proxies: ProxiesTypes | None = None, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, follow_redirects: bool = False, - verify: VerifyTypes = True, - cert: CertTypes | None = None, + verify: ssl.SSLContext | str | bool = True, trust_env: bool = True, ) -> typing.Iterator[Response]: """ @@ -163,8 +152,6 @@ def stream( with Client( cookies=cookies, proxy=proxy, - proxies=proxies, - cert=cert, verify=verify, timeout=timeout, trust_env=trust_env, @@ -192,10 +179,8 @@ def get( cookies: CookieTypes | None = None, auth: AuthTypes | None = None, proxy: ProxyTypes | None = None, - proxies: ProxiesTypes | None = None, follow_redirects: bool = False, - cert: CertTypes | None = None, - verify: VerifyTypes = True, + verify: ssl.SSLContext | str | bool = True, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, trust_env: bool = True, ) -> Response: @@ -215,9 +200,7 @@ def get( cookies=cookies, auth=auth, proxy=proxy, - proxies=proxies, follow_redirects=follow_redirects, - cert=cert, verify=verify, timeout=timeout, trust_env=trust_env, @@ -232,10 +215,8 @@ def options( cookies: CookieTypes | None = None, auth: AuthTypes | None = None, proxy: ProxyTypes | None = None, - proxies: ProxiesTypes | None = None, follow_redirects: bool = False, - cert: CertTypes | None = None, - verify: VerifyTypes = True, + verify: ssl.SSLContext | str | bool = True, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, trust_env: bool = True, ) -> Response: @@ -255,9 +236,7 @@ def options( cookies=cookies, auth=auth, proxy=proxy, - proxies=proxies, follow_redirects=follow_redirects, - cert=cert, verify=verify, timeout=timeout, trust_env=trust_env, @@ -272,10 +251,8 @@ def head( cookies: CookieTypes | None = None, auth: AuthTypes | None = None, proxy: ProxyTypes | None = None, - proxies: ProxiesTypes | None = None, follow_redirects: bool = False, - cert: CertTypes | None = None, - verify: VerifyTypes = True, + verify: ssl.SSLContext | str | bool = True, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, trust_env: bool = True, ) -> Response: @@ -295,9 +272,7 @@ def head( cookies=cookies, auth=auth, proxy=proxy, - proxies=proxies, follow_redirects=follow_redirects, - cert=cert, verify=verify, timeout=timeout, trust_env=trust_env, @@ -316,10 +291,8 @@ def post( cookies: CookieTypes | None = None, auth: AuthTypes | None = None, proxy: ProxyTypes | None = None, - proxies: ProxiesTypes | None = None, follow_redirects: bool = False, - cert: CertTypes | None = None, - verify: VerifyTypes = True, + verify: ssl.SSLContext | str | bool = True, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, trust_env: bool = True, ) -> Response: @@ -340,9 +313,7 @@ def post( cookies=cookies, auth=auth, proxy=proxy, - proxies=proxies, follow_redirects=follow_redirects, - cert=cert, verify=verify, timeout=timeout, trust_env=trust_env, @@ -361,10 +332,8 @@ def put( cookies: CookieTypes | None = None, auth: AuthTypes | None = None, proxy: ProxyTypes | None = None, - proxies: ProxiesTypes | None = None, follow_redirects: bool = False, - cert: CertTypes | None = None, - verify: VerifyTypes = True, + verify: ssl.SSLContext | str | bool = True, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, trust_env: bool = True, ) -> Response: @@ -385,9 +354,7 @@ def put( cookies=cookies, auth=auth, proxy=proxy, - proxies=proxies, follow_redirects=follow_redirects, - cert=cert, verify=verify, timeout=timeout, trust_env=trust_env, @@ -406,10 +373,8 @@ def patch( cookies: CookieTypes | None = None, auth: AuthTypes | None = None, proxy: ProxyTypes | None = None, - proxies: ProxiesTypes | None = None, follow_redirects: bool = False, - cert: CertTypes | None = None, - verify: VerifyTypes = True, + verify: ssl.SSLContext | str | bool = True, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, trust_env: bool = True, ) -> Response: @@ -430,9 +395,7 @@ def patch( cookies=cookies, auth=auth, proxy=proxy, - proxies=proxies, follow_redirects=follow_redirects, - cert=cert, verify=verify, timeout=timeout, trust_env=trust_env, @@ -447,11 +410,9 @@ def delete( cookies: CookieTypes | None = None, auth: AuthTypes | None = None, proxy: ProxyTypes | None = None, - proxies: ProxiesTypes | None = None, follow_redirects: bool = False, - cert: CertTypes | None = None, - verify: VerifyTypes = True, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, + verify: ssl.SSLContext | str | bool = True, trust_env: bool = True, ) -> Response: """ @@ -470,9 +431,7 @@ def delete( cookies=cookies, auth=auth, proxy=proxy, - proxies=proxies, follow_redirects=follow_redirects, - cert=cert, verify=verify, timeout=timeout, trust_env=trust_env, diff --git a/httpx/_client.py b/httpx/_client.py index 26610f6e87..018d440c17 100644 --- a/httpx/_client.py +++ b/httpx/_client.py @@ -3,6 +3,7 @@ import datetime import enum import logging +import time import typing import warnings from contextlib import asynccontextmanager, contextmanager @@ -27,17 +28,14 @@ ) from ._models import Cookies, Headers, Request, Response from ._status_codes import codes -from ._transports.asgi import ASGITransport from ._transports.base import AsyncBaseTransport, BaseTransport from ._transports.default import AsyncHTTPTransport, HTTPTransport -from ._transports.wsgi import WSGITransport from ._types import ( AsyncByteStream, AuthTypes, CertTypes, CookieTypes, HeaderTypes, - ProxiesTypes, ProxyTypes, QueryParamTypes, RequestContent, @@ -46,16 +44,12 @@ RequestFiles, SyncByteStream, TimeoutTypes, - VerifyTypes, ) from ._urls import URL, QueryParams -from ._utils import ( - Timer, - URLPattern, - get_environment_proxies, - is_https_redirect, - same_origin, -) +from ._utils import URLPattern, get_environment_proxies + +if typing.TYPE_CHECKING: + import ssl # pragma: no cover __all__ = ["USE_CLIENT_DEFAULT", "AsyncClient", "Client"] @@ -65,6 +59,38 @@ U = typing.TypeVar("U", bound="AsyncClient") +def _is_https_redirect(url: URL, location: URL) -> bool: + """ + Return 'True' if 'location' is a HTTPS upgrade of 'url' + """ + if url.host != location.host: + return False + + return ( + url.scheme == "http" + and _port_or_default(url) == 80 + and location.scheme == "https" + and _port_or_default(location) == 443 + ) + + +def _port_or_default(url: URL) -> int | None: + if url.port is not None: + return url.port + return {"http": 80, "https": 443}.get(url.scheme) + + +def _same_origin(url: URL, other: URL) -> bool: + """ + Return 'True' if the given URLs share the same origin. + """ + return ( + url.scheme == other.scheme + and url.host == other.host + and _port_or_default(url) == _port_or_default(other) + ) + + class UseClientDefault: """ For some parameters such as `auth=...` and `timeout=...` we need to be able @@ -117,19 +143,19 @@ class BoundSyncStream(SyncByteStream): """ def __init__( - self, stream: SyncByteStream, response: Response, timer: Timer + self, stream: SyncByteStream, response: Response, start: float ) -> None: self._stream = stream self._response = response - self._timer = timer + self._start = start def __iter__(self) -> typing.Iterator[bytes]: for chunk in self._stream: yield chunk def close(self) -> None: - seconds = self._timer.sync_elapsed() - self._response.elapsed = datetime.timedelta(seconds=seconds) + elapsed = time.perf_counter() - self._start + self._response.elapsed = datetime.timedelta(seconds=elapsed) self._stream.close() @@ -140,19 +166,19 @@ class BoundAsyncStream(AsyncByteStream): """ def __init__( - self, stream: AsyncByteStream, response: Response, timer: Timer + self, stream: AsyncByteStream, response: Response, start: float ) -> None: self._stream = stream self._response = response - self._timer = timer + self._start = start async def __aiter__(self) -> typing.AsyncIterator[bytes]: async for chunk in self._stream: yield chunk async def aclose(self) -> None: - seconds = await self._timer.async_elapsed() - self._response.elapsed = datetime.timedelta(seconds=seconds) + elapsed = time.perf_counter() - self._start + self._response.elapsed = datetime.timedelta(seconds=elapsed) await self._stream.aclose() @@ -211,23 +237,17 @@ def _enforce_trailing_slash(self, url: URL) -> URL: return url.copy_with(raw_path=url.raw_path + b"/") def _get_proxy_map( - self, proxies: ProxiesTypes | None, allow_env_proxies: bool + self, proxy: ProxyTypes | None, allow_env_proxies: bool ) -> dict[str, Proxy | None]: - if proxies is None: + if proxy is None: if allow_env_proxies: return { key: None if url is None else Proxy(url=url) for key, url in get_environment_proxies().items() } return {} - if isinstance(proxies, dict): - new_proxies = {} - for key, value in proxies.items(): - proxy = Proxy(url=value) if isinstance(value, (str, URL)) else value - new_proxies[str(key)] = proxy - return new_proxies else: - proxy = Proxy(url=proxies) if isinstance(proxies, (str, URL)) else proxies + proxy = Proxy(url=proxy) if isinstance(proxy, (str, URL)) else proxy return {"all://": proxy} @property @@ -529,8 +549,8 @@ def _redirect_headers(self, request: Request, url: URL, method: str) -> Headers: """ headers = Headers(request.headers) - if not same_origin(url, request.url): - if not is_https_redirect(request.url, url): + if not _same_origin(url, request.url): + if not _is_https_redirect(request.url, url): # Strip Authorization headers when responses are redirected # away from the origin. (Except for direct HTTP to HTTPS redirects.) headers.pop("Authorization", None) @@ -594,14 +614,9 @@ class Client(BaseClient): sending requests. * **cookies** - *(optional)* Dictionary of Cookie items to include when sending requests. - * **verify** - *(optional)* SSL certificates (a.k.a CA bundle) used to - verify the identity of requested hosts. Either `True` (default CA bundle), - a path to an SSL certificate file, an `ssl.SSLContext`, or `False` - (which will disable verification). - * **cert** - *(optional)* An SSL certificate used by the requested host - to authenticate the client. Either a path to an SSL certificate file, or - two-tuple of (certificate file, key file), or a three-tuple of (certificate - file, key file, password). + * **verify** - *(optional)* Either `True` to use an SSL context with the + default CA bundle, `False` to disable verification, or an instance of + `ssl.SSLContext` to use a custom context. * **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. @@ -616,8 +631,6 @@ class Client(BaseClient): request URLs. * **transport** - *(optional)* A transport class to use for sending requests over the network. - * **app** - *(optional)* An WSGI application to send requests to, - rather than sending actual network requests. * **trust_env** - *(optional)* Enables or disables usage of environment variables for configuration. * **default_encoding** - *(optional)* The default encoding to use for decoding @@ -632,12 +645,12 @@ def __init__( params: QueryParamTypes | None = None, headers: HeaderTypes | None = None, cookies: CookieTypes | None = None, - verify: VerifyTypes = 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, - proxies: ProxiesTypes | None = None, mounts: None | (typing.Mapping[str, BaseTransport | None]) = None, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, follow_redirects: bool = False, @@ -646,8 +659,6 @@ def __init__( event_hooks: None | (typing.Mapping[str, list[EventHook]]) = None, base_url: URL | str = "", transport: BaseTransport | None = None, - app: typing.Callable[..., typing.Any] | None = None, - trust_env: bool = True, default_encoding: str | typing.Callable[[bytes], str] = "utf-8", ) -> None: super().__init__( @@ -673,34 +684,17 @@ def __init__( "Make sure to install httpx using `pip install httpx[http2]`." ) from None - if proxies: - message = ( - "The 'proxies' argument is now deprecated." - " Use 'proxy' or 'mounts' instead." - ) - warnings.warn(message, DeprecationWarning) - if proxy: - raise RuntimeError("Use either `proxy` or 'proxies', not both.") - - if app: - message = ( - "The 'app' shortcut is now deprecated." - " Use the explicit style 'transport=WSGITransport(app=...)' instead." - ) - warnings.warn(message, DeprecationWarning) - - allow_env_proxies = trust_env and app is None and transport is None - proxy_map = self._get_proxy_map(proxies or proxy, allow_env_proxies) + allow_env_proxies = trust_env and transport is None + proxy_map = self._get_proxy_map(proxy, allow_env_proxies) self._transport = self._init_transport( verify=verify, cert=cert, + trust_env=trust_env, http1=http1, http2=http2, limits=limits, transport=transport, - app=app, - trust_env=trust_env, ) self._mounts: dict[URLPattern, BaseTransport | None] = { URLPattern(key): None @@ -709,10 +703,10 @@ def __init__( proxy, verify=verify, cert=cert, + trust_env=trust_env, http1=http1, http2=http2, limits=limits, - trust_env=trust_env, ) for key, proxy in proxy_map.items() } @@ -725,47 +719,43 @@ def __init__( def _init_transport( self, - verify: VerifyTypes = 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, transport: BaseTransport | None = None, - app: typing.Callable[..., typing.Any] | None = None, - trust_env: bool = True, ) -> BaseTransport: if transport is not None: return transport - if app is not None: - return WSGITransport(app=app) - return HTTPTransport( verify=verify, cert=cert, + trust_env=trust_env, http1=http1, http2=http2, limits=limits, - trust_env=trust_env, ) def _init_proxy_transport( self, proxy: Proxy, - verify: VerifyTypes = 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, - trust_env: bool = True, ) -> BaseTransport: return HTTPTransport( verify=verify, cert=cert, + trust_env=trust_env, http1=http1, http2=http2, limits=limits, - trust_env=trust_env, proxy=proxy, ) @@ -819,7 +809,7 @@ def request( "the expected behaviour on cookie persistence is ambiguous. Set " "cookies directly on the client instance instead." ) - warnings.warn(message, DeprecationWarning) + warnings.warn(message, DeprecationWarning, stacklevel=2) request = self.build_request( method=method, @@ -1015,8 +1005,7 @@ def _send_single_request(self, request: Request) -> Response: Sends a single request, without handling any redirections. """ transport = self._transport_for_url(request.url) - timer = Timer() - timer.sync_start() + start = time.perf_counter() if not isinstance(request.stream, SyncByteStream): raise RuntimeError( @@ -1030,7 +1019,7 @@ def _send_single_request(self, request: Request) -> Response: response.request = request response.stream = BoundSyncStream( - response.stream, response=response, timer=timer + response.stream, response=response, start=start ) self.cookies.extract_cookies(response) response.default_encoding = self._default_encoding @@ -1341,19 +1330,12 @@ class AsyncClient(BaseClient): sending requests. * **cookies** - *(optional)* Dictionary of Cookie items to include when sending requests. - * **verify** - *(optional)* SSL certificates (a.k.a CA bundle) used to - verify the identity of requested hosts. Either `True` (default CA bundle), - a path to an SSL certificate file, an `ssl.SSLContext`, or `False` - (which will disable verification). - * **cert** - *(optional)* An SSL certificate used by the requested host - to authenticate the client. Either a path to an SSL certificate file, or - two-tuple of (certificate file, key file), or a three-tuple of (certificate - file, key file, password). + * **verify** - *(optional)* Either `True` to use an SSL context with the + default CA bundle, `False` to disable verification, or an instance of + `ssl.SSLContext` to use a custom context. * **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 HTTP protocols to proxy - URLs. * **timeout** - *(optional)* The timeout configuration to use when sending requests. * **limits** - *(optional)* The limits configuration to use. @@ -1363,8 +1345,6 @@ class AsyncClient(BaseClient): request URLs. * **transport** - *(optional)* A transport class to use for sending requests over the network. - * **app** - *(optional)* An ASGI application to send requests to, - rather than sending actual network requests. * **trust_env** - *(optional)* Enables or disables usage of environment variables for configuration. * **default_encoding** - *(optional)* The default encoding to use for decoding @@ -1379,12 +1359,11 @@ def __init__( params: QueryParamTypes | None = None, headers: HeaderTypes | None = None, cookies: CookieTypes | None = None, - verify: VerifyTypes = True, + verify: ssl.SSLContext | str | bool = True, cert: CertTypes | None = None, http1: bool = True, http2: bool = False, proxy: ProxyTypes | None = None, - proxies: ProxiesTypes | None = None, mounts: None | (typing.Mapping[str, AsyncBaseTransport | None]) = None, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, follow_redirects: bool = False, @@ -1393,7 +1372,6 @@ def __init__( event_hooks: None | (typing.Mapping[str, list[EventHook]]) = None, base_url: URL | str = "", transport: AsyncBaseTransport | None = None, - app: typing.Callable[..., typing.Any] | None = None, trust_env: bool = True, default_encoding: str | typing.Callable[[bytes], str] = "utf-8", ) -> None: @@ -1420,34 +1398,17 @@ def __init__( "Make sure to install httpx using `pip install httpx[http2]`." ) from None - if proxies: - message = ( - "The 'proxies' argument is now deprecated." - " Use 'proxy' or 'mounts' instead." - ) - warnings.warn(message, DeprecationWarning) - if proxy: - raise RuntimeError("Use either `proxy` or 'proxies', not both.") - - if app: - message = ( - "The 'app' shortcut is now deprecated." - " Use the explicit style 'transport=ASGITransport(app=...)' instead." - ) - warnings.warn(message, DeprecationWarning) - - allow_env_proxies = trust_env and app is None and transport is None - proxy_map = self._get_proxy_map(proxies or proxy, allow_env_proxies) + allow_env_proxies = trust_env and transport is None + proxy_map = self._get_proxy_map(proxy, allow_env_proxies) self._transport = self._init_transport( verify=verify, cert=cert, + trust_env=trust_env, http1=http1, http2=http2, limits=limits, transport=transport, - app=app, - trust_env=trust_env, ) self._mounts: dict[URLPattern, AsyncBaseTransport | None] = { @@ -1457,10 +1418,10 @@ def __init__( proxy, verify=verify, cert=cert, + trust_env=trust_env, http1=http1, http2=http2, limits=limits, - trust_env=trust_env, ) for key, proxy in proxy_map.items() } @@ -1472,47 +1433,43 @@ def __init__( def _init_transport( self, - verify: VerifyTypes = 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, transport: AsyncBaseTransport | None = None, - app: typing.Callable[..., typing.Any] | None = None, - trust_env: bool = True, ) -> AsyncBaseTransport: if transport is not None: return transport - if app is not None: - return ASGITransport(app=app) - return AsyncHTTPTransport( verify=verify, cert=cert, + trust_env=trust_env, http1=http1, http2=http2, limits=limits, - trust_env=trust_env, ) def _init_proxy_transport( self, proxy: Proxy, - verify: VerifyTypes = 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, - trust_env: bool = True, ) -> AsyncBaseTransport: return AsyncHTTPTransport( verify=verify, cert=cert, + trust_env=trust_env, http1=http1, http2=http2, limits=limits, - trust_env=trust_env, proxy=proxy, ) @@ -1567,7 +1524,7 @@ async def request( "the expected behaviour on cookie persistence is ambiguous. Set " "cookies directly on the client instance instead." ) - warnings.warn(message, DeprecationWarning) + warnings.warn(message, DeprecationWarning, stacklevel=2) request = self.build_request( method=method, @@ -1764,8 +1721,7 @@ async def _send_single_request(self, request: Request) -> Response: Sends a single request, without handling any redirections. """ transport = self._transport_for_url(request.url) - timer = Timer() - await timer.async_start() + start = time.perf_counter() if not isinstance(request.stream, AsyncByteStream): raise RuntimeError( @@ -1778,7 +1734,7 @@ async def _send_single_request(self, request: Request) -> Response: assert isinstance(response.stream, AsyncByteStream) response.request = request response.stream = BoundAsyncStream( - response.stream, response=response, timer=timer + response.stream, response=response, start=start ) self.cookies.extract_cookies(response) response.default_encoding = self._default_encoding diff --git a/httpx/_compat.py b/httpx/_compat.py deleted file mode 100644 index 7d86dced46..0000000000 --- a/httpx/_compat.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -The _compat module is used for code which requires branching between different -Python environments. It is excluded from the code coverage checks. -""" - -import re -import ssl -import sys -from types import ModuleType -from typing import Optional - -# Brotli support is optional -# The C bindings in `brotli` are recommended for CPython. -# The CFFI bindings in `brotlicffi` are recommended for PyPy and everything else. -try: - import brotlicffi as brotli -except ImportError: # pragma: no cover - try: - import brotli - except ImportError: - brotli = None - -# Zstandard support is optional -zstd: Optional[ModuleType] = None -try: - import zstandard as zstd -except (AttributeError, ImportError, ValueError): # Defensive: - zstd = None -else: - # The package 'zstandard' added the 'eof' property starting - # in v0.18.0 which we require to ensure a complete and - # valid zstd stream was fed into the ZstdDecoder. - # See: https://github.com/urllib3/urllib3/pull/2624 - _zstd_version = tuple( - map(int, re.search(r"^([0-9]+)\.([0-9]+)", zstd.__version__).groups()) # type: ignore[union-attr] - ) - if _zstd_version < (0, 18): # Defensive: - zstd = None - - -if sys.version_info >= (3, 10) or ssl.OPENSSL_VERSION_INFO >= (1, 1, 0, 7): - - def set_minimum_tls_version_1_2(context: ssl.SSLContext) -> None: - # The OP_NO_SSL* and OP_NO_TLS* become deprecated in favor of - # 'SSLContext.minimum_version' from Python 3.7 onwards, however - # this attribute is not available unless the ssl module is compiled - # with OpenSSL 1.1.0g or newer. - # https://docs.python.org/3.10/library/ssl.html#ssl.SSLContext.minimum_version - # https://docs.python.org/3.7/library/ssl.html#ssl.SSLContext.minimum_version - context.minimum_version = ssl.TLSVersion.TLSv1_2 - -else: - - def set_minimum_tls_version_1_2(context: ssl.SSLContext) -> None: - # If 'minimum_version' isn't available, we configure these options with - # the older deprecated variants. - context.options |= ssl.OP_NO_SSLv2 - context.options |= ssl.OP_NO_SSLv3 - context.options |= ssl.OP_NO_TLSv1 - context.options |= ssl.OP_NO_TLSv1_1 - - -__all__ = ["brotli", "set_minimum_tls_version_1_2"] diff --git a/httpx/_config.py b/httpx/_config.py index 1b12911faf..dbd2b46cd1 100644 --- a/httpx/_config.py +++ b/httpx/_config.py @@ -1,42 +1,16 @@ from __future__ import annotations -import logging import os -import ssl import typing -from pathlib import Path -import certifi - -from ._compat import set_minimum_tls_version_1_2 from ._models import Headers -from ._types import CertTypes, HeaderTypes, TimeoutTypes, VerifyTypes +from ._types import CertTypes, HeaderTypes, TimeoutTypes from ._urls import URL -from ._utils import get_ca_bundle_from_env -__all__ = ["Limits", "Proxy", "Timeout", "create_ssl_context"] +if typing.TYPE_CHECKING: + import ssl # pragma: no cover -DEFAULT_CIPHERS = ":".join( - [ - "ECDHE+AESGCM", - "ECDHE+CHACHA20", - "DHE+AESGCM", - "DHE+CHACHA20", - "ECDH+AESGCM", - "DH+AESGCM", - "ECDH+AES", - "DH+AES", - "RSA+AESGCM", - "RSA+AES", - "!aNULL", - "!eNULL", - "!MD5", - "!DSS", - ] -) - - -logger = logging.getLogger("httpx") +__all__ = ["Limits", "Proxy", "Timeout", "create_ssl_context"] class UnsetType: @@ -47,150 +21,53 @@ class UnsetType: def create_ssl_context( + verify: ssl.SSLContext | str | bool = True, cert: CertTypes | None = None, - verify: VerifyTypes = True, trust_env: bool = True, - http2: bool = False, ) -> ssl.SSLContext: - return SSLConfig( - cert=cert, verify=verify, trust_env=trust_env, http2=http2 - ).ssl_context - + import ssl + import warnings -class SSLConfig: - """ - SSL Configuration. - """ + import certifi - DEFAULT_CA_BUNDLE_PATH = Path(certifi.where()) - - def __init__( - self, - *, - cert: CertTypes | None = None, - verify: VerifyTypes = True, - trust_env: bool = True, - http2: bool = False, - ) -> None: - self.cert = cert - self.verify = verify - self.trust_env = trust_env - self.http2 = http2 - self.ssl_context = self.load_ssl_context() - - def load_ssl_context(self) -> ssl.SSLContext: - logger.debug( - "load_ssl_context verify=%r cert=%r trust_env=%r http2=%r", - self.verify, - self.cert, - self.trust_env, - self.http2, + if verify is True: + 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 + 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." ) - - if self.verify: - return self.load_ssl_context_verify() - return self.load_ssl_context_no_verify() - - def load_ssl_context_no_verify(self) -> ssl.SSLContext: - """ - Return an SSL context for unverified connections. - """ - context = self._create_default_ssl_context() - context.check_hostname = False - context.verify_mode = ssl.CERT_NONE - self._load_client_certs(context) - return context - - def load_ssl_context_verify(self) -> ssl.SSLContext: - """ - Return an SSL context for verified connections. - """ - if self.trust_env and self.verify is True: - ca_bundle = get_ca_bundle_from_env() - if ca_bundle is not None: - self.verify = ca_bundle - - if isinstance(self.verify, ssl.SSLContext): - # Allow passing in our own SSLContext object that's pre-configured. - context = self.verify - self._load_client_certs(context) - return context - elif isinstance(self.verify, bool): - ca_bundle_path = self.DEFAULT_CA_BUNDLE_PATH - elif Path(self.verify).exists(): - ca_bundle_path = Path(self.verify) + 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 + + 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: - raise IOError( - "Could not find a suitable TLS CA certificate bundle, " - "invalid path: {}".format(self.verify) - ) - - context = self._create_default_ssl_context() - context.verify_mode = ssl.CERT_REQUIRED - context.check_hostname = True - - # Signal to server support for PHA in TLS 1.3. Raises an - # AttributeError if only read-only access is implemented. - try: - context.post_handshake_auth = True - except AttributeError: # pragma: no cover - pass - - # Disable using 'commonName' for SSLContext.check_hostname - # when the 'subjectAltName' extension isn't available. - try: - context.hostname_checks_common_name = False - except AttributeError: # pragma: no cover - pass - - if ca_bundle_path.is_file(): - cafile = str(ca_bundle_path) - logger.debug("load_verify_locations cafile=%r", cafile) - context.load_verify_locations(cafile=cafile) - elif ca_bundle_path.is_dir(): - capath = str(ca_bundle_path) - logger.debug("load_verify_locations capath=%r", capath) - context.load_verify_locations(capath=capath) - - self._load_client_certs(context) - - return context - - def _create_default_ssl_context(self) -> ssl.SSLContext: - """ - Creates the default SSLContext object that's used for both verified - and unverified connections. - """ - context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - set_minimum_tls_version_1_2(context) - context.options |= ssl.OP_NO_COMPRESSION - context.set_ciphers(DEFAULT_CIPHERS) - - if ssl.HAS_ALPN: - alpn_idents = ["http/1.1", "h2"] if self.http2 else ["http/1.1"] - context.set_alpn_protocols(alpn_idents) - - keylogfile = os.environ.get("SSLKEYLOGFILE") - if keylogfile and self.trust_env: - context.keylog_filename = keylogfile - - return context - - def _load_client_certs(self, ssl_context: ssl.SSLContext) -> None: - """ - Loads client certificates into our SSLContext object - """ - if self.cert is not None: - if isinstance(self.cert, str): - ssl_context.load_cert_chain(certfile=self.cert) - elif isinstance(self.cert, tuple) and len(self.cert) == 2: - ssl_context.load_cert_chain(certfile=self.cert[0], keyfile=self.cert[1]) - elif isinstance(self.cert, tuple) and len(self.cert) == 3: - ssl_context.load_cert_chain( - certfile=self.cert[0], - keyfile=self.cert[1], - password=self.cert[2], - ) + ctx.load_cert_chain(*cert) + + return ctx class Timeout: @@ -334,7 +211,7 @@ def __init__( url = URL(url) headers = Headers(headers) - if url.scheme not in ("http", "https", "socks5"): + if url.scheme not in ("http", "https", "socks5", "socks5h"): raise ValueError(f"Unknown scheme for proxy URL {url!r}") if url.username or url.password: diff --git a/httpx/_content.py b/httpx/_content.py index 786699f38f..6f479a0885 100644 --- a/httpx/_content.py +++ b/httpx/_content.py @@ -174,7 +174,9 @@ def encode_html(html: str) -> tuple[dict[str, str], ByteStream]: def encode_json(json: Any) -> tuple[dict[str, str], ByteStream]: - body = json_dumps(json).encode("utf-8") + body = json_dumps( + json, ensure_ascii=False, separators=(",", ":"), allow_nan=False + ).encode("utf-8") content_length = str(len(body)) content_type = "application/json" headers = {"Content-Length": content_length, "Content-Type": content_type} @@ -201,7 +203,7 @@ def encode_request( # `data=` usages. We deal with that case here, treating it # as if `content=<...>` had been supplied instead. message = "Use 'content=<...>' to upload raw bytes/text content." - warnings.warn(message, DeprecationWarning) + warnings.warn(message, DeprecationWarning, stacklevel=2) return encode_content(data) if content is not None: diff --git a/httpx/_decoders.py b/httpx/_decoders.py index 62f2c0b911..899dfada87 100644 --- a/httpx/_decoders.py +++ b/httpx/_decoders.py @@ -11,9 +11,27 @@ import typing import zlib -from ._compat import brotli, zstd from ._exceptions import DecodingError +# Brotli support is optional +try: + # The C bindings in `brotli` are recommended for CPython. + import brotli +except ImportError: # pragma: no cover + try: + # The CFFI bindings in `brotlicffi` are recommended for PyPy + # and other environments. + import brotlicffi as brotli + except ImportError: + brotli = None + + +# Zstandard support is optional +try: + import zstandard +except ImportError: # pragma: no cover + zstandard = None # type: ignore + class ContentDecoder: def decode(self, data: bytes) -> bytes: @@ -150,28 +168,32 @@ class ZStandardDecoder(ContentDecoder): # inspired by the ZstdDecoder implementation in urllib3 def __init__(self) -> None: - if zstd is None: # pragma: no cover + if zstandard is None: # pragma: no cover raise ImportError( "Using 'ZStandardDecoder', ..." "Make sure to install httpx using `pip install httpx[zstd]`." ) from None - self.decompressor = zstd.ZstdDecompressor().decompressobj() + self.decompressor = zstandard.ZstdDecompressor().decompressobj() + self.seen_data = False def decode(self, data: bytes) -> bytes: - assert zstd is not None + assert zstandard is not None + self.seen_data = True output = io.BytesIO() try: output.write(self.decompressor.decompress(data)) while self.decompressor.eof and self.decompressor.unused_data: unused_data = self.decompressor.unused_data - self.decompressor = zstd.ZstdDecompressor().decompressobj() + self.decompressor = zstandard.ZstdDecompressor().decompressobj() output.write(self.decompressor.decompress(unused_data)) - except zstd.ZstdError as exc: + except zstandard.ZstdError as exc: raise DecodingError(str(exc)) from exc 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 @@ -367,5 +389,5 @@ def flush(self) -> list[str]: if brotli is None: SUPPORTED_DECODERS.pop("br") # pragma: no cover -if zstd is None: +if zstandard is None: SUPPORTED_DECODERS.pop("zstd") # pragma: no cover diff --git a/httpx/_main.py b/httpx/_main.py index 72657f8ca3..cffa4bb7db 100644 --- a/httpx/_main.py +++ b/httpx/_main.py @@ -6,7 +6,6 @@ import typing import click -import httpcore import pygments.lexers import pygments.util import rich.console @@ -20,6 +19,9 @@ from ._models import Response from ._status_codes import codes +if typing.TYPE_CHECKING: + import httpcore # pragma: no cover + def print_help() -> None: console = rich.console.Console() @@ -474,12 +476,7 @@ def main( method = "POST" if content or data or files or json else "GET" try: - with Client( - proxy=proxy, - timeout=timeout, - verify=verify, - http2=http2, - ) as client: + with Client(proxy=proxy, timeout=timeout, http2=http2, verify=verify) as client: with client.stream( method, url, diff --git a/httpx/_models.py b/httpx/_models.py index 01d9583bc5..67d74bf86b 100644 --- a/httpx/_models.py +++ b/httpx/_models.py @@ -1,8 +1,10 @@ from __future__ import annotations +import codecs import datetime import email.message import json as jsonlib +import re import typing import urllib.request from collections.abc import Mapping @@ -44,17 +46,95 @@ SyncByteStream, ) from ._urls import URL -from ._utils import ( - is_known_encoding, - normalize_header_key, - normalize_header_value, - obfuscate_sensitive_headers, - parse_content_type_charset, - parse_header_links, -) +from ._utils import to_bytes_or_str, to_str __all__ = ["Cookies", "Headers", "Request", "Response"] +SENSITIVE_HEADERS = {"authorization", "proxy-authorization"} + + +def _is_known_encoding(encoding: str) -> bool: + """ + Return `True` if `encoding` is a known codec. + """ + try: + codecs.lookup(encoding) + except LookupError: + return False + return True + + +def _normalize_header_key(key: str | bytes, encoding: str | None = None) -> bytes: + """ + Coerce str/bytes into a strictly byte-wise HTTP header key. + """ + return key if isinstance(key, bytes) else key.encode(encoding or "ascii") + + +def _normalize_header_value(value: str | bytes, encoding: str | None = None) -> bytes: + """ + Coerce str/bytes into a strictly byte-wise HTTP header value. + """ + if isinstance(value, bytes): + return value + if not isinstance(value, str): + raise TypeError(f"Header value must be str or bytes, not {type(value)}") + return value.encode(encoding or "ascii") + + +def _parse_content_type_charset(content_type: str) -> str | None: + # We used to use `cgi.parse_header()` here, but `cgi` became a dead battery. + # See: https://peps.python.org/pep-0594/#cgi + msg = email.message.Message() + msg["content-type"] = content_type + return msg.get_content_charset(failobj=None) + + +def _parse_header_links(value: str) -> list[dict[str, str]]: + """ + Returns a list of parsed link headers, for more info see: + https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link + The generic syntax of those is: + Link: < uri-reference >; param1=value1; param2="value2" + So for instance: + Link; '; type="image/jpeg",;' + would return + [ + {"url": "http:/.../front.jpeg", "type": "image/jpeg"}, + {"url": "http://.../back.jpeg"}, + ] + :param value: HTTP Link entity-header field + :return: list of parsed link headers + """ + links: list[dict[str, str]] = [] + replace_chars = " '\"" + value = value.strip(replace_chars) + if not value: + return links + for val in re.split(", *<", value): + try: + url, params = val.split(";", 1) + except ValueError: + url, params = val, "" + link = {"url": url.strip("<> '\"")} + for param in params.split(";"): + try: + key, value = param.split("=") + except ValueError: + break + link[key.strip(replace_chars)] = value.strip(replace_chars) + links.append(link) + return links + + +def _obfuscate_sensitive_headers( + items: typing.Iterable[tuple[typing.AnyStr, typing.AnyStr]], +) -> typing.Iterator[tuple[typing.AnyStr, typing.AnyStr]]: + for k, v in items: + if to_str(k.lower()) in SENSITIVE_HEADERS: + v = to_bytes_or_str("[secure]", match_type_of=v) + yield k, v + class Headers(typing.MutableMapping[str, str]): """ @@ -66,28 +146,20 @@ def __init__( headers: HeaderTypes | None = None, encoding: str | None = None, ) -> None: - if headers is None: - self._list = [] # type: typing.List[typing.Tuple[bytes, bytes, bytes]] - elif isinstance(headers, Headers): + self._list = [] # type: typing.List[typing.Tuple[bytes, bytes, bytes]] + + if isinstance(headers, Headers): self._list = list(headers._list) elif isinstance(headers, Mapping): - self._list = [ - ( - normalize_header_key(k, lower=False, encoding=encoding), - normalize_header_key(k, lower=True, encoding=encoding), - normalize_header_value(v, encoding), - ) - for k, v in headers.items() - ] - else: - self._list = [ - ( - normalize_header_key(k, lower=False, encoding=encoding), - normalize_header_key(k, lower=True, encoding=encoding), - normalize_header_value(v, encoding), - ) - for k, v in headers - ] + for k, v in headers.items(): + bytes_key = _normalize_header_key(k, encoding) + bytes_value = _normalize_header_value(v, encoding) + self._list.append((bytes_key, bytes_key.lower(), bytes_value)) + elif headers is not None: + for k, v in headers: + bytes_key = _normalize_header_key(k, encoding) + bytes_value = _normalize_header_value(v, encoding) + self._list.append((bytes_key, bytes_key.lower(), bytes_value)) self._encoding = encoding @@ -298,7 +370,7 @@ def __repr__(self) -> str: if self.encoding != "ascii": encoding_str = f", encoding={self.encoding!r}" - as_list = list(obfuscate_sensitive_headers(self.multi_items())) + as_list = list(_obfuscate_sensitive_headers(self.multi_items())) as_dict = dict(as_list) no_duplicate_keys = len(as_dict) == len(as_list) @@ -310,7 +382,7 @@ def __repr__(self) -> str: class Request: def __init__( self, - method: str | bytes, + method: str, url: URL | str, *, params: QueryParamTypes | None = None, @@ -323,16 +395,10 @@ def __init__( stream: SyncByteStream | AsyncByteStream | None = None, extensions: RequestExtensions | None = None, ) -> None: - self.method = ( - method.decode("ascii").upper() - if isinstance(method, bytes) - else method.upper() - ) - self.url = URL(url) - if params is not None: - self.url = self.url.copy_merge_params(params=params) + 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) @@ -471,7 +537,7 @@ def __init__( # 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 @@ -597,7 +663,7 @@ def encoding(self) -> str | None: """ if not hasattr(self, "_encoding"): encoding = self.charset_encoding - if encoding is None or not is_known_encoding(encoding): + if encoding is None or not _is_known_encoding(encoding): if isinstance(self.default_encoding, str): encoding = self.default_encoding elif hasattr(self, "_content"): @@ -628,7 +694,7 @@ def charset_encoding(self) -> str | None: if content_type is None: return None - return parse_content_type_charset(content_type) + return _parse_content_type_charset(content_type) def _get_content_decoder(self) -> ContentDecoder: """ @@ -783,7 +849,7 @@ def links(self) -> dict[str | None, dict[str, str]]: return { (link.get("rel") or link.get("url")): link - for link in parse_header_links(header) + for link in _parse_header_links(header) } @property diff --git a/httpx/_multipart.py b/httpx/_multipart.py index 8edb622778..b4761af9b2 100644 --- a/httpx/_multipart.py +++ b/httpx/_multipart.py @@ -1,7 +1,9 @@ from __future__ import annotations import io +import mimetypes import os +import re import typing from pathlib import Path @@ -14,13 +16,42 @@ SyncByteStream, ) from ._utils import ( - format_form_param, - guess_content_type, peek_filelike_length, primitive_value_to_str, to_bytes, ) +_HTML5_FORM_ENCODING_REPLACEMENTS = {'"': "%22", "\\": "\\\\"} +_HTML5_FORM_ENCODING_REPLACEMENTS.update( + {chr(c): "%{:02X}".format(c) for c in range(0x1F + 1) if c != 0x1B} +) +_HTML5_FORM_ENCODING_RE = re.compile( + r"|".join([re.escape(c) for c in _HTML5_FORM_ENCODING_REPLACEMENTS.keys()]) +) + + +def _format_form_param(name: str, value: str) -> bytes: + """ + Encode a name/value pair within a multipart form. + """ + + def replacer(match: typing.Match[str]) -> str: + return _HTML5_FORM_ENCODING_REPLACEMENTS[match.group(0)] + + value = _HTML5_FORM_ENCODING_RE.sub(replacer, value) + return f'{name}="{value}"'.encode() + + +def _guess_content_type(filename: str | None) -> str | None: + """ + Guesses the mimetype based on a filename. Defaults to `application/octet-stream`. + + Returns `None` if `filename` is `None` or empty. + """ + if filename: + return mimetypes.guess_type(filename)[0] or "application/octet-stream" + return None + def get_multipart_boundary_from_content_type( content_type: bytes | None, @@ -58,7 +89,7 @@ def __init__(self, name: str, value: str | bytes | int | float | None) -> None: def render_headers(self) -> bytes: if not hasattr(self, "_headers"): - name = format_form_param("name", self.name) + name = _format_form_param("name", self.name) self._headers = b"".join( [b"Content-Disposition: form-data; ", name, b"\r\n\r\n"] ) @@ -115,7 +146,7 @@ def __init__(self, name: str, value: FileTypes) -> None: fileobj = value if content_type is None: - content_type = guess_content_type(filename) + content_type = _guess_content_type(filename) has_content_type_header = any("content-type" in key.lower() for key in headers) if content_type is not None and not has_content_type_header: @@ -156,10 +187,10 @@ def render_headers(self) -> bytes: if not hasattr(self, "_headers"): parts = [ b"Content-Disposition: form-data; ", - format_form_param("name", self.name), + _format_form_param("name", self.name), ] if self.filename: - filename = format_form_param("filename", self.filename) + filename = _format_form_param("filename", self.filename) parts.extend([b"; ", filename]) for header_name, header_value in self.headers.items(): key, val = f"\r\n{header_name}: ".encode(), header_value.encode() diff --git a/httpx/_transports/asgi.py b/httpx/_transports/asgi.py index 8578d4aeff..2bc4efae0e 100644 --- a/httpx/_transports/asgi.py +++ b/httpx/_transports/asgi.py @@ -2,8 +2,6 @@ import typing -import sniffio - from .._models import Request, Response from .._types import AsyncByteStream from .base import AsyncBaseTransport @@ -28,15 +26,30 @@ __all__ = ["ASGITransport"] +def is_running_trio() -> bool: + try: + # sniffio is a dependency of trio. + + # See https://github.com/python-trio/trio/issues/2802 + import sniffio + + if sniffio.current_async_library() == "trio": + return True + except ImportError: # pragma: nocover + pass + + return False + + def create_event() -> Event: - if sniffio.current_async_library() == "trio": + if is_running_trio(): import trio return trio.Event() - else: - import asyncio - return asyncio.Event() + import asyncio + + return asyncio.Event() class ASGIResponseStream(AsyncByteStream): diff --git a/httpx/_transports/default.py b/httpx/_transports/default.py index 33db416dd1..d5aa05ff23 100644 --- a/httpx/_transports/default.py +++ b/httpx/_transports/default.py @@ -30,7 +30,10 @@ import typing from types import TracebackType -import httpcore +if typing.TYPE_CHECKING: + import ssl # pragma: no cover + + import httpx # pragma: no cover from .._config import DEFAULT_LIMITS, Limits, Proxy, create_ssl_context from .._exceptions import ( @@ -50,7 +53,7 @@ WriteTimeout, ) from .._models import Request, Response -from .._types import AsyncByteStream, CertTypes, ProxyTypes, SyncByteStream, VerifyTypes +from .._types import AsyncByteStream, CertTypes, ProxyTypes, SyncByteStream from .._urls import URL from .base import AsyncBaseTransport, BaseTransport @@ -65,9 +68,35 @@ __all__ = ["AsyncHTTPTransport", "HTTPTransport"] +HTTPCORE_EXC_MAP: dict[type[Exception], type[httpx.HTTPError]] = {} + + +def _load_httpcore_exceptions() -> dict[type[Exception], type[httpx.HTTPError]]: + import httpcore + + return { + httpcore.TimeoutException: TimeoutException, + httpcore.ConnectTimeout: ConnectTimeout, + httpcore.ReadTimeout: ReadTimeout, + httpcore.WriteTimeout: WriteTimeout, + httpcore.PoolTimeout: PoolTimeout, + httpcore.NetworkError: NetworkError, + httpcore.ConnectError: ConnectError, + httpcore.ReadError: ReadError, + httpcore.WriteError: WriteError, + httpcore.ProxyError: ProxyError, + httpcore.UnsupportedProtocol: UnsupportedProtocol, + httpcore.ProtocolError: ProtocolError, + httpcore.LocalProtocolError: LocalProtocolError, + httpcore.RemoteProtocolError: RemoteProtocolError, + } + @contextlib.contextmanager def map_httpcore_exceptions() -> typing.Iterator[None]: + global HTTPCORE_EXC_MAP + if len(HTTPCORE_EXC_MAP) == 0: + HTTPCORE_EXC_MAP = _load_httpcore_exceptions() try: yield except Exception as exc: @@ -89,24 +118,6 @@ def map_httpcore_exceptions() -> typing.Iterator[None]: raise mapped_exc(message) from exc -HTTPCORE_EXC_MAP = { - httpcore.TimeoutException: TimeoutException, - httpcore.ConnectTimeout: ConnectTimeout, - httpcore.ReadTimeout: ReadTimeout, - httpcore.WriteTimeout: WriteTimeout, - httpcore.PoolTimeout: PoolTimeout, - httpcore.NetworkError: NetworkError, - httpcore.ConnectError: ConnectError, - httpcore.ReadError: ReadError, - httpcore.WriteError: WriteError, - httpcore.ProxyError: ProxyError, - httpcore.UnsupportedProtocol: UnsupportedProtocol, - httpcore.ProtocolError: ProtocolError, - httpcore.LocalProtocolError: LocalProtocolError, - httpcore.RemoteProtocolError: RemoteProtocolError, -} - - class ResponseStream(SyncByteStream): def __init__(self, httpcore_stream: typing.Iterable[bytes]) -> None: self._httpcore_stream = httpcore_stream @@ -124,20 +135,22 @@ def close(self) -> None: class HTTPTransport(BaseTransport): def __init__( self, - verify: VerifyTypes = 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, - trust_env: bool = True, proxy: ProxyTypes | None = None, uds: str | None = None, local_address: str | None = None, retries: int = 0, socket_options: typing.Iterable[SOCKET_OPTION] | None = None, ) -> None: - ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env) + import httpcore + proxy = Proxy(url=proxy) if isinstance(proxy, (str, URL)) else proxy + ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env) if proxy is None: self._pool = httpcore.ConnectionPool( @@ -171,7 +184,7 @@ def __init__( http2=http2, socket_options=socket_options, ) - elif proxy.url.scheme == "socks5": + elif proxy.url.scheme in ("socks5", "socks5h"): try: import socksio # noqa except ImportError: # pragma: no cover @@ -197,7 +210,7 @@ def __init__( ) else: # pragma: no cover raise ValueError( - "Proxy protocol must be either 'http', 'https', or 'socks5'," + "Proxy protocol must be either 'http', 'https', 'socks5', or 'socks5h'," f" but got {proxy.url.scheme!r}." ) @@ -219,6 +232,7 @@ def handle_request( request: Request, ) -> Response: assert isinstance(request.stream, SyncByteStream) + import httpcore req = httpcore.Request( method=request.method, @@ -265,20 +279,22 @@ async def aclose(self) -> None: class AsyncHTTPTransport(AsyncBaseTransport): def __init__( self, - verify: VerifyTypes = 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, - trust_env: bool = True, proxy: ProxyTypes | None = None, uds: str | None = None, local_address: str | None = None, retries: int = 0, socket_options: typing.Iterable[SOCKET_OPTION] | None = None, ) -> None: - ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env) + import httpcore + proxy = Proxy(url=proxy) if isinstance(proxy, (str, URL)) else proxy + ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env) if proxy is None: self._pool = httpcore.AsyncConnectionPool( @@ -312,7 +328,7 @@ def __init__( http2=http2, socket_options=socket_options, ) - elif proxy.url.scheme == "socks5": + elif proxy.url.scheme in ("socks5", "socks5h"): try: import socksio # noqa except ImportError: # pragma: no cover @@ -338,7 +354,7 @@ def __init__( ) else: # pragma: no cover raise ValueError( - "Proxy protocol must be either 'http', 'https', or 'socks5'," + "Proxy protocol must be either 'http', 'https', 'socks5', or 'socks5h'," " but got {proxy.url.scheme!r}." ) @@ -360,6 +376,7 @@ async def handle_async_request( request: Request, ) -> Response: assert isinstance(request.stream, AsyncByteStream) + import httpcore req = httpcore.Request( method=request.method, diff --git a/httpx/_types.py b/httpx/_types.py index 661af262e7..704dfdffc8 100644 --- a/httpx/_types.py +++ b/httpx/_types.py @@ -2,7 +2,6 @@ Type definitions for type checking purposes. """ -import ssl from http.cookiejar import CookieJar from typing import ( IO, @@ -16,8 +15,6 @@ Iterator, List, Mapping, - MutableMapping, - NamedTuple, Optional, Sequence, Tuple, @@ -33,16 +30,6 @@ PrimitiveData = Optional[Union[str, int, float, bool]] -RawURL = NamedTuple( - "RawURL", - [ - ("raw_scheme", bytes), - ("raw_host", bytes), - ("port", Optional[int]), - ("raw_path", bytes), - ], -) - URLTypes = Union["URL", str] QueryParamTypes = Union[ @@ -64,22 +51,13 @@ CookieTypes = Union["Cookies", CookieJar, Dict[str, str], List[Tuple[str, str]]] -CertTypes = Union[ - # certfile - str, - # (certfile, keyfile) - Tuple[str, Optional[str]], - # (certfile, keyfile, password) - Tuple[str, Optional[str], Optional[str]], -] -VerifyTypes = Union[str, bool, ssl.SSLContext] TimeoutTypes = Union[ Optional[float], Tuple[Optional[float], Optional[float], Optional[float], Optional[float]], "Timeout", ] ProxyTypes = Union["URL", str, "Proxy"] -ProxiesTypes = Union[ProxyTypes, Dict[Union["URL", str], Union[None, ProxyTypes]]] +CertTypes = Union[str, Tuple[str, str], Tuple[str, str, str]] AuthTypes = Union[ Tuple[Union[str, bytes], Union[str, bytes]], @@ -89,7 +67,7 @@ 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] @@ -106,7 +84,7 @@ ] RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]] -RequestExtensions = MutableMapping[str, Any] +RequestExtensions = Mapping[str, Any] __all__ = ["AsyncByteStream", "SyncByteStream"] diff --git a/httpx/_urlparse.py b/httpx/_urlparse.py index 479c2ef8a1..bf190fd560 100644 --- a/httpx/_urlparse.py +++ b/httpx/_urlparse.py @@ -36,6 +36,67 @@ PERCENT_ENCODED_REGEX = re.compile("%[A-Fa-f0-9]{2}") +# https://url.spec.whatwg.org/#percent-encoded-bytes + +# The fragment percent-encode set is the C0 control percent-encode set +# and U+0020 SPACE, U+0022 ("), U+003C (<), U+003E (>), and U+0060 (`). +FRAG_SAFE = "".join( + [chr(i) for i in range(0x20, 0x7F) if i not in (0x20, 0x22, 0x3C, 0x3E, 0x60)] +) + +# The query percent-encode set is the C0 control percent-encode set +# and U+0020 SPACE, U+0022 ("), U+0023 (#), U+003C (<), and U+003E (>). +QUERY_SAFE = "".join( + [chr(i) for i in range(0x20, 0x7F) if i not in (0x20, 0x22, 0x23, 0x3C, 0x3E)] +) + +# The path percent-encode set is the query percent-encode set +# and U+003F (?), U+0060 (`), U+007B ({), and U+007D (}). +PATH_SAFE = "".join( + [ + chr(i) + for i in range(0x20, 0x7F) + if i not in (0x20, 0x22, 0x23, 0x3C, 0x3E) + (0x3F, 0x60, 0x7B, 0x7D) + ] +) + +# The userinfo percent-encode set is the path percent-encode set +# and U+002F (/), U+003A (:), U+003B (;), U+003D (=), U+0040 (@), +# U+005B ([) to U+005E (^), inclusive, and U+007C (|). +USERNAME_SAFE = "".join( + [ + chr(i) + for i in range(0x20, 0x7F) + if i + not in (0x20, 0x22, 0x23, 0x3C, 0x3E) + + (0x3F, 0x60, 0x7B, 0x7D) + + (0x2F, 0x3A, 0x3B, 0x3D, 0x40, 0x5B, 0x5C, 0x5D, 0x5E, 0x7C) + ] +) +PASSWORD_SAFE = "".join( + [ + chr(i) + for i in range(0x20, 0x7F) + if i + not in (0x20, 0x22, 0x23, 0x3C, 0x3E) + + (0x3F, 0x60, 0x7B, 0x7D) + + (0x2F, 0x3A, 0x3B, 0x3D, 0x40, 0x5B, 0x5C, 0x5D, 0x5E, 0x7C) + ] +) +# Note... The terminology 'userinfo' percent-encode set in the WHATWG document +# is used for the username and password quoting. For the joint userinfo component +# we remove U+003A (:) from the safe set. +USERINFO_SAFE = "".join( + [ + chr(i) + for i in range(0x20, 0x7F) + if i + not in (0x20, 0x22, 0x23, 0x3C, 0x3E) + + (0x3F, 0x60, 0x7B, 0x7D) + + (0x2F, 0x3B, 0x3D, 0x40, 0x5B, 0x5C, 0x5D, 0x5E, 0x7C) + ] +) + # {scheme}: (optional) # //{authority} (optional) @@ -182,8 +243,8 @@ def urlparse(url: str = "", **kwargs: str | None) -> ParseResult: # Replace "username" and/or "password" with "userinfo". if "username" in kwargs or "password" in kwargs: - username = quote(kwargs.pop("username", "") or "") - password = quote(kwargs.pop("password", "") or "") + username = quote(kwargs.pop("username", "") or "", safe=USERNAME_SAFE) + password = quote(kwargs.pop("password", "") or "", safe=PASSWORD_SAFE) kwargs["userinfo"] = f"{username}:{password}" if password else username # Replace "raw_path" with "path" and "query". @@ -238,7 +299,7 @@ def urlparse(url: str = "", **kwargs: str | None) -> ParseResult: authority = kwargs.get("authority", url_dict["authority"]) or "" path = kwargs.get("path", url_dict["path"]) or "" query = kwargs.get("query", url_dict["query"]) - fragment = kwargs.get("fragment", url_dict["fragment"]) + frag = kwargs.get("fragment", url_dict["fragment"]) # The AUTHORITY_REGEX will always match, but may have empty components. authority_match = AUTHORITY_REGEX.match(authority) @@ -255,7 +316,7 @@ def urlparse(url: str = "", **kwargs: str | None) -> ParseResult: # We end up with a parsed representation of the URL, # with components that are plain ASCII bytestrings. parsed_scheme: str = scheme.lower() - parsed_userinfo: str = quote(userinfo, safe=SUB_DELIMS + ":") + parsed_userinfo: str = quote(userinfo, safe=USERINFO_SAFE) parsed_host: str = encode_host(host) parsed_port: int | None = normalize_port(port, scheme) @@ -267,25 +328,9 @@ def urlparse(url: str = "", **kwargs: str | None) -> ParseResult: if has_scheme or has_authority: path = normalize_path(path) - # The GEN_DELIMS set is... : / ? # [ ] @ - # These do not need to be percent-quoted unless they serve as delimiters for the - # specific component. - WHATWG_SAFE = '`{}%|^\\"' - - # For 'path' we need to drop ? and # from the GEN_DELIMS set. - parsed_path: str = quote(path, safe=SUB_DELIMS + WHATWG_SAFE + ":/[]@") - # For 'query' we need to drop '#' from the GEN_DELIMS set. - parsed_query: str | None = ( - None - if query is None - else quote(query, safe=SUB_DELIMS + WHATWG_SAFE + ":/?[]@") - ) - # For 'fragment' we can include all of the GEN_DELIMS set. - parsed_fragment: str | None = ( - None - if fragment is None - else quote(fragment, safe=SUB_DELIMS + WHATWG_SAFE + ":/?#[]@") - ) + parsed_path: str = quote(path, safe=PATH_SAFE) + parsed_query: str | None = None if query is None else quote(query, safe=QUERY_SAFE) + parsed_frag: str | None = None if frag is None else quote(frag, safe=FRAG_SAFE) # The parsed ASCII bytestrings are our canonical form. # All properties of the URL are derived from these. @@ -296,7 +341,7 @@ def urlparse(url: str = "", **kwargs: str | None) -> ParseResult: parsed_port, parsed_path, parsed_query, - parsed_fragment, + parsed_frag, ) @@ -434,7 +479,7 @@ def PERCENT(string: str) -> str: return "".join([f"%{byte:02X}" for byte in string.encode("utf-8")]) -def percent_encoded(string: str, safe: str = "/") -> str: +def percent_encoded(string: str, safe: str) -> str: """ Use percent-encoding to quote a string. """ @@ -449,7 +494,7 @@ def percent_encoded(string: str, safe: str = "/") -> str: ) -def quote(string: str, safe: str = "/") -> str: +def quote(string: str, safe: str) -> str: """ Use percent-encoding to quote a string, omitting existing '%xx' escape sequences. @@ -480,26 +525,3 @@ def quote(string: str, safe: str = "/") -> str: parts.append(percent_encoded(trailing_text, safe=safe)) return "".join(parts) - - -def urlencode(items: list[tuple[str, str]]) -> str: - """ - We can use a much simpler version of the stdlib urlencode here because - we don't need to handle a bunch of different typing cases, such as bytes vs str. - - https://github.com/python/cpython/blob/b2f7b2ef0b5421e01efb8c7bee2ef95d3bab77eb/Lib/urllib/parse.py#L926 - - Note that we use '%20' encoding for spaces. and '%2F for '/'. - This is slightly different than `requests`, but is the behaviour that browsers use. - - See - - https://github.com/encode/httpx/issues/2536 - - https://github.com/encode/httpx/issues/2721 - - https://docs.python.org/3/library/urllib.parse.html#urllib.parse.urlencode - """ - return "&".join( - [ - percent_encoded(k, safe="") + "=" + percent_encoded(v, safe="") - for k, v in items - ] - ) diff --git a/httpx/_urls.py b/httpx/_urls.py index ec4ea6b399..147a8fa333 100644 --- a/httpx/_urls.py +++ b/httpx/_urls.py @@ -1,12 +1,12 @@ from __future__ import annotations import typing -from urllib.parse import parse_qs, unquote +from urllib.parse import parse_qs, unquote, urlencode import idna -from ._types import QueryParamTypes, RawURL -from ._urlparse import urlencode, urlparse +from ._types import QueryParamTypes +from ._urlparse import urlparse from ._utils import primitive_value_to_str __all__ = ["URL", "QueryParams"] @@ -304,22 +304,6 @@ def fragment(self) -> str: """ return unquote(self._uri_reference.fragment or "") - @property - def raw(self) -> RawURL: - """ - Provides the (scheme, host, port, target) for the outgoing request. - - In older versions of `httpx` this was used in the low-level transport API. - We no longer use `RawURL`, and this property will be deprecated - in a future release. - """ - return RawURL( - self.raw_scheme, - self.raw_host, - self.port, - self.raw_path, - ) - @property def is_absolute_url(self) -> bool: """ @@ -416,6 +400,22 @@ def __repr__(self) -> str: return f"{self.__class__.__name__}({url!r})" + @property + def raw(self) -> tuple[bytes, bytes, int, bytes]: # pragma: nocover + import collections + import warnings + + warnings.warn("URL.raw is deprecated.") + RawURL = collections.namedtuple( + "RawURL", ["raw_scheme", "raw_host", "port", "raw_path"] + ) + return RawURL( + raw_scheme=self.raw_scheme, + raw_host=self.raw_host, + port=self.port, + raw_path=self.raw_path, + ) + class QueryParams(typing.Mapping[str, str]): """ @@ -621,13 +621,6 @@ def __eq__(self, other: typing.Any) -> bool: return sorted(self.multi_items()) == sorted(other.multi_items()) def __str__(self) -> str: - """ - Note that we use '%20' encoding for spaces, and treat '/' as a safe - character. - - See https://github.com/encode/httpx/issues/2536 and - https://docs.python.org/3/library/urllib.parse.html#urllib.parse.urlencode - """ return urlencode(self.multi_items()) def __repr__(self) -> str: diff --git a/httpx/_utils.py b/httpx/_utils.py index a9ece19438..7fe827da4d 100644 --- a/httpx/_utils.py +++ b/httpx/_utils.py @@ -1,58 +1,17 @@ from __future__ import annotations -import codecs -import email.message import ipaddress -import mimetypes import os import re -import time import typing -from pathlib import Path from urllib.request import getproxies -import sniffio - from ._types import PrimitiveData if typing.TYPE_CHECKING: # pragma: no cover from ._urls import URL -_HTML5_FORM_ENCODING_REPLACEMENTS = {'"': "%22", "\\": "\\\\"} -_HTML5_FORM_ENCODING_REPLACEMENTS.update( - {chr(c): "%{:02X}".format(c) for c in range(0x1F + 1) if c != 0x1B} -) -_HTML5_FORM_ENCODING_RE = re.compile( - r"|".join([re.escape(c) for c in _HTML5_FORM_ENCODING_REPLACEMENTS.keys()]) -) - - -def normalize_header_key( - value: str | bytes, - lower: bool, - encoding: str | None = None, -) -> bytes: - """ - Coerce str/bytes into a strictly byte-wise HTTP header key. - """ - if isinstance(value, bytes): - bytes_value = value - else: - bytes_value = value.encode(encoding or "ascii") - - return bytes_value.lower() if lower else bytes_value - - -def normalize_header_value(value: str | bytes, encoding: str | None = None) -> bytes: - """ - Coerce str/bytes into a strictly byte-wise HTTP header value. - """ - if isinstance(value, bytes): - return value - return value.encode(encoding or "ascii") - - def primitive_value_to_str(value: PrimitiveData) -> str: """ Coerce a primitive data type into a string value. @@ -68,130 +27,6 @@ def primitive_value_to_str(value: PrimitiveData) -> str: return str(value) -def is_known_encoding(encoding: str) -> bool: - """ - Return `True` if `encoding` is a known codec. - """ - try: - codecs.lookup(encoding) - except LookupError: - return False - return True - - -def format_form_param(name: str, value: str) -> bytes: - """ - Encode a name/value pair within a multipart form. - """ - - def replacer(match: typing.Match[str]) -> str: - return _HTML5_FORM_ENCODING_REPLACEMENTS[match.group(0)] - - value = _HTML5_FORM_ENCODING_RE.sub(replacer, value) - return f'{name}="{value}"'.encode() - - -def get_ca_bundle_from_env() -> str | None: - if "SSL_CERT_FILE" in os.environ: - ssl_file = Path(os.environ["SSL_CERT_FILE"]) - if ssl_file.is_file(): - return str(ssl_file) - if "SSL_CERT_DIR" in os.environ: - ssl_path = Path(os.environ["SSL_CERT_DIR"]) - if ssl_path.is_dir(): - return str(ssl_path) - return None - - -def parse_header_links(value: str) -> list[dict[str, str]]: - """ - Returns a list of parsed link headers, for more info see: - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link - The generic syntax of those is: - Link: < uri-reference >; param1=value1; param2="value2" - So for instance: - Link; '; type="image/jpeg",;' - would return - [ - {"url": "http:/.../front.jpeg", "type": "image/jpeg"}, - {"url": "http://.../back.jpeg"}, - ] - :param value: HTTP Link entity-header field - :return: list of parsed link headers - """ - links: list[dict[str, str]] = [] - replace_chars = " '\"" - value = value.strip(replace_chars) - if not value: - return links - for val in re.split(", *<", value): - try: - url, params = val.split(";", 1) - except ValueError: - url, params = val, "" - link = {"url": url.strip("<> '\"")} - for param in params.split(";"): - try: - key, value = param.split("=") - except ValueError: - break - link[key.strip(replace_chars)] = value.strip(replace_chars) - links.append(link) - return links - - -def parse_content_type_charset(content_type: str) -> str | None: - # We used to use `cgi.parse_header()` here, but `cgi` became a dead battery. - # See: https://peps.python.org/pep-0594/#cgi - msg = email.message.Message() - msg["content-type"] = content_type - return msg.get_content_charset(failobj=None) - - -SENSITIVE_HEADERS = {"authorization", "proxy-authorization"} - - -def obfuscate_sensitive_headers( - items: typing.Iterable[tuple[typing.AnyStr, typing.AnyStr]], -) -> typing.Iterator[tuple[typing.AnyStr, typing.AnyStr]]: - for k, v in items: - if to_str(k.lower()) in SENSITIVE_HEADERS: - v = to_bytes_or_str("[secure]", match_type_of=v) - yield k, v - - -def port_or_default(url: URL) -> int | None: - if url.port is not None: - return url.port - return {"http": 80, "https": 443}.get(url.scheme) - - -def same_origin(url: URL, other: URL) -> bool: - """ - Return 'True' if the given URLs share the same origin. - """ - return ( - url.scheme == other.scheme - and url.host == other.host - and port_or_default(url) == port_or_default(other) - ) - - -def is_https_redirect(url: URL, location: URL) -> bool: - """ - Return 'True' if 'location' is a HTTPS upgrade of 'url' - """ - if url.host != location.host: - return False - - return ( - url.scheme == "http" - and port_or_default(url) == 80 - and location.scheme == "https" - and port_or_default(location) == 443 - ) - - def get_environment_proxies() -> dict[str, str | None]: """Gets proxy information from the environment""" @@ -257,12 +92,6 @@ def unquote(value: str) -> str: return value[1:-1] if value[0] == value[-1] == '"' else value -def guess_content_type(filename: str | None) -> str | None: - if filename: - return mimetypes.guess_type(filename)[0] or "application/octet-stream" - return None - - def peek_filelike_length(stream: typing.Any) -> int | None: """ Given a file-like stream object, return its length in number of bytes @@ -288,33 +117,6 @@ def peek_filelike_length(stream: typing.Any) -> int | None: return length -class Timer: - async def _get_time(self) -> float: - library = sniffio.current_async_library() - if library == "trio": - import trio - - return trio.current_time() - else: - import asyncio - - return asyncio.get_event_loop().time() - - def sync_start(self) -> None: - self.started = time.perf_counter() - - async def async_start(self) -> None: - self.started = await self._get_time() - - def sync_elapsed(self) -> float: - now = time.perf_counter() - return now - self.started - - async def async_elapsed(self) -> float: - now = await self._get_time() - return now - self.started - - class URLPattern: """ A utility class currently used for making lookups against proxy keys... diff --git a/mkdocs.yml b/mkdocs.yml index f6e4dfde9b..86ca1e53b7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,6 +4,7 @@ site_url: https://www.python-httpx.org/ theme: name: 'material' + custom_dir: 'docs/overrides' palette: - scheme: 'default' media: '(prefers-color-scheme: light)' diff --git a/pyproject.toml b/pyproject.toml index c4c188052e..9e67191135 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,6 @@ dependencies = [ "httpcore==1.*", "anyio", "idna", - "sniffio", ] dynamic = ["readme", "version"] @@ -129,5 +128,5 @@ markers = [ ] [tool.coverage.run] -omit = ["venv/*", "httpx/_compat.py"] +omit = ["venv/*"] include = ["httpx/*", "tests/*"] diff --git a/requirements.txt b/requirements.txt index a25d8b74a6..53fd0a6c25 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,21 +9,21 @@ chardet==5.2.0 # Documentation -mkdocs==1.6.0 +mkdocs==1.6.1 mkautodoc==0.2.0 -mkdocs-material==9.5.30 +mkdocs-material==9.5.39 # Packaging -build==1.2.1 +build==1.2.2 twine==5.1.1 # Tests & Linting -coverage[toml]==7.6.0 -cryptography==43.0.0 -mypy==1.11.0 -pytest==8.3.1 -ruff==0.5.4 -trio==0.26.0 +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 trio-typing==0.10.0 trustme==1.1.0 -uvicorn==0.30.3 +uvicorn==0.31.0 diff --git a/scripts/lint b/scripts/lint index 3d8685a065..6d096d760b 100755 --- a/scripts/lint +++ b/scripts/lint @@ -8,5 +8,5 @@ export SOURCE_FILES="httpx tests" set -x -${PREFIX}ruff --fix $SOURCE_FILES +${PREFIX}ruff check --fix $SOURCE_FILES ${PREFIX}ruff format $SOURCE_FILES diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 5776fc33ba..7638b8bd68 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -270,29 +270,6 @@ def test_netrc_auth_credentials_do_not_exist() -> None: assert response.json() == {"auth": None} -@pytest.mark.skipif( - sys.version_info < (3, 11), - reason="netrc files without a password are invalid with Python < 3.11", -) -def test_netrc_auth_nopassword() -> None: # pragma: no cover - """ - Python has different netrc parsing behaviours with different versions. - For Python 3.11+ a netrc file with no password is valid. In this case - we want to check that we allow the netrc auth, and simply don't provide - any credentials in the request. - """ - netrc_file = str(FIXTURES_DIR / ".netrc-nopassword") - url = "http://example.org" - app = App() - auth = httpx.NetRCAuth(netrc_file) - - with httpx.Client(transport=httpx.MockTransport(app), auth=auth) as client: - response = client.get(url) - - assert response.status_code == 200 - assert response.json() == {"auth": None} - - @pytest.mark.skipif( sys.version_info >= (3, 11), reason="netrc files without a password are valid from Python >= 3.11", @@ -743,7 +720,7 @@ async def test_async_auth_reads_response_body() -> None: response = await client.get(url, auth=auth) assert response.status_code == 200 - assert response.json() == {"auth": '{"auth": "xyz"}'} + assert response.json() == {"auth": '{"auth":"xyz"}'} def test_sync_auth_reads_response_body() -> None: @@ -759,7 +736,7 @@ def test_sync_auth_reads_response_body() -> None: response = client.get(url, auth=auth) assert response.status_code == 200 - assert response.json() == {"auth": '{"auth": "xyz"}'} + assert response.json() == {"auth": '{"auth":"xyz"}'} @pytest.mark.anyio diff --git a/tests/client/test_headers.py b/tests/client/test_headers.py index c51e40c335..47f5a4d731 100755 --- a/tests/client/test_headers.py +++ b/tests/client/test_headers.py @@ -177,6 +177,14 @@ def test_header_does_not_exist(): del headers["baz"] +def test_header_with_incorrect_value(): + with pytest.raises( + TypeError, + match=f"Header value must be str or bytes, not {type(None)}", + ): + httpx.Headers({"foo": None}) # type: ignore + + def test_host_with_auth_and_port_in_url(): """ The Host header should only include the hostname, or hostname:port @@ -227,3 +235,59 @@ def test_host_with_non_default_port_in_url(): def test_request_auto_headers(): request = httpx.Request("GET", "https://www.example.org/") assert "host" in request.headers + + +def test_same_origin(): + origin = httpx.URL("https://example.com") + request = httpx.Request("GET", "HTTPS://EXAMPLE.COM:443") + + client = httpx.Client() + headers = client._redirect_headers(request, origin, "GET") + + assert headers["Host"] == request.url.netloc.decode("ascii") + + +def test_not_same_origin(): + origin = httpx.URL("https://example.com") + request = httpx.Request("GET", "HTTP://EXAMPLE.COM:80") + + client = httpx.Client() + headers = client._redirect_headers(request, origin, "GET") + + assert headers["Host"] == origin.netloc.decode("ascii") + + +def test_is_https_redirect(): + url = httpx.URL("https://example.com") + request = httpx.Request( + "GET", "http://example.com", headers={"Authorization": "empty"} + ) + + client = httpx.Client() + headers = client._redirect_headers(request, url, "GET") + + assert "Authorization" in headers + + +def test_is_not_https_redirect(): + url = httpx.URL("https://www.example.com") + request = httpx.Request( + "GET", "http://example.com", headers={"Authorization": "empty"} + ) + + client = httpx.Client() + headers = client._redirect_headers(request, url, "GET") + + assert "Authorization" not in headers + + +def test_is_not_https_redirect_if_not_default_ports(): + url = httpx.URL("https://example.com:1337") + request = httpx.Request( + "GET", "http://example.com:9999", headers={"Authorization": "empty"} + ) + + client = httpx.Client() + headers = client._redirect_headers(request, url, "GET") + + assert "Authorization" not in headers diff --git a/tests/client/test_proxies.py b/tests/client/test_proxies.py index 7bba1ab2c3..3e4090dcec 100644 --- a/tests/client/test_proxies.py +++ b/tests/client/test_proxies.py @@ -13,69 +13,19 @@ def url_to_origin(url: str) -> httpcore.URL: return httpcore.URL(scheme=u.raw_scheme, host=u.raw_host, port=u.port, target="/") -@pytest.mark.parametrize( - ["proxies", "expected_proxies"], - [ - ("http://127.0.0.1", [("all://", "http://127.0.0.1")]), - ({"all://": "http://127.0.0.1"}, [("all://", "http://127.0.0.1")]), - ( - {"http://": "http://127.0.0.1", "https://": "https://127.0.0.1"}, - [("http://", "http://127.0.0.1"), ("https://", "https://127.0.0.1")], - ), - (httpx.Proxy("http://127.0.0.1"), [("all://", "http://127.0.0.1")]), - ( - { - "https://": httpx.Proxy("https://127.0.0.1"), - "all://": "http://127.0.0.1", - }, - [("all://", "http://127.0.0.1"), ("https://", "https://127.0.0.1")], - ), - ], -) -def test_proxies_parameter(proxies, expected_proxies): - with pytest.warns(DeprecationWarning): - client = httpx.Client(proxies=proxies) - client_patterns = [p.pattern for p in client._mounts.keys()] - client_proxies = list(client._mounts.values()) - - for proxy_key, url in expected_proxies: - assert proxy_key in client_patterns - proxy = client_proxies[client_patterns.index(proxy_key)] - assert isinstance(proxy, httpx.HTTPTransport) - assert isinstance(proxy._pool, httpcore.HTTPProxy) - assert proxy._pool._proxy_url == url_to_origin(url) - - assert len(expected_proxies) == len(client._mounts) - - -def test_socks_proxy_deprecated(): - url = httpx.URL("http://www.example.com") - - with pytest.warns(DeprecationWarning): - client = httpx.Client(proxies="socks5://localhost/") - transport = client._transport_for_url(url) - assert isinstance(transport, httpx.HTTPTransport) - assert isinstance(transport._pool, httpcore.SOCKSProxy) - - with pytest.warns(DeprecationWarning): - async_client = httpx.AsyncClient(proxies="socks5://localhost/") - async_transport = async_client._transport_for_url(url) - assert isinstance(async_transport, httpx.AsyncHTTPTransport) - assert isinstance(async_transport._pool, httpcore.AsyncSOCKSProxy) - - def test_socks_proxy(): url = httpx.URL("http://www.example.com") - client = httpx.Client(proxy="socks5://localhost/") - transport = client._transport_for_url(url) - assert isinstance(transport, httpx.HTTPTransport) - assert isinstance(transport._pool, httpcore.SOCKSProxy) + for proxy in ("socks5://localhost/", "socks5h://localhost/"): + client = httpx.Client(proxy=proxy) + transport = client._transport_for_url(url) + assert isinstance(transport, httpx.HTTPTransport) + assert isinstance(transport._pool, httpcore.SOCKSProxy) - async_client = httpx.AsyncClient(proxy="socks5://localhost/") - async_transport = async_client._transport_for_url(url) - assert isinstance(async_transport, httpx.AsyncHTTPTransport) - assert isinstance(async_transport._pool, httpcore.AsyncSOCKSProxy) + async_client = httpx.AsyncClient(proxy=proxy) + async_transport = async_client._transport_for_url(url) + assert isinstance(async_transport, httpx.AsyncHTTPTransport) + assert isinstance(async_transport._pool, httpcore.AsyncSOCKSProxy) PROXY_URL = "http://[::1]" @@ -84,7 +34,6 @@ def test_socks_proxy(): @pytest.mark.parametrize( ["url", "proxies", "expected"], [ - ("http://example.com", None, None), ("http://example.com", {}, None), ("http://example.com", {"https://": PROXY_URL}, None), ("http://example.com", {"http://example.net": PROXY_URL}, None), @@ -104,7 +53,6 @@ def test_socks_proxy(): # ... ("http://example.com:443", {"http://example.com": PROXY_URL}, PROXY_URL), ("http://example.com", {"all://": PROXY_URL}, PROXY_URL), - ("http://example.com", {"all://": PROXY_URL, "http://example.com": None}, None), ("http://example.com", {"http://": PROXY_URL}, PROXY_URL), ("http://example.com", {"all://example.com": PROXY_URL}, PROXY_URL), ("http://example.com", {"http://example.com": PROXY_URL}, PROXY_URL), @@ -138,11 +86,8 @@ def test_socks_proxy(): ], ) def test_transport_for_request(url, proxies, expected): - if proxies: - with pytest.warns(DeprecationWarning): - client = httpx.Client(proxies=proxies) - else: - client = httpx.Client(proxies=proxies) + mounts = {key: httpx.HTTPTransport(proxy=value) for key, value in proxies.items()} + client = httpx.Client(mounts=mounts) transport = client._transport_for_url(httpx.URL(url)) @@ -158,8 +103,8 @@ def test_transport_for_request(url, proxies, expected): @pytest.mark.network async def test_async_proxy_close(): try: - with pytest.warns(DeprecationWarning): - client = httpx.AsyncClient(proxies={"https://": PROXY_URL}) + transport = httpx.AsyncHTTPTransport(proxy=PROXY_URL) + client = httpx.AsyncClient(mounts={"https://": transport}) await client.get("http://example.com") finally: await client.aclose() @@ -168,18 +113,13 @@ async def test_async_proxy_close(): @pytest.mark.network def test_sync_proxy_close(): try: - with pytest.warns(DeprecationWarning): - client = httpx.Client(proxies={"https://": PROXY_URL}) + transport = httpx.HTTPTransport(proxy=PROXY_URL) + client = httpx.Client(mounts={"https://": transport}) client.get("http://example.com") finally: client.close() -def test_unsupported_proxy_scheme_deprecated(): - with pytest.warns(DeprecationWarning), pytest.raises(ValueError): - httpx.Client(proxies="ftp://127.0.0.1") - - def test_unsupported_proxy_scheme(): with pytest.raises(ValueError): httpx.Client(proxy="ftp://127.0.0.1") @@ -308,26 +248,13 @@ def test_proxies_environ(monkeypatch, client_class, url, env, expected): ], ) def test_for_deprecated_proxy_params(proxies, is_valid): - with pytest.warns(DeprecationWarning): - if not is_valid: - with pytest.raises(ValueError): - httpx.Client(proxies=proxies) - else: - httpx.Client(proxies=proxies) + mounts = {key: httpx.HTTPTransport(proxy=value) for key, value in proxies.items()} - -def test_proxy_and_proxies_together(): - with pytest.warns(DeprecationWarning), pytest.raises( - RuntimeError, - ): - httpx.Client(proxies={"all://": "http://127.0.0.1"}, proxy="http://127.0.0.1") - - with pytest.warns(DeprecationWarning), pytest.raises( - RuntimeError, - ): - httpx.AsyncClient( - proxies={"all://": "http://127.0.0.1"}, proxy="http://127.0.0.1" - ) + if not is_valid: + with pytest.raises(ValueError): + httpx.Client(mounts=mounts) + else: + httpx.Client(mounts=mounts) def test_proxy_with_mounts(): diff --git a/tests/conftest.py b/tests/conftest.py index 5c4a6ae577..858bca1397 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -187,12 +187,6 @@ def cert_authority(): return trustme.CA() -@pytest.fixture(scope="session") -def ca_cert_pem_file(cert_authority): - with cert_authority.cert_pem.tempfile() as tmp: - yield tmp - - @pytest.fixture(scope="session") def localhost_cert(cert_authority): return cert_authority.issue_cert("localhost") @@ -291,17 +285,3 @@ def server() -> typing.Iterator[TestServer]: config = Config(app=app, lifespan="off", loop="asyncio") server = TestServer(config=config) yield from serve_in_thread(server) - - -@pytest.fixture(scope="session") -def https_server(cert_pem_file, cert_private_key_file): - config = Config( - app=app, - lifespan="off", - ssl_certfile=cert_pem_file, - ssl_keyfile=cert_private_key_file, - port=8001, - loop="asyncio", - ) - server = TestServer(config=config) - yield from serve_in_thread(server) diff --git a/tests/models/test_headers.py b/tests/models/test_headers.py index d671dc4186..a87a446784 100644 --- a/tests/models/test_headers.py +++ b/tests/models/test_headers.py @@ -174,3 +174,46 @@ def test_sensitive_headers(header): value = "s3kr3t" h = httpx.Headers({header: value}) assert repr(h) == "Headers({'%s': '[secure]'})" % header + + +@pytest.mark.parametrize( + "headers, output", + [ + ([("content-type", "text/html")], [("content-type", "text/html")]), + ([("authorization", "s3kr3t")], [("authorization", "[secure]")]), + ([("proxy-authorization", "s3kr3t")], [("proxy-authorization", "[secure]")]), + ], +) +def test_obfuscate_sensitive_headers(headers, output): + as_dict = {k: v for k, v in output} + headers_class = httpx.Headers({k: v for k, v in headers}) + assert repr(headers_class) == f"Headers({as_dict!r})" + + +@pytest.mark.parametrize( + "value, expected", + ( + ( + '; rel=front; type="image/jpeg"', + [{"url": "http:/.../front.jpeg", "rel": "front", "type": "image/jpeg"}], + ), + ("", [{"url": "http:/.../front.jpeg"}]), + (";", [{"url": "http:/.../front.jpeg"}]), + ( + '; type="image/jpeg",;', + [ + {"url": "http:/.../front.jpeg", "type": "image/jpeg"}, + {"url": "http://.../back.jpeg"}, + ], + ), + ("", []), + ), +) +def test_parse_header_links(value, expected): + all_links = httpx.Response(200, headers={"link": value}).links.values() + assert all(link in all_links for link in expected) + + +def test_parse_header_links_no_link(): + all_links = httpx.Response(200).links + assert all_links == {} diff --git a/tests/models/test_requests.py b/tests/models/test_requests.py index ad6d6705f2..d2a458d57e 100644 --- a/tests/models/test_requests.py +++ b/tests/models/test_requests.py @@ -62,7 +62,7 @@ def test_json_encoded_data(): request.read() assert request.headers["Content-Type"] == "application/json" - assert request.content == b'{"test": 123}' + assert request.content == b'{"test":123}' def test_headers(): @@ -71,7 +71,7 @@ def test_headers(): assert request.headers == { "Host": "example.org", "Content-Type": "application/json", - "Content-Length": "13", + "Content-Length": "12", } @@ -183,12 +183,12 @@ def test_request_picklable(): assert pickle_request.method == "POST" assert pickle_request.url.path == "/" assert pickle_request.headers["Content-Type"] == "application/json" - assert pickle_request.content == b'{"test": 123}' + assert pickle_request.content == b'{"test":123}' assert pickle_request.stream is not None assert request.headers == { "Host": "example.org", "Content-Type": "application/json", - "content-length": "13", + "content-length": "12", } diff --git a/tests/models/test_responses.py b/tests/models/test_responses.py index d639625825..06c28e1e30 100644 --- a/tests/models/test_responses.py +++ b/tests/models/test_responses.py @@ -81,9 +81,9 @@ def test_response_json(): assert response.status_code == 200 assert response.reason_phrase == "OK" - assert response.json() == {"hello": "world"} + assert str(response.json()) == "{'hello': 'world'}" assert response.headers == { - "Content-Length": "18", + "Content-Length": "17", "Content-Type": "application/json", } diff --git a/tests/models/test_url.py b/tests/models/test_url.py index 523a89bf65..03072e8f5c 100644 --- a/tests/models/test_url.py +++ b/tests/models/test_url.py @@ -141,19 +141,14 @@ def test_path_query_fragment(url, raw_path, path, query, fragment): def test_url_query_encoding(): - """ - URL query parameters should use '%20' for encoding spaces, - and should treat '/' as a safe character. This behaviour differs - across clients, but we're matching browser behaviour here. - - See https://github.com/encode/httpx/issues/2536 - and https://github.com/encode/httpx/discussions/2460 - """ url = httpx.URL("https://www.example.com/?a=b c&d=e/f") assert url.raw_path == b"/?a=b%20c&d=e/f" + url = httpx.URL("https://www.example.com/?a=b+c&d=e/f") + assert url.raw_path == b"/?a=b+c&d=e/f" + url = httpx.URL("https://www.example.com/", params={"a": "b c", "d": "e/f"}) - assert url.raw_path == b"/?a=b%20c&d=e%2Ff" + assert url.raw_path == b"/?a=b+c&d=e%2Ff" def test_url_params(): @@ -289,12 +284,13 @@ def test_url_leading_dot_prefix_on_relative_url(): def test_param_with_space(): - # Params passed as form key-value pairs should be escaped. + # Params passed as form key-value pairs should be form escaped, + # Including the special case of "+" for space seperators. url = httpx.URL("http://webservice", params={"u": "with spaces"}) - assert str(url) == "http://webservice?u=with%20spaces" + assert str(url) == "http://webservice?u=with+spaces" -def test_param_does_not_require_encoding(): +def test_param_requires_encoding(): # Params passed as form key-value pairs should be escaped. url = httpx.URL("http://webservice", params={"u": "%"}) assert str(url) == "http://webservice?u=%25" @@ -614,10 +610,10 @@ def test_url_copywith_userinfo_subcomponents(): } url = httpx.URL("https://example.org") new = url.copy_with(**copy_with_kwargs) - assert str(new) == "https://tom%40example.org:abc123%40%20%25@example.org" + assert str(new) == "https://tom%40example.org:abc123%40%20%@example.org" assert new.username == "tom@example.org" assert new.password == "abc123@ %" - assert new.userinfo == b"tom%40example.org:abc123%40%20%25" + assert new.userinfo == b"tom%40example.org:abc123%40%20%" def test_url_copywith_invalid_component(): @@ -865,19 +861,3 @@ def test_ipv6_url_copy_with_host(url_str, new_host): assert url.host == "::ffff:192.168.0.1" assert url.netloc == b"[::ffff:192.168.0.1]:1234" assert str(url) == "http://[::ffff:192.168.0.1]:1234" - - -# Test for deprecated API - - -def test_url_raw_compatibility(): - """ - Test case for the (to-be-deprecated) `url.raw` accessor. - """ - url = httpx.URL("https://www.example.com/path") - scheme, host, port, raw_path = url.raw - - assert scheme == b"https" - assert host == b"www.example.com" - assert port is None - assert raw_path == b"/path" diff --git a/tests/models/test_whatwg.py b/tests/models/test_whatwg.py index 6e00a921ae..14af682586 100644 --- a/tests/models/test_whatwg.py +++ b/tests/models/test_whatwg.py @@ -10,7 +10,7 @@ # URL test cases from... # https://github.com/web-platform-tests/wpt/blob/master/url/resources/urltestdata.json -with open("tests/models/whatwg.json", "r") as input: +with open("tests/models/whatwg.json", "r", encoding="utf-8") as input: test_cases = json.load(input) test_cases = [ item diff --git a/tests/test_api.py b/tests/test_api.py index fe8083fc40..225f384ede 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -85,3 +85,18 @@ def test_stream(server): def test_get_invalid_url(): with pytest.raises(httpx.UnsupportedProtocol): httpx.get("invalid://example.org") + + +# check that httpcore isn't imported until we do a request +def test_httpcore_lazy_loading(server): + import sys + + # unload our module if it is already loaded + if "httpx" in sys.modules: + del sys.modules["httpx"] + del sys.modules["httpcore"] + import httpx + + assert "httpcore" not in sys.modules + _response = httpx.get(server.url) + assert "httpcore" in sys.modules diff --git a/tests/test_asgi.py b/tests/test_asgi.py index 8b817891e4..ffbc91bc00 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -222,13 +222,3 @@ async def test_asgi_exc_no_raise(): response = await client.get("http://www.example.org/") assert response.status_code == 500 - - -@pytest.mark.anyio -async def test_deprecated_shortcut(): - """ - The `app=...` shortcut is now deprecated. - Use the explicit transport style instead. - """ - with pytest.warns(DeprecationWarning): - httpx.AsyncClient(app=hello_world) diff --git a/tests/test_config.py b/tests/test_config.py index 6f6ee4f575..22abd4c22c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,5 @@ -import os import ssl +import typing from pathlib import Path import certifi @@ -14,43 +14,35 @@ def test_load_ssl_config(): assert context.check_hostname is True -def test_load_ssl_config_verify_non_existing_path(): +def test_load_ssl_config_verify_non_existing_file(): with pytest.raises(IOError): - httpx.create_ssl_context(verify="/path/to/nowhere") + context = httpx.create_ssl_context() + context.load_verify_locations(cafile="/path/to/nowhere") -def test_load_ssl_config_verify_existing_file(): - context = httpx.create_ssl_context(verify=certifi.where()) - assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED - assert context.check_hostname is True - +def test_load_ssl_with_keylog(monkeypatch: typing.Any) -> None: + monkeypatch.setenv("SSLKEYLOGFILE", "test") + context = httpx.create_ssl_context() + assert context.keylog_filename == "test" -@pytest.mark.parametrize("config", ("SSL_CERT_FILE", "SSL_CERT_DIR")) -def test_load_ssl_config_verify_env_file( - https_server, ca_cert_pem_file, config, cert_authority -): - os.environ[config] = ( - ca_cert_pem_file - if config.endswith("_FILE") - else str(Path(ca_cert_pem_file).parent) - ) - context = httpx.create_ssl_context(trust_env=True) - cert_authority.configure_trust(context) +def test_load_ssl_config_verify_existing_file(): + context = httpx.create_ssl_context() + context.load_verify_locations(capath=certifi.where()) assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED assert context.check_hostname is True - assert len(context.get_ca_certs()) == 1 def test_load_ssl_config_verify_directory(): - path = Path(certifi.where()).parent - context = httpx.create_ssl_context(verify=str(path)) + context = httpx.create_ssl_context() + context.load_verify_locations(capath=Path(certifi.where()).parent) assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED assert context.check_hostname is True def test_load_ssl_config_cert_and_key(cert_pem_file, cert_private_key_file): - context = httpx.create_ssl_context(cert=(cert_pem_file, cert_private_key_file)) + context = httpx.create_ssl_context() + context.load_cert_chain(cert_pem_file, cert_private_key_file) assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED assert context.check_hostname is True @@ -59,9 +51,8 @@ def test_load_ssl_config_cert_and_key(cert_pem_file, cert_private_key_file): def test_load_ssl_config_cert_and_encrypted_key( cert_pem_file, cert_encrypted_private_key_file, password ): - context = httpx.create_ssl_context( - cert=(cert_pem_file, cert_encrypted_private_key_file, password) - ) + context = httpx.create_ssl_context() + context.load_cert_chain(cert_pem_file, cert_encrypted_private_key_file, password) assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED assert context.check_hostname is True @@ -70,14 +61,16 @@ def test_load_ssl_config_cert_and_key_invalid_password( cert_pem_file, cert_encrypted_private_key_file ): with pytest.raises(ssl.SSLError): - httpx.create_ssl_context( - cert=(cert_pem_file, cert_encrypted_private_key_file, "password1") + context = httpx.create_ssl_context() + context.load_cert_chain( + cert_pem_file, cert_encrypted_private_key_file, "password1" ) def test_load_ssl_config_cert_without_key_raises(cert_pem_file): with pytest.raises(ssl.SSLError): - httpx.create_ssl_context(cert=cert_pem_file) + context = httpx.create_ssl_context() + context.load_cert_chain(cert_pem_file) def test_load_ssl_config_no_verify(): @@ -86,15 +79,9 @@ def test_load_ssl_config_no_verify(): assert context.check_hostname is False -def test_load_ssl_context(): - ssl_context = ssl.create_default_context() - context = httpx.create_ssl_context(verify=ssl_context) - - assert context is ssl_context - - -def test_create_ssl_context_with_get_request(server, cert_pem_file): - context = httpx.create_ssl_context(verify=cert_pem_file) +def test_SSLContext_with_get_request(server, cert_pem_file): + context = httpx.create_ssl_context() + context.load_verify_locations(cert_pem_file) response = httpx.get(server.url, verify=context) assert response.status_code == 200 @@ -174,32 +161,6 @@ def test_timeout_repr(): assert repr(timeout) == "Timeout(connect=None, read=5.0, write=None, pool=None)" -@pytest.mark.skipif( - not hasattr(ssl.SSLContext, "keylog_filename"), - reason="requires OpenSSL 1.1.1 or higher", -) -def test_ssl_config_support_for_keylog_file(tmpdir, monkeypatch): # pragma: no cover - with monkeypatch.context() as m: - m.delenv("SSLKEYLOGFILE", raising=False) - - context = httpx.create_ssl_context(trust_env=True) - - assert context.keylog_filename is None - - filename = str(tmpdir.join("test.log")) - - with monkeypatch.context() as m: - m.setenv("SSLKEYLOGFILE", filename) - - context = httpx.create_ssl_context(trust_env=True) - - assert context.keylog_filename == filename - - context = httpx.create_ssl_context(trust_env=False) - - assert context.keylog_filename is None - - def test_proxy_from_url(): proxy = httpx.Proxy("https://example.com") diff --git a/tests/test_content.py b/tests/test_content.py index 21c92dd799..f63ec18a6b 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -173,11 +173,11 @@ async def test_json_content(): assert request.headers == { "Host": "www.example.com", - "Content-Length": "19", + "Content-Length": "18", "Content-Type": "application/json", } - assert sync_content == b'{"Hello": "world!"}' - assert async_content == b'{"Hello": "world!"}' + assert sync_content == b'{"Hello":"world!"}' + assert async_content == b'{"Hello":"world!"}' @pytest.mark.anyio @@ -484,3 +484,35 @@ async def hello_world() -> typing.AsyncIterator[bytes]: def test_response_invalid_argument(): with pytest.raises(TypeError): httpx.Response(200, content=123) # type: ignore + + +def test_ensure_ascii_false_with_french_characters(): + data = {"greeting": "Bonjour, ça va ?"} + response = httpx.Response(200, json=data) + assert ( + "ça va" in response.text + ), "ensure_ascii=False should preserve French accented characters" + assert response.headers["Content-Type"] == "application/json" + + +def test_separators_for_compact_json(): + data = {"clé": "valeur", "liste": [1, 2, 3]} + response = httpx.Response(200, json=data) + assert ( + response.text == '{"clé":"valeur","liste":[1,2,3]}' + ), "separators=(',', ':') should produce a compact representation" + assert response.headers["Content-Type"] == "application/json" + + +def test_allow_nan_false(): + data_with_nan = {"nombre": float("nan")} + data_with_inf = {"nombre": float("inf")} + + with pytest.raises( + ValueError, match="Out of range float values are not JSON compliant" + ): + httpx.Response(200, json=data_with_nan) + with pytest.raises( + ValueError, match="Out of range float values are not JSON compliant" + ): + httpx.Response(200, json=data_with_inf) diff --git a/tests/test_decoders.py b/tests/test_decoders.py index bcbb18bb0e..9ffaba189d 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 = ( diff --git a/tests/test_main.py b/tests/test_main.py index feb796e155..b1a77d485b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -114,7 +114,7 @@ def test_post(server): "content-type: text/plain", "Transfer-Encoding: chunked", "", - '{"hello": "world"}', + '{"hello":"world"}', ] diff --git a/tests/test_utils.py b/tests/test_utils.py index f98a18f2cd..f9c215f65a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -3,17 +3,10 @@ import os import random -import certifi import pytest import httpx -from httpx._utils import ( - URLPattern, - get_ca_bundle_from_env, - get_environment_proxies, -) - -from .common import TESTS_DIR +from httpx._utils import URLPattern, get_environment_proxies @pytest.mark.parametrize( @@ -57,35 +50,6 @@ def test_guess_by_bom(encoding, expected): assert response.json() == {"abc": 123} -@pytest.mark.parametrize( - "value, expected", - ( - ( - '; rel=front; type="image/jpeg"', - [{"url": "http:/.../front.jpeg", "rel": "front", "type": "image/jpeg"}], - ), - ("", [{"url": "http:/.../front.jpeg"}]), - (";", [{"url": "http:/.../front.jpeg"}]), - ( - '; type="image/jpeg",;', - [ - {"url": "http:/.../front.jpeg", "type": "image/jpeg"}, - {"url": "http://.../back.jpeg"}, - ], - ), - ("", []), - ), -) -def test_parse_header_links(value, expected): - all_links = httpx.Response(200, headers={"link": value}).links.values() - assert all(link in all_links for link in expected) - - -def test_parse_header_links_no_link(): - all_links = httpx.Response(200).links - assert all_links == {} - - def test_logging_request(server, caplog): caplog.set_level(logging.INFO) with httpx.Client() as client: @@ -122,66 +86,6 @@ def test_logging_redirect_chain(server, caplog): ] -def test_logging_ssl(caplog): - caplog.set_level(logging.DEBUG) - with httpx.Client(): - pass - - cafile = certifi.where() - assert caplog.record_tuples == [ - ( - "httpx", - logging.DEBUG, - "load_ssl_context verify=True cert=None trust_env=True http2=False", - ), - ( - "httpx", - logging.DEBUG, - f"load_verify_locations cafile='{cafile}'", - ), - ] - - -def test_get_ssl_cert_file(): - # Two environments is not set. - assert get_ca_bundle_from_env() is None - - os.environ["SSL_CERT_DIR"] = str(TESTS_DIR) - # SSL_CERT_DIR is correctly set, SSL_CERT_FILE is not set. - ca_bundle = get_ca_bundle_from_env() - assert ca_bundle is not None and ca_bundle.endswith("tests") - - del os.environ["SSL_CERT_DIR"] - os.environ["SSL_CERT_FILE"] = str(TESTS_DIR / "test_utils.py") - # SSL_CERT_FILE is correctly set, SSL_CERT_DIR is not set. - ca_bundle = get_ca_bundle_from_env() - assert ca_bundle is not None and ca_bundle.endswith("tests/test_utils.py") - - os.environ["SSL_CERT_FILE"] = "wrongfile" - # SSL_CERT_FILE is set with wrong file, SSL_CERT_DIR is not set. - assert get_ca_bundle_from_env() is None - - del os.environ["SSL_CERT_FILE"] - os.environ["SSL_CERT_DIR"] = "wrongpath" - # SSL_CERT_DIR is set with wrong path, SSL_CERT_FILE is not set. - assert get_ca_bundle_from_env() is None - - os.environ["SSL_CERT_DIR"] = str(TESTS_DIR) - os.environ["SSL_CERT_FILE"] = str(TESTS_DIR / "test_utils.py") - # Two environments is correctly set. - ca_bundle = get_ca_bundle_from_env() - assert ca_bundle is not None and ca_bundle.endswith("tests/test_utils.py") - - os.environ["SSL_CERT_FILE"] = "wrongfile" - # Two environments is set but SSL_CERT_FILE is not a file. - ca_bundle = get_ca_bundle_from_env() - assert ca_bundle is not None and ca_bundle.endswith("tests") - - os.environ["SSL_CERT_DIR"] = "wrongpath" - # Two environments is set but both are not correct. - assert get_ca_bundle_from_env() is None - - @pytest.mark.parametrize( ["environment", "proxies"], [ @@ -208,76 +112,6 @@ def test_get_environment_proxies(environment, proxies): assert get_environment_proxies() == proxies -@pytest.mark.parametrize( - "headers, output", - [ - ([("content-type", "text/html")], [("content-type", "text/html")]), - ([("authorization", "s3kr3t")], [("authorization", "[secure]")]), - ([("proxy-authorization", "s3kr3t")], [("proxy-authorization", "[secure]")]), - ], -) -def test_obfuscate_sensitive_headers(headers, output): - as_dict = {k: v for k, v in output} - headers_class = httpx.Headers({k: v for k, v in headers}) - assert repr(headers_class) == f"Headers({as_dict!r})" - - -def test_same_origin(): - origin = httpx.URL("https://example.com") - request = httpx.Request("GET", "HTTPS://EXAMPLE.COM:443") - - client = httpx.Client() - headers = client._redirect_headers(request, origin, "GET") - - assert headers["Host"] == request.url.netloc.decode("ascii") - - -def test_not_same_origin(): - origin = httpx.URL("https://example.com") - request = httpx.Request("GET", "HTTP://EXAMPLE.COM:80") - - client = httpx.Client() - headers = client._redirect_headers(request, origin, "GET") - - assert headers["Host"] == origin.netloc.decode("ascii") - - -def test_is_https_redirect(): - url = httpx.URL("https://example.com") - request = httpx.Request( - "GET", "http://example.com", headers={"Authorization": "empty"} - ) - - client = httpx.Client() - headers = client._redirect_headers(request, url, "GET") - - assert "Authorization" in headers - - -def test_is_not_https_redirect(): - url = httpx.URL("https://www.example.com") - request = httpx.Request( - "GET", "http://example.com", headers={"Authorization": "empty"} - ) - - client = httpx.Client() - headers = client._redirect_headers(request, url, "GET") - - assert "Authorization" not in headers - - -def test_is_not_https_redirect_if_not_default_ports(): - url = httpx.URL("https://example.com:1337") - request = httpx.Request( - "GET", "http://example.com:9999", headers={"Authorization": "empty"} - ) - - client = httpx.Client() - headers = client._redirect_headers(request, url, "GET") - - assert "Authorization" not in headers - - @pytest.mark.parametrize( ["pattern", "url", "expected"], [ diff --git a/tests/test_wsgi.py b/tests/test_wsgi.py index 0134bee854..dc2b52885a 100644 --- a/tests/test_wsgi.py +++ b/tests/test_wsgi.py @@ -201,12 +201,3 @@ def app(environ, start_response): assert response.status_code == 200 assert response.text == "success" assert server_protocol == "HTTP/1.1" - - -def test_deprecated_shortcut(): - """ - The `app=...` shortcut is now deprecated. - Use the explicit transport style instead. - """ - with pytest.warns(DeprecationWarning): - httpx.Client(app=application_factory([b"Hello, World!"]))