Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5addb64f0 | ||
|
|
ae1b9f6623 | ||
|
|
ca097c96f9 | ||
|
|
def4778d62 | ||
|
|
435e1dac89 | ||
|
|
4b23574cf8 | ||
|
|
652f051fea | ||
|
|
3fee27838e | ||
|
|
bc00d2bd9f | ||
|
|
767cf6baa6 | ||
|
|
b55d463570 | ||
|
|
15e9759e65 | ||
|
|
364697efca | ||
|
|
89102021fc | ||
|
|
4fb9528c2f | ||
|
|
336204f012 | ||
|
|
6c7af96773 | ||
|
|
9e8ab40369 | ||
|
|
ce7a6e91fb | ||
|
|
4189b7f051 | ||
|
|
e70d0b08c9 | ||
|
|
b395e6626b | ||
|
|
10b7295922 | ||
|
|
c7c13f18a5 | ||
|
|
26d48e0634 | ||
|
|
89599a9541 | ||
|
|
8ecb86f0d7 | ||
|
|
0cb7e5a2e7 | ||
|
|
15e21e9ea3 | ||
|
|
80960fa319 | ||
|
|
a33c87852b | ||
|
|
ce7e14da27 | ||
|
|
47f4a96ffa | ||
|
|
189fc4bcbe | ||
|
|
7b19cd5f4b | ||
|
|
b47d94c904 | ||
|
|
2ea2286db4 | ||
|
|
1805ee0d22 | ||
|
|
41597adffa | ||
|
|
6212e8fa3b | ||
|
|
83a85189c7 | ||
|
|
6622553979 | ||
|
|
12be5c44ca | ||
|
|
e9cabc8e1d | ||
|
|
eeb5e3c2a3 | ||
|
|
5dda2aa306 | ||
|
|
5440381553 | ||
|
|
ba2e51215e | ||
|
|
d293374b66 | ||
|
|
489fef48ba | ||
|
|
9fd6f0ca66 | ||
|
|
8e36f2bc68 | ||
|
|
3f76571d34 | ||
|
|
6f9b50990d | ||
|
|
1bf1fc0ea8 | ||
|
|
95a9527ed6 | ||
|
|
3849e1518f | ||
|
|
49d74a2e7f | ||
|
|
2e01aa0075 | ||
|
|
f06171fd5a | ||
|
|
d4961b9f8e | ||
|
|
0aa20e449e | ||
|
|
d46fa57a6a | ||
|
|
609df7ecc0 | ||
|
|
1d6b663433 | ||
|
|
1bf1ba5124 | ||
|
|
7c0cda153d | ||
|
|
beb501fc28 | ||
|
|
359f77d4f6 | ||
|
|
b351a44fb6 | ||
|
|
db9072f998 | ||
|
|
92e9dfb399 | ||
|
|
e186ecc9f8 | ||
|
|
37593c1952 | ||
|
|
88a81c5d31 | ||
|
|
fa6dac8383 | ||
|
|
a7092af2fd | ||
|
|
be56b74735 | ||
|
|
2f5ae50726 | ||
|
|
4b85e6c389 | ||
|
|
7354ed70ce | ||
|
|
5bb2ea0f4e | ||
|
|
45bb65bba1 | ||
|
|
392dbe45f0 | ||
|
|
7df47ce4d9 | ||
|
|
0006ed0547 | ||
|
|
f3eb3c90fd | ||
|
|
7e10342c2a | ||
|
|
4941b40cbb | ||
|
|
6045186f7d | ||
|
|
6d852d319a | ||
|
|
df5345140e | ||
|
|
fc84f7f6eb | ||
|
|
e745060c75 | ||
|
|
4de13707ee | ||
|
|
87713d2172 | ||
|
|
77cb36f181 |
5
.github/CONTRIBUTING.md
vendored
5
.github/CONTRIBUTING.md
vendored
@ -211,9 +211,10 @@ this is where our previously generated `client.pem` comes in:
|
|||||||
```
|
```
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
proxies = {"all": "http://127.0.0.1:8080/"}
|
ssl_context = httpx.SSLContext()
|
||||||
|
ssl_context.load_verify_locations("/path/to/client.pem")
|
||||||
|
|
||||||
with httpx.Client(proxies=proxies, verify="/path/to/client.pem") as client:
|
with httpx.Client(proxy="http://127.0.0.1:8080/", ssl_context=ssl_context) as client:
|
||||||
response = client.get("https://example.org")
|
response = client.get("https://example.org")
|
||||||
print(response.status_code) # should print 200
|
print(response.status_code) # should print 200
|
||||||
```
|
```
|
||||||
|
|||||||
4
.github/workflows/publish.yml
vendored
4
.github/workflows/publish.yml
vendored
@ -15,9 +15,9 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: "actions/checkout@v4"
|
- uses: "actions/checkout@v4"
|
||||||
- uses: "actions/setup-python@v5"
|
- uses: "actions/setup-python@v6"
|
||||||
with:
|
with:
|
||||||
python-version: 3.8
|
python-version: 3.9
|
||||||
- name: "Install dependencies"
|
- name: "Install dependencies"
|
||||||
run: "scripts/install"
|
run: "scripts/install"
|
||||||
- name: "Build package & docs"
|
- name: "Build package & docs"
|
||||||
|
|||||||
6
.github/workflows/test-suite.yml
vendored
6
.github/workflows/test-suite.yml
vendored
@ -5,7 +5,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: ["master"]
|
branches: ["master"]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ["master"]
|
branches: ["master", "version-*"]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tests:
|
tests:
|
||||||
@ -14,11 +14,11 @@ jobs:
|
|||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: "actions/checkout@v4"
|
- uses: "actions/checkout@v4"
|
||||||
- uses: "actions/setup-python@v5"
|
- uses: "actions/setup-python@v6"
|
||||||
with:
|
with:
|
||||||
python-version: "${{ matrix.python-version }}"
|
python-version: "${{ matrix.python-version }}"
|
||||||
allow-prereleases: true
|
allow-prereleases: true
|
||||||
|
|||||||
75
CHANGELOG.md
75
CHANGELOG.md
@ -4,7 +4,64 @@ 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
|
## [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)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* Reintroduced supposedly-private `URLTypes` shortcut. (#2673)
|
||||||
|
|
||||||
|
## 0.27.1 (27th August, 2024)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* Support for `zstd` content decoding using the python `zstandard` package is added. Installable using `httpx[zstd]`. (#3139)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* Improved error messaging for `InvalidURL` exceptions. (#3250)
|
||||||
|
* Fix `app` type signature in `ASGITransport`. (#3109)
|
||||||
|
|
||||||
## 0.27.0 (21st February, 2024)
|
## 0.27.0 (21st February, 2024)
|
||||||
|
|
||||||
@ -94,7 +151,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
* The logging behaviour has been changed to be more in-line with other standard Python logging usages. We no longer have a custom `TRACE` log level, and we no longer use the `HTTPX_LOG_LEVEL` environment variable to auto-configure logging. We now have a significant amount of `DEBUG` logging available at the network level. Full documentation is available at https://www.python-httpx.org/logging/ (#2547, encode/httpcore#648)
|
* The logging behaviour has been changed to be more in-line with other standard Python logging usages. We no longer have a custom `TRACE` log level, and we no longer use the `HTTPX_LOG_LEVEL` environment variable to auto-configure logging. We now have a significant amount of `DEBUG` logging available at the network level. Full documentation is available at https://www.python-httpx.org/logging/ (#2547, encode/httpcore#648)
|
||||||
* The `Response.iter_lines()` method now matches the stdlib behaviour and does not include the newline characters. It also resolves a performance issue. (#2423)
|
* The `Response.iter_lines()` method now matches the stdlib behaviour and does not include the newline characters. It also resolves a performance issue. (#2423)
|
||||||
* Query parameter encoding switches from using + for spaces and %2F for forward slash, to instead using %20 for spaces and treating forward slash as a safe, unescaped character. This differs from `requests`, but is in line with browser behavior in Chrome, Safari, and Firefox. Both options are RFC valid. (#2543)
|
* Query parameter encoding switches from using + for spaces and %2F for forward slash, to instead using %20 for spaces and treating forward slash as a safe, unescaped character. This differs from `requests`, but is in line with browser behavior in Chrome, Safari, and Firefox. Both options are RFC valid. (#2543)
|
||||||
* NetRC authentication is no longer automatically handled, but is instead supported by an explicit `httpx.NetRCAuth()` authentication class. See the documentation at https://www.python-httpx.org/advanced/#netrc-support (#2525)
|
* NetRC authentication is no longer automatically handled, but is instead supported by an explicit `httpx.NetRCAuth()` authentication class. See the documentation at https://www.python-httpx.org/advanced/authentication/#netrc-authentication (#2525)
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
@ -147,7 +204,7 @@ See the "Removed" section of these release notes for details.
|
|||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
* Drop support for Python 3.6. (#2097)
|
* Drop support for Python 3.6. (#2097)
|
||||||
* Use `utf-8` as the default character set, instead of falling back to `charset-normalizer` for auto-detection. To enable automatic character set detection, see [the documentation](https://www.python-httpx.org/advanced/#character-set-encodings-and-auto-detection). (#2165)
|
* Use `utf-8` as the default character set, instead of falling back to `charset-normalizer` for auto-detection. To enable automatic character set detection, see [the documentation](https://www.python-httpx.org/advanced/text-encodings/#using-auto-detection). (#2165)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@ -166,7 +223,7 @@ See the "Removed" section of these release notes for details.
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
* Support for [the SOCKS5 proxy protocol](https://www.python-httpx.org/advanced/#socks) via [the `socksio` package](https://github.com/sethmlarson/socksio). (#2034)
|
* Support for [the SOCKS5 proxy protocol](https://www.python-httpx.org/advanced/proxies/#socks) via [the `socksio` package](https://github.com/sethmlarson/socksio). (#2034)
|
||||||
* Support for custom headers in multipart/form-data requests (#1936)
|
* Support for custom headers in multipart/form-data requests (#1936)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@ -321,7 +378,7 @@ finally:
|
|||||||
|
|
||||||
The 0.18.x release series formalises our low-level Transport API, introducing the base classes `httpx.BaseTransport` and `httpx.AsyncBaseTransport`.
|
The 0.18.x release series formalises our low-level Transport API, introducing the base classes `httpx.BaseTransport` and `httpx.AsyncBaseTransport`.
|
||||||
|
|
||||||
See the "[Writing custom transports](https://www.python-httpx.org/advanced/#writing-custom-transports)" documentation and the [`httpx.BaseTransport.handle_request()`](https://github.com/encode/httpx/blob/397aad98fdc8b7580a5fc3e88f1578b4302c6382/httpx/_transports/base.py#L77-L147) docstring for more complete details on implementing custom transports.
|
See the "[Custom transports](https://www.python-httpx.org/advanced/transports/#custom-transports)" documentation and the [`httpx.BaseTransport.handle_request()`](https://github.com/encode/httpx/blob/397aad98fdc8b7580a5fc3e88f1578b4302c6382/httpx/_transports/base.py#L77-L147) docstring for more complete details on implementing custom transports.
|
||||||
|
|
||||||
Pull request #1522 includes a checklist of differences from the previous `httpcore` transport API, for developers implementing custom transports.
|
Pull request #1522 includes a checklist of differences from the previous `httpcore` transport API, for developers implementing custom transports.
|
||||||
|
|
||||||
@ -575,7 +632,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 exlcusions like `proxies={"https://www.example.com": None}`. (Pull #1099)
|
* Support for proxy exclusions like `proxies={"https://www.example.com": None}`. (Pull #1099)
|
||||||
* Support `QueryParams(None)` and `client.params = None`. (Pull #1060)
|
* Support `QueryParams(None)` and `client.params = None`. (Pull #1060)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@ -638,7 +695,7 @@ This release switches to `httpcore` for all the internal networking, which means
|
|||||||
|
|
||||||
It also means we've had to remove our UDS support, since maintaining that would have meant having to push back our work towards a 1.0 release, which isn't a trade-off we wanted to make.
|
It also means we've had to remove our UDS support, since maintaining that would have meant having to push back our work towards a 1.0 release, which isn't a trade-off we wanted to make.
|
||||||
|
|
||||||
We also now have [a public "Transport API"](https://www.python-httpx.org/advanced/#custom-transports), which you can use to implement custom transport implementations against. This formalises and replaces our previously private "Dispatch API".
|
We also now have [a public "Transport API"](https://www.python-httpx.org/advanced/transports/#custom-transports), which you can use to implement custom transport implementations against. This formalises and replaces our previously private "Dispatch API".
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@ -803,7 +860,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 acquiry. (Pull #700)
|
- Fix issue with concurrent connection acquisition. (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)
|
||||||
@ -1052,7 +1109,7 @@ importing modules within the package.
|
|||||||
|
|
||||||
## 0.6.7 (July 8, 2019)
|
## 0.6.7 (July 8, 2019)
|
||||||
|
|
||||||
- Check for connection aliveness on re-acquiry (Pull #111)
|
- Check for connection aliveness on re-acquisition (Pull #111)
|
||||||
|
|
||||||
## 0.6.6 (July 3, 2019)
|
## 0.6.6 (July 3, 2019)
|
||||||
|
|
||||||
|
|||||||
@ -13,9 +13,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
HTTPX is a fully featured HTTP client library for Python 3. It includes **an integrated
|
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**.
|
||||||
command line client**, has support for both **HTTP/1.1 and HTTP/2**, and provides both **sync
|
|
||||||
and async APIs**.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -66,7 +64,7 @@ HTTPX builds on the well-established usability of `requests`, and gives you:
|
|||||||
* An integrated command-line client.
|
* An integrated command-line client.
|
||||||
* HTTP/1.1 [and HTTP/2 support](https://www.python-httpx.org/http2/).
|
* HTTP/1.1 [and HTTP/2 support](https://www.python-httpx.org/http2/).
|
||||||
* Standard synchronous interface, but with [async support if you need it](https://www.python-httpx.org/async/).
|
* Standard synchronous interface, but with [async support if you need it](https://www.python-httpx.org/async/).
|
||||||
* Ability to make requests directly to [WSGI applications](https://www.python-httpx.org/advanced/#calling-into-python-web-apps) or [ASGI applications](https://www.python-httpx.org/async/#calling-into-python-web-apps).
|
* Ability to make requests directly to [WSGI applications](https://www.python-httpx.org/advanced/transports/#wsgi-transport) or [ASGI applications](https://www.python-httpx.org/advanced/transports/#asgi-transport).
|
||||||
* Strict timeouts everywhere.
|
* Strict timeouts everywhere.
|
||||||
* Fully type annotated.
|
* Fully type annotated.
|
||||||
* 100% test coverage.
|
* 100% test coverage.
|
||||||
@ -103,7 +101,7 @@ Or, to include the optional HTTP/2 support, use:
|
|||||||
$ pip install httpx[http2]
|
$ pip install httpx[http2]
|
||||||
```
|
```
|
||||||
|
|
||||||
HTTPX requires Python 3.8+.
|
HTTPX requires Python 3.9+.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
@ -138,6 +136,7 @@ As well as these optional installs:
|
|||||||
* `rich` - Rich terminal support. *(Optional, with `httpx[cli]`)*
|
* `rich` - Rich terminal support. *(Optional, with `httpx[cli]`)*
|
||||||
* `click` - Command line client support. *(Optional, with `httpx[cli]`)*
|
* `click` - Command line client support. *(Optional, with `httpx[cli]`)*
|
||||||
* `brotli` or `brotlicffi` - Decoding for "brotli" compressed responses. *(Optional, with `httpx[brotli]`)*
|
* `brotli` or `brotlicffi` - Decoding for "brotli" compressed responses. *(Optional, with `httpx[brotli]`)*
|
||||||
|
* `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
|
||||||
much of this work follows, as well as to `urllib3` for plenty of design
|
much of this work follows, as well as to `urllib3` for plenty of design
|
||||||
|
|||||||
@ -1,144 +0,0 @@
|
|||||||
<p align="center">
|
|
||||||
<a href="https://www.python-httpx.org/"><img width="350" height="208" src="https://raw.githubusercontent.com/encode/httpx/master/docs/img/butterfly.png" alt='HTTPX'></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center"><strong>HTTPX</strong> <em>- 适用于 Python 的下一代 HTTP 客户端</em></p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://github.com/encode/httpx/actions">
|
|
||||||
<img src="https://github.com/encode/httpx/workflows/Test%20Suite/badge.svg" alt="Test Suite">
|
|
||||||
</a>
|
|
||||||
<a href="https://pypi.org/project/httpx/">
|
|
||||||
<img src="https://badge.fury.io/py/httpx.svg" alt="Package version">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
HTTPX 是适用于 Python3 的功能齐全的 HTTP 客户端。 它集成了 **一个命令行客户端**,同时支持 **HTTP/1.1 和 HTTP/2**,并提供了 **同步和异步 API**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
通过 pip 安装 HTTPX:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
$ pip install httpx
|
|
||||||
```
|
|
||||||
|
|
||||||
使用 httpx:
|
|
||||||
|
|
||||||
```pycon
|
|
||||||
>>> import httpx
|
|
||||||
>>> r = httpx.get('https://www.example.org/')
|
|
||||||
>>> r
|
|
||||||
<Response [200 OK]>
|
|
||||||
>>> r.status_code
|
|
||||||
200
|
|
||||||
>>> r.headers['content-type']
|
|
||||||
'text/html; charset=UTF-8'
|
|
||||||
>>> r.text
|
|
||||||
'<!doctype html>\n<html>\n<head>\n<title>Example Domain</title>...'
|
|
||||||
```
|
|
||||||
|
|
||||||
或者使用命令行客户端。
|
|
||||||
|
|
||||||
```shell
|
|
||||||
$ pip install 'httpx[cli]' # 命令行功能是可选的。
|
|
||||||
```
|
|
||||||
|
|
||||||
它允许我们直接通过命令行来使用 HTTPX...
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img width="700" src="docs/img/httpx-help.png" alt='httpx --help'>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
发送一个请求...
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img width="700" src="docs/img/httpx-request.png" alt='httpx http://httpbin.org/json'>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## 特性
|
|
||||||
|
|
||||||
HTTPX 建立在成熟的 requests 可用性基础上,为您提供以下功能:
|
|
||||||
|
|
||||||
* 广泛的 [requests 兼容 API](https://www.python-httpx.org/compatibility/)。
|
|
||||||
* 内置的命令行客户端功能。
|
|
||||||
* HTTP/1.1 [和 HTTP/2 支持](https://www.python-httpx.org/http2/)。
|
|
||||||
* 标准同步接口,也支持 [异步](https://www.python-httpx.org/async/)。
|
|
||||||
* 能够直接向 [WSGI 应用发送请求](https://www.python-httpx.org/advanced/#calling-into-python-web-apps) 或向 [ASGI 应用发送请求](https://www.python-httpx.org/async/#calling-into-python-web-apps)。
|
|
||||||
* 每一处严格的超时控制。
|
|
||||||
* 完整的类型注解。
|
|
||||||
* 100% 测试。
|
|
||||||
|
|
||||||
加上这些应该具备的标准功能...
|
|
||||||
|
|
||||||
* 国际化域名与 URL
|
|
||||||
* Keep-Alive & 连接池
|
|
||||||
* Cookie 持久性会话
|
|
||||||
* 浏览器风格的 SSL 验证
|
|
||||||
* 基础或摘要身份验证
|
|
||||||
* 优雅的键值 Cookies
|
|
||||||
* 自动解压缩
|
|
||||||
* 内容自动解码
|
|
||||||
* Unicode 响应正文
|
|
||||||
* 分段文件上传
|
|
||||||
* HTTP(S)代理支持
|
|
||||||
* 可配置的连接超时
|
|
||||||
* 流式下载
|
|
||||||
* .netrc 支持
|
|
||||||
* 分块请求
|
|
||||||
|
|
||||||
## 安装
|
|
||||||
|
|
||||||
使用 pip 安装:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
$ pip install httpx
|
|
||||||
```
|
|
||||||
|
|
||||||
或者,安装可选的 HTTP/2 支持:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
$ pip install httpx[http2]
|
|
||||||
```
|
|
||||||
|
|
||||||
HTTPX 要求 Python 3.8+ 版本。
|
|
||||||
|
|
||||||
## 文档
|
|
||||||
|
|
||||||
项目文档现已就绪,请访问 [https://www.python-httpx.org/](https://www.python-httpx.org/) 来阅读。
|
|
||||||
|
|
||||||
要浏览所有基础知识,请访问 [快速开始](https://www.python-httpx.org/quickstart/)。
|
|
||||||
|
|
||||||
更高级的主题,可参阅 [高级用法](https://www.python-httpx.org/advanced/) 章节, [异步支持](https://www.python-httpx.org/async/) 或者 [HTTP/2](https://www.python-httpx.org/http2/) 章节。
|
|
||||||
|
|
||||||
[Developer Interface](https://www.python-httpx.org/api/) 提供了全面的 API 参考。
|
|
||||||
|
|
||||||
要了解与 HTTPX 集成的工具, 请访问 [第三方包](https://www.python-httpx.org/third_party_packages/)。
|
|
||||||
|
|
||||||
## 贡献
|
|
||||||
|
|
||||||
如果您想对本项目做出贡献,请访问 [贡献者指南](https://www.python-httpx.org/contributing/) 来了解如何开始。
|
|
||||||
|
|
||||||
## 依赖
|
|
||||||
|
|
||||||
HTTPX 项目依赖于这些优秀的库:
|
|
||||||
|
|
||||||
* `httpcore` - `httpx` 基础传输接口实现。
|
|
||||||
* `h11` - HTTP/1.1 支持。
|
|
||||||
* `certifi` - SSL 证书。
|
|
||||||
* `idna` - 国际化域名支持。
|
|
||||||
* `sniffio` - 异步库自动检测。
|
|
||||||
|
|
||||||
以及这些可选的安装:
|
|
||||||
|
|
||||||
* `h2` - HTTP/2 支持。 *(可选的,通过 `httpx[http2]`)*
|
|
||||||
* `socksio` - SOCKS 代理支持。 *(可选的, 通过 `httpx[socks]`)*
|
|
||||||
* `rich` - 丰富的终端支持。 *(可选的,通过 `httpx[cli]`)*
|
|
||||||
* `click` - 命令行客户端支持。 *(可选的,通过 `httpx[cli]`)*
|
|
||||||
* `brotli` 或者 `brotlicffi` - 对 “brotli” 压缩响应的解码。*(可选的,通过 `httpx[brotli]`)*
|
|
||||||
|
|
||||||
这项工作的大量功劳都归功于参考了 `requests` 所遵循的 API 结构,以及 `urllib3` 中众多围绕底层网络细节的设计灵感。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<p align="center"><i>HTTPX 使用 <a href="https://github.com/encode/httpx/blob/master/LICENSE.md">BSD 开源协议</a> code。<br/>精心设计和制作。</i><br/>— 🦋 —</p>
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
Authentication can either be included on a per-request basis...
|
Authentication can either be included on a per-request basis...
|
||||||
|
|
||||||
```pycon
|
```pycon
|
||||||
>>> auth = httpx.BasicAuthentication(username="username", password="secret")
|
>>> auth = httpx.BasicAuth(username="username", password="secret")
|
||||||
>>> client = httpx.Client()
|
>>> client = httpx.Client()
|
||||||
>>> response = client.get("https://www.example.com/", auth=auth)
|
>>> response = client.get("https://www.example.com/", auth=auth)
|
||||||
```
|
```
|
||||||
@ -9,7 +9,7 @@ Authentication can either be included on a per-request basis...
|
|||||||
Or configured on the client instance, ensuring that all outgoing requests will include authentication credentials...
|
Or configured on the client instance, ensuring that all outgoing requests will include authentication credentials...
|
||||||
|
|
||||||
```pycon
|
```pycon
|
||||||
>>> auth = httpx.BasicAuthentication(username="username", password="secret")
|
>>> auth = httpx.BasicAuth(username="username", password="secret")
|
||||||
>>> client = httpx.Client(auth=auth)
|
>>> client = httpx.Client(auth=auth)
|
||||||
>>> response = client.get("https://www.example.com/")
|
>>> response = client.get("https://www.example.com/")
|
||||||
```
|
```
|
||||||
@ -19,7 +19,7 @@ Or configured on the client instance, ensuring that all outgoing requests will i
|
|||||||
HTTP basic authentication is an unencrypted authentication scheme that uses a simple encoding of the username and password in the request `Authorization` header. Since it is unencrypted it should typically only be used over `https`, although this is not strictly enforced.
|
HTTP basic authentication is an unencrypted authentication scheme that uses a simple encoding of the username and password in the request `Authorization` header. Since it is unencrypted it should typically only be used over `https`, although this is not strictly enforced.
|
||||||
|
|
||||||
```pycon
|
```pycon
|
||||||
>>> auth = httpx.BasicAuthentication(username="finley", password="secret")
|
>>> auth = httpx.BasicAuth(username="finley", password="secret")
|
||||||
>>> client = httpx.Client(auth=auth)
|
>>> client = httpx.Client(auth=auth)
|
||||||
>>> response = client.get("https://httpbin.org/basic-auth/finley/secret")
|
>>> response = client.get("https://httpbin.org/basic-auth/finley/secret")
|
||||||
>>> response
|
>>> response
|
||||||
|
|||||||
@ -270,8 +270,9 @@ 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
|
||||||
>>> files = {'upload-file': ('report.xls', open('report.xls', 'rb'), 'application/vnd.ms-excel')}
|
>>> with open('report.xls', 'rb') as report_file:
|
||||||
>>> r = httpx.post("https://httpbin.org/post", files=files)
|
... files = {'upload-file': ('report.xls', report_file, 'application/vnd.ms-excel')}
|
||||||
|
... r = httpx.post("https://httpbin.org/post", files=files)
|
||||||
>>> print(r.text)
|
>>> print(r.text)
|
||||||
{
|
{
|
||||||
...
|
...
|
||||||
@ -318,7 +319,10 @@ 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
|
||||||
>>> files = [('images', ('foo.png', open('foo.png', 'rb'), 'image/png')),
|
>>> with open('foo.png', 'rb') as foo_file, open('bar.png', 'rb') as bar_file:
|
||||||
('images', ('bar.png', open('bar.png', 'rb'), 'image/png'))]
|
... files = [
|
||||||
>>> r = httpx.post("https://httpbin.org/post", files=files)
|
... ('images', ('foo.png', foo_file, 'image/png')),
|
||||||
|
... ('images', ('bar.png', bar_file, 'image/png')),
|
||||||
|
... ]
|
||||||
|
... r = httpx.post("https://httpbin.org/post", files=files)
|
||||||
```
|
```
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Request and response extensions provide a untyped space where additional information may be added.
|
Request and response extensions provide a untyped space where additional information may be added.
|
||||||
|
|
||||||
Extensions should be used for features that may not be available on all transports, and that do not fit neatly into [the simplified request/response model](https://www.encode.io/httpcore/extensions/) that the underlying `httpcore` pacakge uses as it's API.
|
Extensions should be used for features that may not be available on all transports, and that do not fit neatly into [the simplified request/response model](https://www.encode.io/httpcore/extensions/) that the underlying `httpcore` package uses as its API.
|
||||||
|
|
||||||
Several extensions are supported on the request:
|
Several extensions are supported on the request:
|
||||||
|
|
||||||
@ -138,6 +138,47 @@ response = client.get(
|
|||||||
|
|
||||||
This extension is how the `httpx` timeouts are implemented, ensuring that the timeout values are associated with the request instance and passed throughout the stack. You shouldn't typically be working with this extension directly, but use the higher level `timeout` API instead.
|
This extension is how the `httpx` timeouts are implemented, ensuring that the timeout values are associated with the request instance and passed throughout the stack. You shouldn't typically be working with this extension directly, but use the higher level `timeout` API instead.
|
||||||
|
|
||||||
|
### `"target"`
|
||||||
|
|
||||||
|
The target that is used as [the HTTP target instead of the URL path](https://datatracker.ietf.org/doc/html/rfc2616#section-5.1.2).
|
||||||
|
|
||||||
|
This enables support constructing requests that would otherwise be unsupported.
|
||||||
|
|
||||||
|
* URL paths with non-standard escaping applied.
|
||||||
|
* Forward proxy requests using an absolute URI.
|
||||||
|
* Tunneling proxy requests using `CONNECT` with hostname as the target.
|
||||||
|
* Server-wide `OPTIONS *` requests.
|
||||||
|
|
||||||
|
Some examples:
|
||||||
|
|
||||||
|
Using the 'target' extension to send requests without the standard path escaping rules...
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Typically a request to "https://www.example.com/test^path" would
|
||||||
|
# connect to "www.example.com" and send an HTTP/1.1 request like...
|
||||||
|
#
|
||||||
|
# GET /test%5Epath HTTP/1.1
|
||||||
|
#
|
||||||
|
# Using the target extension we can include the literal '^'...
|
||||||
|
#
|
||||||
|
# GET /test^path HTTP/1.1
|
||||||
|
#
|
||||||
|
# Note that requests must still be valid HTTP requests.
|
||||||
|
# For example including whitespace in the target will raise a `LocalProtocolError`.
|
||||||
|
extensions = {"target": b"/test^path"}
|
||||||
|
response = httpx.get("https://www.example.com", extensions=extensions)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `target` extension also allows server-wide `OPTIONS *` requests to be constructed...
|
||||||
|
|
||||||
|
```python
|
||||||
|
# This will send the following request...
|
||||||
|
#
|
||||||
|
# CONNECT * HTTP/1.1
|
||||||
|
extensions = {"target": b"*"}
|
||||||
|
response = httpx.request("CONNECT", "https://www.example.com", extensions=extensions)
|
||||||
|
```
|
||||||
|
|
||||||
## Response Extensions
|
## Response Extensions
|
||||||
|
|
||||||
### `"http_version"`
|
### `"http_version"`
|
||||||
|
|||||||
@ -1,100 +1,89 @@
|
|||||||
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).
|
||||||
|
|
||||||
## Changing the verification defaults
|
### Enabling and disabling verification
|
||||||
|
|
||||||
By default, HTTPX uses the CA bundle provided by [Certifi](https://pypi.org/project/certifi/). This is what you want in most cases, even though some advanced situations may require you to use a different set of certificates.
|
By default httpx will verify HTTPS connections, and raise an error for invalid SSL cases...
|
||||||
|
|
||||||
If you'd like to use a custom CA bundle, you can use the `verify` parameter.
|
|
||||||
|
|
||||||
```python
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
r = httpx.get("https://example.org", verify="path/to/client.pem")
|
|
||||||
```
|
|
||||||
|
|
||||||
Alternatively, you can pass a standard library `ssl.SSLContext`.
|
|
||||||
|
|
||||||
```pycon
|
```pycon
|
||||||
>>> import ssl
|
>>> httpx.get("https://expired.badssl.com/")
|
||||||
>>> import httpx
|
httpx.ConnectError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:997)
|
||||||
>>> context = ssl.create_default_context()
|
```
|
||||||
>>> context.load_verify_locations(cafile="/tmp/client.pem")
|
|
||||||
>>> httpx.get('https://example.org', verify=context)
|
You can disable SSL verification completely and allow insecure requests...
|
||||||
|
|
||||||
|
```pycon
|
||||||
|
>>> httpx.get("https://expired.badssl.com/", verify=False)
|
||||||
<Response [200 OK]>
|
<Response [200 OK]>
|
||||||
```
|
```
|
||||||
|
|
||||||
We also include a helper function for creating properly configured `SSLContext` instances.
|
### Configuring client instances
|
||||||
|
|
||||||
```pycon
|
If you're using a `Client()` instance you should pass any `verify=<...>` configuration when instantiating the client.
|
||||||
>>> 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)
|
||||||
```
|
```
|
||||||
|
|
||||||
The `create_ssl_context` function accepts the same set of SSL configuration arguments
|
Using [the `truststore` package](https://truststore.readthedocs.io/) to support system certificate stores...
|
||||||
(`trust_env`, `verify`, `cert` and `http2` arguments)
|
|
||||||
as `httpx.Client` or `httpx.AsyncClient`
|
|
||||||
|
|
||||||
```pycon
|
```python
|
||||||
>>> import httpx
|
import ssl
|
||||||
>>> context = httpx.create_ssl_context(verify="/tmp/client.pem")
|
import truststore
|
||||||
>>> httpx.get('https://example.org', verify=context)
|
import httpx
|
||||||
<Response [200 OK]>
|
|
||||||
|
# Use system certificate stores.
|
||||||
|
ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
client = httpx.Client(verify=ctx)
|
||||||
```
|
```
|
||||||
|
|
||||||
Or you can also disable the SSL verification entirely, which is _not_ recommended.
|
Loding an alternative certificate verification store using [the standard SSL context API](https://docs.python.org/3/library/ssl.html)...
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import httpx
|
import httpx
|
||||||
|
import ssl
|
||||||
|
|
||||||
r = httpx.get("https://example.org", verify=False)
|
# Use an explicitly configured certificate store.
|
||||||
|
ctx = ssl.create_default_context(cafile="path/to/certs.pem") # Either cafile or capath.
|
||||||
|
client = httpx.Client(verify=ctx)
|
||||||
```
|
```
|
||||||
|
|
||||||
## SSL configuration on client instances
|
### Client side certificates
|
||||||
|
|
||||||
If you're using a `Client()` instance, then you should pass any SSL settings when instantiating the client.
|
Client side certificates allow a remote server to verify the client. They tend to be used within private organizations to authenticate requests to remote servers.
|
||||||
|
|
||||||
|
You can 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
|
||||||
client = httpx.Client(verify=False)
|
ctx = ssl.create_default_context()
|
||||||
|
ctx.load_cert_chain(certfile="path/to/client.pem") # Optionally also keyfile or password.
|
||||||
|
client = httpx.Client(verify=ctx)
|
||||||
```
|
```
|
||||||
|
|
||||||
The `client.get(...)` method and other request methods *do not* support changing the SSL settings on a per-request basis. If you need different SSL settings in different cases you should use more that one client instance, with different settings on each. Each client will then be using an isolated connection pool with a specific fixed SSL configuration on all connections within that pool.
|
### Working with `SSL_CERT_FILE` and `SSL_CERT_DIR`
|
||||||
|
|
||||||
## Client Side Certificates
|
`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).
|
||||||
|
|
||||||
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)
|
### Making HTTPS requests to a local server
|
||||||
|
|
||||||
```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.
|
||||||
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.)
|
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. Tell HTTPX to use the certificates stored in `client.pem`:
|
3. Configure `httpx` to use the certificates stored in `client.pem`.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
client = httpx.Client(verify="/tmp/client.pem")
|
ctx = ssl.create_default_context(cafile="client.pem")
|
||||||
response = client.get("https://localhost:8000")
|
client = httpx.Client(verify=ctx)
|
||||||
```
|
```
|
||||||
|
|||||||
17
docs/api.md
17
docs/api.md
@ -114,7 +114,7 @@ what gets sent over the wire.*
|
|||||||
'example.org'
|
'example.org'
|
||||||
```
|
```
|
||||||
|
|
||||||
* `def __init__(url, allow_relative=False, params=None)`
|
* `def __init__(url, **kwargs)`
|
||||||
* `.scheme` - **str**
|
* `.scheme` - **str**
|
||||||
* `.authority` - **str**
|
* `.authority` - **str**
|
||||||
* `.host` - **str**
|
* `.host` - **str**
|
||||||
@ -159,3 +159,18 @@ 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**
|
||||||
|
|||||||
@ -23,7 +23,7 @@ To make asynchronous requests, you'll need an `AsyncClient`.
|
|||||||
```
|
```
|
||||||
|
|
||||||
!!! tip
|
!!! tip
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## API Differences
|
## API Differences
|
||||||
|
|
||||||
|
|||||||
@ -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 `request.text` and `response.content` available.
|
* `.read()` - Read the entire response body, making `response.text` and `response.content` available.
|
||||||
|
|
||||||
## Timeouts
|
## Timeouts
|
||||||
|
|
||||||
@ -171,12 +171,10 @@ Also note that `requests.Session.request(...)` allows a `proxies=...` parameter,
|
|||||||
|
|
||||||
## SSL configuration
|
## SSL configuration
|
||||||
|
|
||||||
When using a `Client` instance, the `trust_env`, `verify`, and `cert` arguments should always be passed on client instantiation, rather than passed to the request method.
|
When using a `Client` instance, the ssl configurations should always be passed on client instantiation, rather than passed to the request method.
|
||||||
|
|
||||||
If you need more than one different SSL configuration, you should use different client instances for each SSL configuration.
|
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.
|
||||||
@ -228,3 +226,7 @@ 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/).
|
||||||
|
|||||||
@ -206,16 +206,13 @@ UI options.
|
|||||||
|
|
||||||
At this point the server is ready to start serving requests, you'll need to
|
At this point the server is ready to start serving requests, you'll need to
|
||||||
configure HTTPX as described in the
|
configure HTTPX as described in the
|
||||||
[proxy section](https://www.python-httpx.org/advanced/#http-proxying) and
|
[proxy section](https://www.python-httpx.org/advanced/proxies/#http-proxies) and
|
||||||
the [SSL certificates section](https://www.python-httpx.org/advanced/#ssl-certificates),
|
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
|
||||||
import httpx
|
ctx = ssl.create_default_context(cafile="/path/to/client.pem")
|
||||||
|
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
|
||||||
|
|||||||
@ -8,66 +8,6 @@ Environment variables are used by default. To ignore environment variables, `tru
|
|||||||
|
|
||||||
Here is a list of environment variables that HTTPX recognizes and what function they serve:
|
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:
|
||||||
@ -111,3 +51,29 @@ 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')"
|
||||||
|
```
|
||||||
|
|||||||
BIN
docs/img/speakeasy.png
Normal file
BIN
docs/img/speakeasy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
@ -68,7 +68,7 @@ HTTPX builds on the well-established usability of `requests`, and gives you:
|
|||||||
* A broadly [requests-compatible API](compatibility.md).
|
* A broadly [requests-compatible API](compatibility.md).
|
||||||
* Standard synchronous interface, but with [async support if you need it](async.md).
|
* Standard synchronous interface, but with [async support if you need it](async.md).
|
||||||
* HTTP/1.1 [and HTTP/2 support](http2.md).
|
* HTTP/1.1 [and HTTP/2 support](http2.md).
|
||||||
* Ability to make requests directly to [WSGI applications](async.md#calling-into-python-web-apps) or [ASGI applications](async.md#calling-into-python-web-apps).
|
* Ability to make requests directly to [WSGI applications](advanced/transports.md#wsgi-transport) or [ASGI applications](advanced/transports.md#asgi-transport).
|
||||||
* Strict timeouts everywhere.
|
* Strict timeouts everywhere.
|
||||||
* Fully type annotated.
|
* Fully type annotated.
|
||||||
* 100% test coverage.
|
* 100% test coverage.
|
||||||
@ -119,6 +119,7 @@ As well as these optional installs:
|
|||||||
* `rich` - Rich terminal support. *(Optional, with `httpx[cli]`)*
|
* `rich` - Rich terminal support. *(Optional, with `httpx[cli]`)*
|
||||||
* `click` - Command line client support. *(Optional, with `httpx[cli]`)*
|
* `click` - Command line client support. *(Optional, with `httpx[cli]`)*
|
||||||
* `brotli` or `brotlicffi` - Decoding for "brotli" compressed responses. *(Optional, with `httpx[brotli]`)*
|
* `brotli` or `brotlicffi` - Decoding for "brotli" compressed responses. *(Optional, with `httpx[brotli]`)*
|
||||||
|
* `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
|
||||||
much of this work follows, as well as to `urllib3` for plenty of design
|
much of this work follows, as well as to `urllib3` for plenty of design
|
||||||
@ -138,12 +139,12 @@ Or, to include the optional HTTP/2 support, use:
|
|||||||
$ pip install httpx[http2]
|
$ pip install httpx[http2]
|
||||||
```
|
```
|
||||||
|
|
||||||
To include the optional brotli decoder support, use:
|
To include the optional brotli and zstandard decoders support, use:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
$ pip install httpx[brotli]
|
$ pip install httpx[brotli,zstd]
|
||||||
```
|
```
|
||||||
|
|
||||||
HTTPX requires Python 3.8+
|
HTTPX requires Python 3.9+
|
||||||
|
|
||||||
[sync-support]: https://github.com/encode/httpx/issues/572
|
[sync-support]: https://github.com/encode/httpx/issues/572
|
||||||
|
|||||||
@ -20,25 +20,23 @@ 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 [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: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_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 - connect_tcp.complete return_value=<httpcore._backends.sync.SyncStream object at 0x101f1e8e0>
|
||||||
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.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.complete return_value=<httpcore.backends.sync.SyncStream object at 0x1068fd270>
|
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.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.started request=<Request [b'GET']>
|
||||||
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_headers.complete
|
||||||
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.started request=<Request [b'GET']>
|
||||||
DEBUG [2023-03-16 14:36:20] httpcore - http11.send_request_headers.complete
|
DEBUG [2024-09-28 17:27:41] httpcore.http11 - send_request_body.complete
|
||||||
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.started request=<Request [b'GET']>
|
||||||
DEBUG [2023-03-16 14:36:20] httpcore - http11.send_request_body.complete
|
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.receive_response_headers.started request=<Request [b'GET']>
|
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: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.started request=<Request [b'GET']>
|
||||||
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 - receive_response_body.complete
|
||||||
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.started
|
||||||
DEBUG [2023-03-16 14:36:21] httpcore - http11.receive_response_body.complete
|
DEBUG [2024-09-28 17:27:41] httpcore.http11 - response_closed.complete
|
||||||
DEBUG [2023-03-16 14:36:21] httpcore - http11.response_closed.started
|
DEBUG [2024-09-28 17:27:41] httpcore.connection - close.started
|
||||||
DEBUG [2023-03-16 14:36:21] httpcore - http11.response_closed.complete
|
DEBUG [2024-09-28 17:27:41] httpcore.connection - close.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.
|
||||||
|
|||||||
54
docs/overrides/partials/nav.html
Normal file
54
docs/overrides/partials/nav.html
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
{% import "partials/nav-item.html" as item with context %}
|
||||||
|
|
||||||
|
<!-- Determine class according to configuration -->
|
||||||
|
{% set class = "md-nav md-nav--primary" %}
|
||||||
|
{% if "navigation.tabs" in features %}
|
||||||
|
{% set class = class ~ " md-nav--lifted" %}
|
||||||
|
{% endif %}
|
||||||
|
{% if "toc.integrate" in features %}
|
||||||
|
{% set class = class ~ " md-nav--integrated" %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Main navigation -->
|
||||||
|
<nav
|
||||||
|
class="{{ class }}"
|
||||||
|
aria-label="{{ lang.t('nav.title') }}"
|
||||||
|
data-md-level="0"
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- Site title -->
|
||||||
|
<label class="md-nav__title" for="__drawer">
|
||||||
|
<a
|
||||||
|
href="{{ config.extra.homepage | d(nav.homepage.url, true) | url }}"
|
||||||
|
title="{{ config.site_name | e }}"
|
||||||
|
class="md-nav__button md-logo"
|
||||||
|
aria-label="{{ config.site_name }}"
|
||||||
|
data-md-component="logo"
|
||||||
|
>
|
||||||
|
{% include "partials/logo.html" %}
|
||||||
|
</a>
|
||||||
|
{{ config.site_name }}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Repository information -->
|
||||||
|
{% if config.repo_url %}
|
||||||
|
<div class="md-nav__source">
|
||||||
|
{% include "partials/source.html" %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Navigation list -->
|
||||||
|
<ul class="md-nav__list" data-md-scrollfix>
|
||||||
|
{% for nav_item in nav %}
|
||||||
|
{% set path = "__nav_" ~ loop.index %}
|
||||||
|
{{ item.render(nav_item, path, 1) }}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<ul class="md-nav__list" data-md-scrollfix style="padding-top: 15px; padding-left: 10px">
|
||||||
|
<div>
|
||||||
|
<a href="https://speakeasy.com"><img src="/img/speakeasy.png" width=150px style=></img></a>
|
||||||
|
</div>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
@ -100,7 +100,8 @@ b'<!doctype html>\n<html>\n<head>\n<title>Example Domain</title>...'
|
|||||||
|
|
||||||
Any `gzip` and `deflate` HTTP response encodings will automatically
|
Any `gzip` and `deflate` HTTP response encodings will automatically
|
||||||
be decoded for you. If `brotlipy` is installed, then the `brotli` response
|
be decoded for you. If `brotlipy` is installed, then the `brotli` response
|
||||||
encoding will also be supported.
|
encoding will be supported. If `zstandard` is installed, then `zstd`
|
||||||
|
response encodings will also be supported.
|
||||||
|
|
||||||
For example, to create an image from binary data returned by a request, you can use the following code:
|
For example, to create an image from binary data returned by a request, you can use the following code:
|
||||||
|
|
||||||
@ -173,8 +174,9 @@ 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
|
||||||
>>> files = {'upload-file': open('report.xls', 'rb')}
|
>>> with open('report.xls', 'rb') as report_file:
|
||||||
>>> r = httpx.post("https://httpbin.org/post", files=files)
|
... files = {'upload-file': report_file}
|
||||||
|
... r = httpx.post("https://httpbin.org/post", files=files)
|
||||||
>>> print(r.text)
|
>>> print(r.text)
|
||||||
{
|
{
|
||||||
...
|
...
|
||||||
@ -189,8 +191,9 @@ 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
|
||||||
>>> files = {'upload-file': ('report.xls', open('report.xls', 'rb'), 'application/vnd.ms-excel')}
|
>>> with open('report.xls', 'rb') as report_file:
|
||||||
>>> r = httpx.post("https://httpbin.org/post", files=files)
|
... files = {'upload-file': ('report.xls', report_file, 'application/vnd.ms-excel')}
|
||||||
|
... r = httpx.post("https://httpbin.org/post", files=files)
|
||||||
>>> print(r.text)
|
>>> print(r.text)
|
||||||
{
|
{
|
||||||
...
|
...
|
||||||
@ -205,8 +208,9 @@ 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!'}
|
||||||
>>> files = {'file': open('report.xls', 'rb')}
|
>>> with open('report.xls', 'rb') as report_file:
|
||||||
>>> r = httpx.post("https://httpbin.org/post", data=data, files=files)
|
... files = {'file': report_file}
|
||||||
|
... r = httpx.post("https://httpbin.org/post", data=data, files=files)
|
||||||
>>> print(r.text)
|
>>> print(r.text)
|
||||||
{
|
{
|
||||||
...
|
...
|
||||||
@ -362,7 +366,8 @@ Or stream the text, on a line-by-line basis...
|
|||||||
|
|
||||||
HTTPX will use universal line endings, normalising all cases to `\n`.
|
HTTPX will use universal line endings, normalising all cases to `\n`.
|
||||||
|
|
||||||
In some cases you might want to access the raw bytes on the response without applying any HTTP content decoding. In this case any content encoding that the web server has applied such as `gzip`, `deflate`, or `brotli` will not be automatically decoded.
|
In some cases you might want to access the raw bytes on the response without applying any HTTP content decoding. In this case any content encoding that the web server has applied such as `gzip`, `deflate`, `brotli`, or `zstd` will
|
||||||
|
not be automatically decoded.
|
||||||
|
|
||||||
```pycon
|
```pycon
|
||||||
>>> with httpx.stream("GET", "https://www.example.com") as r:
|
>>> with httpx.stream("GET", "https://www.example.com") as r:
|
||||||
|
|||||||
@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||
## Plugins
|
<!-- NOTE: Entries are alphabetised. -->
|
||||||
|
|
||||||
<!-- NOTE: this list is in alphabetical order. -->
|
## Plugins
|
||||||
|
|
||||||
### Hishel
|
### Hishel
|
||||||
|
|
||||||
@ -12,47 +12,11 @@ As HTTPX usage grows, there is an expanding community of developers building too
|
|||||||
|
|
||||||
An elegant HTTP Cache implementation for HTTPX and HTTP Core.
|
An elegant HTTP Cache implementation for HTTPX and HTTP Core.
|
||||||
|
|
||||||
### Authlib
|
|
||||||
|
|
||||||
[GitHub](https://github.com/lepture/authlib) - [Documentation](https://docs.authlib.org/en/latest/)
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
[GitHub](https://github.com/brettcannon/gidgethub) - [Documentation](https://gidgethub.readthedocs.io/en/latest/index.html)
|
|
||||||
|
|
||||||
An asynchronous GitHub API library. Includes [HTTPX support](https://gidgethub.readthedocs.io/en/latest/httpx.html).
|
|
||||||
|
|
||||||
### HTTPX-Auth
|
### HTTPX-Auth
|
||||||
|
|
||||||
[GitHub](https://github.com/Colin-b/httpx_auth) - [Documentation](https://colin-b.github.io/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 [authentication parameter](advanced/authentication.md#customizing-authentication).
|
Provides authentication classes to be used with HTTPX's [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
|
|
||||||
|
|
||||||
[GitHub](https://github.com/kevin1024/vcrpy) - [Documentation](https://vcrpy.readthedocs.io/)
|
|
||||||
|
|
||||||
A utility for record and repeat an http request.
|
|
||||||
|
|
||||||
### httpx-caching
|
### httpx-caching
|
||||||
|
|
||||||
@ -60,22 +24,82 @@ A utility for record and repeat an http request.
|
|||||||
|
|
||||||
This package adds caching functionality to HTTPX
|
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
|
### httpx-sse
|
||||||
|
|
||||||
[GitHub](https://github.com/florimondmanca/httpx-sse)
|
[GitHub](https://github.com/florimondmanca/httpx-sse)
|
||||||
|
|
||||||
Allows consuming Server-Sent Events (SSE) with HTTPX.
|
Allows consuming Server-Sent Events (SSE) with HTTPX.
|
||||||
|
|
||||||
### robox
|
### httpx-retries
|
||||||
|
|
||||||
[Github](https://github.com/danclaudiupop/robox)
|
[GitHub](https://github.com/will-ockmore/httpx-retries) - [Documentation](https://will-ockmore.github.io/httpx-retries/)
|
||||||
|
|
||||||
A library for scraping the web built on top of HTTPX.
|
A retry layer for HTTPX.
|
||||||
|
|
||||||
|
### httpx-ws
|
||||||
|
|
||||||
|
[GitHub](https://github.com/frankie567/httpx-ws) - [Documentation](https://frankie567.github.io/httpx-ws/)
|
||||||
|
|
||||||
|
WebSocket support for HTTPX.
|
||||||
|
|
||||||
|
### pytest-HTTPX
|
||||||
|
|
||||||
|
[GitHub](https://github.com/Colin-b/pytest_httpx) - [Documentation](https://colin-b.github.io/pytest_httpx/)
|
||||||
|
|
||||||
|
Provides a [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 HTTPX.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
[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).
|
||||||
|
|
||||||
|
### Gidgethub
|
||||||
|
|
||||||
|
[GitHub](https://github.com/brettcannon/gidgethub) - [Documentation](https://gidgethub.readthedocs.io/en/latest/index.html)
|
||||||
|
|
||||||
|
An asynchronous GitHub API library. Includes [HTTPX support](https://gidgethub.readthedocs.io/en/latest/httpx.html).
|
||||||
|
|
||||||
|
### httpdbg
|
||||||
|
|
||||||
|
[GitHub](https://github.com/cle-b/httpdbg) - [Documentation](https://httpdbg.readthedocs.io/)
|
||||||
|
|
||||||
|
A tool for python developers to easily debug the HTTP(S) client requests in a python program.
|
||||||
|
|
||||||
|
### VCR.py
|
||||||
|
|
||||||
|
[GitHub](https://github.com/kevin1024/vcrpy) - [Documentation](https://vcrpy.readthedocs.io/)
|
||||||
|
|
||||||
|
Record and repeat requests.
|
||||||
|
|
||||||
## 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)
|
||||||
|
|||||||
@ -1,48 +1,15 @@
|
|||||||
from .__version__ import __description__, __title__, __version__
|
from .__version__ import __description__, __title__, __version__
|
||||||
from ._api import delete, get, head, options, patch, post, put, request, stream
|
from ._api import *
|
||||||
from ._auth import Auth, BasicAuth, DigestAuth, NetRCAuth
|
from ._auth import *
|
||||||
from ._client import USE_CLIENT_DEFAULT, AsyncClient, Client
|
from ._client import *
|
||||||
from ._config import Limits, Proxy, Timeout, create_ssl_context
|
from ._config import *
|
||||||
from ._content import ByteStream
|
from ._content import *
|
||||||
from ._exceptions import (
|
from ._exceptions import *
|
||||||
CloseError,
|
from ._models import *
|
||||||
ConnectError,
|
from ._status_codes import *
|
||||||
ConnectTimeout,
|
from ._transports import *
|
||||||
CookieConflict,
|
from ._types import *
|
||||||
DecodingError,
|
from ._urls import *
|
||||||
HTTPError,
|
|
||||||
HTTPStatusError,
|
|
||||||
InvalidURL,
|
|
||||||
LocalProtocolError,
|
|
||||||
NetworkError,
|
|
||||||
PoolTimeout,
|
|
||||||
ProtocolError,
|
|
||||||
ProxyError,
|
|
||||||
ReadError,
|
|
||||||
ReadTimeout,
|
|
||||||
RemoteProtocolError,
|
|
||||||
RequestError,
|
|
||||||
RequestNotRead,
|
|
||||||
ResponseNotRead,
|
|
||||||
StreamClosed,
|
|
||||||
StreamConsumed,
|
|
||||||
StreamError,
|
|
||||||
TimeoutException,
|
|
||||||
TooManyRedirects,
|
|
||||||
TransportError,
|
|
||||||
UnsupportedProtocol,
|
|
||||||
WriteError,
|
|
||||||
WriteTimeout,
|
|
||||||
)
|
|
||||||
from ._models import Cookies, Headers, Request, Response
|
|
||||||
from ._status_codes import codes
|
|
||||||
from ._transports.asgi import ASGITransport
|
|
||||||
from ._transports.base import AsyncBaseTransport, BaseTransport
|
|
||||||
from ._transports.default import AsyncHTTPTransport, HTTPTransport
|
|
||||||
from ._transports.mock import MockTransport
|
|
||||||
from ._transports.wsgi import WSGITransport
|
|
||||||
from ._types import AsyncByteStream, SyncByteStream
|
|
||||||
from ._urls import URL, QueryParams
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from ._main import main
|
from ._main import main
|
||||||
@ -83,6 +50,7 @@ __all__ = [
|
|||||||
"DecodingError",
|
"DecodingError",
|
||||||
"delete",
|
"delete",
|
||||||
"DigestAuth",
|
"DigestAuth",
|
||||||
|
"FunctionAuth",
|
||||||
"get",
|
"get",
|
||||||
"head",
|
"head",
|
||||||
"Headers",
|
"Headers",
|
||||||
|
|||||||
@ -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.27.0"
|
__version__ = "0.28.1"
|
||||||
|
|||||||
105
httpx/_api.py
105
httpx/_api.py
@ -8,24 +8,37 @@ 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,
|
||||||
URLTypes,
|
|
||||||
VerifyTypes,
|
|
||||||
)
|
)
|
||||||
|
from ._urls import URL
|
||||||
|
|
||||||
|
if typing.TYPE_CHECKING:
|
||||||
|
import ssl # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"delete",
|
||||||
|
"get",
|
||||||
|
"head",
|
||||||
|
"options",
|
||||||
|
"patch",
|
||||||
|
"post",
|
||||||
|
"put",
|
||||||
|
"request",
|
||||||
|
"stream",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def request(
|
def request(
|
||||||
method: str,
|
method: str,
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
params: QueryParamTypes | None = None,
|
params: QueryParamTypes | None = None,
|
||||||
content: RequestContent | None = None,
|
content: RequestContent | None = None,
|
||||||
@ -36,11 +49,9 @@ 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: VerifyTypes = True,
|
verify: ssl.SSLContext | str | bool = True,
|
||||||
cert: CertTypes | None = None,
|
|
||||||
trust_env: bool = True,
|
trust_env: bool = True,
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""
|
"""
|
||||||
@ -68,18 +79,12 @@ 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)* SSL certificates (a.k.a CA bundle) used to
|
* **verify** - *(optional)* Either `True` to use an SSL context with the
|
||||||
verify the identity of requested hosts. Either `True` (default CA bundle),
|
default CA bundle, `False` to disable verification, or an instance of
|
||||||
a path to an SSL certificate file, an `ssl.SSLContext`, or `False`
|
`ssl.SSLContext` to use a custom context.
|
||||||
(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.
|
||||||
|
|
||||||
@ -97,8 +102,6 @@ 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,
|
||||||
@ -120,7 +123,7 @@ def request(
|
|||||||
@contextmanager
|
@contextmanager
|
||||||
def stream(
|
def stream(
|
||||||
method: str,
|
method: str,
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
params: QueryParamTypes | None = None,
|
params: QueryParamTypes | None = None,
|
||||||
content: RequestContent | None = None,
|
content: RequestContent | None = None,
|
||||||
@ -131,11 +134,9 @@ 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: VerifyTypes = True,
|
verify: ssl.SSLContext | str | bool = True,
|
||||||
cert: CertTypes | None = None,
|
|
||||||
trust_env: bool = True,
|
trust_env: bool = True,
|
||||||
) -> typing.Iterator[Response]:
|
) -> typing.Iterator[Response]:
|
||||||
"""
|
"""
|
||||||
@ -151,8 +152,6 @@ 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,
|
||||||
@ -173,17 +172,15 @@ def stream(
|
|||||||
|
|
||||||
|
|
||||||
def get(
|
def get(
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
params: QueryParamTypes | None = None,
|
params: QueryParamTypes | None = None,
|
||||||
headers: HeaderTypes | None = None,
|
headers: HeaderTypes | None = None,
|
||||||
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: ssl.SSLContext | str | bool = True,
|
||||||
verify: VerifyTypes = True,
|
|
||||||
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
|
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
|
||||||
trust_env: bool = True,
|
trust_env: bool = True,
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@ -203,9 +200,7 @@ 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,
|
||||||
@ -213,17 +208,15 @@ def get(
|
|||||||
|
|
||||||
|
|
||||||
def options(
|
def options(
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
params: QueryParamTypes | None = None,
|
params: QueryParamTypes | None = None,
|
||||||
headers: HeaderTypes | None = None,
|
headers: HeaderTypes | None = None,
|
||||||
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: ssl.SSLContext | str | bool = True,
|
||||||
verify: VerifyTypes = True,
|
|
||||||
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
|
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
|
||||||
trust_env: bool = True,
|
trust_env: bool = True,
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@ -243,9 +236,7 @@ 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,
|
||||||
@ -253,17 +244,15 @@ def options(
|
|||||||
|
|
||||||
|
|
||||||
def head(
|
def head(
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
params: QueryParamTypes | None = None,
|
params: QueryParamTypes | None = None,
|
||||||
headers: HeaderTypes | None = None,
|
headers: HeaderTypes | None = None,
|
||||||
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: ssl.SSLContext | str | bool = True,
|
||||||
verify: VerifyTypes = True,
|
|
||||||
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
|
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
|
||||||
trust_env: bool = True,
|
trust_env: bool = True,
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@ -283,9 +272,7 @@ 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,
|
||||||
@ -293,7 +280,7 @@ def head(
|
|||||||
|
|
||||||
|
|
||||||
def post(
|
def post(
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
content: RequestContent | None = None,
|
content: RequestContent | None = None,
|
||||||
data: RequestData | None = None,
|
data: RequestData | None = None,
|
||||||
@ -304,10 +291,8 @@ 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,
|
||||||
cert: CertTypes | None = None,
|
verify: ssl.SSLContext | str | bool = True,
|
||||||
verify: VerifyTypes = True,
|
|
||||||
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
|
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
|
||||||
trust_env: bool = True,
|
trust_env: bool = True,
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@ -328,9 +313,7 @@ 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,
|
||||||
@ -338,7 +321,7 @@ def post(
|
|||||||
|
|
||||||
|
|
||||||
def put(
|
def put(
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
content: RequestContent | None = None,
|
content: RequestContent | None = None,
|
||||||
data: RequestData | None = None,
|
data: RequestData | None = None,
|
||||||
@ -349,10 +332,8 @@ 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,
|
||||||
cert: CertTypes | None = None,
|
verify: ssl.SSLContext | str | bool = True,
|
||||||
verify: VerifyTypes = True,
|
|
||||||
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
|
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
|
||||||
trust_env: bool = True,
|
trust_env: bool = True,
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@ -373,9 +354,7 @@ 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,
|
||||||
@ -383,7 +362,7 @@ def put(
|
|||||||
|
|
||||||
|
|
||||||
def patch(
|
def patch(
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
content: RequestContent | None = None,
|
content: RequestContent | None = None,
|
||||||
data: RequestData | None = None,
|
data: RequestData | None = None,
|
||||||
@ -394,10 +373,8 @@ 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,
|
||||||
cert: CertTypes | None = None,
|
verify: ssl.SSLContext | str | bool = True,
|
||||||
verify: VerifyTypes = True,
|
|
||||||
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
|
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
|
||||||
trust_env: bool = True,
|
trust_env: bool = True,
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@ -418,9 +395,7 @@ 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,
|
||||||
@ -428,18 +403,16 @@ def patch(
|
|||||||
|
|
||||||
|
|
||||||
def delete(
|
def delete(
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
params: QueryParamTypes | None = None,
|
params: QueryParamTypes | None = None,
|
||||||
headers: HeaderTypes | None = None,
|
headers: HeaderTypes | None = None,
|
||||||
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:
|
||||||
"""
|
"""
|
||||||
@ -458,9 +431,7 @@ 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,
|
||||||
|
|||||||
@ -16,6 +16,9 @@ if typing.TYPE_CHECKING: # pragma: no cover
|
|||||||
from hashlib import _Hash
|
from hashlib import _Hash
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["Auth", "BasicAuth", "DigestAuth", "FunctionAuth", "NetRCAuth"]
|
||||||
|
|
||||||
|
|
||||||
class Auth:
|
class Auth:
|
||||||
"""
|
"""
|
||||||
Base class for all authentication schemes.
|
Base class for all authentication schemes.
|
||||||
|
|||||||
297
httpx/_client.py
297
httpx/_client.py
@ -3,6 +3,7 @@ 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
|
||||||
@ -27,17 +28,14 @@ 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,
|
||||||
@ -46,17 +44,14 @@ from ._types import (
|
|||||||
RequestFiles,
|
RequestFiles,
|
||||||
SyncByteStream,
|
SyncByteStream,
|
||||||
TimeoutTypes,
|
TimeoutTypes,
|
||||||
URLTypes,
|
|
||||||
VerifyTypes,
|
|
||||||
)
|
)
|
||||||
from ._urls import URL, QueryParams
|
from ._urls import URL, QueryParams
|
||||||
from ._utils import (
|
from ._utils import URLPattern, get_environment_proxies
|
||||||
Timer,
|
|
||||||
URLPattern,
|
if typing.TYPE_CHECKING:
|
||||||
get_environment_proxies,
|
import ssl # pragma: no cover
|
||||||
is_https_redirect,
|
|
||||||
same_origin,
|
__all__ = ["USE_CLIENT_DEFAULT", "AsyncClient", "Client"]
|
||||||
)
|
|
||||||
|
|
||||||
# The type annotation for @classmethod and context managers here follows PEP 484
|
# The type annotation for @classmethod and context managers here follows PEP 484
|
||||||
# https://www.python.org/dev/peps/pep-0484/#annotating-instance-and-class-methods
|
# https://www.python.org/dev/peps/pep-0484/#annotating-instance-and-class-methods
|
||||||
@ -64,6 +59,38 @@ 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
|
||||||
@ -116,19 +143,19 @@ class BoundSyncStream(SyncByteStream):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, stream: SyncByteStream, response: Response, timer: Timer
|
self, stream: SyncByteStream, response: Response, start: float
|
||||||
) -> None:
|
) -> None:
|
||||||
self._stream = stream
|
self._stream = stream
|
||||||
self._response = response
|
self._response = response
|
||||||
self._timer = timer
|
self._start = start
|
||||||
|
|
||||||
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:
|
||||||
seconds = self._timer.sync_elapsed()
|
elapsed = time.perf_counter() - self._start
|
||||||
self._response.elapsed = datetime.timedelta(seconds=seconds)
|
self._response.elapsed = datetime.timedelta(seconds=elapsed)
|
||||||
self._stream.close()
|
self._stream.close()
|
||||||
|
|
||||||
|
|
||||||
@ -139,19 +166,19 @@ class BoundAsyncStream(AsyncByteStream):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, stream: AsyncByteStream, response: Response, timer: Timer
|
self, stream: AsyncByteStream, response: Response, start: float
|
||||||
) -> None:
|
) -> None:
|
||||||
self._stream = stream
|
self._stream = stream
|
||||||
self._response = response
|
self._response = response
|
||||||
self._timer = timer
|
self._start = start
|
||||||
|
|
||||||
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:
|
||||||
seconds = await self._timer.async_elapsed()
|
elapsed = time.perf_counter() - self._start
|
||||||
self._response.elapsed = datetime.timedelta(seconds=seconds)
|
self._response.elapsed = datetime.timedelta(seconds=elapsed)
|
||||||
await self._stream.aclose()
|
await self._stream.aclose()
|
||||||
|
|
||||||
|
|
||||||
@ -170,7 +197,7 @@ class BaseClient:
|
|||||||
follow_redirects: bool = False,
|
follow_redirects: bool = False,
|
||||||
max_redirects: int = DEFAULT_MAX_REDIRECTS,
|
max_redirects: int = DEFAULT_MAX_REDIRECTS,
|
||||||
event_hooks: None | (typing.Mapping[str, list[EventHook]]) = None,
|
event_hooks: None | (typing.Mapping[str, list[EventHook]]) = None,
|
||||||
base_url: URLTypes = "",
|
base_url: URL | str = "",
|
||||||
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:
|
||||||
@ -210,23 +237,17 @@ 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, proxies: ProxiesTypes | None, allow_env_proxies: bool
|
self, proxy: ProxyTypes | None, allow_env_proxies: bool
|
||||||
) -> dict[str, Proxy | None]:
|
) -> dict[str, Proxy | None]:
|
||||||
if proxies is None:
|
if proxy 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=proxies) if isinstance(proxies, (str, URL)) else proxies
|
proxy = Proxy(url=proxy) if isinstance(proxy, (str, URL)) else proxy
|
||||||
return {"all://": proxy}
|
return {"all://": proxy}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -271,7 +292,7 @@ class BaseClient:
|
|||||||
return self._base_url
|
return self._base_url
|
||||||
|
|
||||||
@base_url.setter
|
@base_url.setter
|
||||||
def base_url(self, url: URLTypes) -> None:
|
def base_url(self, url: URL | str) -> None:
|
||||||
self._base_url = self._enforce_trailing_slash(URL(url))
|
self._base_url = self._enforce_trailing_slash(URL(url))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -319,7 +340,7 @@ class BaseClient:
|
|||||||
def build_request(
|
def build_request(
|
||||||
self,
|
self,
|
||||||
method: str,
|
method: str,
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
content: RequestContent | None = None,
|
content: RequestContent | None = None,
|
||||||
data: RequestData | None = None,
|
data: RequestData | None = None,
|
||||||
@ -340,7 +361,7 @@ class BaseClient:
|
|||||||
|
|
||||||
See also: [Request instances][0]
|
See also: [Request instances][0]
|
||||||
|
|
||||||
[0]: /advanced/#request-instances
|
[0]: /advanced/clients/#request-instances
|
||||||
"""
|
"""
|
||||||
url = self._merge_url(url)
|
url = self._merge_url(url)
|
||||||
headers = self._merge_headers(headers)
|
headers = self._merge_headers(headers)
|
||||||
@ -367,7 +388,7 @@ class BaseClient:
|
|||||||
extensions=extensions,
|
extensions=extensions,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _merge_url(self, url: URLTypes) -> URL:
|
def _merge_url(self, url: URL | str) -> URL:
|
||||||
"""
|
"""
|
||||||
Merge a URL argument together with any 'base_url' on the client,
|
Merge a URL argument together with any 'base_url' on the client,
|
||||||
to create the URL used for the outgoing request.
|
to create the URL used for the outgoing request.
|
||||||
@ -528,8 +549,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)
|
||||||
@ -560,6 +581,15 @@ class BaseClient:
|
|||||||
|
|
||||||
return request.stream
|
return request.stream
|
||||||
|
|
||||||
|
def _set_timeout(self, request: Request) -> None:
|
||||||
|
if "timeout" not in request.extensions:
|
||||||
|
timeout = (
|
||||||
|
self.timeout
|
||||||
|
if isinstance(self.timeout, UseClientDefault)
|
||||||
|
else Timeout(self.timeout)
|
||||||
|
)
|
||||||
|
request.extensions = dict(**request.extensions, timeout=timeout.as_dict())
|
||||||
|
|
||||||
|
|
||||||
class Client(BaseClient):
|
class Client(BaseClient):
|
||||||
"""
|
"""
|
||||||
@ -584,19 +614,12 @@ 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)* SSL certificates (a.k.a CA bundle) used to
|
* **verify** - *(optional)* Either `True` to use an SSL context with the
|
||||||
verify the identity of requested hosts. Either `True` (default CA bundle),
|
default CA bundle, `False` to disable verification, or an instance of
|
||||||
a path to an SSL certificate file, an `ssl.SSLContext`, or `False`
|
`ssl.SSLContext` to use a custom context.
|
||||||
(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.
|
||||||
@ -606,8 +629,6 @@ 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
|
||||||
@ -622,22 +643,20 @@ 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: VerifyTypes = True,
|
verify: ssl.SSLContext | str | bool = 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,
|
||||||
limits: Limits = DEFAULT_LIMITS,
|
limits: Limits = DEFAULT_LIMITS,
|
||||||
max_redirects: int = DEFAULT_MAX_REDIRECTS,
|
max_redirects: int = DEFAULT_MAX_REDIRECTS,
|
||||||
event_hooks: None | (typing.Mapping[str, list[EventHook]]) = None,
|
event_hooks: None | (typing.Mapping[str, list[EventHook]]) = None,
|
||||||
base_url: URLTypes = "",
|
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__(
|
||||||
@ -663,34 +682,17 @@ 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
|
||||||
|
|
||||||
if proxies:
|
allow_env_proxies = trust_env and transport is None
|
||||||
message = (
|
proxy_map = self._get_proxy_map(proxy, allow_env_proxies)
|
||||||
"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
|
||||||
@ -699,10 +701,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()
|
||||||
}
|
}
|
||||||
@ -715,47 +717,43 @@ class Client(BaseClient):
|
|||||||
|
|
||||||
def _init_transport(
|
def _init_transport(
|
||||||
self,
|
self,
|
||||||
verify: VerifyTypes = True,
|
verify: ssl.SSLContext | str | bool = 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: VerifyTypes = True,
|
verify: ssl.SSLContext | str | bool = 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -773,7 +771,7 @@ class Client(BaseClient):
|
|||||||
def request(
|
def request(
|
||||||
self,
|
self,
|
||||||
method: str,
|
method: str,
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
content: RequestContent | None = None,
|
content: RequestContent | None = None,
|
||||||
data: RequestData | None = None,
|
data: RequestData | None = None,
|
||||||
@ -801,7 +799,7 @@ class Client(BaseClient):
|
|||||||
[Merging of configuration][0] for how the various parameters
|
[Merging of configuration][0] for how the various parameters
|
||||||
are merged with client-level configuration.
|
are merged with client-level configuration.
|
||||||
|
|
||||||
[0]: /advanced/#merging-of-configuration
|
[0]: /advanced/clients/#merging-of-configuration
|
||||||
"""
|
"""
|
||||||
if cookies is not None:
|
if cookies is not None:
|
||||||
message = (
|
message = (
|
||||||
@ -809,7 +807,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)
|
warnings.warn(message, DeprecationWarning, stacklevel=2)
|
||||||
|
|
||||||
request = self.build_request(
|
request = self.build_request(
|
||||||
method=method,
|
method=method,
|
||||||
@ -830,7 +828,7 @@ class Client(BaseClient):
|
|||||||
def stream(
|
def stream(
|
||||||
self,
|
self,
|
||||||
method: str,
|
method: str,
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
content: RequestContent | None = None,
|
content: RequestContent | None = None,
|
||||||
data: RequestData | None = None,
|
data: RequestData | None = None,
|
||||||
@ -897,7 +895,7 @@ class Client(BaseClient):
|
|||||||
|
|
||||||
See also: [Request instances][0]
|
See also: [Request instances][0]
|
||||||
|
|
||||||
[0]: /advanced/#request-instances
|
[0]: /advanced/clients/#request-instances
|
||||||
"""
|
"""
|
||||||
if self._state == ClientState.CLOSED:
|
if self._state == ClientState.CLOSED:
|
||||||
raise RuntimeError("Cannot send a request, as the client has been closed.")
|
raise RuntimeError("Cannot send a request, as the client has been closed.")
|
||||||
@ -909,6 +907,8 @@ class Client(BaseClient):
|
|||||||
else follow_redirects
|
else follow_redirects
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._set_timeout(request)
|
||||||
|
|
||||||
auth = self._build_request_auth(request, auth)
|
auth = self._build_request_auth(request, auth)
|
||||||
|
|
||||||
response = self._send_handling_auth(
|
response = self._send_handling_auth(
|
||||||
@ -1003,8 +1003,7 @@ 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)
|
||||||
timer = Timer()
|
start = time.perf_counter()
|
||||||
timer.sync_start()
|
|
||||||
|
|
||||||
if not isinstance(request.stream, SyncByteStream):
|
if not isinstance(request.stream, SyncByteStream):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@ -1018,7 +1017,7 @@ class Client(BaseClient):
|
|||||||
|
|
||||||
response.request = request
|
response.request = request
|
||||||
response.stream = BoundSyncStream(
|
response.stream = BoundSyncStream(
|
||||||
response.stream, response=response, timer=timer
|
response.stream, response=response, start=start
|
||||||
)
|
)
|
||||||
self.cookies.extract_cookies(response)
|
self.cookies.extract_cookies(response)
|
||||||
response.default_encoding = self._default_encoding
|
response.default_encoding = self._default_encoding
|
||||||
@ -1036,12 +1035,12 @@ class Client(BaseClient):
|
|||||||
|
|
||||||
def get(
|
def get(
|
||||||
self,
|
self,
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
params: QueryParamTypes | None = None,
|
params: QueryParamTypes | None = None,
|
||||||
headers: HeaderTypes | None = None,
|
headers: HeaderTypes | None = None,
|
||||||
cookies: CookieTypes | None = None,
|
cookies: CookieTypes | None = None,
|
||||||
auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
|
auth: AuthTypes | UseClientDefault | None = USE_CLIENT_DEFAULT,
|
||||||
follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
|
follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
|
||||||
timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
|
timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
|
||||||
extensions: RequestExtensions | None = None,
|
extensions: RequestExtensions | None = None,
|
||||||
@ -1065,7 +1064,7 @@ class Client(BaseClient):
|
|||||||
|
|
||||||
def options(
|
def options(
|
||||||
self,
|
self,
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
params: QueryParamTypes | None = None,
|
params: QueryParamTypes | None = None,
|
||||||
headers: HeaderTypes | None = None,
|
headers: HeaderTypes | None = None,
|
||||||
@ -1094,7 +1093,7 @@ class Client(BaseClient):
|
|||||||
|
|
||||||
def head(
|
def head(
|
||||||
self,
|
self,
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
params: QueryParamTypes | None = None,
|
params: QueryParamTypes | None = None,
|
||||||
headers: HeaderTypes | None = None,
|
headers: HeaderTypes | None = None,
|
||||||
@ -1123,7 +1122,7 @@ class Client(BaseClient):
|
|||||||
|
|
||||||
def post(
|
def post(
|
||||||
self,
|
self,
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
content: RequestContent | None = None,
|
content: RequestContent | None = None,
|
||||||
data: RequestData | None = None,
|
data: RequestData | None = None,
|
||||||
@ -1160,7 +1159,7 @@ class Client(BaseClient):
|
|||||||
|
|
||||||
def put(
|
def put(
|
||||||
self,
|
self,
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
content: RequestContent | None = None,
|
content: RequestContent | None = None,
|
||||||
data: RequestData | None = None,
|
data: RequestData | None = None,
|
||||||
@ -1197,7 +1196,7 @@ class Client(BaseClient):
|
|||||||
|
|
||||||
def patch(
|
def patch(
|
||||||
self,
|
self,
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
content: RequestContent | None = None,
|
content: RequestContent | None = None,
|
||||||
data: RequestData | None = None,
|
data: RequestData | None = None,
|
||||||
@ -1234,7 +1233,7 @@ class Client(BaseClient):
|
|||||||
|
|
||||||
def delete(
|
def delete(
|
||||||
self,
|
self,
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
params: QueryParamTypes | None = None,
|
params: QueryParamTypes | None = None,
|
||||||
headers: HeaderTypes | None = None,
|
headers: HeaderTypes | None = None,
|
||||||
@ -1329,19 +1328,12 @@ 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)* SSL certificates (a.k.a CA bundle) used to
|
* **verify** - *(optional)* Either `True` to use an SSL context with the
|
||||||
verify the identity of requested hosts. Either `True` (default CA bundle),
|
default CA bundle, `False` to disable verification, or an instance of
|
||||||
a path to an SSL certificate file, an `ssl.SSLContext`, or `False`
|
`ssl.SSLContext` to use a custom context.
|
||||||
(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.
|
||||||
@ -1351,8 +1343,6 @@ 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
|
||||||
@ -1367,22 +1357,19 @@ 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: VerifyTypes = True,
|
verify: ssl.SSLContext | str | bool = 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,
|
||||||
limits: Limits = DEFAULT_LIMITS,
|
limits: Limits = DEFAULT_LIMITS,
|
||||||
max_redirects: int = DEFAULT_MAX_REDIRECTS,
|
max_redirects: int = DEFAULT_MAX_REDIRECTS,
|
||||||
event_hooks: None
|
event_hooks: None | (typing.Mapping[str, list[EventHook]]) = None,
|
||||||
| (typing.Mapping[str, list[typing.Callable[..., typing.Any]]]) = None,
|
base_url: URL | str = "",
|
||||||
base_url: URLTypes = "",
|
|
||||||
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:
|
||||||
@ -1409,34 +1396,17 @@ 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
|
||||||
|
|
||||||
if proxies:
|
|
||||||
message = (
|
|
||||||
"The 'proxies' argument is now deprecated."
|
|
||||||
" Use 'proxy' or 'mounts' instead."
|
|
||||||
)
|
|
||||||
warnings.warn(message, DeprecationWarning)
|
|
||||||
if proxy:
|
|
||||||
raise RuntimeError("Use either `proxy` or 'proxies', not both.")
|
|
||||||
|
|
||||||
if app:
|
|
||||||
message = (
|
|
||||||
"The 'app' shortcut is now deprecated."
|
|
||||||
" Use the explicit style 'transport=ASGITransport(app=...)' instead."
|
|
||||||
)
|
|
||||||
warnings.warn(message, DeprecationWarning)
|
|
||||||
|
|
||||||
allow_env_proxies = trust_env and transport is None
|
allow_env_proxies = trust_env and transport is None
|
||||||
proxy_map = self._get_proxy_map(proxies or proxy, allow_env_proxies)
|
proxy_map = self._get_proxy_map(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] = {
|
||||||
@ -1446,10 +1416,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()
|
||||||
}
|
}
|
||||||
@ -1461,47 +1431,43 @@ class AsyncClient(BaseClient):
|
|||||||
|
|
||||||
def _init_transport(
|
def _init_transport(
|
||||||
self,
|
self,
|
||||||
verify: VerifyTypes = True,
|
verify: ssl.SSLContext | str | bool = 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: VerifyTypes = True,
|
verify: ssl.SSLContext | str | bool = 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1519,7 +1485,7 @@ class AsyncClient(BaseClient):
|
|||||||
async def request(
|
async def request(
|
||||||
self,
|
self,
|
||||||
method: str,
|
method: str,
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
content: RequestContent | None = None,
|
content: RequestContent | None = None,
|
||||||
data: RequestData | None = None,
|
data: RequestData | None = None,
|
||||||
@ -1547,7 +1513,7 @@ class AsyncClient(BaseClient):
|
|||||||
and [Merging of configuration][0] for how the various parameters
|
and [Merging of configuration][0] for how the various parameters
|
||||||
are merged with client-level configuration.
|
are merged with client-level configuration.
|
||||||
|
|
||||||
[0]: /advanced/#merging-of-configuration
|
[0]: /advanced/clients/#merging-of-configuration
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if cookies is not None: # pragma: no cover
|
if cookies is not None: # pragma: no cover
|
||||||
@ -1556,7 +1522,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)
|
warnings.warn(message, DeprecationWarning, stacklevel=2)
|
||||||
|
|
||||||
request = self.build_request(
|
request = self.build_request(
|
||||||
method=method,
|
method=method,
|
||||||
@ -1577,7 +1543,7 @@ class AsyncClient(BaseClient):
|
|||||||
async def stream(
|
async def stream(
|
||||||
self,
|
self,
|
||||||
method: str,
|
method: str,
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
content: RequestContent | None = None,
|
content: RequestContent | None = None,
|
||||||
data: RequestData | None = None,
|
data: RequestData | None = None,
|
||||||
@ -1586,7 +1552,7 @@ 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,
|
||||||
auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
|
auth: AuthTypes | UseClientDefault | None = USE_CLIENT_DEFAULT,
|
||||||
follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
|
follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
|
||||||
timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
|
timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
|
||||||
extensions: RequestExtensions | None = None,
|
extensions: RequestExtensions | None = None,
|
||||||
@ -1644,7 +1610,7 @@ class AsyncClient(BaseClient):
|
|||||||
|
|
||||||
See also: [Request instances][0]
|
See also: [Request instances][0]
|
||||||
|
|
||||||
[0]: /advanced/#request-instances
|
[0]: /advanced/clients/#request-instances
|
||||||
"""
|
"""
|
||||||
if self._state == ClientState.CLOSED:
|
if self._state == ClientState.CLOSED:
|
||||||
raise RuntimeError("Cannot send a request, as the client has been closed.")
|
raise RuntimeError("Cannot send a request, as the client has been closed.")
|
||||||
@ -1656,6 +1622,8 @@ class AsyncClient(BaseClient):
|
|||||||
else follow_redirects
|
else follow_redirects
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._set_timeout(request)
|
||||||
|
|
||||||
auth = self._build_request_auth(request, auth)
|
auth = self._build_request_auth(request, auth)
|
||||||
|
|
||||||
response = await self._send_handling_auth(
|
response = await self._send_handling_auth(
|
||||||
@ -1751,12 +1719,11 @@ 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)
|
||||||
timer = Timer()
|
start = time.perf_counter()
|
||||||
await timer.async_start()
|
|
||||||
|
|
||||||
if not isinstance(request.stream, AsyncByteStream):
|
if not isinstance(request.stream, AsyncByteStream):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Attempted to send an sync request with an AsyncClient instance."
|
"Attempted to send a sync request with an AsyncClient instance."
|
||||||
)
|
)
|
||||||
|
|
||||||
with request_context(request=request):
|
with request_context(request=request):
|
||||||
@ -1765,7 +1732,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, timer=timer
|
response.stream, response=response, start=start
|
||||||
)
|
)
|
||||||
self.cookies.extract_cookies(response)
|
self.cookies.extract_cookies(response)
|
||||||
response.default_encoding = self._default_encoding
|
response.default_encoding = self._default_encoding
|
||||||
@ -1783,7 +1750,7 @@ class AsyncClient(BaseClient):
|
|||||||
|
|
||||||
async def get(
|
async def get(
|
||||||
self,
|
self,
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
params: QueryParamTypes | None = None,
|
params: QueryParamTypes | None = None,
|
||||||
headers: HeaderTypes | None = None,
|
headers: HeaderTypes | None = None,
|
||||||
@ -1812,7 +1779,7 @@ class AsyncClient(BaseClient):
|
|||||||
|
|
||||||
async def options(
|
async def options(
|
||||||
self,
|
self,
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
params: QueryParamTypes | None = None,
|
params: QueryParamTypes | None = None,
|
||||||
headers: HeaderTypes | None = None,
|
headers: HeaderTypes | None = None,
|
||||||
@ -1841,7 +1808,7 @@ class AsyncClient(BaseClient):
|
|||||||
|
|
||||||
async def head(
|
async def head(
|
||||||
self,
|
self,
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
params: QueryParamTypes | None = None,
|
params: QueryParamTypes | None = None,
|
||||||
headers: HeaderTypes | None = None,
|
headers: HeaderTypes | None = None,
|
||||||
@ -1870,7 +1837,7 @@ class AsyncClient(BaseClient):
|
|||||||
|
|
||||||
async def post(
|
async def post(
|
||||||
self,
|
self,
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
content: RequestContent | None = None,
|
content: RequestContent | None = None,
|
||||||
data: RequestData | None = None,
|
data: RequestData | None = None,
|
||||||
@ -1907,7 +1874,7 @@ class AsyncClient(BaseClient):
|
|||||||
|
|
||||||
async def put(
|
async def put(
|
||||||
self,
|
self,
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
content: RequestContent | None = None,
|
content: RequestContent | None = None,
|
||||||
data: RequestData | None = None,
|
data: RequestData | None = None,
|
||||||
@ -1944,7 +1911,7 @@ class AsyncClient(BaseClient):
|
|||||||
|
|
||||||
async def patch(
|
async def patch(
|
||||||
self,
|
self,
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
content: RequestContent | None = None,
|
content: RequestContent | None = None,
|
||||||
data: RequestData | None = None,
|
data: RequestData | None = None,
|
||||||
@ -1981,7 +1948,7 @@ class AsyncClient(BaseClient):
|
|||||||
|
|
||||||
async def delete(
|
async def delete(
|
||||||
self,
|
self,
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
params: QueryParamTypes | None = None,
|
params: QueryParamTypes | None = None,
|
||||||
headers: HeaderTypes | None = None,
|
headers: HeaderTypes | None = None,
|
||||||
|
|||||||
@ -1,41 +0,0 @@
|
|||||||
"""
|
|
||||||
The _compat module is used for code which requires branching between different
|
|
||||||
Python environments. It is excluded from the code coverage checks.
|
|
||||||
"""
|
|
||||||
import ssl
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
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"]
|
|
||||||
210
httpx/_config.py
210
httpx/_config.py
@ -1,40 +1,16 @@
|
|||||||
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, URLTypes, VerifyTypes
|
from ._types import CertTypes, HeaderTypes, TimeoutTypes
|
||||||
from ._urls import URL
|
from ._urls import URL
|
||||||
from ._utils import get_ca_bundle_from_env
|
|
||||||
|
|
||||||
DEFAULT_CIPHERS = ":".join(
|
if typing.TYPE_CHECKING:
|
||||||
[
|
import ssl # pragma: no cover
|
||||||
"ECDHE+AESGCM",
|
|
||||||
"ECDHE+CHACHA20",
|
|
||||||
"DHE+AESGCM",
|
|
||||||
"DHE+CHACHA20",
|
|
||||||
"ECDH+AESGCM",
|
|
||||||
"DH+AESGCM",
|
|
||||||
"ECDH+AES",
|
|
||||||
"DH+AES",
|
|
||||||
"RSA+AESGCM",
|
|
||||||
"RSA+AES",
|
|
||||||
"!aNULL",
|
|
||||||
"!eNULL",
|
|
||||||
"!MD5",
|
|
||||||
"!DSS",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
__all__ = ["Limits", "Proxy", "Timeout", "create_ssl_context"]
|
||||||
logger = logging.getLogger("httpx")
|
|
||||||
|
|
||||||
|
|
||||||
class UnsetType:
|
class UnsetType:
|
||||||
@ -45,150 +21,52 @@ 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:
|
||||||
return SSLConfig(
|
import ssl
|
||||||
cert=cert, verify=verify, trust_env=trust_env, http2=http2
|
import warnings
|
||||||
).ssl_context
|
|
||||||
|
|
||||||
|
import certifi
|
||||||
|
|
||||||
class SSLConfig:
|
if verify is True:
|
||||||
"""
|
if trust_env and os.environ.get("SSL_CERT_FILE"): # pragma: nocover
|
||||||
SSL Configuration.
|
ctx = ssl.create_default_context(cafile=os.environ["SSL_CERT_FILE"])
|
||||||
"""
|
elif trust_env and os.environ.get("SSL_CERT_DIR"): # pragma: nocover
|
||||||
|
ctx = ssl.create_default_context(capath=os.environ["SSL_CERT_DIR"])
|
||||||
DEFAULT_CA_BUNDLE_PATH = Path(certifi.where())
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
cert: CertTypes | None = None,
|
|
||||||
verify: VerifyTypes = True,
|
|
||||||
trust_env: bool = True,
|
|
||||||
http2: bool = False,
|
|
||||||
) -> None:
|
|
||||||
self.cert = cert
|
|
||||||
self.verify = verify
|
|
||||||
self.trust_env = trust_env
|
|
||||||
self.http2 = http2
|
|
||||||
self.ssl_context = self.load_ssl_context()
|
|
||||||
|
|
||||||
def load_ssl_context(self) -> ssl.SSLContext:
|
|
||||||
logger.debug(
|
|
||||||
"load_ssl_context verify=%r cert=%r trust_env=%r http2=%r",
|
|
||||||
self.verify,
|
|
||||||
self.cert,
|
|
||||||
self.trust_env,
|
|
||||||
self.http2,
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.verify:
|
|
||||||
return self.load_ssl_context_verify()
|
|
||||||
return self.load_ssl_context_no_verify()
|
|
||||||
|
|
||||||
def load_ssl_context_no_verify(self) -> ssl.SSLContext:
|
|
||||||
"""
|
|
||||||
Return an SSL context for unverified connections.
|
|
||||||
"""
|
|
||||||
context = self._create_default_ssl_context()
|
|
||||||
context.check_hostname = False
|
|
||||||
context.verify_mode = ssl.CERT_NONE
|
|
||||||
self._load_client_certs(context)
|
|
||||||
return context
|
|
||||||
|
|
||||||
def load_ssl_context_verify(self) -> ssl.SSLContext:
|
|
||||||
"""
|
|
||||||
Return an SSL context for verified connections.
|
|
||||||
"""
|
|
||||||
if self.trust_env and self.verify is True:
|
|
||||||
ca_bundle = get_ca_bundle_from_env()
|
|
||||||
if ca_bundle is not None:
|
|
||||||
self.verify = ca_bundle
|
|
||||||
|
|
||||||
if isinstance(self.verify, ssl.SSLContext):
|
|
||||||
# Allow passing in our own SSLContext object that's pre-configured.
|
|
||||||
context = self.verify
|
|
||||||
self._load_client_certs(context)
|
|
||||||
return context
|
|
||||||
elif isinstance(self.verify, bool):
|
|
||||||
ca_bundle_path = self.DEFAULT_CA_BUNDLE_PATH
|
|
||||||
elif Path(self.verify).exists():
|
|
||||||
ca_bundle_path = Path(self.verify)
|
|
||||||
else:
|
else:
|
||||||
raise IOError(
|
# Default case...
|
||||||
"Could not find a suitable TLS CA certificate bundle, "
|
ctx = ssl.create_default_context(cafile=certifi.where())
|
||||||
"invalid path: {}".format(self.verify)
|
elif verify is False:
|
||||||
)
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
elif isinstance(verify, str): # pragma: nocover
|
||||||
|
message = (
|
||||||
|
"`verify=<str>` is deprecated. "
|
||||||
|
"Use `verify=ssl.create_default_context(cafile=...)` "
|
||||||
|
"or `verify=ssl.create_default_context(capath=...)` instead."
|
||||||
|
)
|
||||||
|
warnings.warn(message, DeprecationWarning)
|
||||||
|
if os.path.isdir(verify):
|
||||||
|
return ssl.create_default_context(capath=verify)
|
||||||
|
return ssl.create_default_context(cafile=verify)
|
||||||
|
else:
|
||||||
|
ctx = verify
|
||||||
|
|
||||||
context = self._create_default_ssl_context()
|
if cert: # pragma: nocover
|
||||||
context.verify_mode = ssl.CERT_REQUIRED
|
message = (
|
||||||
context.check_hostname = True
|
"`cert=...` is deprecated. Use `verify=<ssl_context>` instead,"
|
||||||
|
"with `.load_cert_chain()` to configure the certificate chain."
|
||||||
|
)
|
||||||
|
warnings.warn(message, DeprecationWarning)
|
||||||
|
if isinstance(cert, str):
|
||||||
|
ctx.load_cert_chain(cert)
|
||||||
|
else:
|
||||||
|
ctx.load_cert_chain(*cert)
|
||||||
|
|
||||||
# Signal to server support for PHA in TLS 1.3. Raises an
|
return ctx
|
||||||
# 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:
|
||||||
@ -323,7 +201,7 @@ class Limits:
|
|||||||
class Proxy:
|
class Proxy:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
ssl_context: ssl.SSLContext | None = None,
|
ssl_context: ssl.SSLContext | None = None,
|
||||||
auth: tuple[str, str] | None = None,
|
auth: tuple[str, str] | None = None,
|
||||||
@ -332,7 +210,7 @@ class Proxy:
|
|||||||
url = URL(url)
|
url = URL(url)
|
||||||
headers = Headers(headers)
|
headers = Headers(headers)
|
||||||
|
|
||||||
if url.scheme not in ("http", "https", "socks5"):
|
if url.scheme not in ("http", "https", "socks5", "socks5h"):
|
||||||
raise ValueError(f"Unknown scheme for proxy URL {url!r}")
|
raise ValueError(f"Unknown scheme for proxy URL {url!r}")
|
||||||
|
|
||||||
if url.username or url.password:
|
if url.username or url.password:
|
||||||
|
|||||||
@ -25,6 +25,8 @@ from ._types import (
|
|||||||
)
|
)
|
||||||
from ._utils import peek_filelike_length, primitive_value_to_str
|
from ._utils import peek_filelike_length, primitive_value_to_str
|
||||||
|
|
||||||
|
__all__ = ["ByteStream"]
|
||||||
|
|
||||||
|
|
||||||
class ByteStream(AsyncByteStream, SyncByteStream):
|
class ByteStream(AsyncByteStream, SyncByteStream):
|
||||||
def __init__(self, stream: bytes) -> None:
|
def __init__(self, stream: bytes) -> None:
|
||||||
@ -172,7 +174,9 @@ 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(json).encode("utf-8")
|
body = json_dumps(
|
||||||
|
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}
|
||||||
@ -199,7 +203,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)
|
warnings.warn(message, DeprecationWarning, stacklevel=2)
|
||||||
return encode_content(data)
|
return encode_content(data)
|
||||||
|
|
||||||
if content is not None:
|
if content is not None:
|
||||||
|
|||||||
@ -3,6 +3,7 @@ Handlers for Content-Encoding.
|
|||||||
|
|
||||||
See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding
|
See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import codecs
|
import codecs
|
||||||
@ -10,9 +11,27 @@ import io
|
|||||||
import typing
|
import typing
|
||||||
import zlib
|
import zlib
|
||||||
|
|
||||||
from ._compat import brotli
|
|
||||||
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:
|
||||||
@ -139,6 +158,48 @@ class BrotliDecoder(ContentDecoder):
|
|||||||
raise DecodingError(str(exc)) from exc
|
raise DecodingError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
class ZStandardDecoder(ContentDecoder):
|
||||||
|
"""
|
||||||
|
Handle 'zstd' RFC 8878 decoding.
|
||||||
|
|
||||||
|
Requires `pip install zstandard`.
|
||||||
|
Can be installed as a dependency of httpx using `pip install httpx[zstd]`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# inspired by the ZstdDecoder implementation in urllib3
|
||||||
|
def __init__(self) -> None:
|
||||||
|
if zstandard is None: # pragma: no cover
|
||||||
|
raise ImportError(
|
||||||
|
"Using 'ZStandardDecoder', ..."
|
||||||
|
"Make sure to install httpx using `pip install httpx[zstd]`."
|
||||||
|
) from None
|
||||||
|
|
||||||
|
self.decompressor = zstandard.ZstdDecompressor().decompressobj()
|
||||||
|
self.seen_data = False
|
||||||
|
|
||||||
|
def decode(self, data: bytes) -> bytes:
|
||||||
|
assert zstandard is not None
|
||||||
|
self.seen_data = True
|
||||||
|
output = io.BytesIO()
|
||||||
|
try:
|
||||||
|
output.write(self.decompressor.decompress(data))
|
||||||
|
while self.decompressor.eof and self.decompressor.unused_data:
|
||||||
|
unused_data = self.decompressor.unused_data
|
||||||
|
self.decompressor = zstandard.ZstdDecompressor().decompressobj()
|
||||||
|
output.write(self.decompressor.decompress(unused_data))
|
||||||
|
except zstandard.ZstdError as exc:
|
||||||
|
raise DecodingError(str(exc)) from exc
|
||||||
|
return output.getvalue()
|
||||||
|
|
||||||
|
def flush(self) -> bytes:
|
||||||
|
if not self.seen_data:
|
||||||
|
return b""
|
||||||
|
ret = self.decompressor.flush() # note: this is a no-op
|
||||||
|
if not self.decompressor.eof:
|
||||||
|
raise DecodingError("Zstandard data is incomplete") # pragma: no cover
|
||||||
|
return bytes(ret)
|
||||||
|
|
||||||
|
|
||||||
class MultiDecoder(ContentDecoder):
|
class MultiDecoder(ContentDecoder):
|
||||||
"""
|
"""
|
||||||
Handle the case where multiple encodings have been applied.
|
Handle the case where multiple encodings have been applied.
|
||||||
@ -322,8 +383,11 @@ SUPPORTED_DECODERS = {
|
|||||||
"gzip": GZipDecoder,
|
"gzip": GZipDecoder,
|
||||||
"deflate": DeflateDecoder,
|
"deflate": DeflateDecoder,
|
||||||
"br": BrotliDecoder,
|
"br": BrotliDecoder,
|
||||||
|
"zstd": ZStandardDecoder,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
|
SUPPORTED_DECODERS.pop("zstd") # pragma: no cover
|
||||||
|
|||||||
@ -30,6 +30,7 @@ Our exception hierarchy:
|
|||||||
x ResponseNotRead
|
x ResponseNotRead
|
||||||
x RequestNotRead
|
x RequestNotRead
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
@ -38,6 +39,37 @@ import typing
|
|||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
from ._models import Request, Response # pragma: no cover
|
from ._models import Request, Response # pragma: no cover
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CloseError",
|
||||||
|
"ConnectError",
|
||||||
|
"ConnectTimeout",
|
||||||
|
"CookieConflict",
|
||||||
|
"DecodingError",
|
||||||
|
"HTTPError",
|
||||||
|
"HTTPStatusError",
|
||||||
|
"InvalidURL",
|
||||||
|
"LocalProtocolError",
|
||||||
|
"NetworkError",
|
||||||
|
"PoolTimeout",
|
||||||
|
"ProtocolError",
|
||||||
|
"ProxyError",
|
||||||
|
"ReadError",
|
||||||
|
"ReadTimeout",
|
||||||
|
"RemoteProtocolError",
|
||||||
|
"RequestError",
|
||||||
|
"RequestNotRead",
|
||||||
|
"ResponseNotRead",
|
||||||
|
"StreamClosed",
|
||||||
|
"StreamConsumed",
|
||||||
|
"StreamError",
|
||||||
|
"TimeoutException",
|
||||||
|
"TooManyRedirects",
|
||||||
|
"TransportError",
|
||||||
|
"UnsupportedProtocol",
|
||||||
|
"WriteError",
|
||||||
|
"WriteTimeout",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class HTTPError(Exception):
|
class HTTPError(Exception):
|
||||||
"""
|
"""
|
||||||
@ -299,9 +331,7 @@ class StreamClosed(StreamError):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
message = (
|
message = "Attempted to read or stream content, but the stream has been closed."
|
||||||
"Attempted to read or stream content, but the stream has " "been closed."
|
|
||||||
)
|
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,6 @@ 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
|
||||||
@ -20,6 +19,9 @@ 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()
|
||||||
@ -474,12 +476,7 @@ 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(
|
with Client(proxy=proxy, timeout=timeout, http2=http2, verify=verify) as client:
|
||||||
proxy=proxy,
|
|
||||||
timeout=timeout,
|
|
||||||
verify=verify,
|
|
||||||
http2=http2,
|
|
||||||
) as client:
|
|
||||||
with client.stream(
|
with client.stream(
|
||||||
method,
|
method,
|
||||||
url,
|
url,
|
||||||
|
|||||||
164
httpx/_models.py
164
httpx/_models.py
@ -1,8 +1,10 @@
|
|||||||
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
|
||||||
@ -44,14 +46,94 @@ from ._types import (
|
|||||||
SyncByteStream,
|
SyncByteStream,
|
||||||
)
|
)
|
||||||
from ._urls import URL
|
from ._urls import URL
|
||||||
from ._utils import (
|
from ._utils import to_bytes_or_str, to_str
|
||||||
is_known_encoding,
|
|
||||||
normalize_header_key,
|
__all__ = ["Cookies", "Headers", "Request", "Response"]
|
||||||
normalize_header_value,
|
|
||||||
obfuscate_sensitive_headers,
|
SENSITIVE_HEADERS = {"authorization", "proxy-authorization"}
|
||||||
parse_content_type_charset,
|
|
||||||
parse_header_links,
|
|
||||||
)
|
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]):
|
||||||
@ -64,28 +146,20 @@ class Headers(typing.MutableMapping[str, str]):
|
|||||||
headers: HeaderTypes | None = None,
|
headers: HeaderTypes | None = None,
|
||||||
encoding: str | None = None,
|
encoding: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if headers is None:
|
self._list = [] # type: typing.List[typing.Tuple[bytes, bytes, bytes]]
|
||||||
self._list = [] # type: typing.List[typing.Tuple[bytes, bytes, bytes]]
|
|
||||||
elif isinstance(headers, Headers):
|
if isinstance(headers, Headers):
|
||||||
self._list = list(headers._list)
|
self._list = list(headers._list)
|
||||||
elif isinstance(headers, Mapping):
|
elif isinstance(headers, Mapping):
|
||||||
self._list = [
|
for k, v in headers.items():
|
||||||
(
|
bytes_key = _normalize_header_key(k, encoding)
|
||||||
normalize_header_key(k, lower=False, encoding=encoding),
|
bytes_value = _normalize_header_value(v, encoding)
|
||||||
normalize_header_key(k, lower=True, encoding=encoding),
|
self._list.append((bytes_key, bytes_key.lower(), bytes_value))
|
||||||
normalize_header_value(v, encoding),
|
elif headers is not None:
|
||||||
)
|
for k, v in headers:
|
||||||
for k, v in headers.items()
|
bytes_key = _normalize_header_key(k, encoding)
|
||||||
]
|
bytes_value = _normalize_header_value(v, encoding)
|
||||||
else:
|
self._list.append((bytes_key, bytes_key.lower(), bytes_value))
|
||||||
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
|
||||||
|
|
||||||
@ -296,7 +370,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)
|
||||||
@ -308,7 +382,7 @@ class Headers(typing.MutableMapping[str, str]):
|
|||||||
class Request:
|
class Request:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
method: str | bytes,
|
method: str,
|
||||||
url: URL | str,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
params: QueryParamTypes | None = None,
|
params: QueryParamTypes | None = None,
|
||||||
@ -321,16 +395,10 @@ class Request:
|
|||||||
stream: SyncByteStream | AsyncByteStream | None = None,
|
stream: SyncByteStream | AsyncByteStream | None = None,
|
||||||
extensions: RequestExtensions | None = None,
|
extensions: RequestExtensions | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.method = (
|
self.method = method.upper()
|
||||||
method.decode("ascii").upper()
|
self.url = URL(url) if params is None else URL(url, params=params)
|
||||||
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 extensions
|
self.extensions = {} if extensions is None else dict(extensions)
|
||||||
|
|
||||||
if cookies:
|
if cookies:
|
||||||
Cookies(cookies).set_cookie_header(self)
|
Cookies(cookies).set_cookie_header(self)
|
||||||
@ -469,7 +537,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: ResponseExtensions = {} if extensions is None else extensions
|
self.extensions = {} if extensions is None else dict(extensions)
|
||||||
self.history = [] if history is None else list(history)
|
self.history = [] if history is None else list(history)
|
||||||
|
|
||||||
self.is_closed = False
|
self.is_closed = False
|
||||||
@ -595,7 +663,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"):
|
||||||
@ -626,7 +694,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:
|
||||||
"""
|
"""
|
||||||
@ -781,7 +849,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
|
||||||
@ -816,7 +884,7 @@ class Response:
|
|||||||
def iter_bytes(self, chunk_size: int | None = None) -> typing.Iterator[bytes]:
|
def iter_bytes(self, chunk_size: int | None = None) -> typing.Iterator[bytes]:
|
||||||
"""
|
"""
|
||||||
A byte-iterator over the decoded response content.
|
A byte-iterator over the decoded response content.
|
||||||
This allows us to handle gzip, deflate, and brotli encoded responses.
|
This allows us to handle gzip, deflate, brotli, and zstd encoded responses.
|
||||||
"""
|
"""
|
||||||
if hasattr(self, "_content"):
|
if hasattr(self, "_content"):
|
||||||
chunk_size = len(self._content) if chunk_size is None else chunk_size
|
chunk_size = len(self._content) if chunk_size is None else chunk_size
|
||||||
@ -896,7 +964,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 an sync close on an async stream.")
|
raise RuntimeError("Attempted to call a sync close on an async stream.")
|
||||||
|
|
||||||
if not self.is_closed:
|
if not self.is_closed:
|
||||||
self.is_closed = True
|
self.is_closed = True
|
||||||
@ -916,7 +984,7 @@ class Response:
|
|||||||
) -> typing.AsyncIterator[bytes]:
|
) -> typing.AsyncIterator[bytes]:
|
||||||
"""
|
"""
|
||||||
A byte-iterator over the decoded response content.
|
A byte-iterator over the decoded response content.
|
||||||
This allows us to handle gzip, deflate, and brotli encoded responses.
|
This allows us to handle gzip, deflate, brotli, and zstd encoded responses.
|
||||||
"""
|
"""
|
||||||
if hasattr(self, "_content"):
|
if hasattr(self, "_content"):
|
||||||
chunk_size = len(self._content) if chunk_size is None else chunk_size
|
chunk_size = len(self._content) if chunk_size is None else chunk_size
|
||||||
@ -977,7 +1045,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 an sync stream.")
|
raise RuntimeError("Attempted to call an async iterator on a sync stream.")
|
||||||
|
|
||||||
self.is_stream_consumed = True
|
self.is_stream_consumed = True
|
||||||
self._num_bytes_downloaded = 0
|
self._num_bytes_downloaded = 0
|
||||||
@ -1000,7 +1068,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 an sync stream.")
|
raise RuntimeError("Attempted to call an async close on a sync stream.")
|
||||||
|
|
||||||
if not self.is_closed:
|
if not self.is_closed:
|
||||||
self.is_closed = True
|
self.is_closed = True
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
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
|
||||||
|
|
||||||
@ -14,13 +16,42 @@ 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,
|
||||||
@ -58,7 +89,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"]
|
||||||
)
|
)
|
||||||
@ -115,7 +146,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:
|
||||||
@ -156,10 +187,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()
|
||||||
|
|||||||
@ -2,6 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
|
|
||||||
|
__all__ = ["codes"]
|
||||||
|
|
||||||
|
|
||||||
class codes(IntEnum):
|
class codes(IntEnum):
|
||||||
"""HTTP status codes and reason phrases
|
"""HTTP status codes and reason phrases
|
||||||
|
|||||||
@ -0,0 +1,15 @@
|
|||||||
|
from .asgi import *
|
||||||
|
from .base import *
|
||||||
|
from .default import *
|
||||||
|
from .mock import *
|
||||||
|
from .wsgi import *
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ASGITransport",
|
||||||
|
"AsyncBaseTransport",
|
||||||
|
"BaseTransport",
|
||||||
|
"AsyncHTTPTransport",
|
||||||
|
"HTTPTransport",
|
||||||
|
"MockTransport",
|
||||||
|
"WSGITransport",
|
||||||
|
]
|
||||||
@ -2,8 +2,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
import sniffio
|
|
||||||
|
|
||||||
from .._models import Request, Response
|
from .._models import Request, Response
|
||||||
from .._types import AsyncByteStream
|
from .._types import AsyncByteStream
|
||||||
from .base import AsyncBaseTransport
|
from .base import AsyncBaseTransport
|
||||||
@ -16,25 +14,42 @@ if typing.TYPE_CHECKING: # pragma: no cover
|
|||||||
Event = typing.Union[asyncio.Event, trio.Event]
|
Event = typing.Union[asyncio.Event, trio.Event]
|
||||||
|
|
||||||
|
|
||||||
_Message = typing.Dict[str, typing.Any]
|
_Message = typing.MutableMapping[str, typing.Any]
|
||||||
_Receive = typing.Callable[[], typing.Awaitable[_Message]]
|
_Receive = typing.Callable[[], typing.Awaitable[_Message]]
|
||||||
_Send = typing.Callable[
|
_Send = typing.Callable[
|
||||||
[typing.Dict[str, typing.Any]], typing.Coroutine[None, None, None]
|
[typing.MutableMapping[str, typing.Any]], typing.Awaitable[None]
|
||||||
]
|
]
|
||||||
_ASGIApp = typing.Callable[
|
_ASGIApp = typing.Callable[
|
||||||
[typing.Dict[str, typing.Any], _Receive, _Send], typing.Coroutine[None, None, None]
|
[typing.MutableMapping[str, typing.Any], _Receive, _Send], typing.Awaitable[None]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
__all__ = ["ASGITransport"]
|
||||||
|
|
||||||
|
|
||||||
|
def is_running_trio() -> bool:
|
||||||
|
try:
|
||||||
|
# sniffio is a dependency of trio.
|
||||||
|
|
||||||
|
# See https://github.com/python-trio/trio/issues/2802
|
||||||
|
import sniffio
|
||||||
|
|
||||||
|
if sniffio.current_async_library() == "trio":
|
||||||
|
return True
|
||||||
|
except ImportError: # pragma: nocover
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def create_event() -> Event:
|
def create_event() -> Event:
|
||||||
if sniffio.current_async_library() == "trio":
|
if is_running_trio():
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
return trio.Event()
|
return trio.Event()
|
||||||
else:
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
return asyncio.Event()
|
import asyncio
|
||||||
|
|
||||||
|
return asyncio.Event()
|
||||||
|
|
||||||
|
|
||||||
class ASGIResponseStream(AsyncByteStream):
|
class ASGIResponseStream(AsyncByteStream):
|
||||||
@ -48,17 +63,8 @@ class ASGIResponseStream(AsyncByteStream):
|
|||||||
class ASGITransport(AsyncBaseTransport):
|
class ASGITransport(AsyncBaseTransport):
|
||||||
"""
|
"""
|
||||||
A custom AsyncTransport that handles sending requests directly to an ASGI app.
|
A custom AsyncTransport that handles sending requests directly to an ASGI app.
|
||||||
The simplest way to use this functionality is to use the `app` argument.
|
|
||||||
|
|
||||||
```
|
```python
|
||||||
client = httpx.AsyncClient(app=app)
|
|
||||||
```
|
|
||||||
|
|
||||||
Alternatively, you can setup the transport instance explicitly.
|
|
||||||
This allows you to include any additional configuration arguments specific
|
|
||||||
to the ASGITransport class:
|
|
||||||
|
|
||||||
```
|
|
||||||
transport = httpx.ASGITransport(
|
transport = httpx.ASGITransport(
|
||||||
app=app,
|
app=app,
|
||||||
root_path="/submount",
|
root_path="/submount",
|
||||||
@ -139,7 +145,7 @@ class ASGITransport(AsyncBaseTransport):
|
|||||||
return {"type": "http.request", "body": b"", "more_body": False}
|
return {"type": "http.request", "body": b"", "more_body": False}
|
||||||
return {"type": "http.request", "body": body, "more_body": True}
|
return {"type": "http.request", "body": body, "more_body": True}
|
||||||
|
|
||||||
async def send(message: dict[str, typing.Any]) -> None:
|
async def send(message: typing.MutableMapping[str, typing.Any]) -> None:
|
||||||
nonlocal status_code, response_headers, response_started
|
nonlocal status_code, response_headers, response_started
|
||||||
|
|
||||||
if message["type"] == "http.response.start":
|
if message["type"] == "http.response.start":
|
||||||
|
|||||||
@ -8,6 +8,8 @@ from .._models import Request, Response
|
|||||||
T = typing.TypeVar("T", bound="BaseTransport")
|
T = typing.TypeVar("T", bound="BaseTransport")
|
||||||
A = typing.TypeVar("A", bound="AsyncBaseTransport")
|
A = typing.TypeVar("A", bound="AsyncBaseTransport")
|
||||||
|
|
||||||
|
__all__ = ["AsyncBaseTransport", "BaseTransport"]
|
||||||
|
|
||||||
|
|
||||||
class BaseTransport:
|
class BaseTransport:
|
||||||
def __enter__(self: T) -> T:
|
def __enter__(self: T) -> T:
|
||||||
|
|||||||
@ -23,13 +23,17 @@ client = httpx.Client(transport=transport)
|
|||||||
transport = httpx.HTTPTransport(uds="socket.uds")
|
transport = httpx.HTTPTransport(uds="socket.uds")
|
||||||
client = httpx.Client(transport=transport)
|
client = httpx.Client(transport=transport)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import typing
|
import typing
|
||||||
from types import TracebackType
|
from types import TracebackType
|
||||||
|
|
||||||
import httpcore
|
if typing.TYPE_CHECKING:
|
||||||
|
import ssl # pragma: no cover
|
||||||
|
|
||||||
|
import httpx # pragma: no cover
|
||||||
|
|
||||||
from .._config import DEFAULT_LIMITS, Limits, Proxy, create_ssl_context
|
from .._config import DEFAULT_LIMITS, Limits, Proxy, create_ssl_context
|
||||||
from .._exceptions import (
|
from .._exceptions import (
|
||||||
@ -49,7 +53,7 @@ from .._exceptions import (
|
|||||||
WriteTimeout,
|
WriteTimeout,
|
||||||
)
|
)
|
||||||
from .._models import Request, Response
|
from .._models import Request, Response
|
||||||
from .._types import AsyncByteStream, CertTypes, ProxyTypes, SyncByteStream, VerifyTypes
|
from .._types import AsyncByteStream, CertTypes, ProxyTypes, SyncByteStream
|
||||||
from .._urls import URL
|
from .._urls import URL
|
||||||
from .base import AsyncBaseTransport, BaseTransport
|
from .base import AsyncBaseTransport, BaseTransport
|
||||||
|
|
||||||
@ -62,9 +66,37 @@ SOCKET_OPTION = typing.Union[
|
|||||||
typing.Tuple[int, int, None, int],
|
typing.Tuple[int, int, None, int],
|
||||||
]
|
]
|
||||||
|
|
||||||
|
__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:
|
||||||
@ -86,24 +118,6 @@ 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
|
||||||
@ -121,20 +135,22 @@ class ResponseStream(SyncByteStream):
|
|||||||
class HTTPTransport(BaseTransport):
|
class HTTPTransport(BaseTransport):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
verify: VerifyTypes = True,
|
verify: ssl.SSLContext | str | bool = 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:
|
||||||
ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env)
|
import httpcore
|
||||||
|
|
||||||
proxy = Proxy(url=proxy) if isinstance(proxy, (str, URL)) else proxy
|
proxy = Proxy(url=proxy) if isinstance(proxy, (str, URL)) else proxy
|
||||||
|
ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env)
|
||||||
|
|
||||||
if proxy is None:
|
if proxy is None:
|
||||||
self._pool = httpcore.ConnectionPool(
|
self._pool = httpcore.ConnectionPool(
|
||||||
@ -168,7 +184,7 @@ class HTTPTransport(BaseTransport):
|
|||||||
http2=http2,
|
http2=http2,
|
||||||
socket_options=socket_options,
|
socket_options=socket_options,
|
||||||
)
|
)
|
||||||
elif proxy.url.scheme == "socks5":
|
elif proxy.url.scheme in ("socks5", "socks5h"):
|
||||||
try:
|
try:
|
||||||
import socksio # noqa
|
import socksio # noqa
|
||||||
except ImportError: # pragma: no cover
|
except ImportError: # pragma: no cover
|
||||||
@ -194,7 +210,7 @@ class HTTPTransport(BaseTransport):
|
|||||||
)
|
)
|
||||||
else: # pragma: no cover
|
else: # pragma: no cover
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Proxy protocol must be either 'http', 'https', or 'socks5',"
|
"Proxy protocol must be either 'http', 'https', 'socks5', or 'socks5h',"
|
||||||
f" but got {proxy.url.scheme!r}."
|
f" but got {proxy.url.scheme!r}."
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -216,6 +232,7 @@ 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,
|
||||||
@ -262,20 +279,22 @@ class AsyncResponseStream(AsyncByteStream):
|
|||||||
class AsyncHTTPTransport(AsyncBaseTransport):
|
class AsyncHTTPTransport(AsyncBaseTransport):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
verify: VerifyTypes = True,
|
verify: ssl.SSLContext | str | bool = 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:
|
||||||
ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env)
|
import httpcore
|
||||||
|
|
||||||
proxy = Proxy(url=proxy) if isinstance(proxy, (str, URL)) else proxy
|
proxy = Proxy(url=proxy) if isinstance(proxy, (str, URL)) else proxy
|
||||||
|
ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env)
|
||||||
|
|
||||||
if proxy is None:
|
if proxy is None:
|
||||||
self._pool = httpcore.AsyncConnectionPool(
|
self._pool = httpcore.AsyncConnectionPool(
|
||||||
@ -300,6 +319,7 @@ class AsyncHTTPTransport(AsyncBaseTransport):
|
|||||||
),
|
),
|
||||||
proxy_auth=proxy.raw_auth,
|
proxy_auth=proxy.raw_auth,
|
||||||
proxy_headers=proxy.headers.raw,
|
proxy_headers=proxy.headers.raw,
|
||||||
|
proxy_ssl_context=proxy.ssl_context,
|
||||||
ssl_context=ssl_context,
|
ssl_context=ssl_context,
|
||||||
max_connections=limits.max_connections,
|
max_connections=limits.max_connections,
|
||||||
max_keepalive_connections=limits.max_keepalive_connections,
|
max_keepalive_connections=limits.max_keepalive_connections,
|
||||||
@ -308,7 +328,7 @@ class AsyncHTTPTransport(AsyncBaseTransport):
|
|||||||
http2=http2,
|
http2=http2,
|
||||||
socket_options=socket_options,
|
socket_options=socket_options,
|
||||||
)
|
)
|
||||||
elif proxy.url.scheme == "socks5":
|
elif proxy.url.scheme in ("socks5", "socks5h"):
|
||||||
try:
|
try:
|
||||||
import socksio # noqa
|
import socksio # noqa
|
||||||
except ImportError: # pragma: no cover
|
except ImportError: # pragma: no cover
|
||||||
@ -334,8 +354,8 @@ class AsyncHTTPTransport(AsyncBaseTransport):
|
|||||||
)
|
)
|
||||||
else: # pragma: no cover
|
else: # pragma: no cover
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Proxy protocol must be either 'http', 'https', or 'socks5',"
|
"Proxy protocol must be either 'http', 'https', 'socks5', or 'socks5h',"
|
||||||
" but got {proxy.url.scheme!r}."
|
f" 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.
|
||||||
@ -356,6 +376,7 @@ 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,
|
||||||
|
|||||||
@ -9,6 +9,9 @@ SyncHandler = typing.Callable[[Request], Response]
|
|||||||
AsyncHandler = typing.Callable[[Request], typing.Coroutine[None, None, Response]]
|
AsyncHandler = typing.Callable[[Request], typing.Coroutine[None, None, Response]]
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["MockTransport"]
|
||||||
|
|
||||||
|
|
||||||
class MockTransport(AsyncBaseTransport, BaseTransport):
|
class MockTransport(AsyncBaseTransport, BaseTransport):
|
||||||
def __init__(self, handler: SyncHandler | AsyncHandler) -> None:
|
def __init__(self, handler: SyncHandler | AsyncHandler) -> None:
|
||||||
self.handler = handler
|
self.handler = handler
|
||||||
|
|||||||
@ -16,6 +16,9 @@ if typing.TYPE_CHECKING:
|
|||||||
_T = typing.TypeVar("_T")
|
_T = typing.TypeVar("_T")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["WSGITransport"]
|
||||||
|
|
||||||
|
|
||||||
def _skip_leading_empty_chunks(body: typing.Iterable[_T]) -> typing.Iterable[_T]:
|
def _skip_leading_empty_chunks(body: typing.Iterable[_T]) -> typing.Iterable[_T]:
|
||||||
body = iter(body)
|
body = iter(body)
|
||||||
for chunk in body:
|
for chunk in body:
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
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,
|
||||||
@ -16,8 +15,6 @@ from typing import (
|
|||||||
Iterator,
|
Iterator,
|
||||||
List,
|
List,
|
||||||
Mapping,
|
Mapping,
|
||||||
MutableMapping,
|
|
||||||
NamedTuple,
|
|
||||||
Optional,
|
Optional,
|
||||||
Sequence,
|
Sequence,
|
||||||
Tuple,
|
Tuple,
|
||||||
@ -33,16 +30,6 @@ 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[
|
||||||
@ -64,22 +51,13 @@ 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[URLTypes, "Proxy"]
|
ProxyTypes = Union["URL", str, "Proxy"]
|
||||||
ProxiesTypes = Union[ProxyTypes, Dict[URLTypes, Union[None, ProxyTypes]]]
|
CertTypes = Union[str, Tuple[str, str], Tuple[str, str, str]]
|
||||||
|
|
||||||
AuthTypes = Union[
|
AuthTypes = Union[
|
||||||
Tuple[Union[str, bytes], Union[str, bytes]],
|
Tuple[Union[str, bytes], Union[str, bytes]],
|
||||||
@ -89,7 +67,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 = MutableMapping[str, Any]
|
ResponseExtensions = Mapping[str, Any]
|
||||||
|
|
||||||
RequestData = Mapping[str, Any]
|
RequestData = Mapping[str, Any]
|
||||||
|
|
||||||
@ -106,7 +84,9 @@ FileTypes = Union[
|
|||||||
]
|
]
|
||||||
RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]]
|
RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]]
|
||||||
|
|
||||||
RequestExtensions = MutableMapping[str, Any]
|
RequestExtensions = Mapping[str, Any]
|
||||||
|
|
||||||
|
__all__ = ["AsyncByteStream", "SyncByteStream"]
|
||||||
|
|
||||||
|
|
||||||
class SyncByteStream:
|
class SyncByteStream:
|
||||||
|
|||||||
@ -15,6 +15,7 @@ Previously we relied on the excellent `rfc3986` package to handle URL parsing an
|
|||||||
validation, but this module provides a simpler alternative, with less indirection
|
validation, but this module provides a simpler alternative, with less indirection
|
||||||
required.
|
required.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
@ -35,6 +36,67 @@ 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)
|
||||||
@ -159,7 +221,12 @@ def urlparse(url: str = "", **kwargs: str | None) -> ParseResult:
|
|||||||
# If a URL includes any ASCII control characters including \t, \r, \n,
|
# If a URL includes any ASCII control characters including \t, \r, \n,
|
||||||
# then treat it as invalid.
|
# then treat it as invalid.
|
||||||
if any(char.isascii() and not char.isprintable() for char in url):
|
if any(char.isascii() and not char.isprintable() for char in url):
|
||||||
raise InvalidURL("Invalid non-printable ASCII character in URL")
|
char = next(char for char in url if char.isascii() and not char.isprintable())
|
||||||
|
idx = url.find(char)
|
||||||
|
error = (
|
||||||
|
f"Invalid non-printable ASCII character in URL, {char!r} at position {idx}."
|
||||||
|
)
|
||||||
|
raise InvalidURL(error)
|
||||||
|
|
||||||
# Some keyword arguments require special handling.
|
# Some keyword arguments require special handling.
|
||||||
# ------------------------------------------------
|
# ------------------------------------------------
|
||||||
@ -176,8 +243,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 "")
|
username = quote(kwargs.pop("username", "") or "", safe=USERNAME_SAFE)
|
||||||
password = quote(kwargs.pop("password", "") or "")
|
password = quote(kwargs.pop("password", "") or "", safe=PASSWORD_SAFE)
|
||||||
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".
|
||||||
@ -204,9 +271,15 @@ def urlparse(url: str = "", **kwargs: str | None) -> ParseResult:
|
|||||||
# If a component includes any ASCII control characters including \t, \r, \n,
|
# If a component includes any ASCII control characters including \t, \r, \n,
|
||||||
# then treat it as invalid.
|
# then treat it as invalid.
|
||||||
if any(char.isascii() and not char.isprintable() for char in value):
|
if any(char.isascii() and not char.isprintable() for char in value):
|
||||||
raise InvalidURL(
|
char = next(
|
||||||
f"Invalid non-printable ASCII character in URL component '{key}'"
|
char for char in value if char.isascii() and not char.isprintable()
|
||||||
)
|
)
|
||||||
|
idx = value.find(char)
|
||||||
|
error = (
|
||||||
|
f"Invalid non-printable ASCII character in URL {key} component, "
|
||||||
|
f"{char!r} at position {idx}."
|
||||||
|
)
|
||||||
|
raise InvalidURL(error)
|
||||||
|
|
||||||
# Ensure that keyword arguments match as a valid regex.
|
# Ensure that keyword arguments match as a valid regex.
|
||||||
if not COMPONENT_REGEX[key].fullmatch(value):
|
if not COMPONENT_REGEX[key].fullmatch(value):
|
||||||
@ -226,7 +299,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"])
|
||||||
fragment = kwargs.get("fragment", url_dict["fragment"])
|
frag = kwargs.get("fragment", url_dict["fragment"])
|
||||||
|
|
||||||
# The AUTHORITY_REGEX will always match, but may have empty components.
|
# The AUTHORITY_REGEX will always match, but may have empty components.
|
||||||
authority_match = AUTHORITY_REGEX.match(authority)
|
authority_match = AUTHORITY_REGEX.match(authority)
|
||||||
@ -243,7 +316,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=SUB_DELIMS + ":")
|
parsed_userinfo: str = quote(userinfo, safe=USERINFO_SAFE)
|
||||||
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)
|
||||||
|
|
||||||
@ -252,23 +325,12 @@ def urlparse(url: str = "", **kwargs: str | None) -> ParseResult:
|
|||||||
parsed_userinfo != "" or parsed_host != "" or parsed_port is not None
|
parsed_userinfo != "" or parsed_host != "" or parsed_port is not None
|
||||||
)
|
)
|
||||||
validate_path(path, has_scheme=has_scheme, has_authority=has_authority)
|
validate_path(path, has_scheme=has_scheme, has_authority=has_authority)
|
||||||
if has_authority:
|
if has_scheme or has_authority:
|
||||||
path = normalize_path(path)
|
path = normalize_path(path)
|
||||||
|
|
||||||
# The GEN_DELIMS set is... : / ? # [ ] @
|
parsed_path: str = quote(path, safe=PATH_SAFE)
|
||||||
# These do not need to be percent-quoted unless they serve as delimiters for the
|
parsed_query: str | None = None if query is None else quote(query, safe=QUERY_SAFE)
|
||||||
# specific component.
|
parsed_frag: str | None = None if frag is None else quote(frag, safe=FRAG_SAFE)
|
||||||
|
|
||||||
# For 'path' we need to drop ? and # from the GEN_DELIMS set.
|
|
||||||
parsed_path: str = quote(path, safe=SUB_DELIMS + ":/[]@")
|
|
||||||
# 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 + ":/?[]@")
|
|
||||||
)
|
|
||||||
# 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 + ":/?#[]@")
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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.
|
||||||
@ -279,7 +341,7 @@ def urlparse(url: str = "", **kwargs: str | None) -> ParseResult:
|
|||||||
parsed_port,
|
parsed_port,
|
||||||
parsed_path,
|
parsed_path,
|
||||||
parsed_query,
|
parsed_query,
|
||||||
parsed_fragment,
|
parsed_frag,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -320,7 +382,8 @@ def encode_host(host: str) -> str:
|
|||||||
# From https://datatracker.ietf.org/doc/html/rfc3986/#section-3.2.2
|
# From https://datatracker.ietf.org/doc/html/rfc3986/#section-3.2.2
|
||||||
#
|
#
|
||||||
# reg-name = *( unreserved / pct-encoded / sub-delims )
|
# reg-name = *( unreserved / pct-encoded / sub-delims )
|
||||||
return quote(host.lower(), safe=SUB_DELIMS)
|
WHATWG_SAFE = '"`{}%|\\'
|
||||||
|
return quote(host.lower(), safe=SUB_DELIMS + WHATWG_SAFE)
|
||||||
|
|
||||||
# IDNA hostnames
|
# IDNA hostnames
|
||||||
try:
|
try:
|
||||||
@ -368,19 +431,17 @@ def validate_path(path: str, has_scheme: bool, has_authority: bool) -> None:
|
|||||||
# must either be empty or begin with a slash ("/") character."
|
# must either be empty or begin with a slash ("/") character."
|
||||||
if path and not path.startswith("/"):
|
if path and not path.startswith("/"):
|
||||||
raise InvalidURL("For absolute URLs, path must be empty or begin with '/'")
|
raise InvalidURL("For absolute URLs, path must be empty or begin with '/'")
|
||||||
else:
|
|
||||||
|
if not has_scheme and not has_authority:
|
||||||
# If a URI does not contain an authority component, then the path cannot begin
|
# If a URI does not contain an authority component, then the path cannot begin
|
||||||
# with two slash characters ("//").
|
# with two slash characters ("//").
|
||||||
if path.startswith("//"):
|
if path.startswith("//"):
|
||||||
raise InvalidURL(
|
raise InvalidURL("Relative URLs cannot have a path starting with '//'")
|
||||||
"URLs with no authority component cannot have a path starting with '//'"
|
|
||||||
)
|
|
||||||
# In addition, a URI reference (Section 4.1) may be a relative-path reference,
|
# In addition, a URI reference (Section 4.1) may be a relative-path reference,
|
||||||
# in which case the first path segment cannot contain a colon (":") character.
|
# in which case the first path segment cannot contain a colon (":") character.
|
||||||
if path.startswith(":") and not has_scheme:
|
if path.startswith(":"):
|
||||||
raise InvalidURL(
|
raise InvalidURL("Relative URLs cannot have a path starting with ':'")
|
||||||
"URLs with no scheme component cannot have a path starting with ':'"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_path(path: str) -> str:
|
def normalize_path(path: str) -> str:
|
||||||
@ -391,8 +452,17 @@ def normalize_path(path: str) -> str:
|
|||||||
|
|
||||||
normalize_path("/path/./to/somewhere/..") == "/path/to"
|
normalize_path("/path/./to/somewhere/..") == "/path/to"
|
||||||
"""
|
"""
|
||||||
# https://datatracker.ietf.org/doc/html/rfc3986#section-5.2.4
|
# Fast return when no '.' characters in the path.
|
||||||
|
if "." not in path:
|
||||||
|
return path
|
||||||
|
|
||||||
components = path.split("/")
|
components = path.split("/")
|
||||||
|
|
||||||
|
# Fast return when no '.' or '..' components in the path.
|
||||||
|
if "." not in components and ".." not in components:
|
||||||
|
return path
|
||||||
|
|
||||||
|
# https://datatracker.ietf.org/doc/html/rfc3986#section-5.2.4
|
||||||
output: list[str] = []
|
output: list[str] = []
|
||||||
for component in components:
|
for component in components:
|
||||||
if component == ".":
|
if component == ".":
|
||||||
@ -405,48 +475,26 @@ def normalize_path(path: str) -> str:
|
|||||||
return "/".join(output)
|
return "/".join(output)
|
||||||
|
|
||||||
|
|
||||||
def percent_encode(char: str) -> str:
|
def PERCENT(string: str) -> str:
|
||||||
"""
|
return "".join([f"%{byte:02X}" for byte in string.encode("utf-8")])
|
||||||
Replace a single character with the percent-encoded representation.
|
|
||||||
|
|
||||||
Characters outside the ASCII range are represented with their a percent-encoded
|
|
||||||
representation of their UTF-8 byte sequence.
|
|
||||||
|
|
||||||
For example:
|
|
||||||
|
|
||||||
percent_encode(" ") == "%20"
|
|
||||||
"""
|
|
||||||
return "".join([f"%{byte:02x}" for byte in char.encode("utf-8")]).upper()
|
|
||||||
|
|
||||||
|
|
||||||
def is_safe(string: str, safe: str = "/") -> bool:
|
def percent_encoded(string: str, safe: str) -> str:
|
||||||
"""
|
|
||||||
Determine if a given string is already quote-safe.
|
|
||||||
"""
|
|
||||||
NON_ESCAPED_CHARS = UNRESERVED_CHARACTERS + safe + "%"
|
|
||||||
|
|
||||||
# All characters must already be non-escaping or '%'
|
|
||||||
for char in string:
|
|
||||||
if char not in NON_ESCAPED_CHARS:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def percent_encoded(string: str, safe: str = "/") -> str:
|
|
||||||
"""
|
"""
|
||||||
Use percent-encoding to quote a string.
|
Use percent-encoding to quote a string.
|
||||||
"""
|
"""
|
||||||
if is_safe(string, safe=safe):
|
NON_ESCAPED_CHARS = UNRESERVED_CHARACTERS + safe
|
||||||
|
|
||||||
|
# Fast path for strings that don't need escaping.
|
||||||
|
if not string.rstrip(NON_ESCAPED_CHARS):
|
||||||
return string
|
return string
|
||||||
|
|
||||||
NON_ESCAPED_CHARS = UNRESERVED_CHARACTERS + safe
|
|
||||||
return "".join(
|
return "".join(
|
||||||
[char if char in NON_ESCAPED_CHARS else percent_encode(char) for char in string]
|
[char if char in NON_ESCAPED_CHARS else PERCENT(char) for char in string]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@ -477,26 +525,3 @@ 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
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|||||||
@ -1,14 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
from urllib.parse import parse_qs, unquote
|
from urllib.parse import parse_qs, unquote, urlencode
|
||||||
|
|
||||||
import idna
|
import idna
|
||||||
|
|
||||||
from ._types import QueryParamTypes, RawURL, URLTypes
|
from ._types import QueryParamTypes
|
||||||
from ._urlparse import urlencode, urlparse
|
from ._urlparse import urlparse
|
||||||
from ._utils import primitive_value_to_str
|
from ._utils import primitive_value_to_str
|
||||||
|
|
||||||
|
__all__ = ["URL", "QueryParams"]
|
||||||
|
|
||||||
|
|
||||||
class URL:
|
class URL:
|
||||||
"""
|
"""
|
||||||
@ -302,22 +304,6 @@ 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:
|
||||||
"""
|
"""
|
||||||
@ -365,7 +351,7 @@ class URL:
|
|||||||
def copy_merge_params(self, params: QueryParamTypes) -> URL:
|
def copy_merge_params(self, params: QueryParamTypes) -> URL:
|
||||||
return self.copy_with(params=self.params.merge(params))
|
return self.copy_with(params=self.params.merge(params))
|
||||||
|
|
||||||
def join(self, url: URLTypes) -> URL:
|
def join(self, url: URL | str) -> URL:
|
||||||
"""
|
"""
|
||||||
Return an absolute URL, using this URL as the base.
|
Return an absolute URL, using this URL as the base.
|
||||||
|
|
||||||
@ -393,7 +379,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(
|
||||||
[
|
[
|
||||||
@ -414,6 +400,22 @@ 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]):
|
||||||
"""
|
"""
|
||||||
@ -619,13 +621,6 @@ 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:
|
||||||
|
|||||||
198
httpx/_utils.py
198
httpx/_utils.py
@ -1,58 +1,17 @@
|
|||||||
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
|
||||||
|
|
||||||
import sniffio
|
|
||||||
|
|
||||||
from ._types import PrimitiveData
|
from ._types import PrimitiveData
|
||||||
|
|
||||||
if typing.TYPE_CHECKING: # pragma: no cover
|
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
|
|
||||||
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.
|
||||||
@ -68,130 +27,6 @@ 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"""
|
||||||
|
|
||||||
@ -257,12 +92,6 @@ 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
|
||||||
@ -288,33 +117,6 @@ def peek_filelike_length(stream: typing.Any) -> int | None:
|
|||||||
return length
|
return length
|
||||||
|
|
||||||
|
|
||||||
class Timer:
|
|
||||||
async def _get_time(self) -> float:
|
|
||||||
library = sniffio.current_async_library()
|
|
||||||
if library == "trio":
|
|
||||||
import trio
|
|
||||||
|
|
||||||
return trio.current_time()
|
|
||||||
else:
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
return asyncio.get_event_loop().time()
|
|
||||||
|
|
||||||
def sync_start(self) -> None:
|
|
||||||
self.started = time.perf_counter()
|
|
||||||
|
|
||||||
async def async_start(self) -> None:
|
|
||||||
self.started = await self._get_time()
|
|
||||||
|
|
||||||
def sync_elapsed(self) -> float:
|
|
||||||
now = time.perf_counter()
|
|
||||||
return now - self.started
|
|
||||||
|
|
||||||
async def async_elapsed(self) -> float:
|
|
||||||
now = await self._get_time()
|
|
||||||
return now - self.started
|
|
||||||
|
|
||||||
|
|
||||||
class URLPattern:
|
class URLPattern:
|
||||||
"""
|
"""
|
||||||
A utility class currently used for making lookups against proxy keys...
|
A utility class currently used for making lookups against proxy keys...
|
||||||
|
|||||||
@ -4,6 +4,7 @@ site_url: https://www.python-httpx.org/
|
|||||||
|
|
||||||
theme:
|
theme:
|
||||||
name: 'material'
|
name: 'material'
|
||||||
|
custom_dir: 'docs/overrides'
|
||||||
palette:
|
palette:
|
||||||
- scheme: 'default'
|
- scheme: 'default'
|
||||||
media: '(prefers-color-scheme: light)'
|
media: '(prefers-color-scheme: light)'
|
||||||
|
|||||||
@ -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.8"
|
requires-python = ">=3.9"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Tom Christie", email = "tom@tomchristie.com" },
|
{ name = "Tom Christie", email = "tom@tomchristie.com" },
|
||||||
]
|
]
|
||||||
@ -20,11 +20,11 @@ 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 = [
|
||||||
@ -32,7 +32,6 @@ dependencies = [
|
|||||||
"httpcore==1.*",
|
"httpcore==1.*",
|
||||||
"anyio",
|
"anyio",
|
||||||
"idna",
|
"idna",
|
||||||
"sniffio",
|
|
||||||
]
|
]
|
||||||
dynamic = ["readme", "version"]
|
dynamic = ["readme", "version"]
|
||||||
|
|
||||||
@ -44,7 +43,7 @@ brotli = [
|
|||||||
cli = [
|
cli = [
|
||||||
"click==8.*",
|
"click==8.*",
|
||||||
"pygments==2.*",
|
"pygments==2.*",
|
||||||
"rich>=10,<14",
|
"rich>=10,<15",
|
||||||
]
|
]
|
||||||
http2 = [
|
http2 = [
|
||||||
"h2>=3,<5",
|
"h2>=3,<5",
|
||||||
@ -52,6 +51,9 @@ http2 = [
|
|||||||
socks = [
|
socks = [
|
||||||
"socksio==1.*",
|
"socksio==1.*",
|
||||||
]
|
]
|
||||||
|
zstd = [
|
||||||
|
"zstandard>=0.18.0",
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
httpx = "httpx:main"
|
httpx = "httpx:main"
|
||||||
@ -93,14 +95,16 @@ text = "\n---\n\n[Full changelog](https://github.com/encode/httpx/blob/master/CH
|
|||||||
pattern = 'src="(docs/img/.*?)"'
|
pattern = 'src="(docs/img/.*?)"'
|
||||||
replacement = 'src="https://raw.githubusercontent.com/encode/httpx/master/\1"'
|
replacement = 'src="https://raw.githubusercontent.com/encode/httpx/master/\1"'
|
||||||
|
|
||||||
# https://beta.ruff.rs/docs/configuration/#using-rufftoml
|
[tool.ruff.lint]
|
||||||
[tool.ruff]
|
|
||||||
select = ["E", "F", "I", "B", "PIE"]
|
select = ["E", "F", "I", "B", "PIE"]
|
||||||
ignore = ["B904", "B028"]
|
ignore = ["B904", "B028"]
|
||||||
|
|
||||||
[tool.ruff.isort]
|
[tool.ruff.lint.isort]
|
||||||
combine-as-imports = true
|
combine-as-imports = true
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"__init__.py" = ["F403", "F405"]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
strict = true
|
strict = true
|
||||||
@ -124,5 +128,5 @@ markers = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
omit = ["venv/*", "httpx/_compat.py"]
|
omit = ["venv/*"]
|
||||||
include = ["httpx/*", "tests/*"]
|
include = ["httpx/*", "tests/*"]
|
||||||
|
|||||||
@ -2,28 +2,28 @@
|
|||||||
# 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]
|
-e .[brotli,cli,http2,socks,zstd]
|
||||||
|
|
||||||
# Optional charset auto-detection
|
# Optional charset auto-detection
|
||||||
# Used in our test cases
|
# Used in our test cases
|
||||||
chardet==5.2.0
|
chardet==5.2.0
|
||||||
|
|
||||||
# Documentation
|
# Documentation
|
||||||
mkdocs==1.5.3
|
mkdocs==1.6.1
|
||||||
mkautodoc==0.2.0
|
mkautodoc==0.2.0
|
||||||
mkdocs-material==9.5.6
|
mkdocs-material==9.6.18
|
||||||
|
|
||||||
# Packaging
|
# Packaging
|
||||||
build==1.0.3
|
build==1.3.0
|
||||||
twine==4.0.2
|
twine==6.1.0
|
||||||
|
|
||||||
# Tests & Linting
|
# Tests & Linting
|
||||||
coverage[toml]==7.4.1
|
coverage[toml]==7.10.6
|
||||||
cryptography==42.0.2
|
cryptography==45.0.7
|
||||||
mypy==1.8.0
|
mypy==1.17.1
|
||||||
pytest==8.0.0
|
pytest==8.4.1
|
||||||
ruff==0.1.15
|
ruff==0.12.11
|
||||||
trio==0.24.0
|
trio==0.31.0
|
||||||
trio-typing==0.10.0
|
trio-typing==0.10.0
|
||||||
trustme==1.1.0
|
trustme==1.2.1
|
||||||
uvicorn==0.27.0.post1
|
uvicorn==0.35.0
|
||||||
|
|||||||
@ -8,5 +8,5 @@ export SOURCE_FILES="httpx tests"
|
|||||||
|
|
||||||
set -x
|
set -x
|
||||||
|
|
||||||
${PREFIX}ruff --fix $SOURCE_FILES
|
${PREFIX}ruff check --fix $SOURCE_FILES
|
||||||
${PREFIX}ruff format $SOURCE_FILES
|
${PREFIX}ruff format $SOURCE_FILES
|
||||||
|
|||||||
@ -3,6 +3,7 @@ Integration tests for authentication.
|
|||||||
|
|
||||||
Unit tests for auth classes also exist in tests/test_auth.py
|
Unit tests for auth classes also exist in tests/test_auth.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import netrc
|
import netrc
|
||||||
import os
|
import os
|
||||||
@ -269,29 +270,6 @@ 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",
|
||||||
@ -348,7 +326,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") # type: ignore
|
client.auth = ("user", "password123")
|
||||||
assert isinstance(client.auth, httpx.BasicAuth)
|
assert isinstance(client.auth, httpx.BasicAuth)
|
||||||
|
|
||||||
url = "https://example.org/"
|
url = "https://example.org/"
|
||||||
@ -742,7 +720,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:
|
||||||
@ -758,7 +736,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
|
||||||
|
|||||||
@ -357,7 +357,7 @@ def test_raw_client_header():
|
|||||||
assert response.json() == [
|
assert response.json() == [
|
||||||
["Host", "example.org"],
|
["Host", "example.org"],
|
||||||
["Accept", "*/*"],
|
["Accept", "*/*"],
|
||||||
["Accept-Encoding", "gzip, deflate, br"],
|
["Accept-Encoding", "gzip, deflate, br, zstd"],
|
||||||
["Connection", "keep-alive"],
|
["Connection", "keep-alive"],
|
||||||
["User-Agent", f"python-httpx/{httpx.__version__}"],
|
["User-Agent", f"python-httpx/{httpx.__version__}"],
|
||||||
["Example-Header", "example-value"],
|
["Example-Header", "example-value"],
|
||||||
|
|||||||
@ -36,7 +36,7 @@ def test_event_hooks():
|
|||||||
"host": "127.0.0.1:8000",
|
"host": "127.0.0.1:8000",
|
||||||
"user-agent": f"python-httpx/{httpx.__version__}",
|
"user-agent": f"python-httpx/{httpx.__version__}",
|
||||||
"accept": "*/*",
|
"accept": "*/*",
|
||||||
"accept-encoding": "gzip, deflate, br",
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
"connection": "keep-alive",
|
"connection": "keep-alive",
|
||||||
"authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
"authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
||||||
},
|
},
|
||||||
@ -87,7 +87,7 @@ async def test_async_event_hooks():
|
|||||||
"host": "127.0.0.1:8000",
|
"host": "127.0.0.1:8000",
|
||||||
"user-agent": f"python-httpx/{httpx.__version__}",
|
"user-agent": f"python-httpx/{httpx.__version__}",
|
||||||
"accept": "*/*",
|
"accept": "*/*",
|
||||||
"accept-encoding": "gzip, deflate, br",
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
"connection": "keep-alive",
|
"connection": "keep-alive",
|
||||||
"authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
"authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
||||||
},
|
},
|
||||||
@ -144,7 +144,7 @@ def test_event_hooks_with_redirect():
|
|||||||
"host": "127.0.0.1:8000",
|
"host": "127.0.0.1:8000",
|
||||||
"user-agent": f"python-httpx/{httpx.__version__}",
|
"user-agent": f"python-httpx/{httpx.__version__}",
|
||||||
"accept": "*/*",
|
"accept": "*/*",
|
||||||
"accept-encoding": "gzip, deflate, br",
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
"connection": "keep-alive",
|
"connection": "keep-alive",
|
||||||
"authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
"authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
||||||
},
|
},
|
||||||
@ -159,7 +159,7 @@ def test_event_hooks_with_redirect():
|
|||||||
"host": "127.0.0.1:8000",
|
"host": "127.0.0.1:8000",
|
||||||
"user-agent": f"python-httpx/{httpx.__version__}",
|
"user-agent": f"python-httpx/{httpx.__version__}",
|
||||||
"accept": "*/*",
|
"accept": "*/*",
|
||||||
"accept-encoding": "gzip, deflate, br",
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
"connection": "keep-alive",
|
"connection": "keep-alive",
|
||||||
"authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
"authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
||||||
},
|
},
|
||||||
@ -201,7 +201,7 @@ async def test_async_event_hooks_with_redirect():
|
|||||||
"host": "127.0.0.1:8000",
|
"host": "127.0.0.1:8000",
|
||||||
"user-agent": f"python-httpx/{httpx.__version__}",
|
"user-agent": f"python-httpx/{httpx.__version__}",
|
||||||
"accept": "*/*",
|
"accept": "*/*",
|
||||||
"accept-encoding": "gzip, deflate, br",
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
"connection": "keep-alive",
|
"connection": "keep-alive",
|
||||||
"authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
"authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
||||||
},
|
},
|
||||||
@ -216,7 +216,7 @@ async def test_async_event_hooks_with_redirect():
|
|||||||
"host": "127.0.0.1:8000",
|
"host": "127.0.0.1:8000",
|
||||||
"user-agent": f"python-httpx/{httpx.__version__}",
|
"user-agent": f"python-httpx/{httpx.__version__}",
|
||||||
"accept": "*/*",
|
"accept": "*/*",
|
||||||
"accept-encoding": "gzip, deflate, br",
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
"connection": "keep-alive",
|
"connection": "keep-alive",
|
||||||
"authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
"authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -34,7 +34,7 @@ def test_client_header():
|
|||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"headers": {
|
"headers": {
|
||||||
"accept": "*/*",
|
"accept": "*/*",
|
||||||
"accept-encoding": "gzip, deflate, br",
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
"connection": "keep-alive",
|
"connection": "keep-alive",
|
||||||
"example-header": "example-value",
|
"example-header": "example-value",
|
||||||
"host": "example.org",
|
"host": "example.org",
|
||||||
@ -56,7 +56,7 @@ def test_header_merge():
|
|||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"headers": {
|
"headers": {
|
||||||
"accept": "*/*",
|
"accept": "*/*",
|
||||||
"accept-encoding": "gzip, deflate, br",
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
"connection": "keep-alive",
|
"connection": "keep-alive",
|
||||||
"host": "example.org",
|
"host": "example.org",
|
||||||
"user-agent": "python-myclient/0.2.1",
|
"user-agent": "python-myclient/0.2.1",
|
||||||
@ -78,7 +78,7 @@ def test_header_merge_conflicting_headers():
|
|||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"headers": {
|
"headers": {
|
||||||
"accept": "*/*",
|
"accept": "*/*",
|
||||||
"accept-encoding": "gzip, deflate, br",
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
"connection": "keep-alive",
|
"connection": "keep-alive",
|
||||||
"host": "example.org",
|
"host": "example.org",
|
||||||
"user-agent": f"python-httpx/{httpx.__version__}",
|
"user-agent": f"python-httpx/{httpx.__version__}",
|
||||||
@ -100,7 +100,7 @@ def test_header_update():
|
|||||||
assert first_response.json() == {
|
assert first_response.json() == {
|
||||||
"headers": {
|
"headers": {
|
||||||
"accept": "*/*",
|
"accept": "*/*",
|
||||||
"accept-encoding": "gzip, deflate, br",
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
"connection": "keep-alive",
|
"connection": "keep-alive",
|
||||||
"host": "example.org",
|
"host": "example.org",
|
||||||
"user-agent": f"python-httpx/{httpx.__version__}",
|
"user-agent": f"python-httpx/{httpx.__version__}",
|
||||||
@ -111,7 +111,7 @@ def test_header_update():
|
|||||||
assert second_response.json() == {
|
assert second_response.json() == {
|
||||||
"headers": {
|
"headers": {
|
||||||
"accept": "*/*",
|
"accept": "*/*",
|
||||||
"accept-encoding": "gzip, deflate, br",
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
"another-header": "AThing",
|
"another-header": "AThing",
|
||||||
"connection": "keep-alive",
|
"connection": "keep-alive",
|
||||||
"host": "example.org",
|
"host": "example.org",
|
||||||
@ -164,7 +164,7 @@ def test_remove_default_header():
|
|||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"headers": {
|
"headers": {
|
||||||
"accept": "*/*",
|
"accept": "*/*",
|
||||||
"accept-encoding": "gzip, deflate, br",
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
"connection": "keep-alive",
|
"connection": "keep-alive",
|
||||||
"host": "example.org",
|
"host": "example.org",
|
||||||
}
|
}
|
||||||
@ -177,6 +177,14 @@ def test_header_does_not_exist():
|
|||||||
del headers["baz"]
|
del headers["baz"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_with_incorrect_value():
|
||||||
|
with pytest.raises(
|
||||||
|
TypeError,
|
||||||
|
match=f"Header value must be str or bytes, not {type(None)}",
|
||||||
|
):
|
||||||
|
httpx.Headers({"foo": None}) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
def test_host_with_auth_and_port_in_url():
|
def test_host_with_auth_and_port_in_url():
|
||||||
"""
|
"""
|
||||||
The Host header should only include the hostname, or hostname:port
|
The Host header should only include the hostname, or hostname:port
|
||||||
@ -192,7 +200,7 @@ def test_host_with_auth_and_port_in_url():
|
|||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"headers": {
|
"headers": {
|
||||||
"accept": "*/*",
|
"accept": "*/*",
|
||||||
"accept-encoding": "gzip, deflate, br",
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
"connection": "keep-alive",
|
"connection": "keep-alive",
|
||||||
"host": "example.org",
|
"host": "example.org",
|
||||||
"user-agent": f"python-httpx/{httpx.__version__}",
|
"user-agent": f"python-httpx/{httpx.__version__}",
|
||||||
@ -215,7 +223,7 @@ def test_host_with_non_default_port_in_url():
|
|||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"headers": {
|
"headers": {
|
||||||
"accept": "*/*",
|
"accept": "*/*",
|
||||||
"accept-encoding": "gzip, deflate, br",
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
"connection": "keep-alive",
|
"connection": "keep-alive",
|
||||||
"host": "example.org:123",
|
"host": "example.org:123",
|
||||||
"user-agent": f"python-httpx/{httpx.__version__}",
|
"user-agent": f"python-httpx/{httpx.__version__}",
|
||||||
@ -227,3 +235,59 @@ 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
|
||||||
|
|||||||
@ -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/" # type: ignore
|
client.base_url = "https://www.example.org/"
|
||||||
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" # type: ignore
|
client.base_url = "https://www.example.org/path"
|
||||||
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/" # type: ignore
|
client.base_url = "https://www.example.org/path/"
|
||||||
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"} # type: ignore
|
client.headers = {"a": "b"}
|
||||||
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"} # type: ignore
|
client.cookies = {"a": "b"}
|
||||||
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 # type: ignore
|
client.timeout = expected_timeout
|
||||||
|
|
||||||
assert isinstance(client.timeout, httpx.Timeout)
|
assert isinstance(client.timeout, httpx.Timeout)
|
||||||
assert client.timeout.connect == expected_timeout
|
assert client.timeout.connect == expected_timeout
|
||||||
|
|||||||
@ -13,69 +13,19 @@ def url_to_origin(url: str) -> httpcore.URL:
|
|||||||
return httpcore.URL(scheme=u.raw_scheme, host=u.raw_host, port=u.port, target="/")
|
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")
|
||||||
|
|
||||||
client = httpx.Client(proxy="socks5://localhost/")
|
for proxy in ("socks5://localhost/", "socks5h://localhost/"):
|
||||||
transport = client._transport_for_url(url)
|
client = httpx.Client(proxy=proxy)
|
||||||
assert isinstance(transport, httpx.HTTPTransport)
|
transport = client._transport_for_url(url)
|
||||||
assert isinstance(transport._pool, httpcore.SOCKSProxy)
|
assert isinstance(transport, httpx.HTTPTransport)
|
||||||
|
assert isinstance(transport._pool, httpcore.SOCKSProxy)
|
||||||
|
|
||||||
async_client = httpx.AsyncClient(proxy="socks5://localhost/")
|
async_client = httpx.AsyncClient(proxy=proxy)
|
||||||
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]"
|
||||||
@ -84,7 +34,6 @@ 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),
|
||||||
@ -104,7 +53,6 @@ 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),
|
||||||
@ -138,11 +86,8 @@ PROXY_URL = "http://[::1]"
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_transport_for_request(url, proxies, expected):
|
def test_transport_for_request(url, proxies, expected):
|
||||||
if proxies:
|
mounts = {key: httpx.HTTPTransport(proxy=value) for key, value in proxies.items()}
|
||||||
with pytest.warns(DeprecationWarning):
|
client = httpx.Client(mounts=mounts)
|
||||||
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))
|
||||||
|
|
||||||
@ -158,8 +103,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:
|
||||||
with pytest.warns(DeprecationWarning):
|
transport = httpx.AsyncHTTPTransport(proxy=PROXY_URL)
|
||||||
client = httpx.AsyncClient(proxies={"https://": PROXY_URL})
|
client = httpx.AsyncClient(mounts={"https://": transport})
|
||||||
await client.get("http://example.com")
|
await client.get("http://example.com")
|
||||||
finally:
|
finally:
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
@ -168,18 +113,13 @@ async def test_async_proxy_close():
|
|||||||
@pytest.mark.network
|
@pytest.mark.network
|
||||||
def test_sync_proxy_close():
|
def test_sync_proxy_close():
|
||||||
try:
|
try:
|
||||||
with pytest.warns(DeprecationWarning):
|
transport = httpx.HTTPTransport(proxy=PROXY_URL)
|
||||||
client = httpx.Client(proxies={"https://": PROXY_URL})
|
client = httpx.Client(mounts={"https://": transport})
|
||||||
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")
|
||||||
@ -308,26 +248,13 @@ 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):
|
||||||
with pytest.warns(DeprecationWarning):
|
mounts = {key: httpx.HTTPTransport(proxy=value) for key, value in proxies.items()}
|
||||||
if not is_valid:
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
httpx.Client(proxies=proxies)
|
|
||||||
else:
|
|
||||||
httpx.Client(proxies=proxies)
|
|
||||||
|
|
||||||
|
if not is_valid:
|
||||||
def test_proxy_and_proxies_together():
|
with pytest.raises(ValueError):
|
||||||
with pytest.warns(DeprecationWarning), pytest.raises(
|
httpx.Client(mounts=mounts)
|
||||||
RuntimeError,
|
else:
|
||||||
):
|
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():
|
||||||
|
|||||||
@ -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" # type: ignore
|
client.params = "a=b"
|
||||||
assert isinstance(client.params, httpx.QueryParams)
|
assert isinstance(client.params, httpx.QueryParams)
|
||||||
assert client.params["a"] == "b"
|
assert client.params["a"] == "b"
|
||||||
|
|
||||||
|
|||||||
@ -187,12 +187,6 @@ 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")
|
||||||
@ -236,7 +230,7 @@ class TestServer(Server):
|
|||||||
def install_signal_handlers(self) -> None:
|
def install_signal_handlers(self) -> None:
|
||||||
# Disable the default installation of handlers for signals such as SIGTERM,
|
# Disable the default installation of handlers for signals such as SIGTERM,
|
||||||
# because it can only be done in the main thread.
|
# because it can only be done in the main thread.
|
||||||
pass
|
pass # pragma: nocover
|
||||||
|
|
||||||
async def serve(self, sockets=None):
|
async def serve(self, sockets=None):
|
||||||
self.restart_requested = asyncio.Event()
|
self.restart_requested = asyncio.Event()
|
||||||
@ -291,17 +285,3 @@ 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)
|
|
||||||
|
|||||||
@ -174,3 +174,46 @@ 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 == {}
|
||||||
|
|||||||
@ -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": "13",
|
"Content-Length": "12",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -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": "13",
|
"content-length": "12",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -226,3 +226,16 @@ 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"
|
||||||
|
|||||||
@ -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 response.json() == {"hello": "world"}
|
assert str(response.json()) == "{'hello': 'world'}"
|
||||||
assert response.headers == {
|
assert response.headers == {
|
||||||
"Content-Length": "18",
|
"Content-Length": "17",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1011,7 +1011,10 @@ 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"
|
||||||
assert response.encoding == "ISO-8859-1"
|
# The encoded byte string is consistent with either ISO-8859-1 or
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -141,19 +141,14 @@ 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%20c&d=e%2Ff"
|
assert url.raw_path == b"/?a=b+c&d=e%2Ff"
|
||||||
|
|
||||||
|
|
||||||
def test_url_params():
|
def test_url_params():
|
||||||
@ -229,6 +224,11 @@ def test_url_normalized_host():
|
|||||||
assert url.host == "example.com"
|
assert url.host == "example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_url_percent_escape_host():
|
||||||
|
url = httpx.URL("https://exam le.com/")
|
||||||
|
assert url.host == "exam%20le.com"
|
||||||
|
|
||||||
|
|
||||||
def test_url_ipv4_like_host():
|
def test_url_ipv4_like_host():
|
||||||
"""rare host names used to quality as IPv4"""
|
"""rare host names used to quality as IPv4"""
|
||||||
url = httpx.URL("https://023b76x43144/")
|
url = httpx.URL("https://023b76x43144/")
|
||||||
@ -278,24 +278,65 @@ def test_url_leading_dot_prefix_on_relative_url():
|
|||||||
assert url.path == "../abc"
|
assert url.path == "../abc"
|
||||||
|
|
||||||
|
|
||||||
# Tests for optional percent encoding
|
# Tests for query parameter percent encoding.
|
||||||
|
#
|
||||||
|
# Percent-encoding in `params={}` should match browser form behavior.
|
||||||
|
|
||||||
|
|
||||||
|
def test_param_with_space():
|
||||||
|
# Params passed as form key-value pairs should be form escaped,
|
||||||
|
# Including the special case of "+" for space seperators.
|
||||||
|
url = httpx.URL("http://webservice", params={"u": "with spaces"})
|
||||||
|
assert str(url) == "http://webservice?u=with+spaces"
|
||||||
|
|
||||||
|
|
||||||
def test_param_requires_encoding():
|
def test_param_requires_encoding():
|
||||||
url = httpx.URL("http://webservice", params={"u": "with spaces"})
|
# Params passed as form key-value pairs should be escaped.
|
||||||
assert str(url) == "http://webservice?u=with%20spaces"
|
url = httpx.URL("http://webservice", params={"u": "%"})
|
||||||
|
assert str(url) == "http://webservice?u=%25"
|
||||||
|
|
||||||
|
|
||||||
def test_param_does_not_require_encoding():
|
def test_param_with_percent_encoded():
|
||||||
|
# Params passed as form key-value pairs should always be escaped,
|
||||||
|
# even if they include a valid escape sequence.
|
||||||
|
# We want to match browser form behaviour here.
|
||||||
url = httpx.URL("http://webservice", params={"u": "with%20spaces"})
|
url = httpx.URL("http://webservice", params={"u": "with%20spaces"})
|
||||||
assert str(url) == "http://webservice?u=with%20spaces"
|
assert str(url) == "http://webservice?u=with%2520spaces"
|
||||||
|
|
||||||
|
|
||||||
def test_param_with_existing_escape_requires_encoding():
|
def test_param_with_existing_escape_requires_encoding():
|
||||||
|
# Params passed as form key-value pairs should always be escaped,
|
||||||
|
# even if they include a valid escape sequence.
|
||||||
|
# We want to match browser form behaviour here.
|
||||||
url = httpx.URL("http://webservice", params={"u": "http://example.com?q=foo%2Fa"})
|
url = httpx.URL("http://webservice", params={"u": "http://example.com?q=foo%2Fa"})
|
||||||
assert str(url) == "http://webservice?u=http%3A%2F%2Fexample.com%3Fq%3Dfoo%252Fa"
|
assert str(url) == "http://webservice?u=http%3A%2F%2Fexample.com%3Fq%3Dfoo%252Fa"
|
||||||
|
|
||||||
|
|
||||||
|
# Tests for query parameter percent encoding.
|
||||||
|
#
|
||||||
|
# Percent-encoding in `url={}` should match browser URL bar behavior.
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_with_existing_percent_encoding():
|
||||||
|
# Valid percent encoded sequences should not be double encoded.
|
||||||
|
url = httpx.URL("http://webservice?u=phrase%20with%20spaces")
|
||||||
|
assert str(url) == "http://webservice?u=phrase%20with%20spaces"
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_requiring_percent_encoding():
|
||||||
|
# Characters that require percent encoding should be encoded.
|
||||||
|
url = httpx.URL("http://webservice?u=phrase with spaces")
|
||||||
|
assert str(url) == "http://webservice?u=phrase%20with%20spaces"
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_with_mixed_percent_encoding():
|
||||||
|
# When a mix of encoded and unencoded characters are present,
|
||||||
|
# characters that require percent encoding should be encoded,
|
||||||
|
# while existing sequences should not be double encoded.
|
||||||
|
url = httpx.URL("http://webservice?u=phrase%20with spaces")
|
||||||
|
assert str(url) == "http://webservice?u=phrase%20with%20spaces"
|
||||||
|
|
||||||
|
|
||||||
# Tests for invalid URLs
|
# Tests for invalid URLs
|
||||||
|
|
||||||
|
|
||||||
@ -322,15 +363,17 @@ def test_url_excessively_long_component():
|
|||||||
def test_url_non_printing_character_in_url():
|
def test_url_non_printing_character_in_url():
|
||||||
with pytest.raises(httpx.InvalidURL) as exc:
|
with pytest.raises(httpx.InvalidURL) as exc:
|
||||||
httpx.URL("https://www.example.com/\n")
|
httpx.URL("https://www.example.com/\n")
|
||||||
assert str(exc.value) == "Invalid non-printable ASCII character in URL"
|
assert str(exc.value) == (
|
||||||
|
"Invalid non-printable ASCII character in URL, '\\n' at position 24."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_url_non_printing_character_in_component():
|
def test_url_non_printing_character_in_component():
|
||||||
with pytest.raises(httpx.InvalidURL) as exc:
|
with pytest.raises(httpx.InvalidURL) as exc:
|
||||||
httpx.URL("https://www.example.com", path="/\n")
|
httpx.URL("https://www.example.com", path="/\n")
|
||||||
assert (
|
assert str(exc.value) == (
|
||||||
str(exc.value)
|
"Invalid non-printable ASCII character in URL path component, "
|
||||||
== "Invalid non-printable ASCII character in URL component 'path'"
|
"'\\n' at position 1."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -370,17 +413,11 @@ def test_urlparse_with_invalid_path():
|
|||||||
|
|
||||||
with pytest.raises(httpx.InvalidURL) as exc:
|
with pytest.raises(httpx.InvalidURL) as exc:
|
||||||
httpx.URL(path="//abc")
|
httpx.URL(path="//abc")
|
||||||
assert (
|
assert str(exc.value) == "Relative URLs cannot have a path starting with '//'"
|
||||||
str(exc.value)
|
|
||||||
== "URLs with no authority component cannot have a path starting with '//'"
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(httpx.InvalidURL) as exc:
|
with pytest.raises(httpx.InvalidURL) as exc:
|
||||||
httpx.URL(path=":abc")
|
httpx.URL(path=":abc")
|
||||||
assert (
|
assert str(exc.value) == "Relative URLs cannot have a path starting with ':'"
|
||||||
str(exc.value)
|
|
||||||
== "URLs with no scheme component cannot have a path starting with ':'"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_url_with_relative_path():
|
def test_url_with_relative_path():
|
||||||
@ -573,10 +610,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%25@example.org"
|
assert str(new) == "https://tom%40example.org:abc123%40%20%@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%25"
|
assert new.userinfo == b"tom%40example.org:abc123%40%20%"
|
||||||
|
|
||||||
|
|
||||||
def test_url_copywith_invalid_component():
|
def test_url_copywith_invalid_component():
|
||||||
@ -824,19 +861,3 @@ 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"
|
|
||||||
|
|||||||
52
tests/models/test_whatwg.py
Normal file
52
tests/models/test_whatwg.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# The WHATWG have various tests that can be used to validate the URL parsing.
|
||||||
|
#
|
||||||
|
# https://url.spec.whatwg.org/
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from httpx._urlparse import urlparse
|
||||||
|
|
||||||
|
# URL test cases from...
|
||||||
|
# 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:
|
||||||
|
test_cases = json.load(input)
|
||||||
|
test_cases = [
|
||||||
|
item
|
||||||
|
for item in test_cases
|
||||||
|
if not isinstance(item, str) and not item.get("failure")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("test_case", test_cases)
|
||||||
|
def test_urlparse(test_case):
|
||||||
|
if test_case["href"] in ("a: foo.com", "lolscheme:x x#x%20x"):
|
||||||
|
# Skip these two test cases.
|
||||||
|
# WHATWG cases where are not using percent-encoding for the space character.
|
||||||
|
# Anyone know what's going on here?
|
||||||
|
return
|
||||||
|
|
||||||
|
p = urlparse(test_case["href"])
|
||||||
|
|
||||||
|
# Test cases include the protocol with the trailing ":"
|
||||||
|
protocol = p.scheme + ":"
|
||||||
|
# Include the square brackets for IPv6 addresses.
|
||||||
|
hostname = f"[{p.host}]" if ":" in p.host else p.host
|
||||||
|
# The test cases use a string representation of the port.
|
||||||
|
port = "" if p.port is None else str(p.port)
|
||||||
|
# I have nothing to say about this one.
|
||||||
|
path = p.path
|
||||||
|
# The 'search' and 'hash' components in the whatwg tests are semantic, not literal.
|
||||||
|
# Our parsing differentiates between no query/hash and empty-string query/hash.
|
||||||
|
search = "" if p.query in (None, "") else "?" + str(p.query)
|
||||||
|
hash = "" if p.fragment in (None, "") else "#" + str(p.fragment)
|
||||||
|
|
||||||
|
# URL hostnames are case-insensitive.
|
||||||
|
# We normalize these, unlike the WHATWG test cases.
|
||||||
|
assert protocol == test_case["protocol"]
|
||||||
|
assert hostname.lower() == test_case["hostname"].lower()
|
||||||
|
assert port == test_case["port"]
|
||||||
|
assert path == test_case["pathname"]
|
||||||
|
assert search == test_case["search"]
|
||||||
|
assert hash == test_case["hash"]
|
||||||
9746
tests/models/whatwg.json
Normal file
9746
tests/models/whatwg.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -85,3 +85,18 @@ 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
|
||||||
|
|||||||
@ -157,7 +157,7 @@ async def test_asgi_headers():
|
|||||||
"headers": [
|
"headers": [
|
||||||
["host", "www.example.org"],
|
["host", "www.example.org"],
|
||||||
["accept", "*/*"],
|
["accept", "*/*"],
|
||||||
["accept-encoding", "gzip, deflate, br"],
|
["accept-encoding", "gzip, deflate, br, zstd"],
|
||||||
["connection", "keep-alive"],
|
["connection", "keep-alive"],
|
||||||
["user-agent", f"python-httpx/{httpx.__version__}"],
|
["user-agent", f"python-httpx/{httpx.__version__}"],
|
||||||
]
|
]
|
||||||
@ -222,13 +222,3 @@ 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)
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ Unit tests for auth classes.
|
|||||||
|
|
||||||
Integration tests also exist in tests/client/test_auth.py
|
Integration tests also exist in tests/client/test_auth.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from urllib.request import parse_keqv_list
|
from urllib.request import parse_keqv_list
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|||||||
@ -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,43 +14,35 @@ 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_path():
|
def test_load_ssl_config_verify_non_existing_file():
|
||||||
with pytest.raises(IOError):
|
with pytest.raises(IOError):
|
||||||
httpx.create_ssl_context(verify="/path/to/nowhere")
|
context = httpx.create_ssl_context()
|
||||||
|
context.load_verify_locations(cafile="/path/to/nowhere")
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_ssl_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(verify=certifi.where())
|
context = httpx.create_ssl_context()
|
||||||
|
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():
|
||||||
path = Path(certifi.where()).parent
|
context = httpx.create_ssl_context()
|
||||||
context = httpx.create_ssl_context(verify=str(path))
|
context.load_verify_locations(capath=Path(certifi.where()).parent)
|
||||||
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(cert=(cert_pem_file, cert_private_key_file))
|
context = httpx.create_ssl_context()
|
||||||
|
context.load_cert_chain(cert_pem_file, cert_private_key_file)
|
||||||
assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED
|
assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED
|
||||||
assert context.check_hostname is True
|
assert context.check_hostname is True
|
||||||
|
|
||||||
@ -59,9 +51,8 @@ def test_load_ssl_config_cert_and_key(cert_pem_file, cert_private_key_file):
|
|||||||
def test_load_ssl_config_cert_and_encrypted_key(
|
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()
|
||||||
cert=(cert_pem_file, cert_encrypted_private_key_file, password)
|
context.load_cert_chain(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
|
||||||
|
|
||||||
@ -70,14 +61,16 @@ 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):
|
||||||
httpx.create_ssl_context(
|
context = httpx.create_ssl_context()
|
||||||
cert=(cert_pem_file, cert_encrypted_private_key_file, "password1")
|
context.load_cert_chain(
|
||||||
|
cert_pem_file, cert_encrypted_private_key_file, "password1"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_load_ssl_config_cert_without_key_raises(cert_pem_file):
|
def test_load_ssl_config_cert_without_key_raises(cert_pem_file):
|
||||||
with pytest.raises(ssl.SSLError):
|
with pytest.raises(ssl.SSLError):
|
||||||
httpx.create_ssl_context(cert=cert_pem_file)
|
context = httpx.create_ssl_context()
|
||||||
|
context.load_cert_chain(cert_pem_file)
|
||||||
|
|
||||||
|
|
||||||
def test_load_ssl_config_no_verify():
|
def test_load_ssl_config_no_verify():
|
||||||
@ -86,15 +79,9 @@ def test_load_ssl_config_no_verify():
|
|||||||
assert context.check_hostname is False
|
assert context.check_hostname is False
|
||||||
|
|
||||||
|
|
||||||
def test_load_ssl_context():
|
def test_SSLContext_with_get_request(server, cert_pem_file):
|
||||||
ssl_context = ssl.create_default_context()
|
context = httpx.create_ssl_context()
|
||||||
context = httpx.create_ssl_context(verify=ssl_context)
|
context.load_verify_locations(cert_pem_file)
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@ -174,32 +161,6 @@ 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")
|
||||||
|
|
||||||
|
|||||||
@ -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": "19",
|
"Content-Length": "18",
|
||||||
"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,3 +484,35 @@ 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)
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import typing
|
import typing
|
||||||
import zlib
|
import zlib
|
||||||
|
|
||||||
import chardet
|
import chardet
|
||||||
import pytest
|
import pytest
|
||||||
|
import zstandard as zstd
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@ -73,6 +75,72 @@ def test_brotli():
|
|||||||
assert response.content == body
|
assert response.content == body
|
||||||
|
|
||||||
|
|
||||||
|
def test_zstd():
|
||||||
|
body = b"test 123"
|
||||||
|
compressed_body = zstd.compress(body)
|
||||||
|
|
||||||
|
headers = [(b"Content-Encoding", b"zstd")]
|
||||||
|
response = httpx.Response(
|
||||||
|
200,
|
||||||
|
headers=headers,
|
||||||
|
content=compressed_body,
|
||||||
|
)
|
||||||
|
assert response.content == body
|
||||||
|
|
||||||
|
|
||||||
|
def test_zstd_decoding_error():
|
||||||
|
compressed_body = "this_is_not_zstd_compressed_data"
|
||||||
|
|
||||||
|
headers = [(b"Content-Encoding", b"zstd")]
|
||||||
|
with pytest.raises(httpx.DecodingError):
|
||||||
|
httpx.Response(
|
||||||
|
200,
|
||||||
|
headers=headers,
|
||||||
|
content=compressed_body,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zstd_empty():
|
||||||
|
headers = [(b"Content-Encoding", b"zstd")]
|
||||||
|
response = httpx.Response(200, headers=headers, content=b"")
|
||||||
|
assert response.content == b""
|
||||||
|
|
||||||
|
|
||||||
|
def test_zstd_truncated():
|
||||||
|
body = b"test 123"
|
||||||
|
compressed_body = zstd.compress(body)
|
||||||
|
|
||||||
|
headers = [(b"Content-Encoding", b"zstd")]
|
||||||
|
with pytest.raises(httpx.DecodingError):
|
||||||
|
httpx.Response(
|
||||||
|
200,
|
||||||
|
headers=headers,
|
||||||
|
content=compressed_body[1:3],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zstd_multiframe():
|
||||||
|
# test inspired by urllib3 test suite
|
||||||
|
data = (
|
||||||
|
# Zstandard frame
|
||||||
|
zstd.compress(b"foo")
|
||||||
|
# skippable frame (must be ignored)
|
||||||
|
+ bytes.fromhex(
|
||||||
|
"50 2A 4D 18" # Magic_Number (little-endian)
|
||||||
|
"07 00 00 00" # Frame_Size (little-endian)
|
||||||
|
"00 00 00 00 00 00 00" # User_Data
|
||||||
|
)
|
||||||
|
# Zstandard frame
|
||||||
|
+ zstd.compress(b"bar")
|
||||||
|
)
|
||||||
|
compressed_body = io.BytesIO(data)
|
||||||
|
|
||||||
|
headers = [(b"Content-Encoding", b"zstd")]
|
||||||
|
response = httpx.Response(200, headers=headers, content=compressed_body)
|
||||||
|
response.read()
|
||||||
|
assert response.content == b"foobar"
|
||||||
|
|
||||||
|
|
||||||
def test_multi():
|
def test_multi():
|
||||||
body = b"test 123"
|
body = b"test 123"
|
||||||
|
|
||||||
|
|||||||
@ -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"}',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -129,7 +129,7 @@ def test_verbose(server):
|
|||||||
"GET / HTTP/1.1",
|
"GET / HTTP/1.1",
|
||||||
f"Host: {server.url.netloc.decode('ascii')}",
|
f"Host: {server.url.netloc.decode('ascii')}",
|
||||||
"Accept: */*",
|
"Accept: */*",
|
||||||
"Accept-Encoding: gzip, deflate, br",
|
"Accept-Encoding: gzip, deflate, br, zstd",
|
||||||
"Connection: keep-alive",
|
"Connection: keep-alive",
|
||||||
f"User-Agent: python-httpx/{httpx.__version__}",
|
f"User-Agent: python-httpx/{httpx.__version__}",
|
||||||
"",
|
"",
|
||||||
@ -154,7 +154,7 @@ def test_auth(server):
|
|||||||
"GET / HTTP/1.1",
|
"GET / HTTP/1.1",
|
||||||
f"Host: {server.url.netloc.decode('ascii')}",
|
f"Host: {server.url.netloc.decode('ascii')}",
|
||||||
"Accept: */*",
|
"Accept: */*",
|
||||||
"Accept-Encoding: gzip, deflate, br",
|
"Accept-Encoding: gzip, deflate, br, zstd",
|
||||||
"Connection: keep-alive",
|
"Connection: keep-alive",
|
||||||
f"User-Agent: python-httpx/{httpx.__version__}",
|
f"User-Agent: python-httpx/{httpx.__version__}",
|
||||||
"Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
"Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
||||||
|
|||||||
@ -462,8 +462,8 @@ class TestHeaderParamHTML5Formatting:
|
|||||||
assert expected in request.read()
|
assert expected in request.read()
|
||||||
|
|
||||||
def test_unicode_with_control_character(self):
|
def test_unicode_with_control_character(self):
|
||||||
filename = "hello\x1A\x1B\x1C"
|
filename = "hello\x1a\x1b\x1c"
|
||||||
expected = b'filename="hello%1A\x1B%1C"'
|
expected = b'filename="hello%1A\x1b%1C"'
|
||||||
files = {"upload": (filename, b"<file content>")}
|
files = {"upload": (filename, b"<file content>")}
|
||||||
request = httpx.Request("GET", "https://www.example.com", files=files)
|
request = httpx.Request("GET", "https://www.example.com", files=files)
|
||||||
assert expected in request.read()
|
assert expected in request.read()
|
||||||
|
|||||||
@ -42,3 +42,14 @@ async def test_pool_timeout(server):
|
|||||||
with pytest.raises(httpx.PoolTimeout):
|
with pytest.raises(httpx.PoolTimeout):
|
||||||
async with client.stream("GET", server.url):
|
async with client.stream("GET", server.url):
|
||||||
await client.get(server.url)
|
await client.get(server.url)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_async_client_new_request_send_timeout(server):
|
||||||
|
timeout = httpx.Timeout(1e-6)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
with pytest.raises(httpx.TimeoutException):
|
||||||
|
await client.send(
|
||||||
|
httpx.Request("GET", server.url.copy_with(path="/slow_response"))
|
||||||
|
)
|
||||||
|
|||||||
@ -3,19 +3,10 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
|
||||||
import certifi
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from httpx._utils import (
|
from httpx._utils import URLPattern, get_environment_proxies
|
||||||
URLPattern,
|
|
||||||
get_ca_bundle_from_env,
|
|
||||||
get_environment_proxies,
|
|
||||||
is_https_redirect,
|
|
||||||
same_origin,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .common import TESTS_DIR
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@ -59,35 +50,6 @@ 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:
|
||||||
@ -124,66 +86,6 @@ def test_logging_redirect_chain(server, caplog):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_logging_ssl(caplog):
|
|
||||||
caplog.set_level(logging.DEBUG)
|
|
||||||
with httpx.Client():
|
|
||||||
pass
|
|
||||||
|
|
||||||
cafile = certifi.where()
|
|
||||||
assert caplog.record_tuples == [
|
|
||||||
(
|
|
||||||
"httpx",
|
|
||||||
logging.DEBUG,
|
|
||||||
"load_ssl_context verify=True cert=None trust_env=True http2=False",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"httpx",
|
|
||||||
logging.DEBUG,
|
|
||||||
f"load_verify_locations cafile='{cafile}'",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_ssl_cert_file():
|
|
||||||
# Two environments is not set.
|
|
||||||
assert get_ca_bundle_from_env() is None
|
|
||||||
|
|
||||||
os.environ["SSL_CERT_DIR"] = str(TESTS_DIR)
|
|
||||||
# SSL_CERT_DIR is correctly set, SSL_CERT_FILE is not set.
|
|
||||||
ca_bundle = get_ca_bundle_from_env()
|
|
||||||
assert ca_bundle is not None and ca_bundle.endswith("tests")
|
|
||||||
|
|
||||||
del os.environ["SSL_CERT_DIR"]
|
|
||||||
os.environ["SSL_CERT_FILE"] = str(TESTS_DIR / "test_utils.py")
|
|
||||||
# SSL_CERT_FILE is correctly set, SSL_CERT_DIR is not set.
|
|
||||||
ca_bundle = get_ca_bundle_from_env()
|
|
||||||
assert ca_bundle is not None and ca_bundle.endswith("tests/test_utils.py")
|
|
||||||
|
|
||||||
os.environ["SSL_CERT_FILE"] = "wrongfile"
|
|
||||||
# SSL_CERT_FILE is set with wrong file, SSL_CERT_DIR is not set.
|
|
||||||
assert get_ca_bundle_from_env() is None
|
|
||||||
|
|
||||||
del os.environ["SSL_CERT_FILE"]
|
|
||||||
os.environ["SSL_CERT_DIR"] = "wrongpath"
|
|
||||||
# SSL_CERT_DIR is set with wrong path, SSL_CERT_FILE is not set.
|
|
||||||
assert get_ca_bundle_from_env() is None
|
|
||||||
|
|
||||||
os.environ["SSL_CERT_DIR"] = str(TESTS_DIR)
|
|
||||||
os.environ["SSL_CERT_FILE"] = str(TESTS_DIR / "test_utils.py")
|
|
||||||
# Two environments is correctly set.
|
|
||||||
ca_bundle = get_ca_bundle_from_env()
|
|
||||||
assert ca_bundle is not None and ca_bundle.endswith("tests/test_utils.py")
|
|
||||||
|
|
||||||
os.environ["SSL_CERT_FILE"] = "wrongfile"
|
|
||||||
# Two environments is set but SSL_CERT_FILE is not a file.
|
|
||||||
ca_bundle = get_ca_bundle_from_env()
|
|
||||||
assert ca_bundle is not None and ca_bundle.endswith("tests")
|
|
||||||
|
|
||||||
os.environ["SSL_CERT_DIR"] = "wrongpath"
|
|
||||||
# Two environments is set but both are not correct.
|
|
||||||
assert get_ca_bundle_from_env() is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
["environment", "proxies"],
|
["environment", "proxies"],
|
||||||
[
|
[
|
||||||
@ -210,50 +112,6 @@ 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():
|
|
||||||
origin1 = httpx.URL("https://example.com")
|
|
||||||
origin2 = httpx.URL("HTTPS://EXAMPLE.COM:443")
|
|
||||||
assert same_origin(origin1, origin2)
|
|
||||||
|
|
||||||
|
|
||||||
def test_not_same_origin():
|
|
||||||
origin1 = httpx.URL("https://example.com")
|
|
||||||
origin2 = httpx.URL("HTTP://EXAMPLE.COM")
|
|
||||||
assert not same_origin(origin1, origin2)
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_https_redirect():
|
|
||||||
url = httpx.URL("http://example.com")
|
|
||||||
location = httpx.URL("https://example.com")
|
|
||||||
assert is_https_redirect(url, location)
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_not_https_redirect():
|
|
||||||
url = httpx.URL("http://example.com")
|
|
||||||
location = httpx.URL("https://www.example.com")
|
|
||||||
assert not is_https_redirect(url, location)
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_not_https_redirect_if_not_default_ports():
|
|
||||||
url = httpx.URL("http://example.com:9999")
|
|
||||||
location = httpx.URL("https://example.com:1337")
|
|
||||||
assert not is_https_redirect(url, location)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
["pattern", "url", "expected"],
|
["pattern", "url", "expected"],
|
||||||
[
|
[
|
||||||
|
|||||||
@ -201,12 +201,3 @@ 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!"]))
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user