Compare commits

..

15 Commits

Author SHA1 Message Date
Tom Christie
7c10773193
Merge branch 'master' into async-dependencies-optional 2024-09-27 17:07:44 +01:00
Tom Christie
85886d7d43
Update README.md 2024-09-27 17:05:51 +01:00
Tom Christie
6a4ea7a1fe
Update README.md 2024-09-27 17:04:54 +01:00
Tom Christie
4e58ae69e9
Merge branch 'master' into async-dependencies-optional 2024-09-27 13:00:30 +01:00
Tom Christie
3ebb3a5b9b
Update docs/http2.md
Co-authored-by: T-256 <132141463+T-256@users.noreply.github.com>
2024-09-27 09:48:50 +01:00
Tom Christie
88d9324081
Update docs/async.md
Co-authored-by: Zanie Blue <contact@zanie.dev>
2024-01-15 16:57:22 +00:00
Tom Christie
0e34e2ce25
Merge branch 'master' into async-dependencies-optional 2024-01-11 13:02:27 +00:00
Tom Christie
aa9ca07104
Merge branch 'master' into async-dependencies-optional 2024-01-08 14:12:29 +00:00
Tom Christie
9b36a208b3
Update httpx/_transports/default.py 2023-12-28 20:35:16 +00:00
Tom Christie
3741513647
Merge branch 'master' into async-dependencies-optional 2023-12-28 14:21:57 +00:00
Kar Petrosyan
e4e8fb2d71
Merge branch 'master' into async-dependencies-optional 2023-10-25 06:31:19 -04:00
Tom Christie
56b2fb0533
Update README.md
Co-authored-by: T-256 <132141463+T-256@users.noreply.github.com>
2023-10-12 10:42:02 +01:00
Tom Christie
40aa60c2c6
Merge branch 'master' into async-dependencies-optional 2023-10-10 13:43:40 +01:00
Tom Christie
f4dd500f0e Fix dependency formatting 2023-09-20 10:10:12 +01:00
Tom Christie
0dd72fce4a Make async dependencies optional. 2023-09-20 09:46:39 +01:00
59 changed files with 1548 additions and 1043 deletions

View File

@ -211,10 +211,9 @@ this is where our previously generated `client.pem` comes in:
``` ```
import httpx import httpx
ssl_context = httpx.SSLContext() proxies = {"all": "http://127.0.0.1:8080/"}
ssl_context.load_verify_locations("/path/to/client.pem")
with httpx.Client(proxy="http://127.0.0.1:8080/", ssl_context=ssl_context) as client: with httpx.Client(proxies=proxies, verify="/path/to/client.pem") as client:
response = client.get("https://example.org") response = client.get("https://example.org")
print(response.status_code) # should print 200 print(response.status_code) # should print 200
``` ```

View File

@ -15,9 +15,9 @@ jobs:
steps: steps:
- uses: "actions/checkout@v4" - uses: "actions/checkout@v4"
- uses: "actions/setup-python@v6" - uses: "actions/setup-python@v5"
with: with:
python-version: 3.9 python-version: 3.8
- name: "Install dependencies" - name: "Install dependencies"
run: "scripts/install" run: "scripts/install"
- name: "Build package & docs" - name: "Build package & docs"

View File

@ -5,7 +5,7 @@ on:
push: push:
branches: ["master"] branches: ["master"]
pull_request: pull_request:
branches: ["master", "version-*"] branches: ["master", 'version*']
jobs: jobs:
tests: tests:
@ -14,11 +14,11 @@ jobs:
strategy: strategy:
matrix: matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
steps: steps:
- uses: "actions/checkout@v4" - uses: "actions/checkout@v4"
- uses: "actions/setup-python@v6" - uses: "actions/setup-python@v5"
with: with:
python-version: "${{ matrix.python-version }}" python-version: "${{ matrix.python-version }}"
allow-prereleases: true allow-prereleases: true

View File

@ -4,48 +4,6 @@ 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/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [UNRELEASED]
### Removed
* Drop support for Python 3.8
### Added
* Expose `FunctionAuth` from the public API. (#3699)
## 0.28.1 (6th December, 2024)
* Fix SSL case where `verify=False` together with client side certificates.
## 0.28.0 (28th November, 2024)
Be aware that the default *JSON request bodies now use a more compact representation*. This is generally considered a prefered style, tho may require updates to test suites.
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=<ssl_context>` 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)
* Bugfix: When passing `params={}`, always strictly update rather than merge with an existing querystring. (#3364)
## 0.27.2 (27th August, 2024) ## 0.27.2 (27th August, 2024)
### Fixed ### Fixed
@ -632,7 +590,7 @@ See pull requests #1057, #1058.
* Added dedicated exception class `httpx.HTTPStatusError` for `.raise_for_status()` exceptions. (Pull #1072) * Added dedicated exception class `httpx.HTTPStatusError` for `.raise_for_status()` exceptions. (Pull #1072)
* Added `httpx.create_ssl_context()` helper function. (Pull #996) * Added `httpx.create_ssl_context()` helper function. (Pull #996)
* Support for proxy exclusions like `proxies={"https://www.example.com": None}`. (Pull #1099) * Support for proxy exlcusions like `proxies={"https://www.example.com": None}`. (Pull #1099)
* Support `QueryParams(None)` and `client.params = None`. (Pull #1060) * Support `QueryParams(None)` and `client.params = None`. (Pull #1060)
### Changed ### Changed
@ -860,7 +818,7 @@ We believe the API is now pretty much stable, and are aiming for a 1.0 release s
### Fixed ### Fixed
- Fix issue with concurrent connection acquisition. (Pull #700) - Fix issue with concurrent connection acquiry. (Pull #700)
- Fix write error on closing HTTP/2 connections. (Pull #699) - Fix write error on closing HTTP/2 connections. (Pull #699)
## 0.10.0 (December 29th, 2019) ## 0.10.0 (December 29th, 2019)
@ -1109,7 +1067,7 @@ importing modules within the package.
## 0.6.7 (July 8, 2019) ## 0.6.7 (July 8, 2019)
- Check for connection aliveness on re-acquisition (Pull #111) - Check for connection aliveness on re-acquiry (Pull #111)
## 0.6.6 (July 3, 2019) ## 0.6.6 (July 3, 2019)

View File

@ -13,7 +13,9 @@
</a> </a>
</p> </p>
HTTPX is a fully featured HTTP client library for Python 3. It includes **an integrated command line client**, has support for both **HTTP/1.1 and HTTP/2**, and provides both **sync and async APIs**. HTTPX is a fully featured HTTP client library for Python 3. It includes **an integrated
command line client**, has support for both **HTTP/1.1 and HTTP/2**, and provides both **sync
and async APIs**.
--- ---
@ -95,13 +97,15 @@ Install with pip:
$ pip install httpx $ pip install httpx
``` ```
Or, to include the optional HTTP/2 support, use: There are also a number of optional dependancies.
For example to include asyncio and HTTP/2 support, use:
```shell ```shell
$ pip install httpx[http2] $ pip install 'httpx[asyncio,http2]'
``` ```
HTTPX requires Python 3.9+. HTTPX requires Python 3.8+.
## Documentation ## Documentation
@ -127,15 +131,17 @@ The HTTPX project relies on these excellent libraries:
* `h11` - HTTP/1.1 support. * `h11` - HTTP/1.1 support.
* `certifi` - SSL certificates. * `certifi` - SSL certificates.
* `idna` - Internationalized domain name support. * `idna` - Internationalized domain name support.
* `sniffio` - Async library autodetection.
As well as these optional installs: As well as these optional installs:
* `h2` - HTTP/2 support. *(Optional, with `httpx[http2]`)* * `anyio` - Async support for `asyncio`. *(Optional, with `httpx['asyncio']`)*
* `socksio` - SOCKS proxy support. *(Optional, with `httpx[socks]`)* * `trio` - Async support for `trio`. *(Optional, with `httpx['trio']`)*
* `rich` - Rich terminal support. *(Optional, with `httpx[cli]`)* * `h2` - HTTP/2 support. *(Optional, with `httpx['http2']`)*
* `click` - Command line client support. *(Optional, with `httpx[cli]`)* * `socksio` - SOCKS proxy support. *(Optional, with `httpx['socks']`)*
* `brotli` or `brotlicffi` - Decoding for "brotli" compressed responses. *(Optional, with `httpx[brotli]`)* * `click` - Command line client support. *(Optional, with `httpx['cli']`)*
* `rich` - Command line client support. *(Optional, with `httpx['cli']`)*
* `pygments` - Command line client support. *(Optional, with `httpx['cli']`)*
* `brotli` or `brotlicffi` - Decoding for "brotli" compressed responses. *(Optional, with `httpx['brotli']`)*
* `zstandard` - Decoding for "zstd" compressed responses. *(Optional, with `httpx[zstd]`)* * `zstandard` - Decoding for "zstd" compressed responses. *(Optional, with `httpx[zstd]`)*
A huge amount of credit is due to `requests` for the API layout that A huge amount of credit is due to `requests` for the API layout that

View File

@ -270,9 +270,8 @@ multipart file encoding is available by passing a dictionary with the
name of the payloads as keys and either tuple of elements or a file-like object or a string as values. name of the payloads as keys and either tuple of elements or a file-like object or a string as values.
```pycon ```pycon
>>> with open('report.xls', 'rb') as report_file: >>> files = {'upload-file': ('report.xls', open('report.xls', 'rb'), 'application/vnd.ms-excel')}
... files = {'upload-file': ('report.xls', report_file, 'application/vnd.ms-excel')} >>> r = httpx.post("https://httpbin.org/post", files=files)
... r = httpx.post("https://httpbin.org/post", files=files)
>>> print(r.text) >>> print(r.text)
{ {
... ...
@ -319,10 +318,7 @@ To do that, pass a list of `(field, <file>)` items instead of a dictionary, allo
For instance this request sends 2 files, `foo.png` and `bar.png` in one request on the `images` form field: For instance this request sends 2 files, `foo.png` and `bar.png` in one request on the `images` form field:
```pycon ```pycon
>>> with open('foo.png', 'rb') as foo_file, open('bar.png', 'rb') as bar_file: >>> files = [('images', ('foo.png', open('foo.png', 'rb'), 'image/png')),
... files = [ ('images', ('bar.png', open('bar.png', 'rb'), 'image/png'))]
... ('images', ('foo.png', foo_file, 'image/png')), >>> r = httpx.post("https://httpbin.org/post", files=files)
... ('images', ('bar.png', bar_file, 'image/png')),
... ]
... r = httpx.post("https://httpbin.org/post", files=files)
``` ```

View File

@ -1,89 +1,100 @@
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). 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).
### Enabling and disabling verification ## Changing the verification defaults
By default httpx will verify HTTPS connections, and raise an error for invalid SSL cases... 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.
```pycon If you'd like to use a custom CA bundle, you can use the `verify` parameter.
>>> httpx.get("https://expired.badssl.com/")
httpx.ConnectError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:997) ```python
import httpx
r = httpx.get("https://example.org", verify="path/to/client.pem")
``` ```
You can disable SSL verification completely and allow insecure requests... Alternatively, you can pass a standard library `ssl.SSLContext`.
```pycon ```pycon
>>> httpx.get("https://expired.badssl.com/", verify=False) >>> import ssl
>>> import httpx
>>> context = ssl.create_default_context()
>>> context.load_verify_locations(cafile="/tmp/client.pem")
>>> httpx.get('https://example.org', verify=context)
<Response [200 OK]> <Response [200 OK]>
``` ```
### Configuring client instances We also include a helper function for creating properly configured `SSLContext` instances.
If you're using a `Client()` instance you should pass any `verify=<...>` configuration when instantiating the client. ```pycon
>>> context = httpx.create_ssl_context()
By default the [certifi CA bundle](https://certifiio.readthedocs.io/en/latest/) is used for SSL verification.
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 equivalent to the default `verify=True`.
ctx = ssl.create_default_context(cafile=certifi.where())
client = httpx.Client(verify=ctx)
``` ```
Using [the `truststore` package](https://truststore.readthedocs.io/) to support system certificate stores... 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`
```python ```pycon
import ssl >>> import httpx
import truststore >>> context = httpx.create_ssl_context(verify="/tmp/client.pem")
import httpx >>> httpx.get('https://example.org', verify=context)
<Response [200 OK]>
# Use system certificate stores.
ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
client = httpx.Client(verify=ctx)
``` ```
Loding an alternative certificate verification store using [the standard SSL context API](https://docs.python.org/3/library/ssl.html)... Or you can also disable the SSL verification entirely, which is _not_ recommended.
```python ```python
import httpx import httpx
import ssl
# Use an explicitly configured certificate store. r = httpx.get("https://example.org", verify=False)
ctx = ssl.create_default_context(cafile="path/to/certs.pem") # Either cafile or capath.
client = httpx.Client(verify=ctx)
``` ```
### Client side certificates ## SSL configuration on client instances
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. If you're using a `Client()` instance, then you should pass any SSL settings when instantiating the client.
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 ```python
ctx = ssl.create_default_context() client = httpx.Client(verify=False)
ctx.load_cert_chain(certfile="path/to/client.pem") # Optionally also keyfile or password.
client = httpx.Client(verify=ctx)
``` ```
### Working with `SSL_CERT_FILE` and `SSL_CERT_DIR` 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.
`httpx` does respect the `SSL_CERT_FILE` and `SSL_CERT_DIR` environment variables by default. For details, refer to [the section on the environment variables page](../environment_variables.md#ssl_cert_file). ## Client Side Certificates
### Making HTTPS requests to a local server 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)
```python
cert = "path/to/client.pem"
client = httpx.Client(cert=cert)
response = client.get("https://example.org")
```
Alternatively...
```python
cert = ("path/to/client.pem", "path/to/client.key")
client = httpx.Client(cert=cert)
response = client.get("https://example.org")
```
Or...
```python
cert = ("path/to/client.pem", "path/to/client.key", "password")
client = httpx.Client(cert=cert)
response = client.get("https://example.org")
```
## 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. 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. Use [trustme](https://github.com/python-trio/trustme) to generate a pair of server key/cert files, and a client cert file.
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.) 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.)
3. Configure `httpx` to use the certificates stored in `client.pem`. 1. Tell HTTPX to use the certificates stored in `client.pem`:
```python ```python
ctx = ssl.create_default_context(cafile="client.pem") client = httpx.Client(verify="/tmp/client.pem")
client = httpx.Client(verify=ctx) response = client.get("https://localhost:8000")
``` ```

View File

@ -159,18 +159,3 @@ what gets sent over the wire.*
* `def delete(name, [domain], [path])` * `def delete(name, [domain], [path])`
* `def clear([domain], [path])` * `def clear([domain], [path])`
* *Standard mutable mapping interface* * *Standard mutable mapping interface*
## `Proxy`
*A configuration of the proxy server.*
```pycon
>>> proxy = Proxy("http://proxy.example.com:8030")
>>> client = Client(proxy=proxy)
```
* `def __init__(url, [ssl_context], [auth], [headers])`
* `.url` - **URL**
* `.auth` - **tuple[str, str]**
* `.headers` - **Headers**
* `.ssl_context` - **SSLContext**

View File

@ -10,6 +10,24 @@ long-lived network connections such as WebSockets.
If you're working with an async web framework then you'll also want to use an If you're working with an async web framework then you'll also want to use an
async client for sending outgoing HTTP requests. async client for sending outgoing HTTP requests.
## Enabling Async support
To enable async support you'll need to install some additional dependencies:
If you're using Python's [standard `asyncio` support](https://docs.python.org/3/library/asyncio.html) then:
```shell
$ pip install "httpx[asyncio]"
```
Or, if you're working with the [`trio` third party package](https://trio.readthedocs.io/en/stable/):
```shell
$ pip install httpx['trio']
```
We highly recommend `trio` for async support. The `trio` project [pioneered the principles of structured concurrency](https://en.wikipedia.org/wiki/Structured_concurrency), and has a more carefully constrained API against which to work from.
## Making Async requests ## Making Async requests
To make asynchronous requests, you'll need an `AsyncClient`. To make asynchronous requests, you'll need an `AsyncClient`.
@ -23,7 +41,7 @@ To make asynchronous requests, you'll need an `AsyncClient`.
``` ```
!!! tip !!! tip
Use [IPython](https://ipython.readthedocs.io/en/stable/) or Python 3.9+ with `python -m asyncio` to try this code interactively, as they support executing `async`/`await` expressions in the console. Use [IPython](https://ipython.readthedocs.io/en/stable/) or Python 3.8+ with `python -m asyncio` to try this code interactively, as they support executing `async`/`await` expressions in the console.
## API Differences ## API Differences

View File

@ -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_text()` - Instead of `response.iter_content(decode_unicode=True)`
* `.iter_lines()` - Corresponding to `response.iter_lines()` * `.iter_lines()` - Corresponding to `response.iter_lines()`
* `.iter_raw()` - Use this instead of `response.raw` * `.iter_raw()` - Use this instead of `response.raw`
* `.read()` - Read the entire response body, making `response.text` and `response.content` available. * `.read()` - Read the entire response body, making `request.text` and `response.content` available.
## Timeouts ## Timeouts
@ -171,10 +171,12 @@ Also note that `requests.Session.request(...)` allows a `proxies=...` parameter,
## SSL configuration ## SSL configuration
When using a `Client` instance, the ssl configurations should always be passed on client instantiation, rather than passed to the request method. 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.
If you need more than one different SSL configuration, you should use different client instances for each SSL configuration. 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 ## 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. 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.
@ -226,7 +228,3 @@ For both query params (`params=`) and form data (`data=`), `requests` supports s
In HTTPX, event hooks may access properties of requests and responses, but event hook callbacks cannot mutate the original request/response. In HTTPX, event hooks may access properties of requests and responses, but event hook callbacks cannot mutate the original request/response.
If you are looking for more control, consider checking out [Custom Transports](advanced/transports.md#custom-transports). If you are looking for more control, consider checking out [Custom Transports](advanced/transports.md#custom-transports).
## Exceptions and Errors
`requests` exception hierarchy is slightly different to the `httpx` exception hierarchy. `requests` exposes a top level `RequestException`, where as `httpx` exposes a top level `HTTPError`. see the exceptions exposes in requests [here](https://requests.readthedocs.io/en/latest/_modules/requests/exceptions/). See the `httpx` error hierarchy [here](https://www.python-httpx.org/exceptions/).

View File

@ -210,9 +210,12 @@ configure HTTPX as described in the
the [SSL certificates section](https://www.python-httpx.org/advanced/ssl/), the [SSL certificates section](https://www.python-httpx.org/advanced/ssl/),
this is where our previously generated `client.pem` comes in: this is where our previously generated `client.pem` comes in:
```python ```
ctx = ssl.create_default_context(cafile="/path/to/client.pem") import httpx
client = httpx.Client(proxy="http://127.0.0.1:8080/", verify=ctx)
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
``` ```
Note, however, that HTTPS requests will only succeed to the host specified Note, however, that HTTPS requests will only succeed to the host specified

View File

@ -8,6 +8,66 @@ 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: 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 ## Proxies
The environment variables documented below are used as a convention by various HTTP tooling, including: The environment variables documented below are used as a convention by various HTTP tooling, including:
@ -51,29 +111,3 @@ python -c "import httpx; httpx.get('http://example.com')"
python -c "import httpx; httpx.get('http://127.0.0.1:5000/my-api')" python -c "import httpx; httpx.get('http://127.0.0.1:5000/my-api')"
python -c "import httpx; httpx.get('https://www.python-httpx.org')" python -c "import httpx; httpx.get('https://www.python-httpx.org')"
``` ```
## `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')"
```

View File

@ -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... the optional HTTP/2 dependencies...
```shell ```shell
$ pip install httpx[http2] $ pip install 'httpx[http2]'
``` ```
And then instantiating a client with HTTP/2 support enabled: And then instantiating a client with HTTP/2 support enabled:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@ -145,6 +145,6 @@ To include the optional brotli and zstandard decoders support, use:
$ pip install httpx[brotli,zstd] $ pip install httpx[brotli,zstd]
``` ```
HTTPX requires Python 3.9+ HTTPX requires Python 3.8+
[sync-support]: https://github.com/encode/httpx/issues/572 [sync-support]: https://github.com/encode/httpx/issues/572

View File

@ -20,23 +20,25 @@ httpx.get("https://www.example.com")
Will send debug level output to the console, or wherever `stdout` is directed too... Will send debug level output to the console, or wherever `stdout` is directed too...
``` ```
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 [2023-03-16 14:36:20] httpx - load_ssl_context verify=True cert=None trust_env=True http2=False
DEBUG [2024-09-28 17:27:41] httpcore.connection - connect_tcp.complete return_value=<httpcore._backends.sync.SyncStream object at 0x101f1e8e0> 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 [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 [2023-03-16 14:36:20] httpcore - connection.connect_tcp.started host='www.example.com' port=443 local_address=None timeout=5.0
DEBUG [2024-09-28 17:27:41] httpcore.connection - start_tls.complete return_value=<httpcore._backends.sync.SyncStream object at 0x1020f49a0> DEBUG [2023-03-16 14:36:20] httpcore - connection.connect_tcp.complete return_value=<httpcore.backends.sync.SyncStream object at 0x1068fd270>
DEBUG [2024-09-28 17:27:41] httpcore.http11 - send_request_headers.started request=<Request [b'GET']> DEBUG [2023-03-16 14:36:20] httpcore - connection.start_tls.started ssl_context=<ssl.SSLContext object at 0x10689aa40> server_hostname='www.example.com' timeout=5.0
DEBUG [2024-09-28 17:27:41] httpcore.http11 - send_request_headers.complete DEBUG [2023-03-16 14:36:20] httpcore - connection.start_tls.complete return_value=<httpcore.backends.sync.SyncStream object at 0x1068fd240>
DEBUG [2024-09-28 17:27:41] httpcore.http11 - send_request_body.started request=<Request [b'GET']> DEBUG [2023-03-16 14:36:20] httpcore - http11.send_request_headers.started request=<Request [b'GET']>
DEBUG [2024-09-28 17:27:41] httpcore.http11 - send_request_body.complete DEBUG [2023-03-16 14:36:20] httpcore - http11.send_request_headers.complete
DEBUG [2024-09-28 17:27:41] httpcore.http11 - receive_response_headers.started request=<Request [b'GET']> DEBUG [2023-03-16 14:36:20] httpcore - http11.send_request_body.started request=<Request [b'GET']>
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')]) DEBUG [2023-03-16 14:36:20] httpcore - http11.send_request_body.complete
INFO [2024-09-28 17:27:41] httpx - HTTP Request: GET https://www.example.com "HTTP/1.1 200 OK" DEBUG [2023-03-16 14:36:20] httpcore - http11.receive_response_headers.started request=<Request [b'GET']>
DEBUG [2024-09-28 17:27:41] httpcore.http11 - receive_response_body.started request=<Request [b'GET']> 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')])
DEBUG [2024-09-28 17:27:41] httpcore.http11 - receive_response_body.complete INFO [2023-03-16 14:36:21] httpx - HTTP Request: GET https://www.example.com "HTTP/1.1 200 OK"
DEBUG [2024-09-28 17:27:41] httpcore.http11 - response_closed.started DEBUG [2023-03-16 14:36:21] httpcore - http11.receive_response_body.started request=<Request [b'GET']>
DEBUG [2024-09-28 17:27:41] httpcore.http11 - response_closed.complete DEBUG [2023-03-16 14:36:21] httpcore - http11.receive_response_body.complete
DEBUG [2024-09-28 17:27:41] httpcore.connection - close.started DEBUG [2023-03-16 14:36:21] httpcore - http11.response_closed.started
DEBUG [2024-09-28 17:27:41] httpcore.connection - close.complete 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
``` ```
Logging output includes information from both the high-level `httpx` logger, and the network-level `httpcore` logger, which can be configured separately. Logging output includes information from both the high-level `httpx` logger, and the network-level `httpcore` logger, which can be configured separately.
@ -78,4 +80,4 @@ logging.config.dictConfig(LOGGING_CONFIG)
httpx.get('https://www.example.com') httpx.get('https://www.example.com')
``` ```
The exact formatting of the debug logging may be subject to change across different versions of `httpx` and `httpcore`. If you need to rely on a particular format it is recommended that you pin installation of these packages to fixed versions. The exact formatting of the debug logging may be subject to change across different versions of `httpx` and `httpcore`. If you need to rely on a particular format it is recommended that you pin installation of these packages to fixed versions.

View File

@ -174,9 +174,8 @@ Form encoded data can also include multiple values from a given key.
You can also upload files, using HTTP multipart encoding: You can also upload files, using HTTP multipart encoding:
```pycon ```pycon
>>> with open('report.xls', 'rb') as report_file: >>> files = {'upload-file': open('report.xls', 'rb')}
... files = {'upload-file': report_file} >>> r = httpx.post("https://httpbin.org/post", files=files)
... r = httpx.post("https://httpbin.org/post", files=files)
>>> print(r.text) >>> print(r.text)
{ {
... ...
@ -191,9 +190,8 @@ You can also explicitly set the filename and content type, by using a tuple
of items for the file value: of items for the file value:
```pycon ```pycon
>>> with open('report.xls', 'rb') as report_file: >>> files = {'upload-file': ('report.xls', open('report.xls', 'rb'), 'application/vnd.ms-excel')}
... files = {'upload-file': ('report.xls', report_file, 'application/vnd.ms-excel')} >>> r = httpx.post("https://httpbin.org/post", files=files)
... r = httpx.post("https://httpbin.org/post", files=files)
>>> print(r.text) >>> print(r.text)
{ {
... ...
@ -208,9 +206,8 @@ If you need to include non-file data fields in the multipart form, use the `data
```pycon ```pycon
>>> data = {'message': 'Hello, world!'} >>> data = {'message': 'Hello, world!'}
>>> with open('report.xls', 'rb') as report_file: >>> files = {'file': open('report.xls', 'rb')}
... files = {'file': report_file} >>> r = httpx.post("https://httpbin.org/post", data=data, files=files)
... r = httpx.post("https://httpbin.org/post", data=data, files=files)
>>> print(r.text) >>> print(r.text)
{ {
... ...

View File

@ -2,83 +2,31 @@
As HTTPX usage grows, there is an expanding community of developers building tools and libraries that integrate with HTTPX, or depend on HTTPX. Here are some of them. As HTTPX usage grows, there is an expanding community of developers building tools and libraries that integrate with HTTPX, or depend on HTTPX. Here are some of them.
<!-- NOTE: Entries are alphabetised. -->
## Plugins ## Plugins
### Hishel
[GitHub](https://github.com/karpetrosyan/hishel) - [Documentation](https://hishel.com/)
An elegant HTTP Cache implementation for HTTPX and HTTP Core.
### HTTPX-Auth
[GitHub](https://github.com/Colin-b/httpx_auth) - [Documentation](https://colin-b.github.io/httpx_auth/)
Provides authentication classes to be used with HTTPX's [authentication parameter](advanced/authentication.md#customizing-authentication).
### httpx-caching
[Github](https://github.com/johtso/httpx-caching)
This package adds caching functionality to HTTPX
### httpx-secure
[GitHub](https://github.com/Zaczero/httpx-secure)
Drop-in SSRF protection for httpx with DNS caching and custom validation support.
### httpx-socks
[GitHub](https://github.com/romis2012/httpx-socks)
Proxy (HTTP, SOCKS) transports for httpx.
### httpx-sse
[GitHub](https://github.com/florimondmanca/httpx-sse)
Allows consuming Server-Sent Events (SSE) with HTTPX.
### httpx-retries
[GitHub](https://github.com/will-ockmore/httpx-retries) - [Documentation](https://will-ockmore.github.io/httpx-retries/)
A retry layer for HTTPX.
### httpx-ws ### httpx-ws
[GitHub](https://github.com/frankie567/httpx-ws) - [Documentation](https://frankie567.github.io/httpx-ws/) [GitHub](https://github.com/frankie567/httpx-ws) - [Documentation](https://frankie567.github.io/httpx-ws/)
WebSocket support for HTTPX. WebSocket support for HTTPX.
### pytest-HTTPX ### httpx-socks
[GitHub](https://github.com/Colin-b/pytest_httpx) - [Documentation](https://colin-b.github.io/pytest_httpx/) [GitHub](https://github.com/romis2012/httpx-socks)
Provides a [pytest](https://docs.pytest.org/en/latest/) fixture to mock HTTPX within test cases. Proxy (HTTP, SOCKS) transports for httpx.
### RESPX ### Hishel
[GitHub](https://github.com/lundberg/respx) - [Documentation](https://lundberg.github.io/respx/) [GitHub](https://github.com/karpetrosyan/hishel) - [Documentation](https://hishel.com/)
A utility for mocking out HTTPX. An elegant HTTP Cache implementation for HTTPX and HTTP Core.
### rpc.py
[Github](https://github.com/abersheeran/rpc.py) - [Documentation](https://github.com/abersheeran/rpc.py#rpcpy)
A fast and powerful RPC framework based on ASGI/WSGI. Use HTTPX as the client of the RPC service.
## Libraries with HTTPX support
### Authlib ### Authlib
[GitHub](https://github.com/lepture/authlib) - [Documentation](https://docs.authlib.org/en/latest/) [GitHub](https://github.com/lepture/authlib) - [Documentation](https://docs.authlib.org/en/latest/)
A python library for building OAuth and OpenID Connect clients and servers. Includes an [OAuth HTTPX client](https://docs.authlib.org/en/latest/client/httpx.html). The ultimate Python library in building OAuth and OpenID Connect clients and servers. Includes an [OAuth HTTPX client](https://docs.authlib.org/en/latest/client/httpx.html).
### Gidgethub ### Gidgethub
@ -86,20 +34,58 @@ A python library for building OAuth and OpenID Connect clients and servers. Incl
An asynchronous GitHub API library. Includes [HTTPX support](https://gidgethub.readthedocs.io/en/latest/httpx.html). An asynchronous GitHub API library. Includes [HTTPX support](https://gidgethub.readthedocs.io/en/latest/httpx.html).
### httpdbg ### HTTPX-Auth
[GitHub](https://github.com/cle-b/httpdbg) - [Documentation](https://httpdbg.readthedocs.io/) [GitHub](https://github.com/Colin-b/httpx_auth) - [Documentation](https://colin-b.github.io/httpx_auth/)
A tool for python developers to easily debug the HTTP(S) client requests in a python program. Provides authentication classes to be used with HTTPX [authentication parameter](advanced/authentication.md#customizing-authentication).
### pytest-HTTPX
[GitHub](https://github.com/Colin-b/pytest_httpx) - [Documentation](https://colin-b.github.io/pytest_httpx/)
Provides `httpx_mock` [pytest](https://docs.pytest.org/en/latest/) fixture to mock HTTPX within test cases.
### RESPX
[GitHub](https://github.com/lundberg/respx) - [Documentation](https://lundberg.github.io/respx/)
A utility for mocking out the Python HTTPX library.
### rpc.py
[Github](https://github.com/abersheeran/rpc.py) - [Documentation](https://github.com/abersheeran/rpc.py#rpcpy)
An fast and powerful RPC framework based on ASGI/WSGI. Use HTTPX as the client of the RPC service.
### VCR.py ### VCR.py
[GitHub](https://github.com/kevin1024/vcrpy) - [Documentation](https://vcrpy.readthedocs.io/) [GitHub](https://github.com/kevin1024/vcrpy) - [Documentation](https://vcrpy.readthedocs.io/)
Record and repeat requests. A utility for record and repeat an http request.
### httpx-caching
[Github](https://github.com/johtso/httpx-caching)
This package adds caching functionality to HTTPX
### httpx-sse
[GitHub](https://github.com/florimondmanca/httpx-sse)
Allows consuming Server-Sent Events (SSE) with HTTPX.
### robox
[Github](https://github.com/danclaudiupop/robox)
A library for scraping the web built on top of HTTPX.
## Gists ## Gists
<!-- NOTE: this list is in alphabetical order. -->
### urllib3-transport ### urllib3-transport
[GitHub](https://gist.github.com/florimondmanca/d56764d78d748eb9f73165da388e546e) [GitHub](https://gist.github.com/florimondmanca/d56764d78d748eb9f73165da388e546e)

View File

@ -50,7 +50,6 @@ __all__ = [
"DecodingError", "DecodingError",
"delete", "delete",
"DigestAuth", "DigestAuth",
"FunctionAuth",
"get", "get",
"head", "head",
"Headers", "Headers",

View File

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

View File

@ -8,21 +8,20 @@ from ._config import DEFAULT_TIMEOUT_CONFIG
from ._models import Response from ._models import Response
from ._types import ( from ._types import (
AuthTypes, AuthTypes,
CertTypes,
CookieTypes, CookieTypes,
HeaderTypes, HeaderTypes,
ProxiesTypes,
ProxyTypes, ProxyTypes,
QueryParamTypes, QueryParamTypes,
RequestContent, RequestContent,
RequestData, RequestData,
RequestFiles, RequestFiles,
TimeoutTypes, TimeoutTypes,
VerifyTypes,
) )
from ._urls import URL from ._urls import URL
if typing.TYPE_CHECKING:
import ssl # pragma: no cover
__all__ = [ __all__ = [
"delete", "delete",
"get", "get",
@ -49,9 +48,11 @@ def request(
cookies: CookieTypes | None = None, cookies: CookieTypes | None = None,
auth: AuthTypes | None = None, auth: AuthTypes | None = None,
proxy: ProxyTypes | None = None, proxy: ProxyTypes | None = None,
proxies: ProxiesTypes | None = None,
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
follow_redirects: bool = False, follow_redirects: bool = False,
verify: ssl.SSLContext | str | bool = True, verify: VerifyTypes = True,
cert: CertTypes | None = None,
trust_env: bool = True, trust_env: bool = True,
) -> Response: ) -> Response:
""" """
@ -79,12 +80,18 @@ def request(
* **auth** - *(optional)* An authentication class to use when sending the * **auth** - *(optional)* An authentication class to use when sending the
request. request.
* **proxy** - *(optional)* A proxy URL where all the traffic should be routed. * **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 * **timeout** - *(optional)* The timeout configuration to use when sending
the request. the request.
* **follow_redirects** - *(optional)* Enables or disables HTTP redirects. * **follow_redirects** - *(optional)* Enables or disables HTTP redirects.
* **verify** - *(optional)* Either `True` to use an SSL context with the * **verify** - *(optional)* SSL certificates (a.k.a CA bundle) used to
default CA bundle, `False` to disable verification, or an instance of verify the identity of requested hosts. Either `True` (default CA bundle),
`ssl.SSLContext` to use a custom context. 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).
* **trust_env** - *(optional)* Enables or disables usage of environment * **trust_env** - *(optional)* Enables or disables usage of environment
variables for configuration. variables for configuration.
@ -102,6 +109,8 @@ def request(
with Client( with Client(
cookies=cookies, cookies=cookies,
proxy=proxy, proxy=proxy,
proxies=proxies,
cert=cert,
verify=verify, verify=verify,
timeout=timeout, timeout=timeout,
trust_env=trust_env, trust_env=trust_env,
@ -134,9 +143,11 @@ def stream(
cookies: CookieTypes | None = None, cookies: CookieTypes | None = None,
auth: AuthTypes | None = None, auth: AuthTypes | None = None,
proxy: ProxyTypes | None = None, proxy: ProxyTypes | None = None,
proxies: ProxiesTypes | None = None,
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
follow_redirects: bool = False, follow_redirects: bool = False,
verify: ssl.SSLContext | str | bool = True, verify: VerifyTypes = True,
cert: CertTypes | None = None,
trust_env: bool = True, trust_env: bool = True,
) -> typing.Iterator[Response]: ) -> typing.Iterator[Response]:
""" """
@ -152,6 +163,8 @@ def stream(
with Client( with Client(
cookies=cookies, cookies=cookies,
proxy=proxy, proxy=proxy,
proxies=proxies,
cert=cert,
verify=verify, verify=verify,
timeout=timeout, timeout=timeout,
trust_env=trust_env, trust_env=trust_env,
@ -179,8 +192,10 @@ def get(
cookies: CookieTypes | None = None, cookies: CookieTypes | None = None,
auth: AuthTypes | None = None, auth: AuthTypes | None = None,
proxy: ProxyTypes | None = None, proxy: ProxyTypes | None = None,
proxies: ProxiesTypes | None = None,
follow_redirects: bool = False, follow_redirects: bool = False,
verify: ssl.SSLContext | str | bool = True, cert: CertTypes | None = None,
verify: VerifyTypes = True,
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
trust_env: bool = True, trust_env: bool = True,
) -> Response: ) -> Response:
@ -200,7 +215,9 @@ def get(
cookies=cookies, cookies=cookies,
auth=auth, auth=auth,
proxy=proxy, proxy=proxy,
proxies=proxies,
follow_redirects=follow_redirects, follow_redirects=follow_redirects,
cert=cert,
verify=verify, verify=verify,
timeout=timeout, timeout=timeout,
trust_env=trust_env, trust_env=trust_env,
@ -215,8 +232,10 @@ def options(
cookies: CookieTypes | None = None, cookies: CookieTypes | None = None,
auth: AuthTypes | None = None, auth: AuthTypes | None = None,
proxy: ProxyTypes | None = None, proxy: ProxyTypes | None = None,
proxies: ProxiesTypes | None = None,
follow_redirects: bool = False, follow_redirects: bool = False,
verify: ssl.SSLContext | str | bool = True, cert: CertTypes | None = None,
verify: VerifyTypes = True,
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
trust_env: bool = True, trust_env: bool = True,
) -> Response: ) -> Response:
@ -236,7 +255,9 @@ def options(
cookies=cookies, cookies=cookies,
auth=auth, auth=auth,
proxy=proxy, proxy=proxy,
proxies=proxies,
follow_redirects=follow_redirects, follow_redirects=follow_redirects,
cert=cert,
verify=verify, verify=verify,
timeout=timeout, timeout=timeout,
trust_env=trust_env, trust_env=trust_env,
@ -251,8 +272,10 @@ def head(
cookies: CookieTypes | None = None, cookies: CookieTypes | None = None,
auth: AuthTypes | None = None, auth: AuthTypes | None = None,
proxy: ProxyTypes | None = None, proxy: ProxyTypes | None = None,
proxies: ProxiesTypes | None = None,
follow_redirects: bool = False, follow_redirects: bool = False,
verify: ssl.SSLContext | str | bool = True, cert: CertTypes | None = None,
verify: VerifyTypes = True,
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
trust_env: bool = True, trust_env: bool = True,
) -> Response: ) -> Response:
@ -272,7 +295,9 @@ def head(
cookies=cookies, cookies=cookies,
auth=auth, auth=auth,
proxy=proxy, proxy=proxy,
proxies=proxies,
follow_redirects=follow_redirects, follow_redirects=follow_redirects,
cert=cert,
verify=verify, verify=verify,
timeout=timeout, timeout=timeout,
trust_env=trust_env, trust_env=trust_env,
@ -291,8 +316,10 @@ def post(
cookies: CookieTypes | None = None, cookies: CookieTypes | None = None,
auth: AuthTypes | None = None, auth: AuthTypes | None = None,
proxy: ProxyTypes | None = None, proxy: ProxyTypes | None = None,
proxies: ProxiesTypes | None = None,
follow_redirects: bool = False, follow_redirects: bool = False,
verify: ssl.SSLContext | str | bool = True, cert: CertTypes | None = None,
verify: VerifyTypes = True,
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
trust_env: bool = True, trust_env: bool = True,
) -> Response: ) -> Response:
@ -313,7 +340,9 @@ def post(
cookies=cookies, cookies=cookies,
auth=auth, auth=auth,
proxy=proxy, proxy=proxy,
proxies=proxies,
follow_redirects=follow_redirects, follow_redirects=follow_redirects,
cert=cert,
verify=verify, verify=verify,
timeout=timeout, timeout=timeout,
trust_env=trust_env, trust_env=trust_env,
@ -332,8 +361,10 @@ def put(
cookies: CookieTypes | None = None, cookies: CookieTypes | None = None,
auth: AuthTypes | None = None, auth: AuthTypes | None = None,
proxy: ProxyTypes | None = None, proxy: ProxyTypes | None = None,
proxies: ProxiesTypes | None = None,
follow_redirects: bool = False, follow_redirects: bool = False,
verify: ssl.SSLContext | str | bool = True, cert: CertTypes | None = None,
verify: VerifyTypes = True,
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
trust_env: bool = True, trust_env: bool = True,
) -> Response: ) -> Response:
@ -354,7 +385,9 @@ def put(
cookies=cookies, cookies=cookies,
auth=auth, auth=auth,
proxy=proxy, proxy=proxy,
proxies=proxies,
follow_redirects=follow_redirects, follow_redirects=follow_redirects,
cert=cert,
verify=verify, verify=verify,
timeout=timeout, timeout=timeout,
trust_env=trust_env, trust_env=trust_env,
@ -373,8 +406,10 @@ def patch(
cookies: CookieTypes | None = None, cookies: CookieTypes | None = None,
auth: AuthTypes | None = None, auth: AuthTypes | None = None,
proxy: ProxyTypes | None = None, proxy: ProxyTypes | None = None,
proxies: ProxiesTypes | None = None,
follow_redirects: bool = False, follow_redirects: bool = False,
verify: ssl.SSLContext | str | bool = True, cert: CertTypes | None = None,
verify: VerifyTypes = True,
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
trust_env: bool = True, trust_env: bool = True,
) -> Response: ) -> Response:
@ -395,7 +430,9 @@ def patch(
cookies=cookies, cookies=cookies,
auth=auth, auth=auth,
proxy=proxy, proxy=proxy,
proxies=proxies,
follow_redirects=follow_redirects, follow_redirects=follow_redirects,
cert=cert,
verify=verify, verify=verify,
timeout=timeout, timeout=timeout,
trust_env=trust_env, trust_env=trust_env,
@ -410,9 +447,11 @@ def delete(
cookies: CookieTypes | None = None, cookies: CookieTypes | None = None,
auth: AuthTypes | None = None, auth: AuthTypes | None = None,
proxy: ProxyTypes | None = None, proxy: ProxyTypes | None = None,
proxies: ProxiesTypes | None = None,
follow_redirects: bool = False, follow_redirects: bool = False,
cert: CertTypes | None = None,
verify: VerifyTypes = True,
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
verify: ssl.SSLContext | str | bool = True,
trust_env: bool = True, trust_env: bool = True,
) -> Response: ) -> Response:
""" """
@ -431,7 +470,9 @@ def delete(
cookies=cookies, cookies=cookies,
auth=auth, auth=auth,
proxy=proxy, proxy=proxy,
proxies=proxies,
follow_redirects=follow_redirects, follow_redirects=follow_redirects,
cert=cert,
verify=verify, verify=verify,
timeout=timeout, timeout=timeout,
trust_env=trust_env, trust_env=trust_env,

View File

@ -16,7 +16,7 @@ if typing.TYPE_CHECKING: # pragma: no cover
from hashlib import _Hash from hashlib import _Hash
__all__ = ["Auth", "BasicAuth", "DigestAuth", "FunctionAuth", "NetRCAuth"] __all__ = ["Auth", "BasicAuth", "DigestAuth", "NetRCAuth"]
class Auth: class Auth:

View File

@ -3,7 +3,6 @@ from __future__ import annotations
import datetime import datetime
import enum import enum
import logging import logging
import time
import typing import typing
import warnings import warnings
from contextlib import asynccontextmanager, contextmanager from contextlib import asynccontextmanager, contextmanager
@ -28,14 +27,17 @@ from ._exceptions import (
) )
from ._models import Cookies, Headers, Request, Response from ._models import Cookies, Headers, Request, Response
from ._status_codes import codes from ._status_codes import codes
from ._transports.asgi import ASGITransport
from ._transports.base import AsyncBaseTransport, BaseTransport from ._transports.base import AsyncBaseTransport, BaseTransport
from ._transports.default import AsyncHTTPTransport, HTTPTransport from ._transports.default import AsyncHTTPTransport, HTTPTransport
from ._transports.wsgi import WSGITransport
from ._types import ( from ._types import (
AsyncByteStream, AsyncByteStream,
AuthTypes, AuthTypes,
CertTypes, CertTypes,
CookieTypes, CookieTypes,
HeaderTypes, HeaderTypes,
ProxiesTypes,
ProxyTypes, ProxyTypes,
QueryParamTypes, QueryParamTypes,
RequestContent, RequestContent,
@ -44,12 +46,16 @@ from ._types import (
RequestFiles, RequestFiles,
SyncByteStream, SyncByteStream,
TimeoutTypes, TimeoutTypes,
VerifyTypes,
) )
from ._urls import URL, QueryParams from ._urls import URL, QueryParams
from ._utils import URLPattern, get_environment_proxies from ._utils import (
Timer,
if typing.TYPE_CHECKING: URLPattern,
import ssl # pragma: no cover get_environment_proxies,
is_https_redirect,
same_origin,
)
__all__ = ["USE_CLIENT_DEFAULT", "AsyncClient", "Client"] __all__ = ["USE_CLIENT_DEFAULT", "AsyncClient", "Client"]
@ -59,38 +65,6 @@ T = typing.TypeVar("T", bound="Client")
U = typing.TypeVar("U", bound="AsyncClient") 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: class UseClientDefault:
""" """
For some parameters such as `auth=...` and `timeout=...` we need to be able For some parameters such as `auth=...` and `timeout=...` we need to be able
@ -143,19 +117,19 @@ class BoundSyncStream(SyncByteStream):
""" """
def __init__( def __init__(
self, stream: SyncByteStream, response: Response, start: float self, stream: SyncByteStream, response: Response, timer: Timer
) -> None: ) -> None:
self._stream = stream self._stream = stream
self._response = response self._response = response
self._start = start self._timer = timer
def __iter__(self) -> typing.Iterator[bytes]: def __iter__(self) -> typing.Iterator[bytes]:
for chunk in self._stream: for chunk in self._stream:
yield chunk yield chunk
def close(self) -> None: def close(self) -> None:
elapsed = time.perf_counter() - self._start seconds = self._timer.sync_elapsed()
self._response.elapsed = datetime.timedelta(seconds=elapsed) self._response.elapsed = datetime.timedelta(seconds=seconds)
self._stream.close() self._stream.close()
@ -166,19 +140,19 @@ class BoundAsyncStream(AsyncByteStream):
""" """
def __init__( def __init__(
self, stream: AsyncByteStream, response: Response, start: float self, stream: AsyncByteStream, response: Response, timer: Timer
) -> None: ) -> None:
self._stream = stream self._stream = stream
self._response = response self._response = response
self._start = start self._timer = timer
async def __aiter__(self) -> typing.AsyncIterator[bytes]: async def __aiter__(self) -> typing.AsyncIterator[bytes]:
async for chunk in self._stream: async for chunk in self._stream:
yield chunk yield chunk
async def aclose(self) -> None: async def aclose(self) -> None:
elapsed = time.perf_counter() - self._start seconds = await self._timer.async_elapsed()
self._response.elapsed = datetime.timedelta(seconds=elapsed) self._response.elapsed = datetime.timedelta(seconds=seconds)
await self._stream.aclose() await self._stream.aclose()
@ -237,17 +211,23 @@ class BaseClient:
return url.copy_with(raw_path=url.raw_path + b"/") return url.copy_with(raw_path=url.raw_path + b"/")
def _get_proxy_map( def _get_proxy_map(
self, proxy: ProxyTypes | None, allow_env_proxies: bool self, proxies: ProxiesTypes | None, allow_env_proxies: bool
) -> dict[str, Proxy | None]: ) -> dict[str, Proxy | None]:
if proxy is None: if proxies is None:
if allow_env_proxies: if allow_env_proxies:
return { return {
key: None if url is None else Proxy(url=url) key: None if url is None else Proxy(url=url)
for key, url in get_environment_proxies().items() for key, url in get_environment_proxies().items()
} }
return {} 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: else:
proxy = Proxy(url=proxy) if isinstance(proxy, (str, URL)) else proxy proxy = Proxy(url=proxies) if isinstance(proxies, (str, URL)) else proxies
return {"all://": proxy} return {"all://": proxy}
@property @property
@ -549,8 +529,8 @@ class BaseClient:
""" """
headers = Headers(request.headers) headers = Headers(request.headers)
if not _same_origin(url, request.url): if not same_origin(url, request.url):
if not _is_https_redirect(request.url, url): if not is_https_redirect(request.url, url):
# Strip Authorization headers when responses are redirected # Strip Authorization headers when responses are redirected
# away from the origin. (Except for direct HTTP to HTTPS redirects.) # away from the origin. (Except for direct HTTP to HTTPS redirects.)
headers.pop("Authorization", None) headers.pop("Authorization", None)
@ -614,12 +594,19 @@ class Client(BaseClient):
sending requests. sending requests.
* **cookies** - *(optional)* Dictionary of Cookie items to include when * **cookies** - *(optional)* Dictionary of Cookie items to include when
sending requests. sending requests.
* **verify** - *(optional)* Either `True` to use an SSL context with the * **verify** - *(optional)* SSL certificates (a.k.a CA bundle) used to
default CA bundle, `False` to disable verification, or an instance of verify the identity of requested hosts. Either `True` (default CA bundle),
`ssl.SSLContext` to use a custom context. 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).
* **http2** - *(optional)* A boolean indicating if HTTP/2 support should be * **http2** - *(optional)* A boolean indicating if HTTP/2 support should be
enabled. Defaults to `False`. enabled. Defaults to `False`.
* **proxy** - *(optional)* A proxy URL where all the traffic should be routed. * **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 * **timeout** - *(optional)* The timeout configuration to use when sending
requests. requests.
* **limits** - *(optional)* The limits configuration to use. * **limits** - *(optional)* The limits configuration to use.
@ -629,6 +616,8 @@ class Client(BaseClient):
request URLs. request URLs.
* **transport** - *(optional)* A transport class to use for sending requests * **transport** - *(optional)* A transport class to use for sending requests
over the network. 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 * **trust_env** - *(optional)* Enables or disables usage of environment
variables for configuration. variables for configuration.
* **default_encoding** - *(optional)* The default encoding to use for decoding * **default_encoding** - *(optional)* The default encoding to use for decoding
@ -643,12 +632,12 @@ class Client(BaseClient):
params: QueryParamTypes | None = None, params: QueryParamTypes | None = None,
headers: HeaderTypes | None = None, headers: HeaderTypes | None = None,
cookies: CookieTypes | None = None, cookies: CookieTypes | None = None,
verify: ssl.SSLContext | str | bool = True, verify: VerifyTypes = True,
cert: CertTypes | None = None, cert: CertTypes | None = None,
trust_env: bool = True,
http1: bool = True, http1: bool = True,
http2: bool = False, http2: bool = False,
proxy: ProxyTypes | None = None, proxy: ProxyTypes | None = None,
proxies: ProxiesTypes | None = None,
mounts: None | (typing.Mapping[str, BaseTransport | None]) = None, mounts: None | (typing.Mapping[str, BaseTransport | None]) = None,
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
follow_redirects: bool = False, follow_redirects: bool = False,
@ -657,6 +646,8 @@ class Client(BaseClient):
event_hooks: None | (typing.Mapping[str, list[EventHook]]) = None, event_hooks: None | (typing.Mapping[str, list[EventHook]]) = None,
base_url: URL | str = "", base_url: URL | str = "",
transport: BaseTransport | None = None, transport: BaseTransport | None = None,
app: typing.Callable[..., typing.Any] | None = None,
trust_env: bool = True,
default_encoding: str | typing.Callable[[bytes], str] = "utf-8", default_encoding: str | typing.Callable[[bytes], str] = "utf-8",
) -> None: ) -> None:
super().__init__( super().__init__(
@ -682,17 +673,34 @@ class Client(BaseClient):
"Make sure to install httpx using `pip install httpx[http2]`." "Make sure to install httpx using `pip install httpx[http2]`."
) from None ) from None
allow_env_proxies = trust_env and transport is None if proxies:
proxy_map = self._get_proxy_map(proxy, allow_env_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)
self._transport = self._init_transport( self._transport = self._init_transport(
verify=verify, verify=verify,
cert=cert, cert=cert,
trust_env=trust_env,
http1=http1, http1=http1,
http2=http2, http2=http2,
limits=limits, limits=limits,
transport=transport, transport=transport,
app=app,
trust_env=trust_env,
) )
self._mounts: dict[URLPattern, BaseTransport | None] = { self._mounts: dict[URLPattern, BaseTransport | None] = {
URLPattern(key): None URLPattern(key): None
@ -701,10 +709,10 @@ class Client(BaseClient):
proxy, proxy,
verify=verify, verify=verify,
cert=cert, cert=cert,
trust_env=trust_env,
http1=http1, http1=http1,
http2=http2, http2=http2,
limits=limits, limits=limits,
trust_env=trust_env,
) )
for key, proxy in proxy_map.items() for key, proxy in proxy_map.items()
} }
@ -717,43 +725,47 @@ class Client(BaseClient):
def _init_transport( def _init_transport(
self, self,
verify: ssl.SSLContext | str | bool = True, verify: VerifyTypes = True,
cert: CertTypes | None = None, cert: CertTypes | None = None,
trust_env: bool = True,
http1: bool = True, http1: bool = True,
http2: bool = False, http2: bool = False,
limits: Limits = DEFAULT_LIMITS, limits: Limits = DEFAULT_LIMITS,
transport: BaseTransport | None = None, transport: BaseTransport | None = None,
app: typing.Callable[..., typing.Any] | None = None,
trust_env: bool = True,
) -> BaseTransport: ) -> BaseTransport:
if transport is not None: if transport is not None:
return transport return transport
if app is not None:
return WSGITransport(app=app)
return HTTPTransport( return HTTPTransport(
verify=verify, verify=verify,
cert=cert, cert=cert,
trust_env=trust_env,
http1=http1, http1=http1,
http2=http2, http2=http2,
limits=limits, limits=limits,
trust_env=trust_env,
) )
def _init_proxy_transport( def _init_proxy_transport(
self, self,
proxy: Proxy, proxy: Proxy,
verify: ssl.SSLContext | str | bool = True, verify: VerifyTypes = True,
cert: CertTypes | None = None, cert: CertTypes | None = None,
trust_env: bool = True,
http1: bool = True, http1: bool = True,
http2: bool = False, http2: bool = False,
limits: Limits = DEFAULT_LIMITS, limits: Limits = DEFAULT_LIMITS,
trust_env: bool = True,
) -> BaseTransport: ) -> BaseTransport:
return HTTPTransport( return HTTPTransport(
verify=verify, verify=verify,
cert=cert, cert=cert,
trust_env=trust_env,
http1=http1, http1=http1,
http2=http2, http2=http2,
limits=limits, limits=limits,
trust_env=trust_env,
proxy=proxy, proxy=proxy,
) )
@ -807,7 +819,7 @@ class Client(BaseClient):
"the expected behaviour on cookie persistence is ambiguous. Set " "the expected behaviour on cookie persistence is ambiguous. Set "
"cookies directly on the client instance instead." "cookies directly on the client instance instead."
) )
warnings.warn(message, DeprecationWarning, stacklevel=2) warnings.warn(message, DeprecationWarning)
request = self.build_request( request = self.build_request(
method=method, method=method,
@ -1003,7 +1015,8 @@ class Client(BaseClient):
Sends a single request, without handling any redirections. Sends a single request, without handling any redirections.
""" """
transport = self._transport_for_url(request.url) transport = self._transport_for_url(request.url)
start = time.perf_counter() timer = Timer()
timer.sync_start()
if not isinstance(request.stream, SyncByteStream): if not isinstance(request.stream, SyncByteStream):
raise RuntimeError( raise RuntimeError(
@ -1017,7 +1030,7 @@ class Client(BaseClient):
response.request = request response.request = request
response.stream = BoundSyncStream( response.stream = BoundSyncStream(
response.stream, response=response, start=start response.stream, response=response, timer=timer
) )
self.cookies.extract_cookies(response) self.cookies.extract_cookies(response)
response.default_encoding = self._default_encoding response.default_encoding = self._default_encoding
@ -1328,12 +1341,19 @@ class AsyncClient(BaseClient):
sending requests. sending requests.
* **cookies** - *(optional)* Dictionary of Cookie items to include when * **cookies** - *(optional)* Dictionary of Cookie items to include when
sending requests. sending requests.
* **verify** - *(optional)* Either `True` to use an SSL context with the * **verify** - *(optional)* SSL certificates (a.k.a CA bundle) used to
default CA bundle, `False` to disable verification, or an instance of verify the identity of requested hosts. Either `True` (default CA bundle),
`ssl.SSLContext` to use a custom context. 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).
* **http2** - *(optional)* A boolean indicating if HTTP/2 support should be * **http2** - *(optional)* A boolean indicating if HTTP/2 support should be
enabled. Defaults to `False`. enabled. Defaults to `False`.
* **proxy** - *(optional)* A proxy URL where all the traffic should be routed. * **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 * **timeout** - *(optional)* The timeout configuration to use when sending
requests. requests.
* **limits** - *(optional)* The limits configuration to use. * **limits** - *(optional)* The limits configuration to use.
@ -1343,6 +1363,8 @@ class AsyncClient(BaseClient):
request URLs. request URLs.
* **transport** - *(optional)* A transport class to use for sending requests * **transport** - *(optional)* A transport class to use for sending requests
over the network. 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 * **trust_env** - *(optional)* Enables or disables usage of environment
variables for configuration. variables for configuration.
* **default_encoding** - *(optional)* The default encoding to use for decoding * **default_encoding** - *(optional)* The default encoding to use for decoding
@ -1357,11 +1379,12 @@ class AsyncClient(BaseClient):
params: QueryParamTypes | None = None, params: QueryParamTypes | None = None,
headers: HeaderTypes | None = None, headers: HeaderTypes | None = None,
cookies: CookieTypes | None = None, cookies: CookieTypes | None = None,
verify: ssl.SSLContext | str | bool = True, verify: VerifyTypes = True,
cert: CertTypes | None = None, cert: CertTypes | None = None,
http1: bool = True, http1: bool = True,
http2: bool = False, http2: bool = False,
proxy: ProxyTypes | None = None, proxy: ProxyTypes | None = None,
proxies: ProxiesTypes | None = None,
mounts: None | (typing.Mapping[str, AsyncBaseTransport | None]) = None, mounts: None | (typing.Mapping[str, AsyncBaseTransport | None]) = None,
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
follow_redirects: bool = False, follow_redirects: bool = False,
@ -1370,6 +1393,7 @@ class AsyncClient(BaseClient):
event_hooks: None | (typing.Mapping[str, list[EventHook]]) = None, event_hooks: None | (typing.Mapping[str, list[EventHook]]) = None,
base_url: URL | str = "", base_url: URL | str = "",
transport: AsyncBaseTransport | None = None, transport: AsyncBaseTransport | None = None,
app: typing.Callable[..., typing.Any] | None = None,
trust_env: bool = True, trust_env: bool = True,
default_encoding: str | typing.Callable[[bytes], str] = "utf-8", default_encoding: str | typing.Callable[[bytes], str] = "utf-8",
) -> None: ) -> None:
@ -1396,17 +1420,34 @@ class AsyncClient(BaseClient):
"Make sure to install httpx using `pip install httpx[http2]`." "Make sure to install httpx using `pip install httpx[http2]`."
) from None ) from None
allow_env_proxies = trust_env and transport is None if proxies:
proxy_map = self._get_proxy_map(proxy, allow_env_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)
self._transport = self._init_transport( self._transport = self._init_transport(
verify=verify, verify=verify,
cert=cert, cert=cert,
trust_env=trust_env,
http1=http1, http1=http1,
http2=http2, http2=http2,
limits=limits, limits=limits,
transport=transport, transport=transport,
app=app,
trust_env=trust_env,
) )
self._mounts: dict[URLPattern, AsyncBaseTransport | None] = { self._mounts: dict[URLPattern, AsyncBaseTransport | None] = {
@ -1416,10 +1457,10 @@ class AsyncClient(BaseClient):
proxy, proxy,
verify=verify, verify=verify,
cert=cert, cert=cert,
trust_env=trust_env,
http1=http1, http1=http1,
http2=http2, http2=http2,
limits=limits, limits=limits,
trust_env=trust_env,
) )
for key, proxy in proxy_map.items() for key, proxy in proxy_map.items()
} }
@ -1431,43 +1472,47 @@ class AsyncClient(BaseClient):
def _init_transport( def _init_transport(
self, self,
verify: ssl.SSLContext | str | bool = True, verify: VerifyTypes = True,
cert: CertTypes | None = None, cert: CertTypes | None = None,
trust_env: bool = True,
http1: bool = True, http1: bool = True,
http2: bool = False, http2: bool = False,
limits: Limits = DEFAULT_LIMITS, limits: Limits = DEFAULT_LIMITS,
transport: AsyncBaseTransport | None = None, transport: AsyncBaseTransport | None = None,
app: typing.Callable[..., typing.Any] | None = None,
trust_env: bool = True,
) -> AsyncBaseTransport: ) -> AsyncBaseTransport:
if transport is not None: if transport is not None:
return transport return transport
if app is not None:
return ASGITransport(app=app)
return AsyncHTTPTransport( return AsyncHTTPTransport(
verify=verify, verify=verify,
cert=cert, cert=cert,
trust_env=trust_env,
http1=http1, http1=http1,
http2=http2, http2=http2,
limits=limits, limits=limits,
trust_env=trust_env,
) )
def _init_proxy_transport( def _init_proxy_transport(
self, self,
proxy: Proxy, proxy: Proxy,
verify: ssl.SSLContext | str | bool = True, verify: VerifyTypes = True,
cert: CertTypes | None = None, cert: CertTypes | None = None,
trust_env: bool = True,
http1: bool = True, http1: bool = True,
http2: bool = False, http2: bool = False,
limits: Limits = DEFAULT_LIMITS, limits: Limits = DEFAULT_LIMITS,
trust_env: bool = True,
) -> AsyncBaseTransport: ) -> AsyncBaseTransport:
return AsyncHTTPTransport( return AsyncHTTPTransport(
verify=verify, verify=verify,
cert=cert, cert=cert,
trust_env=trust_env,
http1=http1, http1=http1,
http2=http2, http2=http2,
limits=limits, limits=limits,
trust_env=trust_env,
proxy=proxy, proxy=proxy,
) )
@ -1522,7 +1567,7 @@ class AsyncClient(BaseClient):
"the expected behaviour on cookie persistence is ambiguous. Set " "the expected behaviour on cookie persistence is ambiguous. Set "
"cookies directly on the client instance instead." "cookies directly on the client instance instead."
) )
warnings.warn(message, DeprecationWarning, stacklevel=2) warnings.warn(message, DeprecationWarning)
request = self.build_request( request = self.build_request(
method=method, method=method,
@ -1719,11 +1764,12 @@ class AsyncClient(BaseClient):
Sends a single request, without handling any redirections. Sends a single request, without handling any redirections.
""" """
transport = self._transport_for_url(request.url) transport = self._transport_for_url(request.url)
start = time.perf_counter() timer = Timer()
await timer.async_start()
if not isinstance(request.stream, AsyncByteStream): if not isinstance(request.stream, AsyncByteStream):
raise RuntimeError( raise RuntimeError(
"Attempted to send a sync request with an AsyncClient instance." "Attempted to send an sync request with an AsyncClient instance."
) )
with request_context(request=request): with request_context(request=request):
@ -1732,7 +1778,7 @@ class AsyncClient(BaseClient):
assert isinstance(response.stream, AsyncByteStream) assert isinstance(response.stream, AsyncByteStream)
response.request = request response.request = request
response.stream = BoundAsyncStream( response.stream = BoundAsyncStream(
response.stream, response=response, start=start response.stream, response=response, timer=timer
) )
self.cookies.extract_cookies(response) self.cookies.extract_cookies(response)
response.default_encoding = self._default_encoding response.default_encoding = self._default_encoding

63
httpx/_compat.py Normal file
View File

@ -0,0 +1,63 @@
"""
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"]

View File

@ -1,17 +1,43 @@
from __future__ import annotations from __future__ import annotations
import logging
import os import os
import ssl
import typing import typing
from pathlib import Path
import certifi
from ._compat import set_minimum_tls_version_1_2
from ._models import Headers from ._models import Headers
from ._types import CertTypes, HeaderTypes, TimeoutTypes from ._types import CertTypes, HeaderTypes, TimeoutTypes, VerifyTypes
from ._urls import URL from ._urls import URL
from ._utils import get_ca_bundle_from_env
if typing.TYPE_CHECKING:
import ssl # pragma: no cover
__all__ = ["Limits", "Proxy", "Timeout", "create_ssl_context"] __all__ = ["Limits", "Proxy", "Timeout", "create_ssl_context"]
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")
class UnsetType: class UnsetType:
pass # pragma: no cover pass # pragma: no cover
@ -21,52 +47,150 @@ UNSET = UnsetType()
def create_ssl_context( def create_ssl_context(
verify: ssl.SSLContext | str | bool = True,
cert: CertTypes | None = None, cert: CertTypes | None = None,
verify: VerifyTypes = True,
trust_env: bool = True, trust_env: bool = True,
http2: bool = False,
) -> ssl.SSLContext: ) -> ssl.SSLContext:
import ssl return SSLConfig(
import warnings cert=cert, verify=verify, trust_env=trust_env, http2=http2
).ssl_context
import certifi
if verify is True: class SSLConfig:
if trust_env and os.environ.get("SSL_CERT_FILE"): # pragma: nocover """
ctx = ssl.create_default_context(cafile=os.environ["SSL_CERT_FILE"]) SSL Configuration.
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_CA_BUNDLE_PATH = Path(certifi.where())
# Default case...
ctx = ssl.create_default_context(cafile=certifi.where()) def __init__(
elif verify is False: self,
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) *,
ctx.check_hostname = False cert: CertTypes | None = None,
ctx.verify_mode = ssl.CERT_NONE verify: VerifyTypes = True,
elif isinstance(verify, str): # pragma: nocover trust_env: bool = True,
message = ( http2: bool = False,
"`verify=<str>` is deprecated. " ) -> None:
"Use `verify=ssl.create_default_context(cafile=...)` " self.cert = cert
"or `verify=ssl.create_default_context(capath=...)` instead." 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,
) )
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 if self.verify:
message = ( return self.load_ssl_context_verify()
"`cert=...` is deprecated. Use `verify=<ssl_context>` instead," return self.load_ssl_context_no_verify()
"with `.load_cert_chain()` to configure the certificate chain."
) def load_ssl_context_no_verify(self) -> ssl.SSLContext:
warnings.warn(message, DeprecationWarning) """
if isinstance(cert, str): Return an SSL context for unverified connections.
ctx.load_cert_chain(cert) """
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)
else: else:
ctx.load_cert_chain(*cert) raise IOError(
"Could not find a suitable TLS CA certificate bundle, "
"invalid path: {}".format(self.verify)
)
return ctx 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],
)
class Timeout: class Timeout:
@ -210,7 +334,7 @@ class Proxy:
url = URL(url) url = URL(url)
headers = Headers(headers) headers = Headers(headers)
if url.scheme not in ("http", "https", "socks5", "socks5h"): if url.scheme not in ("http", "https", "socks5"):
raise ValueError(f"Unknown scheme for proxy URL {url!r}") raise ValueError(f"Unknown scheme for proxy URL {url!r}")
if url.username or url.password: if url.username or url.password:

View File

@ -174,9 +174,7 @@ def encode_html(html: str) -> tuple[dict[str, str], ByteStream]:
def encode_json(json: Any) -> tuple[dict[str, str], ByteStream]: def encode_json(json: Any) -> tuple[dict[str, str], ByteStream]:
body = json_dumps( body = json_dumps(json).encode("utf-8")
json, ensure_ascii=False, separators=(",", ":"), allow_nan=False
).encode("utf-8")
content_length = str(len(body)) content_length = str(len(body))
content_type = "application/json" content_type = "application/json"
headers = {"Content-Length": content_length, "Content-Type": content_type} headers = {"Content-Length": content_length, "Content-Type": content_type}
@ -203,7 +201,7 @@ def encode_request(
# `data=<bytes...>` usages. We deal with that case here, treating it # `data=<bytes...>` usages. We deal with that case here, treating it
# as if `content=<...>` had been supplied instead. # as if `content=<...>` had been supplied instead.
message = "Use 'content=<...>' to upload raw bytes/text content." message = "Use 'content=<...>' to upload raw bytes/text content."
warnings.warn(message, DeprecationWarning, stacklevel=2) warnings.warn(message, DeprecationWarning)
return encode_content(data) return encode_content(data)
if content is not None: if content is not None:

View File

@ -11,27 +11,9 @@ import io
import typing import typing
import zlib import zlib
from ._compat import brotli, zstd
from ._exceptions import DecodingError 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: class ContentDecoder:
def decode(self, data: bytes) -> bytes: def decode(self, data: bytes) -> bytes:
@ -168,32 +150,28 @@ class ZStandardDecoder(ContentDecoder):
# inspired by the ZstdDecoder implementation in urllib3 # inspired by the ZstdDecoder implementation in urllib3
def __init__(self) -> None: def __init__(self) -> None:
if zstandard is None: # pragma: no cover if zstd is None: # pragma: no cover
raise ImportError( raise ImportError(
"Using 'ZStandardDecoder', ..." "Using 'ZStandardDecoder', ..."
"Make sure to install httpx using `pip install httpx[zstd]`." "Make sure to install httpx using `pip install httpx[zstd]`."
) from None ) from None
self.decompressor = zstandard.ZstdDecompressor().decompressobj() self.decompressor = zstd.ZstdDecompressor().decompressobj()
self.seen_data = False
def decode(self, data: bytes) -> bytes: def decode(self, data: bytes) -> bytes:
assert zstandard is not None assert zstd is not None
self.seen_data = True
output = io.BytesIO() output = io.BytesIO()
try: try:
output.write(self.decompressor.decompress(data)) output.write(self.decompressor.decompress(data))
while self.decompressor.eof and self.decompressor.unused_data: while self.decompressor.eof and self.decompressor.unused_data:
unused_data = self.decompressor.unused_data unused_data = self.decompressor.unused_data
self.decompressor = zstandard.ZstdDecompressor().decompressobj() self.decompressor = zstd.ZstdDecompressor().decompressobj()
output.write(self.decompressor.decompress(unused_data)) output.write(self.decompressor.decompress(unused_data))
except zstandard.ZstdError as exc: except zstd.ZstdError as exc:
raise DecodingError(str(exc)) from exc raise DecodingError(str(exc)) from exc
return output.getvalue() return output.getvalue()
def flush(self) -> bytes: def flush(self) -> bytes:
if not self.seen_data:
return b""
ret = self.decompressor.flush() # note: this is a no-op ret = self.decompressor.flush() # note: this is a no-op
if not self.decompressor.eof: if not self.decompressor.eof:
raise DecodingError("Zstandard data is incomplete") # pragma: no cover raise DecodingError("Zstandard data is incomplete") # pragma: no cover
@ -389,5 +367,5 @@ SUPPORTED_DECODERS = {
if brotli is None: if brotli is None:
SUPPORTED_DECODERS.pop("br") # pragma: no cover SUPPORTED_DECODERS.pop("br") # pragma: no cover
if zstandard is None: if zstd is None:
SUPPORTED_DECODERS.pop("zstd") # pragma: no cover SUPPORTED_DECODERS.pop("zstd") # pragma: no cover

View File

@ -331,7 +331,9 @@ class StreamClosed(StreamError):
""" """
def __init__(self) -> None: def __init__(self) -> None:
message = "Attempted to read or stream content, but the stream has been closed." message = (
"Attempted to read or stream content, but the stream has " "been closed."
)
super().__init__(message) super().__init__(message)

View File

@ -6,6 +6,7 @@ import sys
import typing import typing
import click import click
import httpcore
import pygments.lexers import pygments.lexers
import pygments.util import pygments.util
import rich.console import rich.console
@ -19,9 +20,6 @@ from ._exceptions import RequestError
from ._models import Response from ._models import Response
from ._status_codes import codes from ._status_codes import codes
if typing.TYPE_CHECKING:
import httpcore # pragma: no cover
def print_help() -> None: def print_help() -> None:
console = rich.console.Console() console = rich.console.Console()
@ -476,7 +474,12 @@ def main(
method = "POST" if content or data or files or json else "GET" method = "POST" if content or data or files or json else "GET"
try: try:
with Client(proxy=proxy, timeout=timeout, http2=http2, verify=verify) as client: with Client(
proxy=proxy,
timeout=timeout,
verify=verify,
http2=http2,
) as client:
with client.stream( with client.stream(
method, method,
url, url,

View File

@ -1,10 +1,8 @@
from __future__ import annotations from __future__ import annotations
import codecs
import datetime import datetime
import email.message import email.message
import json as jsonlib import json as jsonlib
import re
import typing import typing
import urllib.request import urllib.request
from collections.abc import Mapping from collections.abc import Mapping
@ -46,95 +44,17 @@ from ._types import (
SyncByteStream, SyncByteStream,
) )
from ._urls import URL from ._urls import URL
from ._utils import to_bytes_or_str, to_str from ._utils import (
is_known_encoding,
normalize_header_key,
normalize_header_value,
obfuscate_sensitive_headers,
parse_content_type_charset,
parse_header_links,
)
__all__ = ["Cookies", "Headers", "Request", "Response"] __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; '<http:/.../front.jpeg>; type="image/jpeg",<http://.../back.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]): class Headers(typing.MutableMapping[str, str]):
""" """
@ -146,20 +66,28 @@ class Headers(typing.MutableMapping[str, str]):
headers: HeaderTypes | None = None, headers: HeaderTypes | None = None,
encoding: str | None = None, encoding: str | None = None,
) -> None: ) -> None:
self._list = [] # type: typing.List[typing.Tuple[bytes, bytes, bytes]] if headers is None:
self._list = [] # type: typing.List[typing.Tuple[bytes, bytes, bytes]]
if isinstance(headers, Headers): elif isinstance(headers, Headers):
self._list = list(headers._list) self._list = list(headers._list)
elif isinstance(headers, Mapping): elif isinstance(headers, Mapping):
for k, v in headers.items(): self._list = [
bytes_key = _normalize_header_key(k, encoding) (
bytes_value = _normalize_header_value(v, encoding) normalize_header_key(k, lower=False, encoding=encoding),
self._list.append((bytes_key, bytes_key.lower(), bytes_value)) normalize_header_key(k, lower=True, encoding=encoding),
elif headers is not None: normalize_header_value(v, encoding),
for k, v in headers: )
bytes_key = _normalize_header_key(k, encoding) for k, v in headers.items()
bytes_value = _normalize_header_value(v, encoding) ]
self._list.append((bytes_key, bytes_key.lower(), bytes_value)) 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
]
self._encoding = encoding self._encoding = encoding
@ -370,7 +298,7 @@ class Headers(typing.MutableMapping[str, str]):
if self.encoding != "ascii": if self.encoding != "ascii":
encoding_str = f", encoding={self.encoding!r}" 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) as_dict = dict(as_list)
no_duplicate_keys = len(as_dict) == len(as_list) no_duplicate_keys = len(as_dict) == len(as_list)
@ -382,7 +310,7 @@ class Headers(typing.MutableMapping[str, str]):
class Request: class Request:
def __init__( def __init__(
self, self,
method: str, method: str | bytes,
url: URL | str, url: URL | str,
*, *,
params: QueryParamTypes | None = None, params: QueryParamTypes | None = None,
@ -395,10 +323,16 @@ class Request:
stream: SyncByteStream | AsyncByteStream | None = None, stream: SyncByteStream | AsyncByteStream | None = None,
extensions: RequestExtensions | None = None, extensions: RequestExtensions | None = None,
) -> None: ) -> None:
self.method = method.upper() self.method = (
self.url = URL(url) if params is None else URL(url, params=params) 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.headers = Headers(headers) self.headers = Headers(headers)
self.extensions = {} if extensions is None else dict(extensions) self.extensions = {} if extensions is None else extensions
if cookies: if cookies:
Cookies(cookies).set_cookie_header(self) Cookies(cookies).set_cookie_header(self)
@ -537,7 +471,7 @@ class Response:
# the client will set `response.next_request`. # the client will set `response.next_request`.
self.next_request: Request | None = None self.next_request: Request | None = None
self.extensions = {} if extensions is None else dict(extensions) self.extensions: ResponseExtensions = {} if extensions is None else extensions
self.history = [] if history is None else list(history) self.history = [] if history is None else list(history)
self.is_closed = False self.is_closed = False
@ -663,7 +597,7 @@ class Response:
""" """
if not hasattr(self, "_encoding"): if not hasattr(self, "_encoding"):
encoding = self.charset_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): if isinstance(self.default_encoding, str):
encoding = self.default_encoding encoding = self.default_encoding
elif hasattr(self, "_content"): elif hasattr(self, "_content"):
@ -694,7 +628,7 @@ class Response:
if content_type is None: if content_type is None:
return None return None
return _parse_content_type_charset(content_type) return parse_content_type_charset(content_type)
def _get_content_decoder(self) -> ContentDecoder: def _get_content_decoder(self) -> ContentDecoder:
""" """
@ -849,7 +783,7 @@ class Response:
return { return {
(link.get("rel") or link.get("url")): link (link.get("rel") or link.get("url")): link
for link in _parse_header_links(header) for link in parse_header_links(header)
} }
@property @property
@ -964,7 +898,7 @@ class Response:
Automatically called if the response body is read to completion. Automatically called if the response body is read to completion.
""" """
if not isinstance(self.stream, SyncByteStream): if not isinstance(self.stream, SyncByteStream):
raise RuntimeError("Attempted to call a sync close on an async stream.") raise RuntimeError("Attempted to call an sync close on an async stream.")
if not self.is_closed: if not self.is_closed:
self.is_closed = True self.is_closed = True
@ -1045,7 +979,7 @@ class Response:
if self.is_closed: if self.is_closed:
raise StreamClosed() raise StreamClosed()
if not isinstance(self.stream, AsyncByteStream): if not isinstance(self.stream, AsyncByteStream):
raise RuntimeError("Attempted to call an async iterator on a sync stream.") raise RuntimeError("Attempted to call an async iterator on an sync stream.")
self.is_stream_consumed = True self.is_stream_consumed = True
self._num_bytes_downloaded = 0 self._num_bytes_downloaded = 0
@ -1068,7 +1002,7 @@ class Response:
Automatically called if the response body is read to completion. Automatically called if the response body is read to completion.
""" """
if not isinstance(self.stream, AsyncByteStream): if not isinstance(self.stream, AsyncByteStream):
raise RuntimeError("Attempted to call an async close on a sync stream.") raise RuntimeError("Attempted to call an async close on an sync stream.")
if not self.is_closed: if not self.is_closed:
self.is_closed = True self.is_closed = True

View File

@ -1,9 +1,7 @@
from __future__ import annotations from __future__ import annotations
import io import io
import mimetypes
import os import os
import re
import typing import typing
from pathlib import Path from pathlib import Path
@ -16,42 +14,13 @@ from ._types import (
SyncByteStream, SyncByteStream,
) )
from ._utils import ( from ._utils import (
format_form_param,
guess_content_type,
peek_filelike_length, peek_filelike_length,
primitive_value_to_str, primitive_value_to_str,
to_bytes, 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( def get_multipart_boundary_from_content_type(
content_type: bytes | None, content_type: bytes | None,
@ -89,7 +58,7 @@ class DataField:
def render_headers(self) -> bytes: def render_headers(self) -> bytes:
if not hasattr(self, "_headers"): if not hasattr(self, "_headers"):
name = _format_form_param("name", self.name) name = format_form_param("name", self.name)
self._headers = b"".join( self._headers = b"".join(
[b"Content-Disposition: form-data; ", name, b"\r\n\r\n"] [b"Content-Disposition: form-data; ", name, b"\r\n\r\n"]
) )
@ -146,7 +115,7 @@ class FileField:
fileobj = value fileobj = value
if content_type is None: 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) 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: if content_type is not None and not has_content_type_header:
@ -187,10 +156,10 @@ class FileField:
if not hasattr(self, "_headers"): if not hasattr(self, "_headers"):
parts = [ parts = [
b"Content-Disposition: form-data; ", b"Content-Disposition: form-data; ",
_format_form_param("name", self.name), format_form_param("name", self.name),
] ]
if self.filename: if self.filename:
filename = _format_form_param("filename", self.filename) filename = format_form_param("filename", self.filename)
parts.extend([b"; ", filename]) parts.extend([b"; ", filename])
for header_name, header_value in self.headers.items(): for header_name, header_value in self.headers.items():
key, val = f"\r\n{header_name}: ".encode(), header_value.encode() key, val = f"\r\n{header_name}: ".encode(), header_value.encode()

View File

@ -26,30 +26,17 @@ _ASGIApp = typing.Callable[
__all__ = ["ASGITransport"] __all__ = ["ASGITransport"]
def is_running_trio() -> bool: def create_event() -> "Event":
try: import sniffio
# sniffio is a dependency of trio.
# See https://github.com/python-trio/trio/issues/2802 if sniffio.current_async_library() == "trio":
import sniffio
if sniffio.current_async_library() == "trio":
return True
except ImportError: # pragma: nocover
pass
return False
def create_event() -> Event:
if is_running_trio():
import trio import trio
return trio.Event() return trio.Event()
else:
import asyncio
import asyncio return asyncio.Event()
return asyncio.Event()
class ASGIResponseStream(AsyncByteStream): class ASGIResponseStream(AsyncByteStream):

View File

@ -30,10 +30,7 @@ import contextlib
import typing import typing
from types import TracebackType from types import TracebackType
if typing.TYPE_CHECKING: import httpcore
import ssl # pragma: no cover
import httpx # pragma: no cover
from .._config import DEFAULT_LIMITS, Limits, Proxy, create_ssl_context from .._config import DEFAULT_LIMITS, Limits, Proxy, create_ssl_context
from .._exceptions import ( from .._exceptions import (
@ -53,7 +50,7 @@ from .._exceptions import (
WriteTimeout, WriteTimeout,
) )
from .._models import Request, Response from .._models import Request, Response
from .._types import AsyncByteStream, CertTypes, ProxyTypes, SyncByteStream from .._types import AsyncByteStream, CertTypes, ProxyTypes, SyncByteStream, VerifyTypes
from .._urls import URL from .._urls import URL
from .base import AsyncBaseTransport, BaseTransport from .base import AsyncBaseTransport, BaseTransport
@ -68,35 +65,9 @@ SOCKET_OPTION = typing.Union[
__all__ = ["AsyncHTTPTransport", "HTTPTransport"] __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 @contextlib.contextmanager
def map_httpcore_exceptions() -> typing.Iterator[None]: def map_httpcore_exceptions() -> typing.Iterator[None]:
global HTTPCORE_EXC_MAP
if len(HTTPCORE_EXC_MAP) == 0:
HTTPCORE_EXC_MAP = _load_httpcore_exceptions()
try: try:
yield yield
except Exception as exc: except Exception as exc:
@ -118,6 +89,24 @@ def map_httpcore_exceptions() -> typing.Iterator[None]:
raise mapped_exc(message) from exc 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): class ResponseStream(SyncByteStream):
def __init__(self, httpcore_stream: typing.Iterable[bytes]) -> None: def __init__(self, httpcore_stream: typing.Iterable[bytes]) -> None:
self._httpcore_stream = httpcore_stream self._httpcore_stream = httpcore_stream
@ -135,22 +124,20 @@ class ResponseStream(SyncByteStream):
class HTTPTransport(BaseTransport): class HTTPTransport(BaseTransport):
def __init__( def __init__(
self, self,
verify: ssl.SSLContext | str | bool = True, verify: VerifyTypes = True,
cert: CertTypes | None = None, cert: CertTypes | None = None,
trust_env: bool = True,
http1: bool = True, http1: bool = True,
http2: bool = False, http2: bool = False,
limits: Limits = DEFAULT_LIMITS, limits: Limits = DEFAULT_LIMITS,
trust_env: bool = True,
proxy: ProxyTypes | None = None, proxy: ProxyTypes | None = None,
uds: str | None = None, uds: str | None = None,
local_address: str | None = None, local_address: str | None = None,
retries: int = 0, retries: int = 0,
socket_options: typing.Iterable[SOCKET_OPTION] | None = None, socket_options: typing.Iterable[SOCKET_OPTION] | None = None,
) -> None: ) -> None:
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) ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env)
proxy = Proxy(url=proxy) if isinstance(proxy, (str, URL)) else proxy
if proxy is None: if proxy is None:
self._pool = httpcore.ConnectionPool( self._pool = httpcore.ConnectionPool(
@ -184,7 +171,7 @@ class HTTPTransport(BaseTransport):
http2=http2, http2=http2,
socket_options=socket_options, socket_options=socket_options,
) )
elif proxy.url.scheme in ("socks5", "socks5h"): elif proxy.url.scheme == "socks5":
try: try:
import socksio # noqa import socksio # noqa
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
@ -210,7 +197,7 @@ class HTTPTransport(BaseTransport):
) )
else: # pragma: no cover else: # pragma: no cover
raise ValueError( raise ValueError(
"Proxy protocol must be either 'http', 'https', 'socks5', or 'socks5h'," "Proxy protocol must be either 'http', 'https', or 'socks5',"
f" but got {proxy.url.scheme!r}." f" but got {proxy.url.scheme!r}."
) )
@ -232,7 +219,6 @@ class HTTPTransport(BaseTransport):
request: Request, request: Request,
) -> Response: ) -> Response:
assert isinstance(request.stream, SyncByteStream) assert isinstance(request.stream, SyncByteStream)
import httpcore
req = httpcore.Request( req = httpcore.Request(
method=request.method, method=request.method,
@ -279,22 +265,28 @@ class AsyncResponseStream(AsyncByteStream):
class AsyncHTTPTransport(AsyncBaseTransport): class AsyncHTTPTransport(AsyncBaseTransport):
def __init__( def __init__(
self, self,
verify: ssl.SSLContext | str | bool = True, verify: VerifyTypes = True,
cert: CertTypes | None = None, cert: CertTypes | None = None,
trust_env: bool = True,
http1: bool = True, http1: bool = True,
http2: bool = False, http2: bool = False,
limits: Limits = DEFAULT_LIMITS, limits: Limits = DEFAULT_LIMITS,
trust_env: bool = True,
proxy: ProxyTypes | None = None, proxy: ProxyTypes | None = None,
uds: str | None = None, uds: str | None = None,
local_address: str | None = None, local_address: str | None = None,
retries: int = 0, retries: int = 0,
socket_options: typing.Iterable[SOCKET_OPTION] | None = None, socket_options: typing.Iterable[SOCKET_OPTION] | None = None,
) -> None: ) -> None:
import httpcore try:
import sniffio # noqa: F401
except ImportError: # pragma: nocover
raise RuntimeError(
"Using httpx in async mode, but neither "
"httpx['asyncio'] or asyncio['trio'] is installed."
)
proxy = Proxy(url=proxy) if isinstance(proxy, (str, URL)) else proxy
ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env) ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env)
proxy = Proxy(url=proxy) if isinstance(proxy, (str, URL)) else proxy
if proxy is None: if proxy is None:
self._pool = httpcore.AsyncConnectionPool( self._pool = httpcore.AsyncConnectionPool(
@ -328,7 +320,7 @@ class AsyncHTTPTransport(AsyncBaseTransport):
http2=http2, http2=http2,
socket_options=socket_options, socket_options=socket_options,
) )
elif proxy.url.scheme in ("socks5", "socks5h"): elif proxy.url.scheme == "socks5":
try: try:
import socksio # noqa import socksio # noqa
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
@ -354,8 +346,8 @@ class AsyncHTTPTransport(AsyncBaseTransport):
) )
else: # pragma: no cover else: # pragma: no cover
raise ValueError( raise ValueError(
"Proxy protocol must be either 'http', 'https', 'socks5', or 'socks5h'," "Proxy protocol must be either 'http', 'https', or 'socks5',"
f" but got {proxy.url.scheme!r}." " but got {proxy.url.scheme!r}."
) )
async def __aenter__(self: A) -> A: # Use generics for subclass support. async def __aenter__(self: A) -> A: # Use generics for subclass support.
@ -376,7 +368,6 @@ class AsyncHTTPTransport(AsyncBaseTransport):
request: Request, request: Request,
) -> Response: ) -> Response:
assert isinstance(request.stream, AsyncByteStream) assert isinstance(request.stream, AsyncByteStream)
import httpcore
req = httpcore.Request( req = httpcore.Request(
method=request.method, method=request.method,

View File

@ -2,6 +2,7 @@
Type definitions for type checking purposes. Type definitions for type checking purposes.
""" """
import ssl
from http.cookiejar import CookieJar from http.cookiejar import CookieJar
from typing import ( from typing import (
IO, IO,
@ -15,6 +16,8 @@ from typing import (
Iterator, Iterator,
List, List,
Mapping, Mapping,
MutableMapping,
NamedTuple,
Optional, Optional,
Sequence, Sequence,
Tuple, Tuple,
@ -30,6 +33,16 @@ if TYPE_CHECKING: # pragma: no cover
PrimitiveData = Optional[Union[str, int, float, bool]] 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] URLTypes = Union["URL", str]
QueryParamTypes = Union[ QueryParamTypes = Union[
@ -51,13 +64,22 @@ HeaderTypes = Union[
CookieTypes = Union["Cookies", CookieJar, Dict[str, str], List[Tuple[str, str]]] 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[ TimeoutTypes = Union[
Optional[float], Optional[float],
Tuple[Optional[float], Optional[float], Optional[float], Optional[float]], Tuple[Optional[float], Optional[float], Optional[float], Optional[float]],
"Timeout", "Timeout",
] ]
ProxyTypes = Union["URL", str, "Proxy"] ProxyTypes = Union["URL", str, "Proxy"]
CertTypes = Union[str, Tuple[str, str], Tuple[str, str, str]] ProxiesTypes = Union[ProxyTypes, Dict[Union["URL", str], Union[None, ProxyTypes]]]
AuthTypes = Union[ AuthTypes = Union[
Tuple[Union[str, bytes], Union[str, bytes]], Tuple[Union[str, bytes], Union[str, bytes]],
@ -67,7 +89,7 @@ AuthTypes = Union[
RequestContent = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]] RequestContent = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]]
ResponseContent = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]] ResponseContent = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]]
ResponseExtensions = Mapping[str, Any] ResponseExtensions = MutableMapping[str, Any]
RequestData = Mapping[str, Any] RequestData = Mapping[str, Any]
@ -84,7 +106,7 @@ FileTypes = Union[
] ]
RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]] RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]]
RequestExtensions = Mapping[str, Any] RequestExtensions = MutableMapping[str, Any]
__all__ = ["AsyncByteStream", "SyncByteStream"] __all__ = ["AsyncByteStream", "SyncByteStream"]

View File

@ -36,67 +36,6 @@ SUB_DELIMS = "!$&'()*+,;="
PERCENT_ENCODED_REGEX = re.compile("%[A-Fa-f0-9]{2}") 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) # {scheme}: (optional)
# //{authority} (optional) # //{authority} (optional)
@ -243,8 +182,8 @@ def urlparse(url: str = "", **kwargs: str | None) -> ParseResult:
# Replace "username" and/or "password" with "userinfo". # Replace "username" and/or "password" with "userinfo".
if "username" in kwargs or "password" in kwargs: if "username" in kwargs or "password" in kwargs:
username = quote(kwargs.pop("username", "") or "", safe=USERNAME_SAFE) username = quote(kwargs.pop("username", "") or "")
password = quote(kwargs.pop("password", "") or "", safe=PASSWORD_SAFE) password = quote(kwargs.pop("password", "") or "")
kwargs["userinfo"] = f"{username}:{password}" if password else username kwargs["userinfo"] = f"{username}:{password}" if password else username
# Replace "raw_path" with "path" and "query". # Replace "raw_path" with "path" and "query".
@ -299,7 +238,7 @@ def urlparse(url: str = "", **kwargs: str | None) -> ParseResult:
authority = kwargs.get("authority", url_dict["authority"]) or "" authority = kwargs.get("authority", url_dict["authority"]) or ""
path = kwargs.get("path", url_dict["path"]) or "" path = kwargs.get("path", url_dict["path"]) or ""
query = kwargs.get("query", url_dict["query"]) query = kwargs.get("query", url_dict["query"])
frag = kwargs.get("fragment", url_dict["fragment"]) fragment = kwargs.get("fragment", url_dict["fragment"])
# The AUTHORITY_REGEX will always match, but may have empty components. # The AUTHORITY_REGEX will always match, but may have empty components.
authority_match = AUTHORITY_REGEX.match(authority) authority_match = AUTHORITY_REGEX.match(authority)
@ -316,7 +255,7 @@ def urlparse(url: str = "", **kwargs: str | None) -> ParseResult:
# We end up with a parsed representation of the URL, # We end up with a parsed representation of the URL,
# with components that are plain ASCII bytestrings. # with components that are plain ASCII bytestrings.
parsed_scheme: str = scheme.lower() parsed_scheme: str = scheme.lower()
parsed_userinfo: str = quote(userinfo, safe=USERINFO_SAFE) parsed_userinfo: str = quote(userinfo, safe=SUB_DELIMS + ":")
parsed_host: str = encode_host(host) parsed_host: str = encode_host(host)
parsed_port: int | None = normalize_port(port, scheme) parsed_port: int | None = normalize_port(port, scheme)
@ -328,9 +267,25 @@ def urlparse(url: str = "", **kwargs: str | None) -> ParseResult:
if has_scheme or has_authority: if has_scheme or has_authority:
path = normalize_path(path) path = normalize_path(path)
parsed_path: str = quote(path, safe=PATH_SAFE) # The GEN_DELIMS set is... : / ? # [ ] @
parsed_query: str | None = None if query is None else quote(query, safe=QUERY_SAFE) # These do not need to be percent-quoted unless they serve as delimiters for the
parsed_frag: str | None = None if frag is None else quote(frag, safe=FRAG_SAFE) # 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 + ":/?#[]@")
)
# The parsed ASCII bytestrings are our canonical form. # The parsed ASCII bytestrings are our canonical form.
# All properties of the URL are derived from these. # All properties of the URL are derived from these.
@ -341,7 +296,7 @@ def urlparse(url: str = "", **kwargs: str | None) -> ParseResult:
parsed_port, parsed_port,
parsed_path, parsed_path,
parsed_query, parsed_query,
parsed_frag, parsed_fragment,
) )
@ -479,7 +434,7 @@ def PERCENT(string: str) -> str:
return "".join([f"%{byte:02X}" for byte in string.encode("utf-8")]) 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. Use percent-encoding to quote a string.
""" """
@ -494,7 +449,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. Use percent-encoding to quote a string, omitting existing '%xx' escape sequences.
@ -525,3 +480,26 @@ def quote(string: str, safe: str) -> str:
parts.append(percent_encoded(trailing_text, safe=safe)) parts.append(percent_encoded(trailing_text, safe=safe))
return "".join(parts) 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
]
)

View File

@ -1,12 +1,12 @@
from __future__ import annotations from __future__ import annotations
import typing import typing
from urllib.parse import parse_qs, unquote, urlencode from urllib.parse import parse_qs, unquote
import idna import idna
from ._types import QueryParamTypes from ._types import QueryParamTypes, RawURL
from ._urlparse import urlparse from ._urlparse import urlencode, urlparse
from ._utils import primitive_value_to_str from ._utils import primitive_value_to_str
__all__ = ["URL", "QueryParams"] __all__ = ["URL", "QueryParams"]
@ -304,6 +304,22 @@ class URL:
""" """
return unquote(self._uri_reference.fragment or "") 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 @property
def is_absolute_url(self) -> bool: def is_absolute_url(self) -> bool:
""" """
@ -379,7 +395,7 @@ class URL:
if ":" in userinfo: if ":" in userinfo:
# Mask any password component. # Mask any password component.
userinfo = f"{userinfo.split(':')[0]}:[secure]" userinfo = f'{userinfo.split(":")[0]}:[secure]'
authority = "".join( authority = "".join(
[ [
@ -400,22 +416,6 @@ class URL:
return f"{self.__class__.__name__}({url!r})" 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]): class QueryParams(typing.Mapping[str, str]):
""" """
@ -621,6 +621,13 @@ class QueryParams(typing.Mapping[str, str]):
return sorted(self.multi_items()) == sorted(other.multi_items()) return sorted(self.multi_items()) == sorted(other.multi_items())
def __str__(self) -> str: 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()) return urlencode(self.multi_items())
def __repr__(self) -> str: def __repr__(self) -> str:

View File

@ -1,9 +1,14 @@
from __future__ import annotations from __future__ import annotations
import codecs
import email.message
import ipaddress import ipaddress
import mimetypes
import os import os
import re import re
import time
import typing import typing
from pathlib import Path
from urllib.request import getproxies from urllib.request import getproxies
from ._types import PrimitiveData from ._types import PrimitiveData
@ -12,6 +17,42 @@ if typing.TYPE_CHECKING: # pragma: no cover
from ._urls import URL 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
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 primitive_value_to_str(value: PrimitiveData) -> str: def primitive_value_to_str(value: PrimitiveData) -> str:
""" """
Coerce a primitive data type into a string value. Coerce a primitive data type into a string value.
@ -27,6 +68,130 @@ def primitive_value_to_str(value: PrimitiveData) -> str:
return str(value) 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; '<http:/.../front.jpeg>; type="image/jpeg",<http://.../back.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]: def get_environment_proxies() -> dict[str, str | None]:
"""Gets proxy information from the environment""" """Gets proxy information from the environment"""
@ -92,6 +257,12 @@ def unquote(value: str) -> str:
return value[1:-1] if value[0] == value[-1] == '"' else value 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: def peek_filelike_length(stream: typing.Any) -> int | None:
""" """
Given a file-like stream object, return its length in number of bytes Given a file-like stream object, return its length in number of bytes
@ -117,6 +288,22 @@ def peek_filelike_length(stream: typing.Any) -> int | None:
return length return length
class Timer:
def sync_start(self) -> None:
self.started = time.perf_counter()
async def async_start(self) -> None:
self.started = time.perf_counter()
def sync_elapsed(self) -> float:
now = time.perf_counter()
return now - self.started
async def async_elapsed(self) -> float:
now = time.perf_counter()
return now - self.started
class URLPattern: class URLPattern:
""" """
A utility class currently used for making lookups against proxy keys... A utility class currently used for making lookups against proxy keys...

View File

@ -6,7 +6,7 @@ build-backend = "hatchling.build"
name = "httpx" name = "httpx"
description = "The next generation HTTP client." description = "The next generation HTTP client."
license = "BSD-3-Clause" license = "BSD-3-Clause"
requires-python = ">=3.9" requires-python = ">=3.8"
authors = [ authors = [
{ name = "Tom Christie", email = "tom@tomchristie.com" }, { name = "Tom Christie", email = "tom@tomchristie.com" },
] ]
@ -20,17 +20,16 @@ classifiers = [
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP",
] ]
dependencies = [ dependencies = [
"certifi", "certifi",
"httpcore==1.*", "httpcore>=1.0.0,<2.0.0",
"anyio",
"idna", "idna",
] ]
dynamic = ["readme", "version"] dynamic = ["readme", "version"]
@ -43,13 +42,19 @@ brotli = [
cli = [ cli = [
"click==8.*", "click==8.*",
"pygments==2.*", "pygments==2.*",
"rich>=10,<15", "rich>=10,<14",
] ]
http2 = [ http2 = [
"h2>=3,<5", "httpcore[http2]",
] ]
socks = [ socks = [
"socksio==1.*", "httpcore[socks]",
]
asyncio = [
"httpcore[asyncio]"
]
trio = [
"httpcore[trio]"
] ]
zstd = [ zstd = [
"zstandard>=0.18.0", "zstandard>=0.18.0",
@ -128,5 +133,5 @@ markers = [
] ]
[tool.coverage.run] [tool.coverage.run]
omit = ["venv/*"] omit = ["venv/*", "httpx/_compat.py"]
include = ["httpx/*", "tests/*"] include = ["httpx/*", "tests/*"]

View File

@ -2,7 +2,7 @@
# On the other hand, we're not pinning package dependencies, because our tests # On the other hand, we're not pinning package dependencies, because our tests
# needs to pass with the latest version of the packages. # needs to pass with the latest version of the packages.
# Reference: https://github.com/encode/httpx/pull/1721#discussion_r661241588 # Reference: https://github.com/encode/httpx/pull/1721#discussion_r661241588
-e .[brotli,cli,http2,socks,zstd] -e .[asyncio,trio,brotli,cli,http2,socks,zstd]
# Optional charset auto-detection # Optional charset auto-detection
# Used in our test cases # Used in our test cases
@ -11,19 +11,18 @@ chardet==5.2.0
# Documentation # Documentation
mkdocs==1.6.1 mkdocs==1.6.1
mkautodoc==0.2.0 mkautodoc==0.2.0
mkdocs-material==9.6.18 mkdocs-material==9.5.34
# Packaging # Packaging
build==1.3.0 build==1.2.1
twine==6.1.0 twine==5.1.1
# Tests & Linting # Tests & Linting
coverage[toml]==7.10.6 coverage[toml]==7.6.1
cryptography==45.0.7 cryptography==43.0.1
mypy==1.17.1 mypy==1.11.2
pytest==8.4.1 pytest==8.3.2
ruff==0.12.11 ruff==0.6.3
trio==0.31.0 trio==0.26.2
trio-typing==0.10.0 trustme==1.1.0
trustme==1.2.1 uvicorn==0.30.6
uvicorn==0.35.0

View File

@ -8,5 +8,5 @@ export SOURCE_FILES="httpx tests"
set -x set -x
${PREFIX}ruff check --fix $SOURCE_FILES ${PREFIX}ruff --fix $SOURCE_FILES
${PREFIX}ruff format $SOURCE_FILES ${PREFIX}ruff format $SOURCE_FILES

View File

@ -270,6 +270,29 @@ def test_netrc_auth_credentials_do_not_exist() -> None:
assert response.json() == {"auth": 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( @pytest.mark.skipif(
sys.version_info >= (3, 11), sys.version_info >= (3, 11),
reason="netrc files without a password are valid from Python >= 3.11", reason="netrc files without a password are valid from Python >= 3.11",
@ -326,7 +349,7 @@ async def test_auth_property() -> None:
async with httpx.AsyncClient(transport=httpx.MockTransport(app)) as client: async with httpx.AsyncClient(transport=httpx.MockTransport(app)) as client:
assert client.auth is None assert client.auth is None
client.auth = ("user", "password123") client.auth = ("user", "password123") # type: ignore
assert isinstance(client.auth, httpx.BasicAuth) assert isinstance(client.auth, httpx.BasicAuth)
url = "https://example.org/" url = "https://example.org/"
@ -720,7 +743,7 @@ async def test_async_auth_reads_response_body() -> None:
response = await client.get(url, auth=auth) response = await client.get(url, auth=auth)
assert response.status_code == 200 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: def test_sync_auth_reads_response_body() -> None:
@ -736,7 +759,7 @@ def test_sync_auth_reads_response_body() -> None:
response = client.get(url, auth=auth) response = client.get(url, auth=auth)
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"auth": '{"auth":"xyz"}'} assert response.json() == {"auth": '{"auth": "xyz"}'}
@pytest.mark.anyio @pytest.mark.anyio

View File

@ -235,59 +235,3 @@ def test_host_with_non_default_port_in_url():
def test_request_auto_headers(): def test_request_auto_headers():
request = httpx.Request("GET", "https://www.example.org/") request = httpx.Request("GET", "https://www.example.org/")
assert "host" in request.headers 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

View File

@ -3,35 +3,35 @@ import httpx
def test_client_base_url(): def test_client_base_url():
client = httpx.Client() client = httpx.Client()
client.base_url = "https://www.example.org/" client.base_url = "https://www.example.org/" # type: ignore
assert isinstance(client.base_url, httpx.URL) assert isinstance(client.base_url, httpx.URL)
assert client.base_url == "https://www.example.org/" assert client.base_url == "https://www.example.org/"
def test_client_base_url_without_trailing_slash(): def test_client_base_url_without_trailing_slash():
client = httpx.Client() client = httpx.Client()
client.base_url = "https://www.example.org/path" client.base_url = "https://www.example.org/path" # type: ignore
assert isinstance(client.base_url, httpx.URL) assert isinstance(client.base_url, httpx.URL)
assert client.base_url == "https://www.example.org/path/" assert client.base_url == "https://www.example.org/path/"
def test_client_base_url_with_trailing_slash(): def test_client_base_url_with_trailing_slash():
client = httpx.Client() client = httpx.Client()
client.base_url = "https://www.example.org/path/" client.base_url = "https://www.example.org/path/" # type: ignore
assert isinstance(client.base_url, httpx.URL) assert isinstance(client.base_url, httpx.URL)
assert client.base_url == "https://www.example.org/path/" assert client.base_url == "https://www.example.org/path/"
def test_client_headers(): def test_client_headers():
client = httpx.Client() client = httpx.Client()
client.headers = {"a": "b"} client.headers = {"a": "b"} # type: ignore
assert isinstance(client.headers, httpx.Headers) assert isinstance(client.headers, httpx.Headers)
assert client.headers["A"] == "b" assert client.headers["A"] == "b"
def test_client_cookies(): def test_client_cookies():
client = httpx.Client() client = httpx.Client()
client.cookies = {"a": "b"} client.cookies = {"a": "b"} # type: ignore
assert isinstance(client.cookies, httpx.Cookies) assert isinstance(client.cookies, httpx.Cookies)
mycookies = list(client.cookies.jar) mycookies = list(client.cookies.jar)
assert len(mycookies) == 1 assert len(mycookies) == 1
@ -42,7 +42,7 @@ def test_client_timeout():
expected_timeout = 12.0 expected_timeout = 12.0
client = httpx.Client() client = httpx.Client()
client.timeout = expected_timeout client.timeout = expected_timeout # type: ignore
assert isinstance(client.timeout, httpx.Timeout) assert isinstance(client.timeout, httpx.Timeout)
assert client.timeout.connect == expected_timeout assert client.timeout.connect == expected_timeout

View File

@ -13,19 +13,69 @@ def url_to_origin(url: str) -> httpcore.URL:
return httpcore.URL(scheme=u.raw_scheme, host=u.raw_host, port=u.port, target="/") 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(): def test_socks_proxy():
url = httpx.URL("http://www.example.com") url = httpx.URL("http://www.example.com")
for proxy in ("socks5://localhost/", "socks5h://localhost/"): client = httpx.Client(proxy="socks5://localhost/")
client = httpx.Client(proxy=proxy) transport = client._transport_for_url(url)
transport = client._transport_for_url(url) assert isinstance(transport, httpx.HTTPTransport)
assert isinstance(transport, httpx.HTTPTransport) assert isinstance(transport._pool, httpcore.SOCKSProxy)
assert isinstance(transport._pool, httpcore.SOCKSProxy)
async_client = httpx.AsyncClient(proxy=proxy) async_client = httpx.AsyncClient(proxy="socks5://localhost/")
async_transport = async_client._transport_for_url(url) async_transport = async_client._transport_for_url(url)
assert isinstance(async_transport, httpx.AsyncHTTPTransport) assert isinstance(async_transport, httpx.AsyncHTTPTransport)
assert isinstance(async_transport._pool, httpcore.AsyncSOCKSProxy) assert isinstance(async_transport._pool, httpcore.AsyncSOCKSProxy)
PROXY_URL = "http://[::1]" PROXY_URL = "http://[::1]"
@ -34,6 +84,7 @@ PROXY_URL = "http://[::1]"
@pytest.mark.parametrize( @pytest.mark.parametrize(
["url", "proxies", "expected"], ["url", "proxies", "expected"],
[ [
("http://example.com", None, None),
("http://example.com", {}, None), ("http://example.com", {}, None),
("http://example.com", {"https://": PROXY_URL}, None), ("http://example.com", {"https://": PROXY_URL}, None),
("http://example.com", {"http://example.net": PROXY_URL}, None), ("http://example.com", {"http://example.net": PROXY_URL}, None),
@ -53,6 +104,7 @@ PROXY_URL = "http://[::1]"
# ... # ...
("http://example.com:443", {"http://example.com": PROXY_URL}, PROXY_URL), ("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}, PROXY_URL),
("http://example.com", {"all://": PROXY_URL, "http://example.com": None}, None),
("http://example.com", {"http://": PROXY_URL}, PROXY_URL), ("http://example.com", {"http://": PROXY_URL}, PROXY_URL),
("http://example.com", {"all://example.com": PROXY_URL}, PROXY_URL), ("http://example.com", {"all://example.com": PROXY_URL}, PROXY_URL),
("http://example.com", {"http://example.com": PROXY_URL}, PROXY_URL), ("http://example.com", {"http://example.com": PROXY_URL}, PROXY_URL),
@ -86,8 +138,11 @@ PROXY_URL = "http://[::1]"
], ],
) )
def test_transport_for_request(url, proxies, expected): def test_transport_for_request(url, proxies, expected):
mounts = {key: httpx.HTTPTransport(proxy=value) for key, value in proxies.items()} if proxies:
client = httpx.Client(mounts=mounts) with pytest.warns(DeprecationWarning):
client = httpx.Client(proxies=proxies)
else:
client = httpx.Client(proxies=proxies)
transport = client._transport_for_url(httpx.URL(url)) transport = client._transport_for_url(httpx.URL(url))
@ -103,8 +158,8 @@ def test_transport_for_request(url, proxies, expected):
@pytest.mark.network @pytest.mark.network
async def test_async_proxy_close(): async def test_async_proxy_close():
try: try:
transport = httpx.AsyncHTTPTransport(proxy=PROXY_URL) with pytest.warns(DeprecationWarning):
client = httpx.AsyncClient(mounts={"https://": transport}) client = httpx.AsyncClient(proxies={"https://": PROXY_URL})
await client.get("http://example.com") await client.get("http://example.com")
finally: finally:
await client.aclose() await client.aclose()
@ -113,13 +168,18 @@ async def test_async_proxy_close():
@pytest.mark.network @pytest.mark.network
def test_sync_proxy_close(): def test_sync_proxy_close():
try: try:
transport = httpx.HTTPTransport(proxy=PROXY_URL) with pytest.warns(DeprecationWarning):
client = httpx.Client(mounts={"https://": transport}) client = httpx.Client(proxies={"https://": PROXY_URL})
client.get("http://example.com") client.get("http://example.com")
finally: finally:
client.close() 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(): def test_unsupported_proxy_scheme():
with pytest.raises(ValueError): with pytest.raises(ValueError):
httpx.Client(proxy="ftp://127.0.0.1") httpx.Client(proxy="ftp://127.0.0.1")
@ -248,13 +308,26 @@ def test_proxies_environ(monkeypatch, client_class, url, env, expected):
], ],
) )
def test_for_deprecated_proxy_params(proxies, is_valid): def test_for_deprecated_proxy_params(proxies, is_valid):
mounts = {key: httpx.HTTPTransport(proxy=value) for key, value in proxies.items()} with pytest.warns(DeprecationWarning):
if not is_valid:
with pytest.raises(ValueError):
httpx.Client(proxies=proxies)
else:
httpx.Client(proxies=proxies)
if not is_valid:
with pytest.raises(ValueError): def test_proxy_and_proxies_together():
httpx.Client(mounts=mounts) with pytest.warns(DeprecationWarning), pytest.raises(
else: RuntimeError,
httpx.Client(mounts=mounts) ):
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"
)
def test_proxy_with_mounts(): def test_proxy_with_mounts():

View File

@ -17,7 +17,7 @@ def test_client_queryparams_string():
assert client.params["a"] == "b" assert client.params["a"] == "b"
client = httpx.Client() client = httpx.Client()
client.params = "a=b" client.params = "a=b" # type: ignore
assert isinstance(client.params, httpx.QueryParams) assert isinstance(client.params, httpx.QueryParams)
assert client.params["a"] == "b" assert client.params["a"] == "b"

View File

@ -187,6 +187,12 @@ def cert_authority():
return trustme.CA() 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") @pytest.fixture(scope="session")
def localhost_cert(cert_authority): def localhost_cert(cert_authority):
return cert_authority.issue_cert("localhost") return cert_authority.issue_cert("localhost")
@ -285,3 +291,17 @@ def server() -> typing.Iterator[TestServer]:
config = Config(app=app, lifespan="off", loop="asyncio") config = Config(app=app, lifespan="off", loop="asyncio")
server = TestServer(config=config) server = TestServer(config=config)
yield from serve_in_thread(server) 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)

View File

@ -174,46 +174,3 @@ def test_sensitive_headers(header):
value = "s3kr3t" value = "s3kr3t"
h = httpx.Headers({header: value}) h = httpx.Headers({header: value})
assert repr(h) == "Headers({'%s': '[secure]'})" % header 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",
(
(
'<http:/.../front.jpeg>; rel=front; type="image/jpeg"',
[{"url": "http:/.../front.jpeg", "rel": "front", "type": "image/jpeg"}],
),
("<http:/.../front.jpeg>", [{"url": "http:/.../front.jpeg"}]),
("<http:/.../front.jpeg>;", [{"url": "http:/.../front.jpeg"}]),
(
'<http:/.../front.jpeg>; type="image/jpeg",<http://.../back.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 == {}

View File

@ -62,7 +62,7 @@ def test_json_encoded_data():
request.read() request.read()
assert request.headers["Content-Type"] == "application/json" assert request.headers["Content-Type"] == "application/json"
assert request.content == b'{"test":123}' assert request.content == b'{"test": 123}'
def test_headers(): def test_headers():
@ -71,7 +71,7 @@ def test_headers():
assert request.headers == { assert request.headers == {
"Host": "example.org", "Host": "example.org",
"Content-Type": "application/json", "Content-Type": "application/json",
"Content-Length": "12", "Content-Length": "13",
} }
@ -183,12 +183,12 @@ def test_request_picklable():
assert pickle_request.method == "POST" assert pickle_request.method == "POST"
assert pickle_request.url.path == "/" assert pickle_request.url.path == "/"
assert pickle_request.headers["Content-Type"] == "application/json" 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 pickle_request.stream is not None
assert request.headers == { assert request.headers == {
"Host": "example.org", "Host": "example.org",
"Content-Type": "application/json", "Content-Type": "application/json",
"content-length": "12", "content-length": "13",
} }
@ -226,16 +226,3 @@ def test_request_generator_content_picklable():
request.read() request.read()
pickle_request = pickle.loads(pickle.dumps(request)) pickle_request = pickle.loads(pickle.dumps(request))
assert pickle_request.content == b"test 123" assert pickle_request.content == b"test 123"
def test_request_params():
request = httpx.Request("GET", "http://example.com", params={})
assert str(request.url) == "http://example.com"
request = httpx.Request(
"GET", "http://example.com?c=3", params={"a": "1", "b": "2"}
)
assert str(request.url) == "http://example.com?a=1&b=2"
request = httpx.Request("GET", "http://example.com?a=1", params={})
assert str(request.url) == "http://example.com"

View File

@ -81,9 +81,9 @@ def test_response_json():
assert response.status_code == 200 assert response.status_code == 200
assert response.reason_phrase == "OK" assert response.reason_phrase == "OK"
assert str(response.json()) == "{'hello': 'world'}" assert response.json() == {"hello": "world"}
assert response.headers == { assert response.headers == {
"Content-Length": "17", "Content-Length": "18",
"Content-Type": "application/json", "Content-Type": "application/json",
} }
@ -1011,10 +1011,7 @@ def test_response_decode_text_using_autodetect():
assert response.status_code == 200 assert response.status_code == 200
assert response.reason_phrase == "OK" assert response.reason_phrase == "OK"
# The encoded byte string is consistent with either ISO-8859-1 or assert response.encoding == "ISO-8859-1"
# WINDOWS-1252. Versions <6.0 of chardet claim the former, while chardet
# 6.0 detects the latter.
assert response.encoding in ("ISO-8859-1", "WINDOWS-1252")
assert response.text == text assert response.text == text

View File

@ -141,14 +141,19 @@ def test_path_query_fragment(url, raw_path, path, query, fragment):
def test_url_query_encoding(): 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") url = httpx.URL("https://www.example.com/?a=b c&d=e/f")
assert url.raw_path == b"/?a=b%20c&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"}) url = httpx.URL("https://www.example.com/", params={"a": "b c", "d": "e/f"})
assert url.raw_path == b"/?a=b+c&d=e%2Ff" assert url.raw_path == b"/?a=b%20c&d=e%2Ff"
def test_url_params(): def test_url_params():
@ -284,13 +289,12 @@ def test_url_leading_dot_prefix_on_relative_url():
def test_param_with_space(): def test_param_with_space():
# Params passed as form key-value pairs should be form escaped, # Params passed as form key-value pairs should be escaped.
# Including the special case of "+" for space seperators.
url = httpx.URL("http://webservice", params={"u": "with spaces"}) url = httpx.URL("http://webservice", params={"u": "with spaces"})
assert str(url) == "http://webservice?u=with+spaces" assert str(url) == "http://webservice?u=with%20spaces"
def test_param_requires_encoding(): def test_param_does_not_require_encoding():
# Params passed as form key-value pairs should be escaped. # Params passed as form key-value pairs should be escaped.
url = httpx.URL("http://webservice", params={"u": "%"}) url = httpx.URL("http://webservice", params={"u": "%"})
assert str(url) == "http://webservice?u=%25" assert str(url) == "http://webservice?u=%25"
@ -610,10 +614,10 @@ def test_url_copywith_userinfo_subcomponents():
} }
url = httpx.URL("https://example.org") url = httpx.URL("https://example.org")
new = url.copy_with(**copy_with_kwargs) new = url.copy_with(**copy_with_kwargs)
assert str(new) == "https://tom%40example.org:abc123%40%20%@example.org" assert str(new) == "https://tom%40example.org:abc123%40%20%25@example.org"
assert new.username == "tom@example.org" assert new.username == "tom@example.org"
assert new.password == "abc123@ %" assert new.password == "abc123@ %"
assert new.userinfo == b"tom%40example.org:abc123%40%20%" assert new.userinfo == b"tom%40example.org:abc123%40%20%25"
def test_url_copywith_invalid_component(): def test_url_copywith_invalid_component():
@ -861,3 +865,19 @@ def test_ipv6_url_copy_with_host(url_str, new_host):
assert url.host == "::ffff:192.168.0.1" assert url.host == "::ffff:192.168.0.1"
assert url.netloc == b"[::ffff:192.168.0.1]:1234" assert url.netloc == b"[::ffff:192.168.0.1]:1234"
assert str(url) == "http://[::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"

View File

@ -10,7 +10,7 @@ from httpx._urlparse import urlparse
# URL test cases from... # URL test cases from...
# https://github.com/web-platform-tests/wpt/blob/master/url/resources/urltestdata.json # https://github.com/web-platform-tests/wpt/blob/master/url/resources/urltestdata.json
with open("tests/models/whatwg.json", "r", encoding="utf-8") as input: with open("tests/models/whatwg.json", "r") as input:
test_cases = json.load(input) test_cases = json.load(input)
test_cases = [ test_cases = [
item item

View File

@ -85,18 +85,3 @@ def test_stream(server):
def test_get_invalid_url(): def test_get_invalid_url():
with pytest.raises(httpx.UnsupportedProtocol): with pytest.raises(httpx.UnsupportedProtocol):
httpx.get("invalid://example.org") 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

View File

@ -222,3 +222,13 @@ async def test_asgi_exc_no_raise():
response = await client.get("http://www.example.org/") response = await client.get("http://www.example.org/")
assert response.status_code == 500 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)

View File

@ -1,5 +1,5 @@
import os
import ssl import ssl
import typing
from pathlib import Path from pathlib import Path
import certifi import certifi
@ -14,35 +14,43 @@ def test_load_ssl_config():
assert context.check_hostname is True assert context.check_hostname is True
def test_load_ssl_config_verify_non_existing_file(): def test_load_ssl_config_verify_non_existing_path():
with pytest.raises(IOError): with pytest.raises(IOError):
context = httpx.create_ssl_context() httpx.create_ssl_context(verify="/path/to/nowhere")
context.load_verify_locations(cafile="/path/to/nowhere")
def test_load_ssl_with_keylog(monkeypatch: typing.Any) -> None:
monkeypatch.setenv("SSLKEYLOGFILE", "test")
context = httpx.create_ssl_context()
assert context.keylog_filename == "test"
def test_load_ssl_config_verify_existing_file(): def test_load_ssl_config_verify_existing_file():
context = httpx.create_ssl_context() context = httpx.create_ssl_context(verify=certifi.where())
context.load_verify_locations(capath=certifi.where())
assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED
assert context.check_hostname is True assert context.check_hostname is True
@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)
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(): def test_load_ssl_config_verify_directory():
context = httpx.create_ssl_context() path = Path(certifi.where()).parent
context.load_verify_locations(capath=Path(certifi.where()).parent) context = httpx.create_ssl_context(verify=str(path))
assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED
assert context.check_hostname is True assert context.check_hostname is True
def test_load_ssl_config_cert_and_key(cert_pem_file, cert_private_key_file): def test_load_ssl_config_cert_and_key(cert_pem_file, cert_private_key_file):
context = httpx.create_ssl_context() context = httpx.create_ssl_context(cert=(cert_pem_file, cert_private_key_file))
context.load_cert_chain(cert_pem_file, cert_private_key_file)
assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED
assert context.check_hostname is True assert context.check_hostname is True
@ -51,8 +59,9 @@ def test_load_ssl_config_cert_and_key(cert_pem_file, cert_private_key_file):
def test_load_ssl_config_cert_and_encrypted_key( def test_load_ssl_config_cert_and_encrypted_key(
cert_pem_file, cert_encrypted_private_key_file, password cert_pem_file, cert_encrypted_private_key_file, password
): ):
context = httpx.create_ssl_context() context = httpx.create_ssl_context(
context.load_cert_chain(cert_pem_file, cert_encrypted_private_key_file, password) cert=(cert_pem_file, cert_encrypted_private_key_file, password)
)
assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED
assert context.check_hostname is True assert context.check_hostname is True
@ -61,16 +70,14 @@ def test_load_ssl_config_cert_and_key_invalid_password(
cert_pem_file, cert_encrypted_private_key_file cert_pem_file, cert_encrypted_private_key_file
): ):
with pytest.raises(ssl.SSLError): with pytest.raises(ssl.SSLError):
context = httpx.create_ssl_context() httpx.create_ssl_context(
context.load_cert_chain( cert=(cert_pem_file, cert_encrypted_private_key_file, "password1")
cert_pem_file, cert_encrypted_private_key_file, "password1"
) )
def test_load_ssl_config_cert_without_key_raises(cert_pem_file): def test_load_ssl_config_cert_without_key_raises(cert_pem_file):
with pytest.raises(ssl.SSLError): with pytest.raises(ssl.SSLError):
context = httpx.create_ssl_context() httpx.create_ssl_context(cert=cert_pem_file)
context.load_cert_chain(cert_pem_file)
def test_load_ssl_config_no_verify(): def test_load_ssl_config_no_verify():
@ -79,9 +86,15 @@ def test_load_ssl_config_no_verify():
assert context.check_hostname is False assert context.check_hostname is False
def test_SSLContext_with_get_request(server, cert_pem_file): def test_load_ssl_context():
context = httpx.create_ssl_context() ssl_context = ssl.create_default_context()
context.load_verify_locations(cert_pem_file) 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)
response = httpx.get(server.url, verify=context) response = httpx.get(server.url, verify=context)
assert response.status_code == 200 assert response.status_code == 200
@ -161,6 +174,32 @@ def test_timeout_repr():
assert repr(timeout) == "Timeout(connect=None, read=5.0, write=None, pool=None)" 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(): def test_proxy_from_url():
proxy = httpx.Proxy("https://example.com") proxy = httpx.Proxy("https://example.com")

View File

@ -173,11 +173,11 @@ async def test_json_content():
assert request.headers == { assert request.headers == {
"Host": "www.example.com", "Host": "www.example.com",
"Content-Length": "18", "Content-Length": "19",
"Content-Type": "application/json", "Content-Type": "application/json",
} }
assert sync_content == b'{"Hello":"world!"}' assert sync_content == b'{"Hello": "world!"}'
assert async_content == b'{"Hello":"world!"}' assert async_content == b'{"Hello": "world!"}'
@pytest.mark.anyio @pytest.mark.anyio
@ -484,35 +484,3 @@ async def test_response_aiterator_content():
def test_response_invalid_argument(): def test_response_invalid_argument():
with pytest.raises(TypeError): with pytest.raises(TypeError):
httpx.Response(200, content=123) # type: ignore 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)

View File

@ -100,25 +100,6 @@ 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(): def test_zstd_multiframe():
# test inspired by urllib3 test suite # test inspired by urllib3 test suite
data = ( data = (

View File

@ -114,7 +114,7 @@ def test_post(server):
"content-type: text/plain", "content-type: text/plain",
"Transfer-Encoding: chunked", "Transfer-Encoding: chunked",
"", "",
'{"hello":"world"}', '{"hello": "world"}',
] ]

View File

@ -3,10 +3,17 @@ import logging
import os import os
import random import random
import certifi
import pytest import pytest
import httpx import httpx
from httpx._utils import URLPattern, get_environment_proxies from httpx._utils import (
URLPattern,
get_ca_bundle_from_env,
get_environment_proxies,
)
from .common import TESTS_DIR
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -50,6 +57,35 @@ def test_guess_by_bom(encoding, expected):
assert response.json() == {"abc": 123} assert response.json() == {"abc": 123}
@pytest.mark.parametrize(
"value, expected",
(
(
'<http:/.../front.jpeg>; rel=front; type="image/jpeg"',
[{"url": "http:/.../front.jpeg", "rel": "front", "type": "image/jpeg"}],
),
("<http:/.../front.jpeg>", [{"url": "http:/.../front.jpeg"}]),
("<http:/.../front.jpeg>;", [{"url": "http:/.../front.jpeg"}]),
(
'<http:/.../front.jpeg>; type="image/jpeg",<http://.../back.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): def test_logging_request(server, caplog):
caplog.set_level(logging.INFO) caplog.set_level(logging.INFO)
with httpx.Client() as client: with httpx.Client() as client:
@ -86,6 +122,66 @@ 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( @pytest.mark.parametrize(
["environment", "proxies"], ["environment", "proxies"],
[ [
@ -112,6 +208,76 @@ def test_get_environment_proxies(environment, proxies):
assert get_environment_proxies() == 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( @pytest.mark.parametrize(
["pattern", "url", "expected"], ["pattern", "url", "expected"],
[ [

View File

@ -201,3 +201,12 @@ def test_wsgi_server_protocol():
assert response.status_code == 200 assert response.status_code == 200
assert response.text == "success" assert response.text == "success"
assert server_protocol == "HTTP/1.1" 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!"]))