Compare commits
215 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 | ||
|
|
326b9431c7 | ||
|
|
3faa4a8f2e | ||
|
|
c51af4ba52 | ||
|
|
cabd1c095e | ||
|
|
6f461522a5 | ||
|
|
37a2901af3 | ||
|
|
371b6e946c | ||
|
|
4f6edf36e9 | ||
|
|
c7cd6aa5bd | ||
|
|
15f925336c | ||
|
|
d76607b112 | ||
|
|
73e688875a | ||
|
|
419d3a9d80 | ||
|
|
8cd952c88f | ||
|
|
ab720d3258 | ||
|
|
99cba6ac64 | ||
|
|
ca51b4532a | ||
|
|
c6907c2203 | ||
|
|
ebc1393c5c | ||
|
|
4ddff16bbe | ||
|
|
f1ed746308 | ||
|
|
ea3071642d | ||
|
|
b871b4b8b2 | ||
|
|
dd5304d3eb | ||
|
|
1a660147ed | ||
|
|
1d526a0180 | ||
|
|
08eff926a6 | ||
|
|
b4b27ff677 | ||
|
|
a11fc3849b | ||
|
|
3b9060ee11 | ||
|
|
2318fd822c | ||
|
|
2c51edd0c0 | ||
|
|
1e11096473 | ||
|
|
90538a3b46 | ||
|
|
f8981f3d12 | ||
|
|
b471f01d66 | ||
|
|
5b5f6d8e17 | ||
|
|
724eced022 | ||
|
|
9ef08c7949 | ||
|
|
266761d8d9 | ||
|
|
fe5954c98f | ||
|
|
0265d95faa | ||
|
|
d4b70fe895 | ||
|
|
fd60b1815c | ||
|
|
90d71e63e0 | ||
|
|
cc206cf2da | ||
|
|
87f39f12c9 | ||
|
|
c51e0466be | ||
|
|
497b315fc7 | ||
|
|
89cbd3c942 | ||
|
|
f653b2f0cf | ||
|
|
fbe35add82 | ||
|
|
c19728ca39 | ||
|
|
b07d4e8ce4 | ||
|
|
280a89a4d1 | ||
|
|
1b7f39eb44 | ||
|
|
05937f4130 | ||
|
|
aea487059b | ||
|
|
2cb3252228 | ||
|
|
1d73150c1f | ||
|
|
5f2d62096a | ||
|
|
ad06741d1e | ||
|
|
9751f76186 | ||
|
|
31a7bb381a | ||
|
|
e63b6594f2 | ||
|
|
3ba5fe0d7a | ||
|
|
05b8e32844 | ||
|
|
8dc2fb3e33 | ||
|
|
e63cec5492 | ||
|
|
47fe956f74 | ||
|
|
c684e9f3aa | ||
|
|
5d32e4c1bf | ||
|
|
7c9db49f0c | ||
|
|
59df8190a4 | ||
|
|
e4241c6155 | ||
|
|
88e8431437 | ||
|
|
c3585a5ccf | ||
|
|
a54ecccd5b | ||
|
|
adbcd0e0e7 | ||
|
|
e874351f04 | ||
|
|
7ecd828237 | ||
|
|
ec4aa5e4ce | ||
|
|
1703da8706 | ||
|
|
b95ef3e489 | ||
|
|
3a7f6d1a5d | ||
|
|
053bc57c37 | ||
|
|
0f61aa58d6 | ||
|
|
c20bacbf76 | ||
|
|
304433ebaa | ||
|
|
534b47ebf2 | ||
|
|
b40c04dfa6 | ||
|
|
76c9cb65f2 | ||
|
|
e99e2948e6 | ||
|
|
9415af643f | ||
|
|
55b8669acb | ||
|
|
6a1841b924 | ||
|
|
18d7721c38 | ||
|
|
f6866ce388 | ||
|
|
f115ce4e09 | ||
|
|
2c49a151d2 | ||
|
|
5b156dca7f | ||
|
|
353fb358eb | ||
|
|
9d022c0a88 | ||
|
|
8a6ef6ed14 | ||
|
|
354a6fc8dc | ||
|
|
2e2949c8ea | ||
|
|
6d183a87e1 | ||
|
|
920333ea98 | ||
|
|
301b8fb03a | ||
|
|
b4f66c2cd7 | ||
|
|
1b8187ca67 | ||
|
|
5eb00b77a3 | ||
|
|
2073ea0046 | ||
|
|
dedd37b9ce | ||
|
|
733595037a | ||
|
|
a682f6f1c7 | ||
|
|
f9abbbbb48 | ||
|
|
abb994c0c2 |
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/dependabot.yml
vendored
4
.github/dependabot.yml
vendored
@ -4,6 +4,10 @@ updates:
|
|||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "monthly"
|
interval: "monthly"
|
||||||
|
groups:
|
||||||
|
python-packages:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
- package-ecosystem: "github-actions"
|
- package-ecosystem: "github-actions"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
|
|||||||
6
.github/workflows/publish.yml
vendored
6
.github/workflows/publish.yml
vendored
@ -14,10 +14,10 @@ jobs:
|
|||||||
name: deploy
|
name: deploy
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: "actions/checkout@v3"
|
- uses: "actions/checkout@v4"
|
||||||
- uses: "actions/setup-python@v4"
|
- uses: "actions/setup-python@v6"
|
||||||
with:
|
with:
|
||||||
python-version: 3.7
|
python-version: 3.9
|
||||||
- name: "Install dependencies"
|
- name: "Install dependencies"
|
||||||
run: "scripts/install"
|
run: "scripts/install"
|
||||||
- name: "Build package & docs"
|
- name: "Build package & docs"
|
||||||
|
|||||||
9
.github/workflows/test-suite.yml
vendored
9
.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,13 +14,14 @@ jobs:
|
|||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
|
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: "actions/checkout@v3"
|
- uses: "actions/checkout@v4"
|
||||||
- uses: "actions/setup-python@v4"
|
- uses: "actions/setup-python@v6"
|
||||||
with:
|
with:
|
||||||
python-version: "${{ matrix.python-version }}"
|
python-version: "${{ matrix.python-version }}"
|
||||||
|
allow-prereleases: true
|
||||||
- name: "Install dependencies"
|
- name: "Install dependencies"
|
||||||
run: "scripts/install"
|
run: "scripts/install"
|
||||||
- name: "Run linting checks"
|
- name: "Run linting checks"
|
||||||
|
|||||||
147
CHANGELOG.md
147
CHANGELOG.md
@ -4,6 +4,131 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
|
|
||||||
|
## [UNRELEASED]
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
* Drop support for Python 3.8
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* Expose `FunctionAuth` from the public API. (#3699)
|
||||||
|
|
||||||
|
## 0.28.1 (6th December, 2024)
|
||||||
|
|
||||||
|
* Fix SSL case where `verify=False` together with client side certificates.
|
||||||
|
|
||||||
|
## 0.28.0 (28th November, 2024)
|
||||||
|
|
||||||
|
Be aware that the default *JSON request bodies now use a more compact representation*. This is generally considered a prefered style, tho may require updates to test suites.
|
||||||
|
|
||||||
|
The 0.28 release includes a limited set of deprecations...
|
||||||
|
|
||||||
|
**Deprecations**:
|
||||||
|
|
||||||
|
We are working towards a simplified SSL configuration API.
|
||||||
|
|
||||||
|
*For users of the standard `verify=True` or `verify=False` cases, or `verify=<ssl_context>` case this should require no changes. The following cases have been deprecated...*
|
||||||
|
|
||||||
|
* The `verify` argument as a string argument is now deprecated and will raise warnings.
|
||||||
|
* The `cert` argument is now deprecated and will raise warnings.
|
||||||
|
|
||||||
|
Our revised [SSL documentation](docs/advanced/ssl.md) covers how to implement the same behaviour with a more constrained API.
|
||||||
|
|
||||||
|
**The following changes are also included**:
|
||||||
|
|
||||||
|
* The deprecated `proxies` argument has now been removed.
|
||||||
|
* The deprecated `app` argument has now been removed.
|
||||||
|
* JSON request bodies use a compact representation. (#3363)
|
||||||
|
* Review URL percent escape sets, based on WHATWG spec. (#3371, #3373)
|
||||||
|
* Ensure `certifi` and `httpcore` are only imported if required. (#3377)
|
||||||
|
* Treat `socks5h` as a valid proxy scheme. (#3178)
|
||||||
|
* Cleanup `Request()` method signature in line with `client.request()` and `httpx.request()`. (#3378)
|
||||||
|
* Bugfix: When passing `params={}`, always strictly update rather than merge with an existing querystring. (#3364)
|
||||||
|
|
||||||
|
## 0.27.2 (27th August, 2024)
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
* The `app=...` shortcut has been deprecated. Use the explicit style of `transport=httpx.WSGITransport()` or `transport=httpx.ASGITransport()` instead.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* Respect the `http1` argument while configuring proxy transports. (#3023)
|
||||||
|
* Fix RFC 2069 mode digest authentication. (#3045)
|
||||||
|
|
||||||
|
## 0.26.0 (20th December, 2023)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* The `proxy` argument was added. You should use the `proxy` argument instead of the deprecated `proxies`, or use `mounts=` for more complex configurations. (#2879)
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
* The `proxies` argument is now deprecated. It will still continue to work, but it will be removed in the future. (#2879)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* Fix cases of double escaping of URL path components. Allow / as a safe character in the query portion. (#2990)
|
||||||
|
* Handle `NO_PROXY` envvar cases when a fully qualified URL is supplied as the value. (#2741)
|
||||||
|
* Allow URLs where username or password contains unescaped '@'. (#2986)
|
||||||
|
* Ensure ASGI `raw_path` does not include URL query component. (#2999)
|
||||||
|
* Ensure `Response.iter_text()` cannot yield empty strings. (#2998)
|
||||||
|
|
||||||
|
## 0.25.2 (24th November, 2023)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* Add missing type hints to few `__init__()` methods. (#2938)
|
||||||
|
|
||||||
|
## 0.25.1 (3rd November, 2023)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* Add support for Python 3.12. (#2854)
|
||||||
|
* Add support for httpcore 1.0 (#2885)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* Raise `ValueError` on `Response.encoding` being set after `Response.text` has been accessed. (#2852)
|
||||||
|
|
||||||
|
## 0.25.0 (11th September, 2023)
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
* Drop support for Python 3.7. (#2813)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* Support HTTPS proxies. (#2845)
|
||||||
|
* Change the type of `Extensions` from `Mapping[Str, Any]` to `MutableMapping[Str, Any]`. (#2803)
|
||||||
|
* Add `socket_options` argument to `httpx.HTTPTransport` and `httpx.AsyncHTTPTransport` classes. (#2716)
|
||||||
|
* The `Response.raise_for_status()` method now returns the response instance. For example: `data = httpx.get('...').raise_for_status().json()`. (#2776)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* Return `500` error response instead of exceptions when `raise_app_exceptions=False` is set on `ASGITransport`. (#2669)
|
||||||
|
* Ensure all `WSGITransport` environs have a `SERVER_PROTOCOL`. (#2708)
|
||||||
|
* Always encode forward slashes as `%2F` in query parameters (#2723)
|
||||||
|
* Use Mozilla documentation instead of `httpstatuses.com` for HTTP error reference (#2768)
|
||||||
|
|
||||||
## 0.24.1 (17th May, 2023)
|
## 0.24.1 (17th May, 2023)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@ -26,19 +151,19 @@ 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
|
||||||
|
|
||||||
* The `rfc3986` dependancy has been removed. (#2252)
|
* The `rfc3986` dependancy has been removed. (#2252)
|
||||||
|
|
||||||
## 0.23.3 (4th Jan, 2023)
|
## 0.23.3 (4th January, 2023)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
* Version 0.23.2 accidentally included stricter type checking on query parameters. This shouldn've have been included in a minor version bump, and is now reverted. (#2523, #2539)
|
* Version 0.23.2 accidentally included stricter type checking on query parameters. This shouldn've have been included in a minor version bump, and is now reverted. (#2523, #2539)
|
||||||
|
|
||||||
## 0.23.2 (2nd Jan, 2023)
|
## 0.23.2 (2nd January, 2023)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@ -50,7 +175,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
* Raise `TypeError` if content is passed a dict-instance. (#2495)
|
* Raise `TypeError` if content is passed a dict-instance. (#2495)
|
||||||
* Partially revert the API breaking change in 0.23.1, which removed `RawURL`. We continue to expose a `url.raw` property which is now a plain named-tuple. This API is still expected to be deprecated, but we will do so with a major version bump. (#2481)
|
* Partially revert the API breaking change in 0.23.1, which removed `RawURL`. We continue to expose a `url.raw` property which is now a plain named-tuple. This API is still expected to be deprecated, but we will do so with a major version bump. (#2481)
|
||||||
|
|
||||||
## 0.23.1 (18th Nov, 2022)
|
## 0.23.1 (18th November, 2022)
|
||||||
|
|
||||||
**Note**: The 0.23.1 release should have used a proper version bump, rather than a minor point release.
|
**Note**: The 0.23.1 release should have used a proper version bump, rather than a minor point release.
|
||||||
There are API surface area changes that may affect some users.
|
There are API surface area changes that may affect some users.
|
||||||
@ -79,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
|
||||||
|
|
||||||
@ -98,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
|
||||||
@ -253,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.
|
||||||
|
|
||||||
@ -507,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
|
||||||
@ -570,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
|
||||||
|
|
||||||
@ -735,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)
|
||||||
@ -984,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.7+.
|
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.7+ 版本。
|
|
||||||
|
|
||||||
## 文档
|
|
||||||
|
|
||||||
项目文档现已就绪,请访问 [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>
|
|
||||||
1264
docs/advanced.md
1264
docs/advanced.md
File diff suppressed because it is too large
Load Diff
232
docs/advanced/authentication.md
Normal file
232
docs/advanced/authentication.md
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
Authentication can either be included on a per-request basis...
|
||||||
|
|
||||||
|
```pycon
|
||||||
|
>>> auth = httpx.BasicAuth(username="username", password="secret")
|
||||||
|
>>> client = httpx.Client()
|
||||||
|
>>> response = client.get("https://www.example.com/", auth=auth)
|
||||||
|
```
|
||||||
|
|
||||||
|
Or configured on the client instance, ensuring that all outgoing requests will include authentication credentials...
|
||||||
|
|
||||||
|
```pycon
|
||||||
|
>>> auth = httpx.BasicAuth(username="username", password="secret")
|
||||||
|
>>> client = httpx.Client(auth=auth)
|
||||||
|
>>> response = client.get("https://www.example.com/")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Basic authentication
|
||||||
|
|
||||||
|
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
|
||||||
|
>>> auth = httpx.BasicAuth(username="finley", password="secret")
|
||||||
|
>>> client = httpx.Client(auth=auth)
|
||||||
|
>>> response = client.get("https://httpbin.org/basic-auth/finley/secret")
|
||||||
|
>>> response
|
||||||
|
<Response [200 OK]>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Digest authentication
|
||||||
|
|
||||||
|
HTTP digest authentication is a challenge-response authentication scheme. Unlike basic authentication it provides encryption, and can be used over unencrypted `http` connections. It requires an additional round-trip in order to negotiate the authentication.
|
||||||
|
|
||||||
|
```pycon
|
||||||
|
>>> auth = httpx.DigestAuth(username="olivia", password="secret")
|
||||||
|
>>> client = httpx.Client(auth=auth)
|
||||||
|
>>> response = client.get("https://httpbin.org/digest-auth/auth/olivia/secret")
|
||||||
|
>>> response
|
||||||
|
<Response [200 OK]>
|
||||||
|
>>> response.history
|
||||||
|
[<Response [401 UNAUTHORIZED]>]
|
||||||
|
```
|
||||||
|
|
||||||
|
## NetRC authentication
|
||||||
|
|
||||||
|
HTTPX can be configured to use [a `.netrc` config file](https://everything.curl.dev/usingcurl/netrc) for authentication.
|
||||||
|
|
||||||
|
The `.netrc` config file allows authentication credentials to be associated with specified hosts. When a request is made to a host that is found in the netrc file, the username and password will be included using HTTP basic authentication.
|
||||||
|
|
||||||
|
Example `.netrc` file:
|
||||||
|
|
||||||
|
```
|
||||||
|
machine example.org
|
||||||
|
login example-username
|
||||||
|
password example-password
|
||||||
|
|
||||||
|
machine python-httpx.org
|
||||||
|
login other-username
|
||||||
|
password other-password
|
||||||
|
```
|
||||||
|
|
||||||
|
Some examples of configuring `.netrc` authentication with `httpx`.
|
||||||
|
|
||||||
|
Use the default `.netrc` file in the users home directory:
|
||||||
|
|
||||||
|
```pycon
|
||||||
|
>>> auth = httpx.NetRCAuth()
|
||||||
|
>>> client = httpx.Client(auth=auth)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use an explicit path to a `.netrc` file:
|
||||||
|
|
||||||
|
```pycon
|
||||||
|
>>> auth = httpx.NetRCAuth(file="/path/to/.netrc")
|
||||||
|
>>> client = httpx.Client(auth=auth)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the `NETRC` environment variable to configure a path to the `.netrc` file,
|
||||||
|
or fallback to the default.
|
||||||
|
|
||||||
|
```pycon
|
||||||
|
>>> auth = httpx.NetRCAuth(file=os.environ.get("NETRC"))
|
||||||
|
>>> client = httpx.Client(auth=auth)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `NetRCAuth()` class uses [the `netrc.netrc()` function from the Python standard library](https://docs.python.org/3/library/netrc.html). See the documentation there for more details on exceptions that may be raised if the `.netrc` file is not found, or cannot be parsed.
|
||||||
|
|
||||||
|
## Custom authentication schemes
|
||||||
|
|
||||||
|
When issuing requests or instantiating a client, the `auth` argument can be used to pass an authentication scheme to use. The `auth` argument may be one of the following...
|
||||||
|
|
||||||
|
* A two-tuple of `username`/`password`, to be used with basic authentication.
|
||||||
|
* An instance of `httpx.BasicAuth()`, `httpx.DigestAuth()`, or `httpx.NetRCAuth()`.
|
||||||
|
* A callable, accepting a request and returning an authenticated request instance.
|
||||||
|
* An instance of subclasses of `httpx.Auth`.
|
||||||
|
|
||||||
|
The most involved of these is the last, which allows you to create authentication flows involving one or more requests. A subclass of `httpx.Auth` should implement `def auth_flow(request)`, and yield any requests that need to be made...
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MyCustomAuth(httpx.Auth):
|
||||||
|
def __init__(self, token):
|
||||||
|
self.token = token
|
||||||
|
|
||||||
|
def auth_flow(self, request):
|
||||||
|
# Send the request, with a custom `X-Authentication` header.
|
||||||
|
request.headers['X-Authentication'] = self.token
|
||||||
|
yield request
|
||||||
|
```
|
||||||
|
|
||||||
|
If the auth flow requires more than one request, you can issue multiple yields, and obtain the response in each case...
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MyCustomAuth(httpx.Auth):
|
||||||
|
def __init__(self, token):
|
||||||
|
self.token = token
|
||||||
|
|
||||||
|
def auth_flow(self, request):
|
||||||
|
response = yield request
|
||||||
|
if response.status_code == 401:
|
||||||
|
# If the server issues a 401 response then resend the request,
|
||||||
|
# with a custom `X-Authentication` header.
|
||||||
|
request.headers['X-Authentication'] = self.token
|
||||||
|
yield request
|
||||||
|
```
|
||||||
|
|
||||||
|
Custom authentication classes are designed to not perform any I/O, so that they may be used with both sync and async client instances. If you are implementing an authentication scheme that requires the request body, then you need to indicate this on the class using a `requires_request_body` property.
|
||||||
|
|
||||||
|
You will then be able to access `request.content` inside the `.auth_flow()` method.
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MyCustomAuth(httpx.Auth):
|
||||||
|
requires_request_body = True
|
||||||
|
|
||||||
|
def __init__(self, token):
|
||||||
|
self.token = token
|
||||||
|
|
||||||
|
def auth_flow(self, request):
|
||||||
|
response = yield request
|
||||||
|
if response.status_code == 401:
|
||||||
|
# If the server issues a 401 response then resend the request,
|
||||||
|
# with a custom `X-Authentication` header.
|
||||||
|
request.headers['X-Authentication'] = self.sign_request(...)
|
||||||
|
yield request
|
||||||
|
|
||||||
|
def sign_request(self, request):
|
||||||
|
# Create a request signature, based on `request.method`, `request.url`,
|
||||||
|
# `request.headers`, and `request.content`.
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Similarly, if you are implementing a scheme that requires access to the response body, then use the `requires_response_body` property. You will then be able to access response body properties and methods such as `response.content`, `response.text`, `response.json()`, etc.
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MyCustomAuth(httpx.Auth):
|
||||||
|
requires_response_body = True
|
||||||
|
|
||||||
|
def __init__(self, access_token, refresh_token, refresh_url):
|
||||||
|
self.access_token = access_token
|
||||||
|
self.refresh_token = refresh_token
|
||||||
|
self.refresh_url = refresh_url
|
||||||
|
|
||||||
|
def auth_flow(self, request):
|
||||||
|
request.headers["X-Authentication"] = self.access_token
|
||||||
|
response = yield request
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
# If the server issues a 401 response, then issue a request to
|
||||||
|
# refresh tokens, and resend the request.
|
||||||
|
refresh_response = yield self.build_refresh_request()
|
||||||
|
self.update_tokens(refresh_response)
|
||||||
|
|
||||||
|
request.headers["X-Authentication"] = self.access_token
|
||||||
|
yield request
|
||||||
|
|
||||||
|
def build_refresh_request(self):
|
||||||
|
# Return an `httpx.Request` for refreshing tokens.
|
||||||
|
...
|
||||||
|
|
||||||
|
def update_tokens(self, response):
|
||||||
|
# Update the `.access_token` and `.refresh_token` tokens
|
||||||
|
# based on a refresh response.
|
||||||
|
data = response.json()
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
If you _do_ need to perform I/O other than HTTP requests, such as accessing a disk-based cache, or you need to use concurrency primitives, such as locks, then you should override `.sync_auth_flow()` and `.async_auth_flow()` (instead of `.auth_flow()`). The former will be used by `httpx.Client`, while the latter will be used by `httpx.AsyncClient`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class MyCustomAuth(httpx.Auth):
|
||||||
|
def __init__(self):
|
||||||
|
self._sync_lock = threading.RLock()
|
||||||
|
self._async_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
def sync_get_token(self):
|
||||||
|
with self._sync_lock:
|
||||||
|
...
|
||||||
|
|
||||||
|
def sync_auth_flow(self, request):
|
||||||
|
token = self.sync_get_token()
|
||||||
|
request.headers["Authorization"] = f"Token {token}"
|
||||||
|
yield request
|
||||||
|
|
||||||
|
async def async_get_token(self):
|
||||||
|
async with self._async_lock:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def async_auth_flow(self, request):
|
||||||
|
token = await self.async_get_token()
|
||||||
|
request.headers["Authorization"] = f"Token {token}"
|
||||||
|
yield request
|
||||||
|
```
|
||||||
|
|
||||||
|
If you only want to support one of the two methods, then you should still override it, but raise an explicit `RuntimeError`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import httpx
|
||||||
|
import sync_only_library
|
||||||
|
|
||||||
|
|
||||||
|
class MyCustomAuth(httpx.Auth):
|
||||||
|
def sync_auth_flow(self, request):
|
||||||
|
token = sync_only_library.get_token(...)
|
||||||
|
request.headers["Authorization"] = f"Token {token}"
|
||||||
|
yield request
|
||||||
|
|
||||||
|
async def async_auth_flow(self, request):
|
||||||
|
raise RuntimeError("Cannot use a sync authentication class with httpx.AsyncClient")
|
||||||
|
```
|
||||||
328
docs/advanced/clients.md
Normal file
328
docs/advanced/clients.md
Normal file
@ -0,0 +1,328 @@
|
|||||||
|
!!! hint
|
||||||
|
If you are coming from Requests, `httpx.Client()` is what you can use instead of `requests.Session()`.
|
||||||
|
|
||||||
|
## Why use a Client?
|
||||||
|
|
||||||
|
!!! note "TL;DR"
|
||||||
|
If you do anything more than experimentation, one-off scripts, or prototypes, then you should use a `Client` instance.
|
||||||
|
|
||||||
|
**More efficient usage of network resources**
|
||||||
|
|
||||||
|
When you make requests using the top-level API as documented in the [Quickstart](../quickstart.md) guide, HTTPX has to establish a new connection _for every single request_ (connections are not reused). As the number of requests to a host increases, this quickly becomes inefficient.
|
||||||
|
|
||||||
|
On the other hand, a `Client` instance uses [HTTP connection pooling](https://en.wikipedia.org/wiki/HTTP_persistent_connection). This means that when you make several requests to the same host, the `Client` will reuse the underlying TCP connection, instead of recreating one for every single request.
|
||||||
|
|
||||||
|
This can bring **significant performance improvements** compared to using the top-level API, including:
|
||||||
|
|
||||||
|
- Reduced latency across requests (no handshaking).
|
||||||
|
- Reduced CPU usage and round-trips.
|
||||||
|
- Reduced network congestion.
|
||||||
|
|
||||||
|
**Extra features**
|
||||||
|
|
||||||
|
`Client` instances also support features that aren't available at the top-level API, such as:
|
||||||
|
|
||||||
|
- Cookie persistence across requests.
|
||||||
|
- Applying configuration across all outgoing requests.
|
||||||
|
- Sending requests through HTTP proxies.
|
||||||
|
- Using [HTTP/2](../http2.md).
|
||||||
|
|
||||||
|
The other sections on this page go into further detail about what you can do with a `Client` instance.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
The recommended way to use a `Client` is as a context manager. This will ensure that connections are properly cleaned up when leaving the `with` block:
|
||||||
|
|
||||||
|
```python
|
||||||
|
with httpx.Client() as client:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, you can explicitly close the connection pool without block-usage using `.close()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
client = httpx.Client()
|
||||||
|
try:
|
||||||
|
...
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Making requests
|
||||||
|
|
||||||
|
Once you have a `Client`, you can send requests using `.get()`, `.post()`, etc. For example:
|
||||||
|
|
||||||
|
```pycon
|
||||||
|
>>> with httpx.Client() as client:
|
||||||
|
... r = client.get('https://example.com')
|
||||||
|
...
|
||||||
|
>>> r
|
||||||
|
<Response [200 OK]>
|
||||||
|
```
|
||||||
|
|
||||||
|
These methods accept the same arguments as `httpx.get()`, `httpx.post()`, etc. This means that all features documented in the [Quickstart](../quickstart.md) guide are also available at the client level.
|
||||||
|
|
||||||
|
For example, to send a request with custom headers:
|
||||||
|
|
||||||
|
```pycon
|
||||||
|
>>> with httpx.Client() as client:
|
||||||
|
... headers = {'X-Custom': 'value'}
|
||||||
|
... r = client.get('https://example.com', headers=headers)
|
||||||
|
...
|
||||||
|
>>> r.request.headers['X-Custom']
|
||||||
|
'value'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sharing configuration across requests
|
||||||
|
|
||||||
|
Clients allow you to apply configuration to all outgoing requests by passing parameters to the `Client` constructor.
|
||||||
|
|
||||||
|
For example, to apply a set of custom headers _on every request_:
|
||||||
|
|
||||||
|
```pycon
|
||||||
|
>>> url = 'http://httpbin.org/headers'
|
||||||
|
>>> headers = {'user-agent': 'my-app/0.0.1'}
|
||||||
|
>>> with httpx.Client(headers=headers) as client:
|
||||||
|
... r = client.get(url)
|
||||||
|
...
|
||||||
|
>>> r.json()['headers']['User-Agent']
|
||||||
|
'my-app/0.0.1'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Merging of configuration
|
||||||
|
|
||||||
|
When a configuration option is provided at both the client-level and request-level, one of two things can happen:
|
||||||
|
|
||||||
|
- For headers, query parameters and cookies, the values are combined together. For example:
|
||||||
|
|
||||||
|
```pycon
|
||||||
|
>>> headers = {'X-Auth': 'from-client'}
|
||||||
|
>>> params = {'client_id': 'client1'}
|
||||||
|
>>> with httpx.Client(headers=headers, params=params) as client:
|
||||||
|
... headers = {'X-Custom': 'from-request'}
|
||||||
|
... params = {'request_id': 'request1'}
|
||||||
|
... r = client.get('https://example.com', headers=headers, params=params)
|
||||||
|
...
|
||||||
|
>>> r.request.url
|
||||||
|
URL('https://example.com?client_id=client1&request_id=request1')
|
||||||
|
>>> r.request.headers['X-Auth']
|
||||||
|
'from-client'
|
||||||
|
>>> r.request.headers['X-Custom']
|
||||||
|
'from-request'
|
||||||
|
```
|
||||||
|
|
||||||
|
- For all other parameters, the request-level value takes priority. For example:
|
||||||
|
|
||||||
|
```pycon
|
||||||
|
>>> with httpx.Client(auth=('tom', 'mot123')) as client:
|
||||||
|
... r = client.get('https://example.com', auth=('alice', 'ecila123'))
|
||||||
|
...
|
||||||
|
>>> _, _, auth = r.request.headers['Authorization'].partition(' ')
|
||||||
|
>>> import base64
|
||||||
|
>>> base64.b64decode(auth)
|
||||||
|
b'alice:ecila123'
|
||||||
|
```
|
||||||
|
|
||||||
|
If you need finer-grained control on the merging of client-level and request-level parameters, see [Request instances](#request-instances).
|
||||||
|
|
||||||
|
## Other Client-only configuration options
|
||||||
|
|
||||||
|
Additionally, `Client` accepts some configuration options that aren't available at the request level.
|
||||||
|
|
||||||
|
For example, `base_url` allows you to prepend an URL to all outgoing requests:
|
||||||
|
|
||||||
|
```pycon
|
||||||
|
>>> with httpx.Client(base_url='http://httpbin.org') as client:
|
||||||
|
... r = client.get('/headers')
|
||||||
|
...
|
||||||
|
>>> r.request.url
|
||||||
|
URL('http://httpbin.org/headers')
|
||||||
|
```
|
||||||
|
|
||||||
|
For a list of all available client parameters, see the [`Client`](../api.md#client) API reference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request instances
|
||||||
|
|
||||||
|
For maximum control on what gets sent over the wire, HTTPX supports building explicit [`Request`](../api.md#request) instances:
|
||||||
|
|
||||||
|
```python
|
||||||
|
request = httpx.Request("GET", "https://example.com")
|
||||||
|
```
|
||||||
|
|
||||||
|
To dispatch a `Request` instance across to the network, create a [`Client` instance](#client-instances) and use `.send()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
with httpx.Client() as client:
|
||||||
|
response = client.send(request)
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
If you need to mix client-level and request-level options in a way that is not supported by the default [Merging of parameters](#merging-of-parameters), you can use `.build_request()` and then make arbitrary modifications to the `Request` instance. For example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
headers = {"X-Api-Key": "...", "X-Client-ID": "ABC123"}
|
||||||
|
|
||||||
|
with httpx.Client(headers=headers) as client:
|
||||||
|
request = client.build_request("GET", "https://api.example.com")
|
||||||
|
|
||||||
|
print(request.headers["X-Client-ID"]) # "ABC123"
|
||||||
|
|
||||||
|
# Don't send the API key for this particular request.
|
||||||
|
del request.headers["X-Api-Key"]
|
||||||
|
|
||||||
|
response = client.send(request)
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring download progress
|
||||||
|
|
||||||
|
If you need to monitor download progress of large responses, you can use response streaming and inspect the `response.num_bytes_downloaded` property.
|
||||||
|
|
||||||
|
This interface is required for properly determining download progress, because the total number of bytes returned by `response.content` or `response.iter_content()` will not always correspond with the raw content length of the response if HTTP response compression is being used.
|
||||||
|
|
||||||
|
For example, showing a progress bar using the [`tqdm`](https://github.com/tqdm/tqdm) library while a response is being downloaded could be done like this…
|
||||||
|
|
||||||
|
```python
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile() as download_file:
|
||||||
|
url = "https://speed.hetzner.de/100MB.bin"
|
||||||
|
with httpx.stream("GET", url) as response:
|
||||||
|
total = int(response.headers["Content-Length"])
|
||||||
|
|
||||||
|
with tqdm(total=total, unit_scale=True, unit_divisor=1024, unit="B") as progress:
|
||||||
|
num_bytes_downloaded = response.num_bytes_downloaded
|
||||||
|
for chunk in response.iter_bytes():
|
||||||
|
download_file.write(chunk)
|
||||||
|
progress.update(response.num_bytes_downloaded - num_bytes_downloaded)
|
||||||
|
num_bytes_downloaded = response.num_bytes_downloaded
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Or an alternate example, this time using the [`rich`](https://github.com/willmcgugan/rich) library…
|
||||||
|
|
||||||
|
```python
|
||||||
|
import tempfile
|
||||||
|
import httpx
|
||||||
|
import rich.progress
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile() as download_file:
|
||||||
|
url = "https://speed.hetzner.de/100MB.bin"
|
||||||
|
with httpx.stream("GET", url) as response:
|
||||||
|
total = int(response.headers["Content-Length"])
|
||||||
|
|
||||||
|
with rich.progress.Progress(
|
||||||
|
"[progress.percentage]{task.percentage:>3.0f}%",
|
||||||
|
rich.progress.BarColumn(bar_width=None),
|
||||||
|
rich.progress.DownloadColumn(),
|
||||||
|
rich.progress.TransferSpeedColumn(),
|
||||||
|
) as progress:
|
||||||
|
download_task = progress.add_task("Download", total=total)
|
||||||
|
for chunk in response.iter_bytes():
|
||||||
|
download_file.write(chunk)
|
||||||
|
progress.update(download_task, completed=response.num_bytes_downloaded)
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Monitoring upload progress
|
||||||
|
|
||||||
|
If you need to monitor upload progress of large responses, you can use request content generator streaming.
|
||||||
|
|
||||||
|
For example, showing a progress bar using the [`tqdm`](https://github.com/tqdm/tqdm) library.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import io
|
||||||
|
import random
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
|
||||||
|
def gen():
|
||||||
|
"""
|
||||||
|
this is a complete example with generated random bytes.
|
||||||
|
you can replace `io.BytesIO` with real file object.
|
||||||
|
"""
|
||||||
|
total = 32 * 1024 * 1024 # 32m
|
||||||
|
with tqdm(ascii=True, unit_scale=True, unit='B', unit_divisor=1024, total=total) as bar:
|
||||||
|
with io.BytesIO(random.randbytes(total)) as f:
|
||||||
|
while data := f.read(1024):
|
||||||
|
yield data
|
||||||
|
bar.update(len(data))
|
||||||
|
|
||||||
|
|
||||||
|
httpx.post("https://httpbin.org/post", content=gen())
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Multipart file encoding
|
||||||
|
|
||||||
|
As mentioned in the [quickstart](../quickstart.md#sending-multipart-file-uploads)
|
||||||
|
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.
|
||||||
|
|
||||||
|
```pycon
|
||||||
|
>>> with open('report.xls', 'rb') as report_file:
|
||||||
|
... files = {'upload-file': ('report.xls', report_file, 'application/vnd.ms-excel')}
|
||||||
|
... r = httpx.post("https://httpbin.org/post", files=files)
|
||||||
|
>>> print(r.text)
|
||||||
|
{
|
||||||
|
...
|
||||||
|
"files": {
|
||||||
|
"upload-file": "<... binary content ...>"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
More specifically, if a tuple is used as a value, it must have between 2 and 3 elements:
|
||||||
|
|
||||||
|
- The first element is an optional file name which can be set to `None`.
|
||||||
|
- The second element may be a file-like object or a string which will be automatically
|
||||||
|
encoded in UTF-8.
|
||||||
|
- An optional third element can be used to specify the
|
||||||
|
[MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_Types)
|
||||||
|
of the file being uploaded. If not specified HTTPX will attempt to guess the MIME type based
|
||||||
|
on the file name, with unknown file extensions defaulting to "application/octet-stream".
|
||||||
|
If the file name is explicitly set to `None` then HTTPX will not include a content-type
|
||||||
|
MIME header field.
|
||||||
|
|
||||||
|
```pycon
|
||||||
|
>>> files = {'upload-file': (None, 'text content', 'text/plain')}
|
||||||
|
>>> r = httpx.post("https://httpbin.org/post", files=files)
|
||||||
|
>>> print(r.text)
|
||||||
|
{
|
||||||
|
...
|
||||||
|
"files": {},
|
||||||
|
"form": {
|
||||||
|
"upload-file": "text-content"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! tip
|
||||||
|
It is safe to upload large files this way. File uploads are streaming by default, meaning that only one chunk will be loaded into memory at a time.
|
||||||
|
|
||||||
|
Non-file data fields can be included in the multipart form using by passing them to `data=...`.
|
||||||
|
|
||||||
|
You can also send multiple files in one go with a multiple file field form.
|
||||||
|
To do that, pass a list of `(field, <file>)` items instead of a dictionary, allowing you to pass multiple items with the same `field`.
|
||||||
|
For instance this request sends 2 files, `foo.png` and `bar.png` in one request on the `images` form field:
|
||||||
|
|
||||||
|
```pycon
|
||||||
|
>>> with open('foo.png', 'rb') as foo_file, open('bar.png', 'rb') as bar_file:
|
||||||
|
... files = [
|
||||||
|
... ('images', ('foo.png', foo_file, 'image/png')),
|
||||||
|
... ('images', ('bar.png', bar_file, 'image/png')),
|
||||||
|
... ]
|
||||||
|
... r = httpx.post("https://httpbin.org/post", files=files)
|
||||||
|
```
|
||||||
65
docs/advanced/event-hooks.md
Normal file
65
docs/advanced/event-hooks.md
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
HTTPX allows you to register "event hooks" with the client, that are called
|
||||||
|
every time a particular type of event takes place.
|
||||||
|
|
||||||
|
There are currently two event hooks:
|
||||||
|
|
||||||
|
* `request` - Called after a request is fully prepared, but before it is sent to the network. Passed the `request` instance.
|
||||||
|
* `response` - Called after the response has been fetched from the network, but before it is returned to the caller. Passed the `response` instance.
|
||||||
|
|
||||||
|
These allow you to install client-wide functionality such as logging, monitoring or tracing.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def log_request(request):
|
||||||
|
print(f"Request event hook: {request.method} {request.url} - Waiting for response")
|
||||||
|
|
||||||
|
def log_response(response):
|
||||||
|
request = response.request
|
||||||
|
print(f"Response event hook: {request.method} {request.url} - Status {response.status_code}")
|
||||||
|
|
||||||
|
client = httpx.Client(event_hooks={'request': [log_request], 'response': [log_response]})
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also use these hooks to install response processing code, such as this
|
||||||
|
example, which creates a client instance that always raises `httpx.HTTPStatusError`
|
||||||
|
on 4xx and 5xx responses.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def raise_on_4xx_5xx(response):
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
client = httpx.Client(event_hooks={'response': [raise_on_4xx_5xx]})
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
Response event hooks are called before determining if the response body
|
||||||
|
should be read or not.
|
||||||
|
|
||||||
|
If you need access to the response body inside an event hook, you'll
|
||||||
|
need to call `response.read()`, or for AsyncClients, `response.aread()`.
|
||||||
|
|
||||||
|
The hooks are also allowed to modify `request` and `response` objects.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def add_timestamp(request):
|
||||||
|
request.headers['x-request-timestamp'] = datetime.now(tz=datetime.utc).isoformat()
|
||||||
|
|
||||||
|
client = httpx.Client(event_hooks={'request': [add_timestamp]})
|
||||||
|
```
|
||||||
|
|
||||||
|
Event hooks must always be set as a **list of callables**, and you may register
|
||||||
|
multiple event hooks for each type of event.
|
||||||
|
|
||||||
|
As well as being able to set event hooks on instantiating the client, there
|
||||||
|
is also an `.event_hooks` property, that allows you to inspect and modify
|
||||||
|
the installed hooks.
|
||||||
|
|
||||||
|
```python
|
||||||
|
client = httpx.Client()
|
||||||
|
client.event_hooks['request'] = [log_request]
|
||||||
|
client.event_hooks['response'] = [log_response, raise_on_4xx_5xx]
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
If you are using HTTPX's async support, then you need to be aware that
|
||||||
|
hooks registered with `httpx.AsyncClient` MUST be async functions,
|
||||||
|
rather than plain functions.
|
||||||
242
docs/advanced/extensions.md
Normal file
242
docs/advanced/extensions.md
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
# Extensions
|
||||||
|
|
||||||
|
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` package uses as its API.
|
||||||
|
|
||||||
|
Several extensions are supported on the request:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Request timeouts actually implemented as an extension on
|
||||||
|
# the request, ensuring that they are passed throughout the
|
||||||
|
# entire call stack.
|
||||||
|
client = httpx.Client()
|
||||||
|
response = client.get(
|
||||||
|
"https://www.example.com",
|
||||||
|
extensions={"timeout": {"connect": 5.0}}
|
||||||
|
)
|
||||||
|
response.request.extensions["timeout"]
|
||||||
|
{"connect": 5.0}
|
||||||
|
```
|
||||||
|
|
||||||
|
And on the response:
|
||||||
|
|
||||||
|
```python
|
||||||
|
client = httpx.Client()
|
||||||
|
response = client.get("https://www.example.com")
|
||||||
|
print(response.extensions["http_version"]) # b"HTTP/1.1"
|
||||||
|
# Other server responses could have been
|
||||||
|
# b"HTTP/0.9", b"HTTP/1.0", or b"HTTP/1.1"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Request Extensions
|
||||||
|
|
||||||
|
### `"trace"`
|
||||||
|
|
||||||
|
The trace extension allows a callback handler to be installed to monitor the internal
|
||||||
|
flow of events within the underlying `httpcore` transport.
|
||||||
|
|
||||||
|
The simplest way to explain this is with an example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
def log(event_name, info):
|
||||||
|
print(event_name, info)
|
||||||
|
|
||||||
|
client = httpx.Client()
|
||||||
|
response = client.get("https://www.example.com/", extensions={"trace": log})
|
||||||
|
# connection.connect_tcp.started {'host': 'www.example.com', 'port': 443, 'local_address': None, 'timeout': None}
|
||||||
|
# connection.connect_tcp.complete {'return_value': <httpcore.backends.sync.SyncStream object at 0x1093f94d0>}
|
||||||
|
# connection.start_tls.started {'ssl_context': <ssl.SSLContext object at 0x1093ee750>, 'server_hostname': b'www.example.com', 'timeout': None}
|
||||||
|
# connection.start_tls.complete {'return_value': <httpcore.backends.sync.SyncStream object at 0x1093f9450>}
|
||||||
|
# http11.send_request_headers.started {'request': <Request [b'GET']>}
|
||||||
|
# http11.send_request_headers.complete {'return_value': None}
|
||||||
|
# http11.send_request_body.started {'request': <Request [b'GET']>}
|
||||||
|
# http11.send_request_body.complete {'return_value': None}
|
||||||
|
# http11.receive_response_headers.started {'request': <Request [b'GET']>}
|
||||||
|
# http11.receive_response_headers.complete {'return_value': (b'HTTP/1.1', 200, b'OK', [(b'Age', b'553715'), (b'Cache-Control', b'max-age=604800'), (b'Content-Type', b'text/html; charset=UTF-8'), (b'Date', b'Thu, 21 Oct 2021 17:08:42 GMT'), (b'Etag', b'"3147526947+ident"'), (b'Expires', b'Thu, 28 Oct 2021 17:08:42 GMT'), (b'Last-Modified', b'Thu, 17 Oct 2019 07:18:26 GMT'), (b'Server', b'ECS (nyb/1DCD)'), (b'Vary', b'Accept-Encoding'), (b'X-Cache', b'HIT'), (b'Content-Length', b'1256')])}
|
||||||
|
# http11.receive_response_body.started {'request': <Request [b'GET']>}
|
||||||
|
# http11.receive_response_body.complete {'return_value': None}
|
||||||
|
# http11.response_closed.started {}
|
||||||
|
# http11.response_closed.complete {'return_value': None}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `event_name` and `info` arguments here will be one of the following:
|
||||||
|
|
||||||
|
* `{event_type}.{event_name}.started`, `<dictionary of keyword arguments>`
|
||||||
|
* `{event_type}.{event_name}.complete`, `{"return_value": <...>}`
|
||||||
|
* `{event_type}.{event_name}.failed`, `{"exception": <...>}`
|
||||||
|
|
||||||
|
Note that when using async code the handler function passed to `"trace"` must be an `async def ...` function.
|
||||||
|
|
||||||
|
The following event types are currently exposed...
|
||||||
|
|
||||||
|
**Establishing the connection**
|
||||||
|
|
||||||
|
* `"connection.connect_tcp"`
|
||||||
|
* `"connection.connect_unix_socket"`
|
||||||
|
* `"connection.start_tls"`
|
||||||
|
|
||||||
|
**HTTP/1.1 events**
|
||||||
|
|
||||||
|
* `"http11.send_request_headers"`
|
||||||
|
* `"http11.send_request_body"`
|
||||||
|
* `"http11.receive_response"`
|
||||||
|
* `"http11.receive_response_body"`
|
||||||
|
* `"http11.response_closed"`
|
||||||
|
|
||||||
|
**HTTP/2 events**
|
||||||
|
|
||||||
|
* `"http2.send_connection_init"`
|
||||||
|
* `"http2.send_request_headers"`
|
||||||
|
* `"http2.send_request_body"`
|
||||||
|
* `"http2.receive_response_headers"`
|
||||||
|
* `"http2.receive_response_body"`
|
||||||
|
* `"http2.response_closed"`
|
||||||
|
|
||||||
|
The exact set of trace events may be subject to change across different versions of `httpcore`. If you need to rely on a particular set of events it is recommended that you pin installation of the package to a fixed version.
|
||||||
|
|
||||||
|
### `"sni_hostname"`
|
||||||
|
|
||||||
|
The server's hostname, which is used to confirm the hostname supplied by the SSL certificate.
|
||||||
|
|
||||||
|
If you want to connect to an explicit IP address rather than using the standard DNS hostname lookup, then you'll need to use this request extension.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
``` python
|
||||||
|
# Connect to '185.199.108.153' but use 'www.encode.io' in the Host header,
|
||||||
|
# and use 'www.encode.io' when SSL verifying the server hostname.
|
||||||
|
client = httpx.Client()
|
||||||
|
headers = {"Host": "www.encode.io"}
|
||||||
|
extensions = {"sni_hostname": "www.encode.io"}
|
||||||
|
response = client.get(
|
||||||
|
"https://185.199.108.153/path",
|
||||||
|
headers=headers,
|
||||||
|
extensions=extensions
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `"timeout"`
|
||||||
|
|
||||||
|
A dictionary of `str: Optional[float]` timeout values.
|
||||||
|
|
||||||
|
May include values for `'connect'`, `'read'`, `'write'`, or `'pool'`.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Timeout if a connection takes more than 5 seconds to established, or if
|
||||||
|
# we are blocked waiting on the connection pool for more than 10 seconds.
|
||||||
|
client = httpx.Client()
|
||||||
|
response = client.get(
|
||||||
|
"https://www.example.com",
|
||||||
|
extensions={"timeout": {"connect": 5.0, "pool": 10.0}}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
### `"http_version"`
|
||||||
|
|
||||||
|
The HTTP version, as bytes. Eg. `b"HTTP/1.1"`.
|
||||||
|
|
||||||
|
When using HTTP/1.1 the response line includes an explicit version, and the value of this key could feasibly be one of `b"HTTP/0.9"`, `b"HTTP/1.0"`, or `b"HTTP/1.1"`.
|
||||||
|
|
||||||
|
When using HTTP/2 there is no further response versioning included in the protocol, and the value of this key will always be `b"HTTP/2"`.
|
||||||
|
|
||||||
|
### `"reason_phrase"`
|
||||||
|
|
||||||
|
The reason-phrase of the HTTP response, as bytes. For example `b"OK"`. Some servers may include a custom reason phrase, although this is not recommended.
|
||||||
|
|
||||||
|
HTTP/2 onwards does not include a reason phrase on the wire.
|
||||||
|
|
||||||
|
When no key is included, a default based on the status code may be used.
|
||||||
|
|
||||||
|
### `"stream_id"`
|
||||||
|
|
||||||
|
When HTTP/2 is being used the `"stream_id"` response extension can be accessed to determine the ID of the data stream that the response was sent on.
|
||||||
|
|
||||||
|
### `"network_stream"`
|
||||||
|
|
||||||
|
The `"network_stream"` extension allows developers to handle HTTP `CONNECT` and `Upgrade` requests, by providing an API that steps outside the standard request/response model, and can directly read or write to the network.
|
||||||
|
|
||||||
|
The interface provided by the network stream:
|
||||||
|
|
||||||
|
* `read(max_bytes, timeout = None) -> bytes`
|
||||||
|
* `write(buffer, timeout = None)`
|
||||||
|
* `close()`
|
||||||
|
* `start_tls(ssl_context, server_hostname = None, timeout = None) -> NetworkStream`
|
||||||
|
* `get_extra_info(info) -> Any`
|
||||||
|
|
||||||
|
This API can be used as the foundation for working with HTTP proxies, WebSocket upgrades, and other advanced use-cases.
|
||||||
|
|
||||||
|
See the [network backends documentation](https://www.encode.io/httpcore/network-backends/) for more information on working directly with network streams.
|
||||||
|
|
||||||
|
**Extra network information**
|
||||||
|
|
||||||
|
The network stream abstraction also allows access to various low-level information that may be exposed by the underlying socket:
|
||||||
|
|
||||||
|
```python
|
||||||
|
response = httpx.get("https://www.example.com")
|
||||||
|
network_stream = response.extensions["network_stream"]
|
||||||
|
|
||||||
|
client_addr = network_stream.get_extra_info("client_addr")
|
||||||
|
server_addr = network_stream.get_extra_info("server_addr")
|
||||||
|
print("Client address", client_addr)
|
||||||
|
print("Server address", server_addr)
|
||||||
|
```
|
||||||
|
|
||||||
|
The socket SSL information is also available through this interface, although you need to ensure that the underlying connection is still open, in order to access it...
|
||||||
|
|
||||||
|
```python
|
||||||
|
with httpx.stream("GET", "https://www.example.com") as response:
|
||||||
|
network_stream = response.extensions["network_stream"]
|
||||||
|
|
||||||
|
ssl_object = network_stream.get_extra_info("ssl_object")
|
||||||
|
print("TLS version", ssl_object.version())
|
||||||
|
```
|
||||||
83
docs/advanced/proxies.md
Normal file
83
docs/advanced/proxies.md
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
HTTPX supports setting up [HTTP proxies](https://en.wikipedia.org/wiki/Proxy_server#Web_proxy_servers) via the `proxy` parameter to be passed on client initialization or top-level API functions like `httpx.get(..., proxy=...)`.
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/2/27/Open_proxy_h2g2bob.svg/480px-Open_proxy_h2g2bob.svg.png"/>
|
||||||
|
<figcaption><em>Diagram of how a proxy works (source: Wikipedia). The left hand side "Internet" blob may be your HTTPX client requesting <code>example.com</code> through a proxy.</em></figcaption>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## HTTP Proxies
|
||||||
|
|
||||||
|
To route all traffic (HTTP and HTTPS) to a proxy located at `http://localhost:8030`, pass the proxy URL to the client...
|
||||||
|
|
||||||
|
```python
|
||||||
|
with httpx.Client(proxy="http://localhost:8030") as client:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
For more advanced use cases, pass a mounts `dict`. For example, to route HTTP and HTTPS requests to 2 different proxies, respectively located at `http://localhost:8030`, and `http://localhost:8031`, pass a `dict` of proxy URLs:
|
||||||
|
|
||||||
|
```python
|
||||||
|
proxy_mounts = {
|
||||||
|
"http://": httpx.HTTPTransport(proxy="http://localhost:8030"),
|
||||||
|
"https://": httpx.HTTPTransport(proxy="http://localhost:8031"),
|
||||||
|
}
|
||||||
|
|
||||||
|
with httpx.Client(mounts=proxy_mounts) as client:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
For detailed information about proxy routing, see the [Routing](#routing) section.
|
||||||
|
|
||||||
|
!!! tip "Gotcha"
|
||||||
|
In most cases, the proxy URL for the `https://` key _should_ use the `http://` scheme (that's not a typo!).
|
||||||
|
|
||||||
|
This is because HTTP proxying requires initiating a connection with the proxy server. While it's possible that your proxy supports doing it via HTTPS, most proxies only support doing it via HTTP.
|
||||||
|
|
||||||
|
For more information, see [FORWARD vs TUNNEL](#forward-vs-tunnel).
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Proxy credentials can be passed as the `userinfo` section of the proxy URL. For example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
with httpx.Client(proxy="http://username:password@localhost:8030") as client:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Proxy mechanisms
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
This section describes **advanced** proxy concepts and functionality.
|
||||||
|
|
||||||
|
### FORWARD vs TUNNEL
|
||||||
|
|
||||||
|
In general, the flow for making an HTTP request through a proxy is as follows:
|
||||||
|
|
||||||
|
1. The client connects to the proxy (initial connection request).
|
||||||
|
2. The proxy transfers data to the server on your behalf.
|
||||||
|
|
||||||
|
How exactly step 2/ is performed depends on which of two proxying mechanisms is used:
|
||||||
|
|
||||||
|
* **Forwarding**: the proxy makes the request for you, and sends back the response it obtained from the server.
|
||||||
|
* **Tunnelling**: the proxy establishes a TCP connection to the server on your behalf, and the client reuses this connection to send the request and receive the response. This is known as an [HTTP Tunnel](https://en.wikipedia.org/wiki/HTTP_tunnel). This mechanism is how you can access websites that use HTTPS from an HTTP proxy (the client "upgrades" the connection to HTTPS by performing the TLS handshake with the server over the TCP connection provided by the proxy).
|
||||||
|
|
||||||
|
### Troubleshooting proxies
|
||||||
|
|
||||||
|
If you encounter issues when setting up proxies, please refer to our [Troubleshooting guide](../troubleshooting.md#proxies).
|
||||||
|
|
||||||
|
## SOCKS
|
||||||
|
|
||||||
|
In addition to HTTP proxies, `httpcore` also supports proxies using the SOCKS protocol.
|
||||||
|
This is an optional feature that requires an additional third-party library be installed before use.
|
||||||
|
|
||||||
|
You can install SOCKS support using `pip`:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ pip install httpx[socks]
|
||||||
|
```
|
||||||
|
|
||||||
|
You can now configure a client to make requests via a proxy using the SOCKS protocol:
|
||||||
|
|
||||||
|
```python
|
||||||
|
httpx.Client(proxy='socks5://user:pass@host:port')
|
||||||
|
```
|
||||||
13
docs/advanced/resource-limits.md
Normal file
13
docs/advanced/resource-limits.md
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
You can control the connection pool size using the `limits` keyword
|
||||||
|
argument on the client. It takes instances of `httpx.Limits` which define:
|
||||||
|
|
||||||
|
- `max_keepalive_connections`, number of allowable keep-alive connections, or `None` to always
|
||||||
|
allow. (Defaults 20)
|
||||||
|
- `max_connections`, maximum number of allowable connections, or `None` for no limits.
|
||||||
|
(Default 100)
|
||||||
|
- `keepalive_expiry`, time limit on idle keep-alive connections in seconds, or `None` for no limits. (Default 5)
|
||||||
|
|
||||||
|
```python
|
||||||
|
limits = httpx.Limits(max_keepalive_connections=5, max_connections=10)
|
||||||
|
client = httpx.Client(limits=limits)
|
||||||
|
```
|
||||||
89
docs/advanced/ssl.md
Normal file
89
docs/advanced/ssl.md
Normal file
@ -0,0 +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).
|
||||||
|
|
||||||
|
### Enabling and disabling verification
|
||||||
|
|
||||||
|
By default httpx will verify HTTPS connections, and raise an error for invalid SSL cases...
|
||||||
|
|
||||||
|
```pycon
|
||||||
|
>>> httpx.get("https://expired.badssl.com/")
|
||||||
|
httpx.ConnectError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:997)
|
||||||
|
```
|
||||||
|
|
||||||
|
You can disable SSL verification completely and allow insecure requests...
|
||||||
|
|
||||||
|
```pycon
|
||||||
|
>>> httpx.get("https://expired.badssl.com/", verify=False)
|
||||||
|
<Response [200 OK]>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuring client instances
|
||||||
|
|
||||||
|
If you're using a `Client()` instance you should pass any `verify=<...>` configuration when instantiating the client.
|
||||||
|
|
||||||
|
By default the [certifi CA bundle](https://certifiio.readthedocs.io/en/latest/) is used for SSL verification.
|
||||||
|
|
||||||
|
For more complex configurations you can pass an [SSL Context](https://docs.python.org/3/library/ssl.html) instance...
|
||||||
|
|
||||||
|
```python
|
||||||
|
import certifi
|
||||||
|
import httpx
|
||||||
|
import ssl
|
||||||
|
|
||||||
|
# This SSL context is equivalent to the default `verify=True`.
|
||||||
|
ctx = ssl.create_default_context(cafile=certifi.where())
|
||||||
|
client = httpx.Client(verify=ctx)
|
||||||
|
```
|
||||||
|
|
||||||
|
Using [the `truststore` package](https://truststore.readthedocs.io/) to support system certificate stores...
|
||||||
|
|
||||||
|
```python
|
||||||
|
import ssl
|
||||||
|
import truststore
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
# Use system certificate stores.
|
||||||
|
ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
client = httpx.Client(verify=ctx)
|
||||||
|
```
|
||||||
|
|
||||||
|
Loding an alternative certificate verification store using [the standard SSL context API](https://docs.python.org/3/library/ssl.html)...
|
||||||
|
|
||||||
|
```python
|
||||||
|
import httpx
|
||||||
|
import ssl
|
||||||
|
|
||||||
|
# Use an explicitly configured certificate store.
|
||||||
|
ctx = ssl.create_default_context(cafile="path/to/certs.pem") # Either cafile or capath.
|
||||||
|
client = httpx.Client(verify=ctx)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client side certificates
|
||||||
|
|
||||||
|
Client side certificates allow a remote server to verify the client. They tend to be used within private organizations to authenticate requests to remote servers.
|
||||||
|
|
||||||
|
You can specify client-side certificates, using the [`.load_cert_chain()`](https://docs.python.org/3/library/ssl.html#ssl.SSLContext.load_cert_chain) API...
|
||||||
|
|
||||||
|
```python
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
ctx.load_cert_chain(certfile="path/to/client.pem") # Optionally also keyfile or password.
|
||||||
|
client = httpx.Client(verify=ctx)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Working with `SSL_CERT_FILE` and `SSL_CERT_DIR`
|
||||||
|
|
||||||
|
`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).
|
||||||
|
|
||||||
|
### Making HTTPS requests to a local server
|
||||||
|
|
||||||
|
When making requests to local servers, such as a development server running on `localhost`, you will typically be using unencrypted HTTP connections.
|
||||||
|
|
||||||
|
If you do need to make HTTPS connections to a local server, for example to test an HTTPS-only service, you will need to create and use your own certificates. Here's one way to do it...
|
||||||
|
|
||||||
|
1. Use [trustme](https://github.com/python-trio/trustme) to generate a pair of server key/cert files, and a client cert file.
|
||||||
|
2. Pass the server key/cert files when starting your local server. (This depends on the particular web server you're using. For example, [Uvicorn](https://www.uvicorn.org) provides the `--ssl-keyfile` and `--ssl-certfile` options.)
|
||||||
|
3. Configure `httpx` to use the certificates stored in `client.pem`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
ctx = ssl.create_default_context(cafile="client.pem")
|
||||||
|
client = httpx.Client(verify=ctx)
|
||||||
|
```
|
||||||
75
docs/advanced/text-encodings.md
Normal file
75
docs/advanced/text-encodings.md
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
When accessing `response.text`, we need to decode the response bytes into a unicode text representation.
|
||||||
|
|
||||||
|
By default `httpx` will use `"charset"` information included in the response `Content-Type` header to determine how the response bytes should be decoded into text.
|
||||||
|
|
||||||
|
In cases where no charset information is included on the response, the default behaviour is to assume "utf-8" encoding, which is by far the most widely used text encoding on the internet.
|
||||||
|
|
||||||
|
## Using the default encoding
|
||||||
|
|
||||||
|
To understand this better let's start by looking at the default behaviour for text decoding...
|
||||||
|
|
||||||
|
```python
|
||||||
|
import httpx
|
||||||
|
# Instantiate a client with the default configuration.
|
||||||
|
client = httpx.Client()
|
||||||
|
# Using the client...
|
||||||
|
response = client.get(...)
|
||||||
|
print(response.encoding) # This will either print the charset given in
|
||||||
|
# the Content-Type charset, or else "utf-8".
|
||||||
|
print(response.text) # The text will either be decoded with the Content-Type
|
||||||
|
# charset, or using "utf-8".
|
||||||
|
```
|
||||||
|
|
||||||
|
This is normally absolutely fine. Most servers will respond with a properly formatted Content-Type header, including a charset encoding. And in most cases where no charset encoding is included, UTF-8 is very likely to be used, since it is so widely adopted.
|
||||||
|
|
||||||
|
## Using an explicit encoding
|
||||||
|
|
||||||
|
In some cases we might be making requests to a site where no character set information is being set explicitly by the server, but we know what the encoding is. In this case it's best to set the default encoding explicitly on the client.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import httpx
|
||||||
|
# Instantiate a client with a Japanese character set as the default encoding.
|
||||||
|
client = httpx.Client(default_encoding="shift-jis")
|
||||||
|
# Using the client...
|
||||||
|
response = client.get(...)
|
||||||
|
print(response.encoding) # This will either print the charset given in
|
||||||
|
# the Content-Type charset, or else "shift-jis".
|
||||||
|
print(response.text) # The text will either be decoded with the Content-Type
|
||||||
|
# charset, or using "shift-jis".
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using auto-detection
|
||||||
|
|
||||||
|
In cases where the server is not reliably including character set information, and where we don't know what encoding is being used, we can enable auto-detection to make a best-guess attempt when decoding from bytes to text.
|
||||||
|
|
||||||
|
To use auto-detection you need to set the `default_encoding` argument to a callable instead of a string. This callable should be a function which takes the input bytes as an argument and returns the character set to use for decoding those bytes to text.
|
||||||
|
|
||||||
|
There are two widely used Python packages which both handle this functionality:
|
||||||
|
|
||||||
|
* [`chardet`](https://chardet.readthedocs.io/) - This is a well established package, and is a port of [the auto-detection code in Mozilla](https://www-archive.mozilla.org/projects/intl/chardet.html).
|
||||||
|
* [`charset-normalizer`](https://charset-normalizer.readthedocs.io/) - A newer package, motivated by `chardet`, with a different approach.
|
||||||
|
|
||||||
|
Let's take a look at installing autodetection using one of these packages...
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ pip install httpx
|
||||||
|
$ pip install chardet
|
||||||
|
```
|
||||||
|
|
||||||
|
Once `chardet` is installed, we can configure a client to use character-set autodetection.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import httpx
|
||||||
|
import chardet
|
||||||
|
|
||||||
|
def autodetect(content):
|
||||||
|
return chardet.detect(content).get("encoding")
|
||||||
|
|
||||||
|
# Using a client with character-set autodetection enabled.
|
||||||
|
client = httpx.Client(default_encoding=autodetect)
|
||||||
|
response = client.get(...)
|
||||||
|
print(response.encoding) # This will either print the charset given in
|
||||||
|
# the Content-Type charset, or else the auto-detected
|
||||||
|
# character set.
|
||||||
|
print(response.text)
|
||||||
|
```
|
||||||
71
docs/advanced/timeouts.md
Normal file
71
docs/advanced/timeouts.md
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
HTTPX is careful to enforce timeouts everywhere by default.
|
||||||
|
|
||||||
|
The default behavior is to raise a `TimeoutException` after 5 seconds of
|
||||||
|
network inactivity.
|
||||||
|
|
||||||
|
## Setting and disabling timeouts
|
||||||
|
|
||||||
|
You can set timeouts for an individual request:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Using the top-level API:
|
||||||
|
httpx.get('http://example.com/api/v1/example', timeout=10.0)
|
||||||
|
|
||||||
|
# Using a client instance:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
client.get("http://example.com/api/v1/example", timeout=10.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
Or disable timeouts for an individual request:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Using the top-level API:
|
||||||
|
httpx.get('http://example.com/api/v1/example', timeout=None)
|
||||||
|
|
||||||
|
# Using a client instance:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
client.get("http://example.com/api/v1/example", timeout=None)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setting a default timeout on a client
|
||||||
|
|
||||||
|
You can set a timeout on a client instance, which results in the given
|
||||||
|
`timeout` being used as the default for requests made with this client:
|
||||||
|
|
||||||
|
```python
|
||||||
|
client = httpx.Client() # Use a default 5s timeout everywhere.
|
||||||
|
client = httpx.Client(timeout=10.0) # Use a default 10s timeout everywhere.
|
||||||
|
client = httpx.Client(timeout=None) # Disable all timeouts by default.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fine tuning the configuration
|
||||||
|
|
||||||
|
HTTPX also allows you to specify the timeout behavior in more fine grained detail.
|
||||||
|
|
||||||
|
There are four different types of timeouts that may occur. These are **connect**,
|
||||||
|
**read**, **write**, and **pool** timeouts.
|
||||||
|
|
||||||
|
* The **connect** timeout specifies the maximum amount of time to wait until
|
||||||
|
a socket connection to the requested host is established. If HTTPX is unable to connect
|
||||||
|
within this time frame, a `ConnectTimeout` exception is raised.
|
||||||
|
* The **read** timeout specifies the maximum duration to wait for a chunk of
|
||||||
|
data to be received (for example, a chunk of the response body). If HTTPX is
|
||||||
|
unable to receive data within this time frame, a `ReadTimeout` exception is raised.
|
||||||
|
* The **write** timeout specifies the maximum duration to wait for a chunk of
|
||||||
|
data to be sent (for example, a chunk of the request body). If HTTPX is unable
|
||||||
|
to send data within this time frame, a `WriteTimeout` exception is raised.
|
||||||
|
* The **pool** timeout specifies the maximum duration to wait for acquiring
|
||||||
|
a connection from the connection pool. If HTTPX is unable to acquire a connection
|
||||||
|
within this time frame, a `PoolTimeout` exception is raised. A related
|
||||||
|
configuration here is the maximum number of allowable connections in the
|
||||||
|
connection pool, which is configured by the `limits` argument.
|
||||||
|
|
||||||
|
You can configure the timeout behavior for any of these values...
|
||||||
|
|
||||||
|
```python
|
||||||
|
# A client with a 60s timeout for connecting, and a 10s timeout elsewhere.
|
||||||
|
timeout = httpx.Timeout(10.0, connect=60.0)
|
||||||
|
client = httpx.Client(timeout=timeout)
|
||||||
|
|
||||||
|
response = client.get('http://example.com/')
|
||||||
|
```
|
||||||
454
docs/advanced/transports.md
Normal file
454
docs/advanced/transports.md
Normal file
@ -0,0 +1,454 @@
|
|||||||
|
HTTPX's `Client` also accepts a `transport` argument. This argument allows you
|
||||||
|
to provide a custom Transport object that will be used to perform the actual
|
||||||
|
sending of the requests.
|
||||||
|
|
||||||
|
## HTTP Transport
|
||||||
|
|
||||||
|
For some advanced configuration you might need to instantiate a transport
|
||||||
|
class directly, and pass it to the client instance. One example is the
|
||||||
|
`local_address` configuration which is only available via this low-level API.
|
||||||
|
|
||||||
|
```pycon
|
||||||
|
>>> import httpx
|
||||||
|
>>> transport = httpx.HTTPTransport(local_address="0.0.0.0")
|
||||||
|
>>> client = httpx.Client(transport=transport)
|
||||||
|
```
|
||||||
|
|
||||||
|
Connection retries are also available via this interface. Requests will be retried the given number of times in case an `httpx.ConnectError` or an `httpx.ConnectTimeout` occurs, allowing smoother operation under flaky networks. If you need other forms of retry behaviors, such as handling read/write errors or reacting to `503 Service Unavailable`, consider general-purpose tools such as [tenacity](https://github.com/jd/tenacity).
|
||||||
|
|
||||||
|
```pycon
|
||||||
|
>>> import httpx
|
||||||
|
>>> transport = httpx.HTTPTransport(retries=1)
|
||||||
|
>>> client = httpx.Client(transport=transport)
|
||||||
|
```
|
||||||
|
|
||||||
|
Similarly, instantiating a transport directly provides a `uds` option for
|
||||||
|
connecting via a Unix Domain Socket that is only available via this low-level API:
|
||||||
|
|
||||||
|
```pycon
|
||||||
|
>>> import httpx
|
||||||
|
>>> # Connect to the Docker API via a Unix Socket.
|
||||||
|
>>> transport = httpx.HTTPTransport(uds="/var/run/docker.sock")
|
||||||
|
>>> client = httpx.Client(transport=transport)
|
||||||
|
>>> response = client.get("http://docker/info")
|
||||||
|
>>> response.json()
|
||||||
|
{"ID": "...", "Containers": 4, "Images": 74, ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
## WSGI Transport
|
||||||
|
|
||||||
|
You can configure an `httpx` client to call directly into a Python web application using the WSGI protocol.
|
||||||
|
|
||||||
|
This is particularly useful for two main use-cases:
|
||||||
|
|
||||||
|
* Using `httpx` as a client inside test cases.
|
||||||
|
* Mocking out external services during tests or in dev or staging environments.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
Here's an example of integrating against a Flask application:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from flask import Flask
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def hello():
|
||||||
|
return "Hello World!"
|
||||||
|
|
||||||
|
transport = httpx.WSGITransport(app=app)
|
||||||
|
with httpx.Client(transport=transport, base_url="http://testserver") as client:
|
||||||
|
r = client.get("/")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.text == "Hello World!"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
For some more complex cases you might need to customize the WSGI transport. This allows you to:
|
||||||
|
|
||||||
|
* Inspect 500 error responses rather than raise exceptions by setting `raise_app_exceptions=False`.
|
||||||
|
* Mount the WSGI application at a subpath by setting `script_name` (WSGI).
|
||||||
|
* Use a given client address for requests by setting `remote_addr` (WSGI).
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Instantiate a client that makes WSGI requests with a client IP of "1.2.3.4".
|
||||||
|
transport = httpx.WSGITransport(app=app, remote_addr="1.2.3.4")
|
||||||
|
with httpx.Client(transport=transport, base_url="http://testserver") as client:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## ASGI Transport
|
||||||
|
|
||||||
|
You can configure an `httpx` client to call directly into an async Python web application using the ASGI protocol.
|
||||||
|
|
||||||
|
This is particularly useful for two main use-cases:
|
||||||
|
|
||||||
|
* Using `httpx` as a client inside test cases.
|
||||||
|
* Mocking out external services during tests or in dev or staging environments.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
Let's take this Starlette application as an example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
from starlette.responses import HTMLResponse
|
||||||
|
from starlette.routing import Route
|
||||||
|
|
||||||
|
|
||||||
|
async def hello(request):
|
||||||
|
return HTMLResponse("Hello World!")
|
||||||
|
|
||||||
|
|
||||||
|
app = Starlette(routes=[Route("/", hello)])
|
||||||
|
```
|
||||||
|
|
||||||
|
We can make requests directly against the application, like so:
|
||||||
|
|
||||||
|
```python
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||||
|
r = await client.get("/")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.text == "Hello World!"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
For some more complex cases you might need to customise the ASGI transport. This allows you to:
|
||||||
|
|
||||||
|
* Inspect 500 error responses rather than raise exceptions by setting `raise_app_exceptions=False`.
|
||||||
|
* Mount the ASGI application at a subpath by setting `root_path`.
|
||||||
|
* Use a given client address for requests by setting `client`.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Instantiate a client that makes ASGI requests with a client IP of "1.2.3.4",
|
||||||
|
# on port 123.
|
||||||
|
transport = httpx.ASGITransport(app=app, client=("1.2.3.4", 123))
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
See [the ASGI documentation](https://asgi.readthedocs.io/en/latest/specs/www.html#connection-scope) for more details on the `client` and `root_path` keys.
|
||||||
|
|
||||||
|
### ASGI startup and shutdown
|
||||||
|
|
||||||
|
It is not in the scope of HTTPX to trigger ASGI lifespan events of your app.
|
||||||
|
|
||||||
|
However it is suggested to use `LifespanManager` from [asgi-lifespan](https://github.com/florimondmanca/asgi-lifespan#usage) in pair with `AsyncClient`.
|
||||||
|
|
||||||
|
## Custom transports
|
||||||
|
|
||||||
|
A transport instance must implement the low-level Transport API which deals
|
||||||
|
with sending a single request, and returning a response. You should either
|
||||||
|
subclass `httpx.BaseTransport` to implement a transport to use with `Client`,
|
||||||
|
or subclass `httpx.AsyncBaseTransport` to implement a transport to
|
||||||
|
use with `AsyncClient`.
|
||||||
|
|
||||||
|
At the layer of the transport API we're using the familiar `Request` and
|
||||||
|
`Response` models.
|
||||||
|
|
||||||
|
See the `handle_request` and `handle_async_request` docstrings for more details
|
||||||
|
on the specifics of the Transport API.
|
||||||
|
|
||||||
|
A complete example of a custom transport implementation would be:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
class HelloWorldTransport(httpx.BaseTransport):
|
||||||
|
"""
|
||||||
|
A mock transport that always returns a JSON "Hello, world!" response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def handle_request(self, request):
|
||||||
|
return httpx.Response(200, json={"text": "Hello, world!"})
|
||||||
|
```
|
||||||
|
|
||||||
|
Or this example, which uses a custom transport and `httpx.Mounts` to always redirect `http://` requests.
|
||||||
|
|
||||||
|
```python
|
||||||
|
class HTTPSRedirect(httpx.BaseTransport):
|
||||||
|
"""
|
||||||
|
A transport that always redirects to HTTPS.
|
||||||
|
"""
|
||||||
|
def handle_request(self, request):
|
||||||
|
url = request.url.copy_with(scheme="https")
|
||||||
|
return httpx.Response(303, headers={"Location": str(url)})
|
||||||
|
|
||||||
|
# A client where any `http` requests are always redirected to `https`
|
||||||
|
transport = httpx.Mounts({
|
||||||
|
'http://': HTTPSRedirect()
|
||||||
|
'https://': httpx.HTTPTransport()
|
||||||
|
})
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
```
|
||||||
|
|
||||||
|
A useful pattern here is custom transport classes that wrap the default HTTP implementation. For example...
|
||||||
|
|
||||||
|
```python
|
||||||
|
class DebuggingTransport(httpx.BaseTransport):
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self._wrapper = httpx.HTTPTransport(**kwargs)
|
||||||
|
|
||||||
|
def handle_request(self, request):
|
||||||
|
print(f">>> {request}")
|
||||||
|
response = self._wrapper.handle_request(request)
|
||||||
|
print(f"<<< {response}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self._wrapper.close()
|
||||||
|
|
||||||
|
transport = DebuggingTransport()
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
```
|
||||||
|
|
||||||
|
Here's another case, where we're using a round-robin across a number of different proxies...
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ProxyRoundRobin(httpx.BaseTransport):
|
||||||
|
def __init__(self, proxies, **kwargs):
|
||||||
|
self._transports = [
|
||||||
|
httpx.HTTPTransport(proxy=proxy, **kwargs)
|
||||||
|
for proxy in proxies
|
||||||
|
]
|
||||||
|
self._idx = 0
|
||||||
|
|
||||||
|
def handle_request(self, request):
|
||||||
|
transport = self._transports[self._idx]
|
||||||
|
self._idx = (self._idx + 1) % len(self._transports)
|
||||||
|
return transport.handle_request(request)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
for transport in self._transports:
|
||||||
|
transport.close()
|
||||||
|
|
||||||
|
proxies = [
|
||||||
|
httpx.Proxy("http://127.0.0.1:8081"),
|
||||||
|
httpx.Proxy("http://127.0.0.1:8082"),
|
||||||
|
httpx.Proxy("http://127.0.0.1:8083"),
|
||||||
|
]
|
||||||
|
transport = ProxyRoundRobin(proxies=proxies)
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mock transports
|
||||||
|
|
||||||
|
During testing it can often be useful to be able to mock out a transport,
|
||||||
|
and return pre-determined responses, rather than making actual network requests.
|
||||||
|
|
||||||
|
The `httpx.MockTransport` class accepts a handler function, which can be used
|
||||||
|
to map requests onto pre-determined responses:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(200, json={"text": "Hello, world!"})
|
||||||
|
|
||||||
|
|
||||||
|
# Switch to a mock transport, if the TESTING environment variable is set.
|
||||||
|
if os.environ.get('TESTING', '').upper() == "TRUE":
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
else:
|
||||||
|
transport = httpx.HTTPTransport()
|
||||||
|
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
```
|
||||||
|
|
||||||
|
For more advanced use-cases you might want to take a look at either [the third-party
|
||||||
|
mocking library, RESPX](https://lundberg.github.io/respx/), or the [pytest-httpx library](https://github.com/Colin-b/pytest_httpx).
|
||||||
|
|
||||||
|
## Mounting transports
|
||||||
|
|
||||||
|
You can also mount transports against given schemes or domains, to control
|
||||||
|
which transport an outgoing request should be routed via, with [the same style
|
||||||
|
used for specifying proxy routing](#routing).
|
||||||
|
|
||||||
|
```python
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
class HTTPSRedirectTransport(httpx.BaseTransport):
|
||||||
|
"""
|
||||||
|
A transport that always redirects to HTTPS.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def handle_request(self, method, url, headers, stream, extensions):
|
||||||
|
scheme, host, port, path = url
|
||||||
|
if port is None:
|
||||||
|
location = b"https://%s%s" % (host, path)
|
||||||
|
else:
|
||||||
|
location = b"https://%s:%d%s" % (host, port, path)
|
||||||
|
stream = httpx.ByteStream(b"")
|
||||||
|
headers = [(b"location", location)]
|
||||||
|
extensions = {}
|
||||||
|
return 303, headers, stream, extensions
|
||||||
|
|
||||||
|
|
||||||
|
# A client where any `http` requests are always redirected to `https`
|
||||||
|
mounts = {'http://': HTTPSRedirectTransport()}
|
||||||
|
client = httpx.Client(mounts=mounts)
|
||||||
|
```
|
||||||
|
|
||||||
|
A couple of other sketches of how you might take advantage of mounted transports...
|
||||||
|
|
||||||
|
Disabling HTTP/2 on a single given domain...
|
||||||
|
|
||||||
|
```python
|
||||||
|
mounts = {
|
||||||
|
"all://": httpx.HTTPTransport(http2=True),
|
||||||
|
"all://*example.org": httpx.HTTPTransport()
|
||||||
|
}
|
||||||
|
client = httpx.Client(mounts=mounts)
|
||||||
|
```
|
||||||
|
|
||||||
|
Mocking requests to a given domain:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# All requests to "example.org" should be mocked out.
|
||||||
|
# Other requests occur as usual.
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(200, json={"text": "Hello, World!"})
|
||||||
|
|
||||||
|
mounts = {"all://example.org": httpx.MockTransport(handler)}
|
||||||
|
client = httpx.Client(mounts=mounts)
|
||||||
|
```
|
||||||
|
|
||||||
|
Adding support for custom schemes:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Support URLs like "file:///Users/sylvia_green/websites/new_client/index.html"
|
||||||
|
mounts = {"file://": FileSystemTransport()}
|
||||||
|
client = httpx.Client(mounts=mounts)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Routing
|
||||||
|
|
||||||
|
HTTPX provides a powerful mechanism for routing requests, allowing you to write complex rules that specify which transport should be used for each request.
|
||||||
|
|
||||||
|
The `mounts` dictionary maps URL patterns to HTTP transports. HTTPX matches requested URLs against URL patterns to decide which transport should be used, if any. Matching is done from most specific URL patterns (e.g. `https://<domain>:<port>`) to least specific ones (e.g. `https://`).
|
||||||
|
|
||||||
|
HTTPX supports routing requests based on **scheme**, **domain**, **port**, or a combination of these.
|
||||||
|
|
||||||
|
### Wildcard routing
|
||||||
|
|
||||||
|
Route everything through a transport...
|
||||||
|
|
||||||
|
```python
|
||||||
|
mounts = {
|
||||||
|
"all://": httpx.HTTPTransport(proxy="http://localhost:8030"),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scheme routing
|
||||||
|
|
||||||
|
Route HTTP requests through one transport, and HTTPS requests through another...
|
||||||
|
|
||||||
|
```python
|
||||||
|
mounts = {
|
||||||
|
"http://": httpx.HTTPTransport(proxy="http://localhost:8030"),
|
||||||
|
"https://": httpx.HTTPTransport(proxy="http://localhost:8031"),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Domain routing
|
||||||
|
|
||||||
|
Proxy all requests on domain "example.com", let other requests pass through...
|
||||||
|
|
||||||
|
```python
|
||||||
|
mounts = {
|
||||||
|
"all://example.com": httpx.HTTPTransport(proxy="http://localhost:8030"),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Proxy HTTP requests on domain "example.com", let HTTPS and other requests pass through...
|
||||||
|
|
||||||
|
```python
|
||||||
|
mounts = {
|
||||||
|
"http://example.com": httpx.HTTPTransport(proxy="http://localhost:8030"),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Proxy all requests to "example.com" and its subdomains, let other requests pass through...
|
||||||
|
|
||||||
|
```python
|
||||||
|
mounts = {
|
||||||
|
"all://*example.com": httpx.HTTPTransport(proxy="http://localhost:8030"),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Proxy all requests to strict subdomains of "example.com", let "example.com" and other requests pass through...
|
||||||
|
|
||||||
|
```python
|
||||||
|
mounts = {
|
||||||
|
"all://*.example.com": httpx.HTTPTransport(proxy="http://localhost:8030"),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port routing
|
||||||
|
|
||||||
|
Proxy HTTPS requests on port 1234 to "example.com"...
|
||||||
|
|
||||||
|
```python
|
||||||
|
mounts = {
|
||||||
|
"https://example.com:1234": httpx.HTTPTransport(proxy="http://localhost:8030"),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Proxy all requests on port 1234...
|
||||||
|
|
||||||
|
```python
|
||||||
|
mounts = {
|
||||||
|
"all://*:1234": httpx.HTTPTransport(proxy="http://localhost:8030"),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### No-proxy support
|
||||||
|
|
||||||
|
It is also possible to define requests that _shouldn't_ be routed through the transport.
|
||||||
|
|
||||||
|
To do so, pass `None` as the proxy URL. For example...
|
||||||
|
|
||||||
|
```python
|
||||||
|
mounts = {
|
||||||
|
# Route requests through a proxy by default...
|
||||||
|
"all://": httpx.HTTPTransport(proxy="http://localhost:8031"),
|
||||||
|
# Except those for "example.com".
|
||||||
|
"all://example.com": None,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complex configuration example
|
||||||
|
|
||||||
|
You can combine the routing features outlined above to build complex proxy routing configurations. For example...
|
||||||
|
|
||||||
|
```python
|
||||||
|
mounts = {
|
||||||
|
# Route all traffic through a proxy by default...
|
||||||
|
"all://": httpx.HTTPTransport(proxy="http://localhost:8030"),
|
||||||
|
# But don't use proxies for HTTPS requests to "domain.io"...
|
||||||
|
"https://domain.io": None,
|
||||||
|
# And use another proxy for requests to "example.com" and its subdomains...
|
||||||
|
"all://*example.com": httpx.HTTPTransport(proxy="http://localhost:8031"),
|
||||||
|
# And yet another proxy if HTTP is used,
|
||||||
|
# and the "internal" subdomain on port 5550 is requested...
|
||||||
|
"http://internal.example.com:5550": httpx.HTTPTransport(proxy="http://localhost:8032"),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
There are also environment variables that can be used to control the dictionary of the client mounts.
|
||||||
|
They can be used to configure HTTP proxying for clients.
|
||||||
|
|
||||||
|
See documentation on [`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`](../environment_variables.md#http_proxy-https_proxy-all_proxy)
|
||||||
|
and [`NO_PROXY`](../environment_variables.md#no_proxy) for more information.
|
||||||
19
docs/api.md
19
docs/api.md
@ -70,7 +70,7 @@
|
|||||||
* The amount of time elapsed between sending the request and calling `close()` on the corresponding response received for that request.
|
* The amount of time elapsed between sending the request and calling `close()` on the corresponding response received for that request.
|
||||||
[total_seconds()](https://docs.python.org/3/library/datetime.html#datetime.timedelta.total_seconds) to correctly get
|
[total_seconds()](https://docs.python.org/3/library/datetime.html#datetime.timedelta.total_seconds) to correctly get
|
||||||
the total elapsed seconds.
|
the total elapsed seconds.
|
||||||
* `def .raise_for_status()` - **None**
|
* `def .raise_for_status()` - **Response**
|
||||||
* `def .json()` - **Any**
|
* `def .json()` - **Any**
|
||||||
* `def .read()` - **bytes**
|
* `def .read()` - **bytes**
|
||||||
* `def .iter_raw([chunk_size])` - **bytes iterator**
|
* `def .iter_raw([chunk_size])` - **bytes iterator**
|
||||||
@ -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
|
||||||
|
|
||||||
@ -53,6 +53,9 @@ async with httpx.AsyncClient() as client:
|
|||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
In order to get the most benefit from connection pooling, make sure you're not instantiating multiple client instances - for example by using `async with` inside a "hot loop". This can be achieved either by having a single scoped client that's passed throughout wherever it's needed, or by having a single global client instance.
|
||||||
|
|
||||||
Alternatively, use `await client.aclose()` if you want to close a client explicitly:
|
Alternatively, use `await client.aclose()` if you want to close a client explicitly:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@ -81,7 +84,7 @@ The async response streaming methods are:
|
|||||||
* `Response.aiter_raw()` - For streaming the raw response bytes, without applying content decoding.
|
* `Response.aiter_raw()` - For streaming the raw response bytes, without applying content decoding.
|
||||||
* `Response.aclose()` - For closing the response. You don't usually need this, since `.stream` block closes the response automatically on exit.
|
* `Response.aclose()` - For closing the response. You don't usually need this, since `.stream` block closes the response automatically on exit.
|
||||||
|
|
||||||
For situations when context block usage is not practical, it is possible to enter "manual mode" by sending a [`Request` instance](./advanced.md#request-instances) using `client.send(..., stream=True)`.
|
For situations when context block usage is not practical, it is possible to enter "manual mode" by sending a [`Request` instance](advanced/clients.md#request-instances) using `client.send(..., stream=True)`.
|
||||||
|
|
||||||
Example in the context of forwarding the response to a streaming web endpoint with [Starlette](https://www.starlette.io):
|
Example in the context of forwarding the response to a streaming web endpoint with [Starlette](https://www.starlette.io):
|
||||||
|
|
||||||
@ -188,54 +191,4 @@ anyio.run(main, backend='trio')
|
|||||||
|
|
||||||
## Calling into Python Web Apps
|
## Calling into Python Web Apps
|
||||||
|
|
||||||
Just as `httpx.Client` allows you to call directly into WSGI web applications,
|
For details on calling directly into ASGI applications, see [the `ASGITransport` docs](../advanced/transports#asgitransport).
|
||||||
the `httpx.AsyncClient` class allows you to call directly into ASGI web applications.
|
|
||||||
|
|
||||||
Let's take this Starlette application as an example:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from starlette.applications import Starlette
|
|
||||||
from starlette.responses import HTMLResponse
|
|
||||||
from starlette.routing import Route
|
|
||||||
|
|
||||||
|
|
||||||
async def hello(request):
|
|
||||||
return HTMLResponse("Hello World!")
|
|
||||||
|
|
||||||
|
|
||||||
app = Starlette(routes=[Route("/", hello)])
|
|
||||||
```
|
|
||||||
|
|
||||||
We can make requests directly against the application, like so:
|
|
||||||
|
|
||||||
```pycon
|
|
||||||
>>> import httpx
|
|
||||||
>>> async with httpx.AsyncClient(app=app, base_url="http://testserver") as client:
|
|
||||||
... r = await client.get("/")
|
|
||||||
... assert r.status_code == 200
|
|
||||||
... assert r.text == "Hello World!"
|
|
||||||
```
|
|
||||||
|
|
||||||
For some more complex cases you might need to customise the ASGI transport. This allows you to:
|
|
||||||
|
|
||||||
* Inspect 500 error responses rather than raise exceptions by setting `raise_app_exceptions=False`.
|
|
||||||
* Mount the ASGI application at a subpath by setting `root_path`.
|
|
||||||
* Use a given client address for requests by setting `client`.
|
|
||||||
|
|
||||||
For example:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Instantiate a client that makes ASGI requests with a client IP of "1.2.3.4",
|
|
||||||
# on port 123.
|
|
||||||
transport = httpx.ASGITransport(app=app, client=("1.2.3.4", 123))
|
|
||||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
See [the ASGI documentation](https://asgi.readthedocs.io/en/latest/specs/www.html#connection-scope) for more details on the `client` and `root_path` keys.
|
|
||||||
|
|
||||||
## Startup/shutdown of ASGI apps
|
|
||||||
|
|
||||||
It is not in the scope of HTTPX to trigger lifespan events of your app.
|
|
||||||
|
|
||||||
However it is suggested to use `LifespanManager` from [asgi-lifespan](https://github.com/florimondmanca/asgi-lifespan#usage) in pair with `AsyncClient`.
|
|
||||||
@ -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
|
||||||
|
|
||||||
@ -157,22 +157,24 @@ httpx.get('https://www.example.com', timeout=None)
|
|||||||
|
|
||||||
## Proxy keys
|
## Proxy keys
|
||||||
|
|
||||||
When using `httpx.Client(proxies={...})` to map to a selection of different proxies, we use full URL schemes, such as `proxies={"http://": ..., "https://": ...}`.
|
HTTPX uses the mounts argument for HTTP proxying and transport routing.
|
||||||
|
It can do much more than proxies and allows you to configure more than just the proxy route.
|
||||||
|
For more detailed documentation, see [Mounting Transports](advanced/transports.md#mounting-transports).
|
||||||
|
|
||||||
|
When using `httpx.Client(mounts={...})` to map to a selection of different transports, we use full URL schemes, such as `mounts={"http://": ..., "https://": ...}`.
|
||||||
|
|
||||||
This is different to the `requests` usage of `proxies={"http": ..., "https": ...}`.
|
This is different to the `requests` usage of `proxies={"http": ..., "https": ...}`.
|
||||||
|
|
||||||
This change is for better consistency with more complex mappings, that might also include domain names, such as `proxies={"all://": ..., "all://www.example.com": None}` which maps all requests onto a proxy, except for requests to "www.example.com" which have an explicit exclusion.
|
This change is for better consistency with more complex mappings, that might also include domain names, such as `mounts={"all://": ..., httpx.HTTPTransport(proxy="all://www.example.com": None})` which maps all requests onto a proxy, except for requests to "www.example.com" which have an explicit exclusion.
|
||||||
|
|
||||||
Also note that `requests.Session.request(...)` allows a `proxies=...` parameter, whereas `httpx.Client.request(...)` does not.
|
Also note that `requests.Session.request(...)` allows a `proxies=...` parameter, whereas `httpx.Client.request(...)` does not allow `mounts=...`.
|
||||||
|
|
||||||
## 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.
|
||||||
@ -193,14 +195,18 @@ We don't support `response.is_ok` since the naming is ambiguous there, and might
|
|||||||
|
|
||||||
## Request instantiation
|
## Request instantiation
|
||||||
|
|
||||||
There is no notion of [prepared requests](https://requests.readthedocs.io/en/stable/user/advanced/#prepared-requests) in HTTPX. If you need to customize request instantiation, see [Request instances](advanced.md#request-instances).
|
There is no notion of [prepared requests](https://requests.readthedocs.io/en/stable/user/advanced/#prepared-requests) in HTTPX. If you need to customize request instantiation, see [Request instances](advanced/clients.md#request-instances).
|
||||||
|
|
||||||
Besides, `httpx.Request()` does not support the `auth`, `timeout`, `follow_redirects`, `proxies`, `verify` and `cert` parameters. However these are available in `httpx.request`, `httpx.get`, `httpx.post` etc., as well as on [`Client` instances](advanced.md#client-instances).
|
Besides, `httpx.Request()` does not support the `auth`, `timeout`, `follow_redirects`, `mounts`, `verify` and `cert` parameters. However these are available in `httpx.request`, `httpx.get`, `httpx.post` etc., as well as on [`Client` instances](advanced/clients.md#client-instances).
|
||||||
|
|
||||||
## Mocking
|
## Mocking
|
||||||
|
|
||||||
If you need to mock HTTPX the same way that test utilities like `responses` and `requests-mock` does for `requests`, see [RESPX](https://github.com/lundberg/respx).
|
If you need to mock HTTPX the same way that test utilities like `responses` and `requests-mock` does for `requests`, see [RESPX](https://github.com/lundberg/respx).
|
||||||
|
|
||||||
|
## Caching
|
||||||
|
|
||||||
|
If you use `cachecontrol` or `requests-cache` to add HTTP Caching support to the `requests` library, you can use [Hishel](https://hishel.com) for HTTPX.
|
||||||
|
|
||||||
## Networking layer
|
## Networking layer
|
||||||
|
|
||||||
`requests` defers most of its HTTP networking code to the excellent [`urllib3` library](https://urllib3.readthedocs.io/en/latest/).
|
`requests` defers most of its HTTP networking code to the excellent [`urllib3` library](https://urllib3.readthedocs.io/en/latest/).
|
||||||
@ -219,4 +225,8 @@ 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.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,18 +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)
|
||||||
proxies = {"all://": "http://127.0.0.1:8080/"}
|
|
||||||
|
|
||||||
with httpx.Client(proxies=proxies, 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:
|
||||||
@ -75,7 +15,7 @@ The environment variables documented below are used as a convention by various H
|
|||||||
* [cURL](https://github.com/curl/curl/blob/master/docs/MANUAL.md#environment-variables)
|
* [cURL](https://github.com/curl/curl/blob/master/docs/MANUAL.md#environment-variables)
|
||||||
* [requests](https://github.com/psf/requests/blob/master/docs/user/advanced.rst#proxies)
|
* [requests](https://github.com/psf/requests/blob/master/docs/user/advanced.rst#proxies)
|
||||||
|
|
||||||
For more information on using proxies in HTTPX, see [HTTP Proxying](advanced.md#http-proxying).
|
For more information on using proxies in HTTPX, see [HTTP Proxying](advanced/proxies.md#http-proxying).
|
||||||
|
|
||||||
### `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`
|
### `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`
|
||||||
|
|
||||||
@ -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](advanced.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.
|
||||||
@ -95,7 +95,7 @@ Plus all the standard features of `requests`...
|
|||||||
|
|
||||||
For a run-through of all the basics, head over to the [QuickStart](quickstart.md).
|
For a run-through of all the basics, head over to the [QuickStart](quickstart.md).
|
||||||
|
|
||||||
For more advanced topics, see the [Advanced Usage](advanced.md) section,
|
For more advanced topics, see the **Advanced** section,
|
||||||
the [async support](async.md) section, or the [HTTP/2](http2.md) section.
|
the [async support](async.md) section, or the [HTTP/2](http2.md) section.
|
||||||
|
|
||||||
The [Developer Interface](api.md) provides a comprehensive API reference.
|
The [Developer Interface](api.md) provides a comprehensive API reference.
|
||||||
@ -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.7+
|
HTTPX requires Python 3.9+
|
||||||
|
|
||||||
[sync-support]: https://github.com/encode/httpx/issues/572
|
[sync-support]: https://github.com/encode/httpx/issues/572
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
((window.gitter = {}).chat = {}).options = {
|
|
||||||
room: 'encode/community'
|
|
||||||
};
|
|
||||||
File diff suppressed because one or more lines are too long
@ -20,28 +20,26 @@ 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 seperately.
|
Logging output includes information from both the high-level `httpx` logger, and the network-level `httpcore` logger, which can be configured separately.
|
||||||
|
|
||||||
For handling more complex logging configurations you might want to use the dictionary configuration style...
|
For handling more complex logging configurations you might want to use the dictionary configuration style...
|
||||||
|
|
||||||
@ -80,4 +78,4 @@ logging.config.dictConfig(LOGGING_CONFIG)
|
|||||||
httpx.get('https://www.example.com')
|
httpx.get('https://www.example.com')
|
||||||
```
|
```
|
||||||
|
|
||||||
The exact formatting of the debug logging may be subject to change across different versions of `httpx` and `httpcore`. If you need to rely on a particular format it is recommended that you pin installation of these packages to fixed versions.
|
The exact formatting of the debug logging may be subject to change across different versions of `httpx` and `httpcore`. If you need to rely on a particular format it is recommended that you pin installation of these packages to fixed versions.
|
||||||
|
|||||||
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)
|
||||||
{
|
{
|
||||||
...
|
...
|
||||||
@ -285,15 +289,22 @@ Traceback (most recent call last):
|
|||||||
File "/Users/tomchristie/GitHub/encode/httpcore/httpx/models.py", line 837, in raise_for_status
|
File "/Users/tomchristie/GitHub/encode/httpcore/httpx/models.py", line 837, in raise_for_status
|
||||||
raise HTTPStatusError(message, response=self)
|
raise HTTPStatusError(message, response=self)
|
||||||
httpx._exceptions.HTTPStatusError: 404 Client Error: Not Found for url: https://httpbin.org/status/404
|
httpx._exceptions.HTTPStatusError: 404 Client Error: Not Found for url: https://httpbin.org/status/404
|
||||||
For more information check: https://httpstatuses.com/404
|
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404
|
||||||
```
|
```
|
||||||
|
|
||||||
Any successful response codes will simply return `None` rather than raising an exception.
|
Any successful response codes will return the `Response` instance rather than raising an exception.
|
||||||
|
|
||||||
```pycon
|
```pycon
|
||||||
>>> r.raise_for_status()
|
>>> r.raise_for_status()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The method returns the response instance, allowing you to use it inline. For example:
|
||||||
|
|
||||||
|
```pycon
|
||||||
|
>>> r = httpx.get('...').raise_for_status()
|
||||||
|
>>> data = httpx.get('...').raise_for_status().json()
|
||||||
|
```
|
||||||
|
|
||||||
## Response Headers
|
## Response Headers
|
||||||
|
|
||||||
The response headers are available as a dictionary-like interface.
|
The response headers are available as a dictionary-like interface.
|
||||||
@ -355,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:
|
||||||
@ -367,7 +379,7 @@ If you're using streaming responses in any of these ways then the `response.cont
|
|||||||
|
|
||||||
```pycon
|
```pycon
|
||||||
>>> with httpx.stream("GET", "https://www.example.com") as r:
|
>>> with httpx.stream("GET", "https://www.example.com") as r:
|
||||||
... if r.headers['Content-Length'] < TOO_LONG:
|
... if int(r.headers['Content-Length']) < TOO_LONG:
|
||||||
... r.read()
|
... r.read()
|
||||||
... print(r.text)
|
... print(r.text)
|
||||||
```
|
```
|
||||||
@ -455,7 +467,7 @@ You can also disable the timeout behavior completely...
|
|||||||
>>> httpx.get('https://github.com/', timeout=None)
|
>>> httpx.get('https://github.com/', timeout=None)
|
||||||
```
|
```
|
||||||
|
|
||||||
For advanced timeout management, see [Timeout fine-tuning](advanced.md#fine-tuning-the-configuration).
|
For advanced timeout management, see [Timeout fine-tuning](advanced/timeouts.md#fine-tuning-the-configuration).
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
|
|||||||
@ -2,51 +2,21 @@
|
|||||||
|
|
||||||
As HTTPX usage grows, there is an expanding community of developers building tools and libraries that integrate with HTTPX, or depend on HTTPX. Here are some of them.
|
As HTTPX usage grows, there is an expanding community of developers building tools and libraries that integrate with HTTPX, or depend on HTTPX. Here are some of them.
|
||||||
|
|
||||||
|
<!-- NOTE: Entries are alphabetised. -->
|
||||||
|
|
||||||
## Plugins
|
## Plugins
|
||||||
|
|
||||||
<!-- NOTE: this list is in alphabetical order. -->
|
### Hishel
|
||||||
|
|
||||||
### Authlib
|
[GitHub](https://github.com/karpetrosyan/hishel) - [Documentation](https://hishel.com/)
|
||||||
|
|
||||||
[GitHub](https://github.com/lepture/authlib) - [Documentation](https://docs.authlib.org/en/latest/)
|
An elegant HTTP Cache implementation for HTTPX and HTTP Core.
|
||||||
|
|
||||||
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.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
|
||||||
|
|
||||||
@ -54,24 +24,84 @@ 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)
|
||||||
|
|
||||||
This public gist provides an example implementation for a [custom transport](advanced.md#custom-transports) implementation on top of the battle-tested [`urllib3`](https://urllib3.readthedocs.io) library.
|
This public gist provides an example implementation for a [custom transport](advanced/transports.md#custom-transports) implementation on top of the battle-tested [`urllib3`](https://urllib3.readthedocs.io) library.
|
||||||
|
|||||||
@ -19,32 +19,34 @@ httpx.ProxyError: _ssl.c:1091: The handshake operation timed out
|
|||||||
**Resolution**: it is likely that you've set up your proxies like this...
|
**Resolution**: it is likely that you've set up your proxies like this...
|
||||||
|
|
||||||
```python
|
```python
|
||||||
proxies = {
|
mounts = {
|
||||||
"http://": "http://myproxy.org",
|
"http://": httpx.HTTPTransport(proxy="http://myproxy.org"),
|
||||||
"https://": "https://myproxy.org",
|
"https://": httpx.HTTPTransport(proxy="https://myproxy.org"),
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Using this setup, you're telling HTTPX to connect to the proxy using HTTP for HTTP requests, and using HTTPS for HTTPS requests.
|
Using this setup, you're telling HTTPX to connect to the proxy using HTTP for HTTP requests, and using HTTPS for HTTPS requests.
|
||||||
|
|
||||||
But if you get the error above, it is likely that your proxy doesn't support connecting via HTTPS. Don't worry: that's a [common gotcha](advanced.md#example).
|
But if you get the error above, it is likely that your proxy doesn't support connecting via HTTPS. Don't worry: that's a [common gotcha](advanced/proxies.md#http-proxies).
|
||||||
|
|
||||||
Change the scheme of your HTTPS proxy to `http://...` instead of `https://...`:
|
Change the scheme of your HTTPS proxy to `http://...` instead of `https://...`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
proxies = {
|
mounts = {
|
||||||
"http://": "http://myproxy.org",
|
"http://": httpx.HTTPTransport(proxy="http://myproxy.org"),
|
||||||
"https://": "http://myproxy.org",
|
"https://": httpx.HTTPTransport(proxy="http://myproxy.org"),
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
This can be simplified to:
|
This can be simplified to:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
proxies = "http://myproxy.org"
|
proxy = "http://myproxy.org"
|
||||||
|
with httpx.Client(proxy=proxy) as client:
|
||||||
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
For more information, see [Proxies: FORWARD vs TUNNEL](advanced.md#forward-vs-tunnel).
|
For more information, see [Proxies: FORWARD vs TUNNEL](advanced/proxies.md#forward-vs-tunnel).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -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.24.1"
|
__version__ = "0.28.1"
|
||||||
|
|||||||
239
httpx/_api.py
239
httpx/_api.py
@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
@ -6,37 +8,50 @@ 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,
|
||||||
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: typing.Optional[QueryParamTypes] = None,
|
params: QueryParamTypes | None = None,
|
||||||
content: typing.Optional[RequestContent] = None,
|
content: RequestContent | None = None,
|
||||||
data: typing.Optional[RequestData] = None,
|
data: RequestData | None = None,
|
||||||
files: typing.Optional[RequestFiles] = None,
|
files: RequestFiles | None = None,
|
||||||
json: typing.Optional[typing.Any] = None,
|
json: typing.Any | None = None,
|
||||||
headers: typing.Optional[HeaderTypes] = None,
|
headers: HeaderTypes | None = None,
|
||||||
cookies: typing.Optional[CookieTypes] = None,
|
cookies: CookieTypes | None = None,
|
||||||
auth: typing.Optional[AuthTypes] = None,
|
auth: AuthTypes | None = None,
|
||||||
proxies: typing.Optional[ProxiesTypes] = None,
|
proxy: ProxyTypes | 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: typing.Optional[CertTypes] = None,
|
|
||||||
trust_env: bool = True,
|
trust_env: bool = True,
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""
|
"""
|
||||||
@ -63,18 +78,13 @@ def request(
|
|||||||
request.
|
request.
|
||||||
* **auth** - *(optional)* An authentication class to use when sending the
|
* **auth** - *(optional)* An authentication class to use when sending the
|
||||||
request.
|
request.
|
||||||
* **proxies** - *(optional)* A dictionary mapping proxy keys to proxy URLs.
|
* **proxy** - *(optional)* A proxy URL where all the traffic should be routed.
|
||||||
* **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.
|
||||||
|
|
||||||
@ -91,8 +101,7 @@ def request(
|
|||||||
"""
|
"""
|
||||||
with Client(
|
with Client(
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
proxies=proxies,
|
proxy=proxy,
|
||||||
cert=cert,
|
|
||||||
verify=verify,
|
verify=verify,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
trust_env=trust_env,
|
trust_env=trust_env,
|
||||||
@ -114,21 +123,20 @@ def request(
|
|||||||
@contextmanager
|
@contextmanager
|
||||||
def stream(
|
def stream(
|
||||||
method: str,
|
method: str,
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
params: typing.Optional[QueryParamTypes] = None,
|
params: QueryParamTypes | None = None,
|
||||||
content: typing.Optional[RequestContent] = None,
|
content: RequestContent | None = None,
|
||||||
data: typing.Optional[RequestData] = None,
|
data: RequestData | None = None,
|
||||||
files: typing.Optional[RequestFiles] = None,
|
files: RequestFiles | None = None,
|
||||||
json: typing.Optional[typing.Any] = None,
|
json: typing.Any | None = None,
|
||||||
headers: typing.Optional[HeaderTypes] = None,
|
headers: HeaderTypes | None = None,
|
||||||
cookies: typing.Optional[CookieTypes] = None,
|
cookies: CookieTypes | None = None,
|
||||||
auth: typing.Optional[AuthTypes] = None,
|
auth: AuthTypes | None = None,
|
||||||
proxies: typing.Optional[ProxiesTypes] = None,
|
proxy: ProxyTypes | 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: typing.Optional[CertTypes] = None,
|
|
||||||
trust_env: bool = True,
|
trust_env: bool = True,
|
||||||
) -> typing.Iterator[Response]:
|
) -> typing.Iterator[Response]:
|
||||||
"""
|
"""
|
||||||
@ -143,8 +151,7 @@ def stream(
|
|||||||
"""
|
"""
|
||||||
with Client(
|
with Client(
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
proxies=proxies,
|
proxy=proxy,
|
||||||
cert=cert,
|
|
||||||
verify=verify,
|
verify=verify,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
trust_env=trust_env,
|
trust_env=trust_env,
|
||||||
@ -165,16 +172,15 @@ def stream(
|
|||||||
|
|
||||||
|
|
||||||
def get(
|
def get(
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
params: typing.Optional[QueryParamTypes] = None,
|
params: QueryParamTypes | None = None,
|
||||||
headers: typing.Optional[HeaderTypes] = None,
|
headers: HeaderTypes | None = None,
|
||||||
cookies: typing.Optional[CookieTypes] = None,
|
cookies: CookieTypes | None = None,
|
||||||
auth: typing.Optional[AuthTypes] = None,
|
auth: AuthTypes | None = None,
|
||||||
proxies: typing.Optional[ProxiesTypes] = None,
|
proxy: ProxyTypes | None = None,
|
||||||
follow_redirects: bool = False,
|
follow_redirects: bool = False,
|
||||||
cert: typing.Optional[CertTypes] = 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:
|
||||||
@ -193,9 +199,8 @@ def get(
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
auth=auth,
|
auth=auth,
|
||||||
proxies=proxies,
|
proxy=proxy,
|
||||||
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,
|
||||||
@ -203,16 +208,15 @@ def get(
|
|||||||
|
|
||||||
|
|
||||||
def options(
|
def options(
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
params: typing.Optional[QueryParamTypes] = None,
|
params: QueryParamTypes | None = None,
|
||||||
headers: typing.Optional[HeaderTypes] = None,
|
headers: HeaderTypes | None = None,
|
||||||
cookies: typing.Optional[CookieTypes] = None,
|
cookies: CookieTypes | None = None,
|
||||||
auth: typing.Optional[AuthTypes] = None,
|
auth: AuthTypes | None = None,
|
||||||
proxies: typing.Optional[ProxiesTypes] = None,
|
proxy: ProxyTypes | None = None,
|
||||||
follow_redirects: bool = False,
|
follow_redirects: bool = False,
|
||||||
cert: typing.Optional[CertTypes] = 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:
|
||||||
@ -231,9 +235,8 @@ def options(
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
auth=auth,
|
auth=auth,
|
||||||
proxies=proxies,
|
proxy=proxy,
|
||||||
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,
|
||||||
@ -241,16 +244,15 @@ def options(
|
|||||||
|
|
||||||
|
|
||||||
def head(
|
def head(
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
params: typing.Optional[QueryParamTypes] = None,
|
params: QueryParamTypes | None = None,
|
||||||
headers: typing.Optional[HeaderTypes] = None,
|
headers: HeaderTypes | None = None,
|
||||||
cookies: typing.Optional[CookieTypes] = None,
|
cookies: CookieTypes | None = None,
|
||||||
auth: typing.Optional[AuthTypes] = None,
|
auth: AuthTypes | None = None,
|
||||||
proxies: typing.Optional[ProxiesTypes] = None,
|
proxy: ProxyTypes | None = None,
|
||||||
follow_redirects: bool = False,
|
follow_redirects: bool = False,
|
||||||
cert: typing.Optional[CertTypes] = 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:
|
||||||
@ -269,9 +271,8 @@ def head(
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
auth=auth,
|
auth=auth,
|
||||||
proxies=proxies,
|
proxy=proxy,
|
||||||
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,
|
||||||
@ -279,20 +280,19 @@ def head(
|
|||||||
|
|
||||||
|
|
||||||
def post(
|
def post(
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
content: typing.Optional[RequestContent] = None,
|
content: RequestContent | None = None,
|
||||||
data: typing.Optional[RequestData] = None,
|
data: RequestData | None = None,
|
||||||
files: typing.Optional[RequestFiles] = None,
|
files: RequestFiles | None = None,
|
||||||
json: typing.Optional[typing.Any] = None,
|
json: typing.Any | None = None,
|
||||||
params: typing.Optional[QueryParamTypes] = None,
|
params: QueryParamTypes | None = None,
|
||||||
headers: typing.Optional[HeaderTypes] = None,
|
headers: HeaderTypes | None = None,
|
||||||
cookies: typing.Optional[CookieTypes] = None,
|
cookies: CookieTypes | None = None,
|
||||||
auth: typing.Optional[AuthTypes] = None,
|
auth: AuthTypes | None = None,
|
||||||
proxies: typing.Optional[ProxiesTypes] = None,
|
proxy: ProxyTypes | None = None,
|
||||||
follow_redirects: bool = False,
|
follow_redirects: bool = False,
|
||||||
cert: typing.Optional[CertTypes] = 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:
|
||||||
@ -312,9 +312,8 @@ def post(
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
auth=auth,
|
auth=auth,
|
||||||
proxies=proxies,
|
proxy=proxy,
|
||||||
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,
|
||||||
@ -322,20 +321,19 @@ def post(
|
|||||||
|
|
||||||
|
|
||||||
def put(
|
def put(
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
content: typing.Optional[RequestContent] = None,
|
content: RequestContent | None = None,
|
||||||
data: typing.Optional[RequestData] = None,
|
data: RequestData | None = None,
|
||||||
files: typing.Optional[RequestFiles] = None,
|
files: RequestFiles | None = None,
|
||||||
json: typing.Optional[typing.Any] = None,
|
json: typing.Any | None = None,
|
||||||
params: typing.Optional[QueryParamTypes] = None,
|
params: QueryParamTypes | None = None,
|
||||||
headers: typing.Optional[HeaderTypes] = None,
|
headers: HeaderTypes | None = None,
|
||||||
cookies: typing.Optional[CookieTypes] = None,
|
cookies: CookieTypes | None = None,
|
||||||
auth: typing.Optional[AuthTypes] = None,
|
auth: AuthTypes | None = None,
|
||||||
proxies: typing.Optional[ProxiesTypes] = None,
|
proxy: ProxyTypes | None = None,
|
||||||
follow_redirects: bool = False,
|
follow_redirects: bool = False,
|
||||||
cert: typing.Optional[CertTypes] = 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:
|
||||||
@ -355,9 +353,8 @@ def put(
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
auth=auth,
|
auth=auth,
|
||||||
proxies=proxies,
|
proxy=proxy,
|
||||||
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,
|
||||||
@ -365,20 +362,19 @@ def put(
|
|||||||
|
|
||||||
|
|
||||||
def patch(
|
def patch(
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
content: typing.Optional[RequestContent] = None,
|
content: RequestContent | None = None,
|
||||||
data: typing.Optional[RequestData] = None,
|
data: RequestData | None = None,
|
||||||
files: typing.Optional[RequestFiles] = None,
|
files: RequestFiles | None = None,
|
||||||
json: typing.Optional[typing.Any] = None,
|
json: typing.Any | None = None,
|
||||||
params: typing.Optional[QueryParamTypes] = None,
|
params: QueryParamTypes | None = None,
|
||||||
headers: typing.Optional[HeaderTypes] = None,
|
headers: HeaderTypes | None = None,
|
||||||
cookies: typing.Optional[CookieTypes] = None,
|
cookies: CookieTypes | None = None,
|
||||||
auth: typing.Optional[AuthTypes] = None,
|
auth: AuthTypes | None = None,
|
||||||
proxies: typing.Optional[ProxiesTypes] = None,
|
proxy: ProxyTypes | None = None,
|
||||||
follow_redirects: bool = False,
|
follow_redirects: bool = False,
|
||||||
cert: typing.Optional[CertTypes] = 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:
|
||||||
@ -398,9 +394,8 @@ def patch(
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
auth=auth,
|
auth=auth,
|
||||||
proxies=proxies,
|
proxy=proxy,
|
||||||
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,
|
||||||
@ -408,17 +403,16 @@ def patch(
|
|||||||
|
|
||||||
|
|
||||||
def delete(
|
def delete(
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
params: typing.Optional[QueryParamTypes] = None,
|
params: QueryParamTypes | None = None,
|
||||||
headers: typing.Optional[HeaderTypes] = None,
|
headers: HeaderTypes | None = None,
|
||||||
cookies: typing.Optional[CookieTypes] = None,
|
cookies: CookieTypes | None = None,
|
||||||
auth: typing.Optional[AuthTypes] = None,
|
auth: AuthTypes | None = None,
|
||||||
proxies: typing.Optional[ProxiesTypes] = None,
|
proxy: ProxyTypes | None = None,
|
||||||
follow_redirects: bool = False,
|
follow_redirects: bool = False,
|
||||||
cert: typing.Optional[CertTypes] = 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:
|
||||||
"""
|
"""
|
||||||
@ -436,9 +430,8 @@ def delete(
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
auth=auth,
|
auth=auth,
|
||||||
proxies=proxies,
|
proxy=proxy,
|
||||||
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,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import netrc
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
@ -8,13 +9,16 @@ from base64 import b64encode
|
|||||||
from urllib.request import parse_http_list
|
from urllib.request import parse_http_list
|
||||||
|
|
||||||
from ._exceptions import ProtocolError
|
from ._exceptions import ProtocolError
|
||||||
from ._models import Request, Response
|
from ._models import Cookies, Request, Response
|
||||||
from ._utils import to_bytes, to_str, unquote
|
from ._utils import to_bytes, to_str, unquote
|
||||||
|
|
||||||
if typing.TYPE_CHECKING: # pragma: no cover
|
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.
|
||||||
@ -125,18 +129,14 @@ class BasicAuth(Auth):
|
|||||||
and uses HTTP Basic authentication.
|
and uses HTTP Basic authentication.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, username: str | bytes, password: str | bytes) -> None:
|
||||||
self, username: typing.Union[str, bytes], password: typing.Union[str, bytes]
|
|
||||||
):
|
|
||||||
self._auth_header = self._build_auth_header(username, password)
|
self._auth_header = self._build_auth_header(username, password)
|
||||||
|
|
||||||
def auth_flow(self, request: Request) -> typing.Generator[Request, Response, None]:
|
def auth_flow(self, request: Request) -> typing.Generator[Request, Response, None]:
|
||||||
request.headers["Authorization"] = self._auth_header
|
request.headers["Authorization"] = self._auth_header
|
||||||
yield request
|
yield request
|
||||||
|
|
||||||
def _build_auth_header(
|
def _build_auth_header(self, username: str | bytes, password: str | bytes) -> str:
|
||||||
self, username: typing.Union[str, bytes], password: typing.Union[str, bytes]
|
|
||||||
) -> str:
|
|
||||||
userpass = b":".join((to_bytes(username), to_bytes(password)))
|
userpass = b":".join((to_bytes(username), to_bytes(password)))
|
||||||
token = b64encode(userpass).decode()
|
token = b64encode(userpass).decode()
|
||||||
return f"Basic {token}"
|
return f"Basic {token}"
|
||||||
@ -147,7 +147,11 @@ class NetRCAuth(Auth):
|
|||||||
Use a 'netrc' file to lookup basic auth credentials based on the url host.
|
Use a 'netrc' file to lookup basic auth credentials based on the url host.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, file: typing.Optional[str] = None):
|
def __init__(self, file: str | None = None) -> None:
|
||||||
|
# Lazily import 'netrc'.
|
||||||
|
# There's no need for us to load this module unless 'NetRCAuth' is being used.
|
||||||
|
import netrc
|
||||||
|
|
||||||
self._netrc_info = netrc.netrc(file)
|
self._netrc_info = netrc.netrc(file)
|
||||||
|
|
||||||
def auth_flow(self, request: Request) -> typing.Generator[Request, Response, None]:
|
def auth_flow(self, request: Request) -> typing.Generator[Request, Response, None]:
|
||||||
@ -162,16 +166,14 @@ class NetRCAuth(Auth):
|
|||||||
)
|
)
|
||||||
yield request
|
yield request
|
||||||
|
|
||||||
def _build_auth_header(
|
def _build_auth_header(self, username: str | bytes, password: str | bytes) -> str:
|
||||||
self, username: typing.Union[str, bytes], password: typing.Union[str, bytes]
|
|
||||||
) -> str:
|
|
||||||
userpass = b":".join((to_bytes(username), to_bytes(password)))
|
userpass = b":".join((to_bytes(username), to_bytes(password)))
|
||||||
token = b64encode(userpass).decode()
|
token = b64encode(userpass).decode()
|
||||||
return f"Basic {token}"
|
return f"Basic {token}"
|
||||||
|
|
||||||
|
|
||||||
class DigestAuth(Auth):
|
class DigestAuth(Auth):
|
||||||
_ALGORITHM_TO_HASH_FUNCTION: typing.Dict[str, typing.Callable[[bytes], "_Hash"]] = {
|
_ALGORITHM_TO_HASH_FUNCTION: dict[str, typing.Callable[[bytes], _Hash]] = {
|
||||||
"MD5": hashlib.md5,
|
"MD5": hashlib.md5,
|
||||||
"MD5-SESS": hashlib.md5,
|
"MD5-SESS": hashlib.md5,
|
||||||
"SHA": hashlib.sha1,
|
"SHA": hashlib.sha1,
|
||||||
@ -182,12 +184,10 @@ class DigestAuth(Auth):
|
|||||||
"SHA-512-SESS": hashlib.sha512,
|
"SHA-512-SESS": hashlib.sha512,
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, username: str | bytes, password: str | bytes) -> None:
|
||||||
self, username: typing.Union[str, bytes], password: typing.Union[str, bytes]
|
|
||||||
) -> None:
|
|
||||||
self._username = to_bytes(username)
|
self._username = to_bytes(username)
|
||||||
self._password = to_bytes(password)
|
self._password = to_bytes(password)
|
||||||
self._last_challenge: typing.Optional[_DigestAuthChallenge] = None
|
self._last_challenge: _DigestAuthChallenge | None = None
|
||||||
self._nonce_count = 1
|
self._nonce_count = 1
|
||||||
|
|
||||||
def auth_flow(self, request: Request) -> typing.Generator[Request, Response, None]:
|
def auth_flow(self, request: Request) -> typing.Generator[Request, Response, None]:
|
||||||
@ -217,11 +217,13 @@ class DigestAuth(Auth):
|
|||||||
request.headers["Authorization"] = self._build_auth_header(
|
request.headers["Authorization"] = self._build_auth_header(
|
||||||
request, self._last_challenge
|
request, self._last_challenge
|
||||||
)
|
)
|
||||||
|
if response.cookies:
|
||||||
|
Cookies(response.cookies).set_cookie_header(request=request)
|
||||||
yield request
|
yield request
|
||||||
|
|
||||||
def _parse_challenge(
|
def _parse_challenge(
|
||||||
self, request: Request, response: Response, auth_header: str
|
self, request: Request, response: Response, auth_header: str
|
||||||
) -> "_DigestAuthChallenge":
|
) -> _DigestAuthChallenge:
|
||||||
"""
|
"""
|
||||||
Returns a challenge from a Digest WWW-Authenticate header.
|
Returns a challenge from a Digest WWW-Authenticate header.
|
||||||
These take the form of:
|
These take the form of:
|
||||||
@ -232,7 +234,7 @@ class DigestAuth(Auth):
|
|||||||
# This method should only ever have been called with a Digest auth header.
|
# This method should only ever have been called with a Digest auth header.
|
||||||
assert scheme.lower() == "digest"
|
assert scheme.lower() == "digest"
|
||||||
|
|
||||||
header_dict: typing.Dict[str, str] = {}
|
header_dict: dict[str, str] = {}
|
||||||
for field in parse_http_list(fields):
|
for field in parse_http_list(fields):
|
||||||
key, value = field.strip().split("=", 1)
|
key, value = field.strip().split("=", 1)
|
||||||
header_dict[key] = unquote(value)
|
header_dict[key] = unquote(value)
|
||||||
@ -251,7 +253,7 @@ class DigestAuth(Auth):
|
|||||||
raise ProtocolError(message, request=request) from exc
|
raise ProtocolError(message, request=request) from exc
|
||||||
|
|
||||||
def _build_auth_header(
|
def _build_auth_header(
|
||||||
self, request: Request, challenge: "_DigestAuthChallenge"
|
self, request: Request, challenge: _DigestAuthChallenge
|
||||||
) -> str:
|
) -> str:
|
||||||
hash_func = self._ALGORITHM_TO_HASH_FUNCTION[challenge.algorithm.upper()]
|
hash_func = self._ALGORITHM_TO_HASH_FUNCTION[challenge.algorithm.upper()]
|
||||||
|
|
||||||
@ -275,17 +277,18 @@ class DigestAuth(Auth):
|
|||||||
|
|
||||||
qop = self._resolve_qop(challenge.qop, request=request)
|
qop = self._resolve_qop(challenge.qop, request=request)
|
||||||
if qop is None:
|
if qop is None:
|
||||||
|
# Following RFC 2069
|
||||||
digest_data = [HA1, challenge.nonce, HA2]
|
digest_data = [HA1, challenge.nonce, HA2]
|
||||||
else:
|
else:
|
||||||
digest_data = [challenge.nonce, nc_value, cnonce, qop, HA2]
|
# Following RFC 2617/7616
|
||||||
key_digest = b":".join(digest_data)
|
digest_data = [HA1, challenge.nonce, nc_value, cnonce, qop, HA2]
|
||||||
|
|
||||||
format_args = {
|
format_args = {
|
||||||
"username": self._username,
|
"username": self._username,
|
||||||
"realm": challenge.realm,
|
"realm": challenge.realm,
|
||||||
"nonce": challenge.nonce,
|
"nonce": challenge.nonce,
|
||||||
"uri": path,
|
"uri": path,
|
||||||
"response": digest(b":".join((HA1, key_digest))),
|
"response": digest(b":".join(digest_data)),
|
||||||
"algorithm": challenge.algorithm.encode(),
|
"algorithm": challenge.algorithm.encode(),
|
||||||
}
|
}
|
||||||
if challenge.opaque:
|
if challenge.opaque:
|
||||||
@ -305,7 +308,7 @@ class DigestAuth(Auth):
|
|||||||
|
|
||||||
return hashlib.sha1(s).hexdigest()[:16].encode()
|
return hashlib.sha1(s).hexdigest()[:16].encode()
|
||||||
|
|
||||||
def _get_header_value(self, header_fields: typing.Dict[str, bytes]) -> str:
|
def _get_header_value(self, header_fields: dict[str, bytes]) -> str:
|
||||||
NON_QUOTED_FIELDS = ("algorithm", "qop", "nc")
|
NON_QUOTED_FIELDS = ("algorithm", "qop", "nc")
|
||||||
QUOTED_TEMPLATE = '{}="{}"'
|
QUOTED_TEMPLATE = '{}="{}"'
|
||||||
NON_QUOTED_TEMPLATE = "{}={}"
|
NON_QUOTED_TEMPLATE = "{}={}"
|
||||||
@ -323,9 +326,7 @@ class DigestAuth(Auth):
|
|||||||
|
|
||||||
return header_value
|
return header_value
|
||||||
|
|
||||||
def _resolve_qop(
|
def _resolve_qop(self, qop: bytes | None, request: Request) -> bytes | None:
|
||||||
self, qop: typing.Optional[bytes], request: Request
|
|
||||||
) -> typing.Optional[bytes]:
|
|
||||||
if qop is None:
|
if qop is None:
|
||||||
return None
|
return None
|
||||||
qops = re.split(b", ?", qop)
|
qops = re.split(b", ?", qop)
|
||||||
@ -343,5 +344,5 @@ class _DigestAuthChallenge(typing.NamedTuple):
|
|||||||
realm: bytes
|
realm: bytes
|
||||||
nonce: bytes
|
nonce: bytes
|
||||||
algorithm: str
|
algorithm: str
|
||||||
opaque: typing.Optional[bytes]
|
opaque: bytes | None
|
||||||
qop: typing.Optional[bytes]
|
qop: bytes | None
|
||||||
|
|||||||
771
httpx/_client.py
771
httpx/_client.py
File diff suppressed because it is too large
Load Diff
@ -1,43 +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 (
|
|
||||||
sys.version_info >= (3, 7) and 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"]
|
|
||||||
249
httpx/_config.py
249
httpx/_config.py
@ -1,39 +1,16 @@
|
|||||||
import logging
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import ssl
|
|
||||||
import sys
|
|
||||||
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:
|
||||||
@ -44,152 +21,52 @@ UNSET = UnsetType()
|
|||||||
|
|
||||||
|
|
||||||
def create_ssl_context(
|
def create_ssl_context(
|
||||||
cert: typing.Optional[CertTypes] = None,
|
verify: ssl.SSLContext | str | bool = True,
|
||||||
verify: VerifyTypes = True,
|
cert: CertTypes | None = None,
|
||||||
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: typing.Optional[CertTypes] = None,
|
|
||||||
verify: VerifyTypes = True,
|
|
||||||
trust_env: bool = True,
|
|
||||||
http2: bool = False,
|
|
||||||
):
|
|
||||||
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.
|
|
||||||
if sys.version_info >= (3, 8): # pragma: no cover
|
|
||||||
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)
|
|
||||||
|
|
||||||
if sys.version_info >= (3, 8): # pragma: no cover
|
|
||||||
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], # type: ignore
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Timeout:
|
class Timeout:
|
||||||
@ -208,13 +85,13 @@ class Timeout:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
|
timeout: TimeoutTypes | UnsetType = UNSET,
|
||||||
*,
|
*,
|
||||||
connect: typing.Union[None, float, UnsetType] = UNSET,
|
connect: None | float | UnsetType = UNSET,
|
||||||
read: typing.Union[None, float, UnsetType] = UNSET,
|
read: None | float | UnsetType = UNSET,
|
||||||
write: typing.Union[None, float, UnsetType] = UNSET,
|
write: None | float | UnsetType = UNSET,
|
||||||
pool: typing.Union[None, float, UnsetType] = UNSET,
|
pool: None | float | UnsetType = UNSET,
|
||||||
):
|
) -> None:
|
||||||
if isinstance(timeout, Timeout):
|
if isinstance(timeout, Timeout):
|
||||||
# Passed as a single explicit Timeout.
|
# Passed as a single explicit Timeout.
|
||||||
assert connect is UNSET
|
assert connect is UNSET
|
||||||
@ -252,7 +129,7 @@ class Timeout:
|
|||||||
self.write = timeout if isinstance(write, UnsetType) else write
|
self.write = timeout if isinstance(write, UnsetType) else write
|
||||||
self.pool = timeout if isinstance(pool, UnsetType) else pool
|
self.pool = timeout if isinstance(pool, UnsetType) else pool
|
||||||
|
|
||||||
def as_dict(self) -> typing.Dict[str, typing.Optional[float]]:
|
def as_dict(self) -> dict[str, float | None]:
|
||||||
return {
|
return {
|
||||||
"connect": self.connect,
|
"connect": self.connect,
|
||||||
"read": self.read,
|
"read": self.read,
|
||||||
@ -296,10 +173,10 @@ class Limits:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
max_connections: typing.Optional[int] = None,
|
max_connections: int | None = None,
|
||||||
max_keepalive_connections: typing.Optional[int] = None,
|
max_keepalive_connections: int | None = None,
|
||||||
keepalive_expiry: typing.Optional[float] = 5.0,
|
keepalive_expiry: float | None = 5.0,
|
||||||
):
|
) -> None:
|
||||||
self.max_connections = max_connections
|
self.max_connections = max_connections
|
||||||
self.max_keepalive_connections = max_keepalive_connections
|
self.max_keepalive_connections = max_keepalive_connections
|
||||||
self.keepalive_expiry = keepalive_expiry
|
self.keepalive_expiry = keepalive_expiry
|
||||||
@ -324,15 +201,16 @@ class Limits:
|
|||||||
class Proxy:
|
class Proxy:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
url: URLTypes,
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
auth: typing.Optional[typing.Tuple[str, str]] = None,
|
ssl_context: ssl.SSLContext | None = None,
|
||||||
headers: typing.Optional[HeaderTypes] = None,
|
auth: tuple[str, str] | None = None,
|
||||||
):
|
headers: HeaderTypes | None = None,
|
||||||
|
) -> None:
|
||||||
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:
|
||||||
@ -343,9 +221,10 @@ class Proxy:
|
|||||||
self.url = url
|
self.url = url
|
||||||
self.auth = auth
|
self.auth = auth
|
||||||
self.headers = headers
|
self.headers = headers
|
||||||
|
self.ssl_context = ssl_context
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def raw_auth(self) -> typing.Optional[typing.Tuple[bytes, bytes]]:
|
def raw_auth(self) -> tuple[bytes, bytes] | None:
|
||||||
# The proxy authentication as raw bytes.
|
# The proxy authentication as raw bytes.
|
||||||
return (
|
return (
|
||||||
None
|
None
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
import warnings
|
import warnings
|
||||||
from json import dumps as json_dumps
|
from json import dumps as json_dumps
|
||||||
@ -5,13 +7,9 @@ from typing import (
|
|||||||
Any,
|
Any,
|
||||||
AsyncIterable,
|
AsyncIterable,
|
||||||
AsyncIterator,
|
AsyncIterator,
|
||||||
Dict,
|
|
||||||
Iterable,
|
Iterable,
|
||||||
Iterator,
|
Iterator,
|
||||||
Mapping,
|
Mapping,
|
||||||
Optional,
|
|
||||||
Tuple,
|
|
||||||
Union,
|
|
||||||
)
|
)
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
@ -27,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:
|
||||||
@ -42,7 +42,7 @@ class ByteStream(AsyncByteStream, SyncByteStream):
|
|||||||
class IteratorByteStream(SyncByteStream):
|
class IteratorByteStream(SyncByteStream):
|
||||||
CHUNK_SIZE = 65_536
|
CHUNK_SIZE = 65_536
|
||||||
|
|
||||||
def __init__(self, stream: Iterable[bytes]):
|
def __init__(self, stream: Iterable[bytes]) -> None:
|
||||||
self._stream = stream
|
self._stream = stream
|
||||||
self._is_stream_consumed = False
|
self._is_stream_consumed = False
|
||||||
self._is_generator = inspect.isgenerator(stream)
|
self._is_generator = inspect.isgenerator(stream)
|
||||||
@ -67,7 +67,7 @@ class IteratorByteStream(SyncByteStream):
|
|||||||
class AsyncIteratorByteStream(AsyncByteStream):
|
class AsyncIteratorByteStream(AsyncByteStream):
|
||||||
CHUNK_SIZE = 65_536
|
CHUNK_SIZE = 65_536
|
||||||
|
|
||||||
def __init__(self, stream: AsyncIterable[bytes]):
|
def __init__(self, stream: AsyncIterable[bytes]) -> None:
|
||||||
self._stream = stream
|
self._stream = stream
|
||||||
self._is_stream_consumed = False
|
self._is_stream_consumed = False
|
||||||
self._is_generator = inspect.isasyncgen(stream)
|
self._is_generator = inspect.isasyncgen(stream)
|
||||||
@ -105,8 +105,8 @@ class UnattachedStream(AsyncByteStream, SyncByteStream):
|
|||||||
|
|
||||||
|
|
||||||
def encode_content(
|
def encode_content(
|
||||||
content: Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]]
|
content: str | bytes | Iterable[bytes] | AsyncIterable[bytes],
|
||||||
) -> Tuple[Dict[str, str], Union[SyncByteStream, AsyncByteStream]]:
|
) -> tuple[dict[str, str], SyncByteStream | AsyncByteStream]:
|
||||||
if isinstance(content, (bytes, str)):
|
if isinstance(content, (bytes, str)):
|
||||||
body = content.encode("utf-8") if isinstance(content, str) else content
|
body = content.encode("utf-8") if isinstance(content, str) else content
|
||||||
content_length = len(body)
|
content_length = len(body)
|
||||||
@ -135,7 +135,7 @@ def encode_content(
|
|||||||
|
|
||||||
def encode_urlencoded_data(
|
def encode_urlencoded_data(
|
||||||
data: RequestData,
|
data: RequestData,
|
||||||
) -> Tuple[Dict[str, str], ByteStream]:
|
) -> tuple[dict[str, str], ByteStream]:
|
||||||
plain_data = []
|
plain_data = []
|
||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
if isinstance(value, (list, tuple)):
|
if isinstance(value, (list, tuple)):
|
||||||
@ -150,14 +150,14 @@ def encode_urlencoded_data(
|
|||||||
|
|
||||||
|
|
||||||
def encode_multipart_data(
|
def encode_multipart_data(
|
||||||
data: RequestData, files: RequestFiles, boundary: Optional[bytes]
|
data: RequestData, files: RequestFiles, boundary: bytes | None
|
||||||
) -> Tuple[Dict[str, str], MultipartStream]:
|
) -> tuple[dict[str, str], MultipartStream]:
|
||||||
multipart = MultipartStream(data=data, files=files, boundary=boundary)
|
multipart = MultipartStream(data=data, files=files, boundary=boundary)
|
||||||
headers = multipart.get_headers()
|
headers = multipart.get_headers()
|
||||||
return headers, multipart
|
return headers, multipart
|
||||||
|
|
||||||
|
|
||||||
def encode_text(text: str) -> Tuple[Dict[str, str], ByteStream]:
|
def encode_text(text: str) -> tuple[dict[str, str], ByteStream]:
|
||||||
body = text.encode("utf-8")
|
body = text.encode("utf-8")
|
||||||
content_length = str(len(body))
|
content_length = str(len(body))
|
||||||
content_type = "text/plain; charset=utf-8"
|
content_type = "text/plain; charset=utf-8"
|
||||||
@ -165,7 +165,7 @@ def encode_text(text: str) -> Tuple[Dict[str, str], ByteStream]:
|
|||||||
return headers, ByteStream(body)
|
return headers, ByteStream(body)
|
||||||
|
|
||||||
|
|
||||||
def encode_html(html: str) -> Tuple[Dict[str, str], ByteStream]:
|
def encode_html(html: str) -> tuple[dict[str, str], ByteStream]:
|
||||||
body = html.encode("utf-8")
|
body = html.encode("utf-8")
|
||||||
content_length = str(len(body))
|
content_length = str(len(body))
|
||||||
content_type = "text/html; charset=utf-8"
|
content_type = "text/html; charset=utf-8"
|
||||||
@ -173,8 +173,10 @@ def encode_html(html: str) -> Tuple[Dict[str, str], ByteStream]:
|
|||||||
return headers, ByteStream(body)
|
return headers, ByteStream(body)
|
||||||
|
|
||||||
|
|
||||||
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}
|
||||||
@ -182,12 +184,12 @@ def encode_json(json: Any) -> Tuple[Dict[str, str], ByteStream]:
|
|||||||
|
|
||||||
|
|
||||||
def encode_request(
|
def encode_request(
|
||||||
content: Optional[RequestContent] = None,
|
content: RequestContent | None = None,
|
||||||
data: Optional[RequestData] = None,
|
data: RequestData | None = None,
|
||||||
files: Optional[RequestFiles] = None,
|
files: RequestFiles | None = None,
|
||||||
json: Optional[Any] = None,
|
json: Any | None = None,
|
||||||
boundary: Optional[bytes] = None,
|
boundary: bytes | None = None,
|
||||||
) -> Tuple[Dict[str, str], Union[SyncByteStream, AsyncByteStream]]:
|
) -> tuple[dict[str, str], SyncByteStream | AsyncByteStream]:
|
||||||
"""
|
"""
|
||||||
Handles encoding the given `content`, `data`, `files`, and `json`,
|
Handles encoding the given `content`, `data`, `files`, and `json`,
|
||||||
returning a two-tuple of (<headers>, <stream>).
|
returning a two-tuple of (<headers>, <stream>).
|
||||||
@ -201,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:
|
||||||
@ -217,11 +219,11 @@ def encode_request(
|
|||||||
|
|
||||||
|
|
||||||
def encode_response(
|
def encode_response(
|
||||||
content: Optional[ResponseContent] = None,
|
content: ResponseContent | None = None,
|
||||||
text: Optional[str] = None,
|
text: str | None = None,
|
||||||
html: Optional[str] = None,
|
html: str | None = None,
|
||||||
json: Optional[Any] = None,
|
json: Any | None = None,
|
||||||
) -> Tuple[Dict[str, str], Union[SyncByteStream, AsyncByteStream]]:
|
) -> tuple[dict[str, str], SyncByteStream | AsyncByteStream]:
|
||||||
"""
|
"""
|
||||||
Handles encoding the given `content`, returning a two-tuple of
|
Handles encoding the given `content`, returning a two-tuple of
|
||||||
(<headers>, <stream>).
|
(<headers>, <stream>).
|
||||||
|
|||||||
@ -3,14 +3,35 @@ 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
|
||||||
|
|
||||||
import codecs
|
import codecs
|
||||||
import io
|
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:
|
||||||
@ -137,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.
|
||||||
@ -167,11 +230,11 @@ class ByteChunker:
|
|||||||
Handles returning byte content in fixed-size chunks.
|
Handles returning byte content in fixed-size chunks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, chunk_size: typing.Optional[int] = None) -> None:
|
def __init__(self, chunk_size: int | None = None) -> None:
|
||||||
self._buffer = io.BytesIO()
|
self._buffer = io.BytesIO()
|
||||||
self._chunk_size = chunk_size
|
self._chunk_size = chunk_size
|
||||||
|
|
||||||
def decode(self, content: bytes) -> typing.List[bytes]:
|
def decode(self, content: bytes) -> list[bytes]:
|
||||||
if self._chunk_size is None:
|
if self._chunk_size is None:
|
||||||
return [content] if content else []
|
return [content] if content else []
|
||||||
|
|
||||||
@ -194,7 +257,7 @@ class ByteChunker:
|
|||||||
else:
|
else:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def flush(self) -> typing.List[bytes]:
|
def flush(self) -> list[bytes]:
|
||||||
value = self._buffer.getvalue()
|
value = self._buffer.getvalue()
|
||||||
self._buffer.seek(0)
|
self._buffer.seek(0)
|
||||||
self._buffer.truncate()
|
self._buffer.truncate()
|
||||||
@ -206,13 +269,13 @@ class TextChunker:
|
|||||||
Handles returning text content in fixed-size chunks.
|
Handles returning text content in fixed-size chunks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, chunk_size: typing.Optional[int] = None) -> None:
|
def __init__(self, chunk_size: int | None = None) -> None:
|
||||||
self._buffer = io.StringIO()
|
self._buffer = io.StringIO()
|
||||||
self._chunk_size = chunk_size
|
self._chunk_size = chunk_size
|
||||||
|
|
||||||
def decode(self, content: str) -> typing.List[str]:
|
def decode(self, content: str) -> list[str]:
|
||||||
if self._chunk_size is None:
|
if self._chunk_size is None:
|
||||||
return [content]
|
return [content] if content else []
|
||||||
|
|
||||||
self._buffer.write(content)
|
self._buffer.write(content)
|
||||||
if self._buffer.tell() >= self._chunk_size:
|
if self._buffer.tell() >= self._chunk_size:
|
||||||
@ -233,7 +296,7 @@ class TextChunker:
|
|||||||
else:
|
else:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def flush(self) -> typing.List[str]:
|
def flush(self) -> list[str]:
|
||||||
value = self._buffer.getvalue()
|
value = self._buffer.getvalue()
|
||||||
self._buffer.seek(0)
|
self._buffer.seek(0)
|
||||||
self._buffer.truncate()
|
self._buffer.truncate()
|
||||||
@ -245,7 +308,7 @@ class TextDecoder:
|
|||||||
Handles incrementally decoding bytes into text
|
Handles incrementally decoding bytes into text
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, encoding: str = "utf-8"):
|
def __init__(self, encoding: str = "utf-8") -> None:
|
||||||
self.decoder = codecs.getincrementaldecoder(encoding)(errors="replace")
|
self.decoder = codecs.getincrementaldecoder(encoding)(errors="replace")
|
||||||
|
|
||||||
def decode(self, data: bytes) -> str:
|
def decode(self, data: bytes) -> str:
|
||||||
@ -259,14 +322,15 @@ class LineDecoder:
|
|||||||
"""
|
"""
|
||||||
Handles incrementally reading lines from text.
|
Handles incrementally reading lines from text.
|
||||||
|
|
||||||
Has the same behaviour as the stdllib splitlines, but handling the input iteratively.
|
Has the same behaviour as the stdllib splitlines,
|
||||||
|
but handling the input iteratively.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.buffer: typing.List[str] = []
|
self.buffer: list[str] = []
|
||||||
self.trailing_cr: bool = False
|
self.trailing_cr: bool = False
|
||||||
|
|
||||||
def decode(self, text: str) -> typing.List[str]:
|
def decode(self, text: str) -> list[str]:
|
||||||
# See https://docs.python.org/3/library/stdtypes.html#str.splitlines
|
# See https://docs.python.org/3/library/stdtypes.html#str.splitlines
|
||||||
NEWLINE_CHARS = "\n\r\x0b\x0c\x1c\x1d\x1e\x85\u2028\u2029"
|
NEWLINE_CHARS = "\n\r\x0b\x0c\x1c\x1d\x1e\x85\u2028\u2029"
|
||||||
|
|
||||||
@ -279,7 +343,9 @@ class LineDecoder:
|
|||||||
text = text[:-1]
|
text = text[:-1]
|
||||||
|
|
||||||
if not text:
|
if not text:
|
||||||
return []
|
# NOTE: the edge case input of empty text doesn't occur in practice,
|
||||||
|
# because other httpx internals filter out this value
|
||||||
|
return [] # pragma: no cover
|
||||||
|
|
||||||
trailing_newline = text[-1] in NEWLINE_CHARS
|
trailing_newline = text[-1] in NEWLINE_CHARS
|
||||||
lines = text.splitlines()
|
lines = text.splitlines()
|
||||||
@ -302,7 +368,7 @@ class LineDecoder:
|
|||||||
|
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
def flush(self) -> typing.List[str]:
|
def flush(self) -> list[str]:
|
||||||
if not self.buffer and not self.trailing_cr:
|
if not self.buffer and not self.trailing_cr:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@ -317,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,12 +30,46 @@ Our exception hierarchy:
|
|||||||
x ResponseNotRead
|
x ResponseNotRead
|
||||||
x RequestNotRead
|
x RequestNotRead
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import typing
|
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):
|
||||||
"""
|
"""
|
||||||
@ -57,16 +91,16 @@ class HTTPError(Exception):
|
|||||||
|
|
||||||
def __init__(self, message: str) -> None:
|
def __init__(self, message: str) -> None:
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
self._request: typing.Optional["Request"] = None
|
self._request: Request | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def request(self) -> "Request":
|
def request(self) -> Request:
|
||||||
if self._request is None:
|
if self._request is None:
|
||||||
raise RuntimeError("The .request property has not been set.")
|
raise RuntimeError("The .request property has not been set.")
|
||||||
return self._request
|
return self._request
|
||||||
|
|
||||||
@request.setter
|
@request.setter
|
||||||
def request(self, request: "Request") -> None:
|
def request(self, request: Request) -> None:
|
||||||
self._request = request
|
self._request = request
|
||||||
|
|
||||||
|
|
||||||
@ -75,9 +109,7 @@ class RequestError(HTTPError):
|
|||||||
Base class for all exceptions that may occur when issuing a `.request()`.
|
Base class for all exceptions that may occur when issuing a `.request()`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, message: str, *, request: Request | None = None) -> None:
|
||||||
self, message: str, *, request: typing.Optional["Request"] = None
|
|
||||||
) -> None:
|
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
# At the point an exception is raised we won't typically have a request
|
# At the point an exception is raised we won't typically have a request
|
||||||
# instance to associate it with.
|
# instance to associate it with.
|
||||||
@ -230,9 +262,7 @@ class HTTPStatusError(HTTPError):
|
|||||||
May be raised when calling `response.raise_for_status()`
|
May be raised when calling `response.raise_for_status()`
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, message: str, *, request: Request, response: Response) -> None:
|
||||||
self, message: str, *, request: "Request", response: "Response"
|
|
||||||
) -> None:
|
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
self.request = request
|
self.request = request
|
||||||
self.response = response
|
self.response = response
|
||||||
@ -301,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)
|
||||||
|
|
||||||
|
|
||||||
@ -313,7 +341,10 @@ class ResponseNotRead(StreamError):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
message = "Attempted to access streaming response content, without having called `read()`."
|
message = (
|
||||||
|
"Attempted to access streaming response content,"
|
||||||
|
" without having called `read()`."
|
||||||
|
)
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
@ -323,13 +354,16 @@ class RequestNotRead(StreamError):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
message = "Attempted to access streaming request content, without having called `read()`."
|
message = (
|
||||||
|
"Attempted to access streaming request content,"
|
||||||
|
" without having called `read()`."
|
||||||
|
)
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def request_context(
|
def request_context(
|
||||||
request: typing.Optional["Request"] = None,
|
request: Request | None = None,
|
||||||
) -> typing.Iterator[None]:
|
) -> typing.Iterator[None]:
|
||||||
"""
|
"""
|
||||||
A context manager that can be used to attach the given request context
|
A context manager that can be used to attach the given request context
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
import json
|
import json
|
||||||
import sys
|
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
|
||||||
@ -18,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()
|
||||||
@ -63,20 +67,21 @@ def print_help() -> None:
|
|||||||
)
|
)
|
||||||
table.add_row(
|
table.add_row(
|
||||||
"--auth [cyan]<USER PASS>",
|
"--auth [cyan]<USER PASS>",
|
||||||
"Username and password to include in the request. Specify '-' for the password to use "
|
"Username and password to include in the request. Specify '-' for the password"
|
||||||
"a password prompt. Note that using --verbose/-v will expose the Authorization "
|
" to use a password prompt. Note that using --verbose/-v will expose"
|
||||||
"header, including the password encoding in a trivially reversible format.",
|
" the Authorization header, including the password encoding"
|
||||||
|
" in a trivially reversible format.",
|
||||||
)
|
)
|
||||||
|
|
||||||
table.add_row(
|
table.add_row(
|
||||||
"--proxies [cyan]URL",
|
"--proxy [cyan]URL",
|
||||||
"Send the request via a proxy. Should be the URL giving the proxy address.",
|
"Send the request via a proxy. Should be the URL giving the proxy address.",
|
||||||
)
|
)
|
||||||
|
|
||||||
table.add_row(
|
table.add_row(
|
||||||
"--timeout [cyan]FLOAT",
|
"--timeout [cyan]FLOAT",
|
||||||
"Timeout value to use for network operations, such as establishing the connection, "
|
"Timeout value to use for network operations, such as establishing the"
|
||||||
"reading some data, etc... [Default: 5.0]",
|
" connection, reading some data, etc... [Default: 5.0]",
|
||||||
)
|
)
|
||||||
|
|
||||||
table.add_row("--follow-redirects", "Automatically follow redirects.")
|
table.add_row("--follow-redirects", "Automatically follow redirects.")
|
||||||
@ -124,8 +129,8 @@ def format_request_headers(request: httpcore.Request, http2: bool = False) -> st
|
|||||||
def format_response_headers(
|
def format_response_headers(
|
||||||
http_version: bytes,
|
http_version: bytes,
|
||||||
status: int,
|
status: int,
|
||||||
reason_phrase: typing.Optional[bytes],
|
reason_phrase: bytes | None,
|
||||||
headers: typing.List[typing.Tuple[bytes, bytes]],
|
headers: list[tuple[bytes, bytes]],
|
||||||
) -> str:
|
) -> str:
|
||||||
version = http_version.decode("ascii")
|
version = http_version.decode("ascii")
|
||||||
reason = (
|
reason = (
|
||||||
@ -151,8 +156,8 @@ def print_request_headers(request: httpcore.Request, http2: bool = False) -> Non
|
|||||||
def print_response_headers(
|
def print_response_headers(
|
||||||
http_version: bytes,
|
http_version: bytes,
|
||||||
status: int,
|
status: int,
|
||||||
reason_phrase: typing.Optional[bytes],
|
reason_phrase: bytes | None,
|
||||||
headers: typing.List[typing.Tuple[bytes, bytes]],
|
headers: list[tuple[bytes, bytes]],
|
||||||
) -> None:
|
) -> None:
|
||||||
console = rich.console.Console()
|
console = rich.console.Console()
|
||||||
http_text = format_response_headers(http_version, status, reason_phrase, headers)
|
http_text = format_response_headers(http_version, status, reason_phrase, headers)
|
||||||
@ -267,7 +272,7 @@ def download_response(response: Response, download: typing.BinaryIO) -> None:
|
|||||||
|
|
||||||
def validate_json(
|
def validate_json(
|
||||||
ctx: click.Context,
|
ctx: click.Context,
|
||||||
param: typing.Union[click.Option, click.Parameter],
|
param: click.Option | click.Parameter,
|
||||||
value: typing.Any,
|
value: typing.Any,
|
||||||
) -> typing.Any:
|
) -> typing.Any:
|
||||||
if value is None:
|
if value is None:
|
||||||
@ -281,7 +286,7 @@ def validate_json(
|
|||||||
|
|
||||||
def validate_auth(
|
def validate_auth(
|
||||||
ctx: click.Context,
|
ctx: click.Context,
|
||||||
param: typing.Union[click.Option, click.Parameter],
|
param: click.Option | click.Parameter,
|
||||||
value: typing.Any,
|
value: typing.Any,
|
||||||
) -> typing.Any:
|
) -> typing.Any:
|
||||||
if value == (None, None):
|
if value == (None, None):
|
||||||
@ -295,7 +300,7 @@ def validate_auth(
|
|||||||
|
|
||||||
def handle_help(
|
def handle_help(
|
||||||
ctx: click.Context,
|
ctx: click.Context,
|
||||||
param: typing.Union[click.Option, click.Parameter],
|
param: click.Option | click.Parameter,
|
||||||
value: typing.Any,
|
value: typing.Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not value or ctx.resilient_parsing:
|
if not value or ctx.resilient_parsing:
|
||||||
@ -385,8 +390,8 @@ def handle_help(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--proxies",
|
"--proxy",
|
||||||
"proxies",
|
"proxy",
|
||||||
type=str,
|
type=str,
|
||||||
default=None,
|
default=None,
|
||||||
help="Send the request via a proxy. Should be the URL giving the proxy address.",
|
help="Send the request via a proxy. Should be the URL giving the proxy address.",
|
||||||
@ -447,20 +452,20 @@ def handle_help(
|
|||||||
def main(
|
def main(
|
||||||
url: str,
|
url: str,
|
||||||
method: str,
|
method: str,
|
||||||
params: typing.List[typing.Tuple[str, str]],
|
params: list[tuple[str, str]],
|
||||||
content: str,
|
content: str,
|
||||||
data: typing.List[typing.Tuple[str, str]],
|
data: list[tuple[str, str]],
|
||||||
files: typing.List[typing.Tuple[str, click.File]],
|
files: list[tuple[str, click.File]],
|
||||||
json: str,
|
json: str,
|
||||||
headers: typing.List[typing.Tuple[str, str]],
|
headers: list[tuple[str, str]],
|
||||||
cookies: typing.List[typing.Tuple[str, str]],
|
cookies: list[tuple[str, str]],
|
||||||
auth: typing.Optional[typing.Tuple[str, str]],
|
auth: tuple[str, str] | None,
|
||||||
proxies: str,
|
proxy: str,
|
||||||
timeout: float,
|
timeout: float,
|
||||||
follow_redirects: bool,
|
follow_redirects: bool,
|
||||||
verify: bool,
|
verify: bool,
|
||||||
http2: bool,
|
http2: bool,
|
||||||
download: typing.Optional[typing.BinaryIO],
|
download: typing.BinaryIO | None,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
@ -471,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:
|
||||||
proxies=proxies,
|
|
||||||
timeout=timeout,
|
|
||||||
verify=verify,
|
|
||||||
http2=http2,
|
|
||||||
) as client:
|
|
||||||
with client.stream(
|
with client.stream(
|
||||||
method,
|
method,
|
||||||
url,
|
url,
|
||||||
|
|||||||
340
httpx/_models.py
340
httpx/_models.py
@ -1,6 +1,10 @@
|
|||||||
|
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
|
||||||
@ -42,15 +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
|
||||||
guess_json_utf,
|
|
||||||
is_known_encoding,
|
__all__ = ["Cookies", "Headers", "Request", "Response"]
|
||||||
normalize_header_key,
|
|
||||||
normalize_header_value,
|
SENSITIVE_HEADERS = {"authorization", "proxy-authorization"}
|
||||||
obfuscate_sensitive_headers,
|
|
||||||
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]):
|
||||||
@ -60,31 +143,23 @@ class Headers(typing.MutableMapping[str, str]):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
headers: typing.Optional[HeaderTypes] = None,
|
headers: HeaderTypes | None = None,
|
||||||
encoding: typing.Optional[str] = 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
|
||||||
|
|
||||||
@ -118,7 +193,7 @@ class Headers(typing.MutableMapping[str, str]):
|
|||||||
self._encoding = value
|
self._encoding = value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def raw(self) -> typing.List[typing.Tuple[bytes, bytes]]:
|
def raw(self) -> list[tuple[bytes, bytes]]:
|
||||||
"""
|
"""
|
||||||
Returns a list of the raw header items, as byte pairs.
|
Returns a list of the raw header items, as byte pairs.
|
||||||
"""
|
"""
|
||||||
@ -128,7 +203,7 @@ class Headers(typing.MutableMapping[str, str]):
|
|||||||
return {key.decode(self.encoding): None for _, key, value in self._list}.keys()
|
return {key.decode(self.encoding): None for _, key, value in self._list}.keys()
|
||||||
|
|
||||||
def values(self) -> typing.ValuesView[str]:
|
def values(self) -> typing.ValuesView[str]:
|
||||||
values_dict: typing.Dict[str, str] = {}
|
values_dict: dict[str, str] = {}
|
||||||
for _, key, value in self._list:
|
for _, key, value in self._list:
|
||||||
str_key = key.decode(self.encoding)
|
str_key = key.decode(self.encoding)
|
||||||
str_value = value.decode(self.encoding)
|
str_value = value.decode(self.encoding)
|
||||||
@ -143,7 +218,7 @@ class Headers(typing.MutableMapping[str, str]):
|
|||||||
Return `(key, value)` items of headers. Concatenate headers
|
Return `(key, value)` items of headers. Concatenate headers
|
||||||
into a single comma separated value when a key occurs multiple times.
|
into a single comma separated value when a key occurs multiple times.
|
||||||
"""
|
"""
|
||||||
values_dict: typing.Dict[str, str] = {}
|
values_dict: dict[str, str] = {}
|
||||||
for _, key, value in self._list:
|
for _, key, value in self._list:
|
||||||
str_key = key.decode(self.encoding)
|
str_key = key.decode(self.encoding)
|
||||||
str_value = value.decode(self.encoding)
|
str_value = value.decode(self.encoding)
|
||||||
@ -153,7 +228,7 @@ class Headers(typing.MutableMapping[str, str]):
|
|||||||
values_dict[str_key] = str_value
|
values_dict[str_key] = str_value
|
||||||
return values_dict.items()
|
return values_dict.items()
|
||||||
|
|
||||||
def multi_items(self) -> typing.List[typing.Tuple[str, str]]:
|
def multi_items(self) -> list[tuple[str, str]]:
|
||||||
"""
|
"""
|
||||||
Return a list of `(key, value)` pairs of headers. Allow multiple
|
Return a list of `(key, value)` pairs of headers. Allow multiple
|
||||||
occurrences of the same key without concatenating into a single
|
occurrences of the same key without concatenating into a single
|
||||||
@ -174,7 +249,7 @@ class Headers(typing.MutableMapping[str, str]):
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
return default
|
return default
|
||||||
|
|
||||||
def get_list(self, key: str, split_commas: bool = False) -> typing.List[str]:
|
def get_list(self, key: str, split_commas: bool = False) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Return a list of all header values for a given key.
|
Return a list of all header values for a given key.
|
||||||
If `split_commas=True` is passed, then any comma separated header
|
If `split_commas=True` is passed, then any comma separated header
|
||||||
@ -196,14 +271,14 @@ class Headers(typing.MutableMapping[str, str]):
|
|||||||
split_values.extend([item.strip() for item in value.split(",")])
|
split_values.extend([item.strip() for item in value.split(",")])
|
||||||
return split_values
|
return split_values
|
||||||
|
|
||||||
def update(self, headers: typing.Optional[HeaderTypes] = None) -> None: # type: ignore
|
def update(self, headers: HeaderTypes | None = None) -> None: # type: ignore
|
||||||
headers = Headers(headers)
|
headers = Headers(headers)
|
||||||
for key in headers.keys():
|
for key in headers.keys():
|
||||||
if key in self:
|
if key in self:
|
||||||
self.pop(key)
|
self.pop(key)
|
||||||
self._list.extend(headers._list)
|
self._list.extend(headers._list)
|
||||||
|
|
||||||
def copy(self) -> "Headers":
|
def copy(self) -> Headers:
|
||||||
return Headers(self, encoding=self.encoding)
|
return Headers(self, encoding=self.encoding)
|
||||||
|
|
||||||
def __getitem__(self, key: str) -> str:
|
def __getitem__(self, key: str) -> str:
|
||||||
@ -295,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)
|
||||||
@ -307,35 +382,29 @@ class Headers(typing.MutableMapping[str, str]):
|
|||||||
class Request:
|
class Request:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
method: typing.Union[str, bytes],
|
method: str,
|
||||||
url: typing.Union["URL", str],
|
url: URL | str,
|
||||||
*,
|
*,
|
||||||
params: typing.Optional[QueryParamTypes] = None,
|
params: QueryParamTypes | None = None,
|
||||||
headers: typing.Optional[HeaderTypes] = None,
|
headers: HeaderTypes | None = None,
|
||||||
cookies: typing.Optional[CookieTypes] = None,
|
cookies: CookieTypes | None = None,
|
||||||
content: typing.Optional[RequestContent] = None,
|
content: RequestContent | None = None,
|
||||||
data: typing.Optional[RequestData] = None,
|
data: RequestData | None = None,
|
||||||
files: typing.Optional[RequestFiles] = None,
|
files: RequestFiles | None = None,
|
||||||
json: typing.Optional[typing.Any] = None,
|
json: typing.Any | None = None,
|
||||||
stream: typing.Union[SyncByteStream, AsyncByteStream, None] = None,
|
stream: SyncByteStream | AsyncByteStream | None = None,
|
||||||
extensions: typing.Optional[RequestExtensions] = None,
|
extensions: RequestExtensions | 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)
|
||||||
|
|
||||||
if stream is None:
|
if stream is None:
|
||||||
content_type: typing.Optional[str] = self.headers.get("content-type")
|
content_type: str | None = self.headers.get("content-type")
|
||||||
headers, stream = encode_request(
|
headers, stream = encode_request(
|
||||||
content=content,
|
content=content,
|
||||||
data=data,
|
data=data,
|
||||||
@ -359,7 +428,8 @@ class Request:
|
|||||||
# Using `content=...` implies automatically populated `Host` and content
|
# Using `content=...` implies automatically populated `Host` and content
|
||||||
# headers, of either `Content-Length: ...` or `Transfer-Encoding: chunked`.
|
# headers, of either `Content-Length: ...` or `Transfer-Encoding: chunked`.
|
||||||
#
|
#
|
||||||
# Using `stream=...` will not automatically include *any* auto-populated headers.
|
# Using `stream=...` will not automatically include *any*
|
||||||
|
# auto-populated headers.
|
||||||
#
|
#
|
||||||
# As an end-user you don't really need `stream=...`. It's only
|
# As an end-user you don't really need `stream=...`. It's only
|
||||||
# useful when:
|
# useful when:
|
||||||
@ -368,14 +438,14 @@ class Request:
|
|||||||
# * Creating request instances on the *server-side* of the transport API.
|
# * Creating request instances on the *server-side* of the transport API.
|
||||||
self.stream = stream
|
self.stream = stream
|
||||||
|
|
||||||
def _prepare(self, default_headers: typing.Dict[str, str]) -> None:
|
def _prepare(self, default_headers: dict[str, str]) -> None:
|
||||||
for key, value in default_headers.items():
|
for key, value in default_headers.items():
|
||||||
# Ignore Transfer-Encoding if the Content-Length has been set explicitly.
|
# Ignore Transfer-Encoding if the Content-Length has been set explicitly.
|
||||||
if key.lower() == "transfer-encoding" and "Content-Length" in self.headers:
|
if key.lower() == "transfer-encoding" and "Content-Length" in self.headers:
|
||||||
continue
|
continue
|
||||||
self.headers.setdefault(key, value)
|
self.headers.setdefault(key, value)
|
||||||
|
|
||||||
auto_headers: typing.List[typing.Tuple[bytes, bytes]] = []
|
auto_headers: list[tuple[bytes, bytes]] = []
|
||||||
|
|
||||||
has_host = "Host" in self.headers
|
has_host = "Host" in self.headers
|
||||||
has_content_length = (
|
has_content_length = (
|
||||||
@ -428,14 +498,14 @@ class Request:
|
|||||||
url = str(self.url)
|
url = str(self.url)
|
||||||
return f"<{class_name}({self.method!r}, {url!r})>"
|
return f"<{class_name}({self.method!r}, {url!r})>"
|
||||||
|
|
||||||
def __getstate__(self) -> typing.Dict[str, typing.Any]:
|
def __getstate__(self) -> dict[str, typing.Any]:
|
||||||
return {
|
return {
|
||||||
name: value
|
name: value
|
||||||
for name, value in self.__dict__.items()
|
for name, value in self.__dict__.items()
|
||||||
if name not in ["extensions", "stream"]
|
if name not in ["extensions", "stream"]
|
||||||
}
|
}
|
||||||
|
|
||||||
def __setstate__(self, state: typing.Dict[str, typing.Any]) -> None:
|
def __setstate__(self, state: dict[str, typing.Any]) -> None:
|
||||||
for name, value in state.items():
|
for name, value in state.items():
|
||||||
setattr(self, name, value)
|
setattr(self, name, value)
|
||||||
self.extensions = {}
|
self.extensions = {}
|
||||||
@ -447,27 +517,27 @@ class Response:
|
|||||||
self,
|
self,
|
||||||
status_code: int,
|
status_code: int,
|
||||||
*,
|
*,
|
||||||
headers: typing.Optional[HeaderTypes] = None,
|
headers: HeaderTypes | None = None,
|
||||||
content: typing.Optional[ResponseContent] = None,
|
content: ResponseContent | None = None,
|
||||||
text: typing.Optional[str] = None,
|
text: str | None = None,
|
||||||
html: typing.Optional[str] = None,
|
html: str | None = None,
|
||||||
json: typing.Any = None,
|
json: typing.Any = None,
|
||||||
stream: typing.Union[SyncByteStream, AsyncByteStream, None] = None,
|
stream: SyncByteStream | AsyncByteStream | None = None,
|
||||||
request: typing.Optional[Request] = None,
|
request: Request | None = None,
|
||||||
extensions: typing.Optional[ResponseExtensions] = None,
|
extensions: ResponseExtensions | None = None,
|
||||||
history: typing.Optional[typing.List["Response"]] = None,
|
history: list[Response] | None = None,
|
||||||
default_encoding: typing.Union[str, typing.Callable[[bytes], str]] = "utf-8",
|
default_encoding: str | typing.Callable[[bytes], str] = "utf-8",
|
||||||
):
|
) -> None:
|
||||||
self.status_code = status_code
|
self.status_code = status_code
|
||||||
self.headers = Headers(headers)
|
self.headers = Headers(headers)
|
||||||
|
|
||||||
self._request: typing.Optional[Request] = request
|
self._request: Request | None = request
|
||||||
|
|
||||||
# When follow_redirects=False and a redirect is received,
|
# When follow_redirects=False and a redirect is received,
|
||||||
# the client will set `response.next_request`.
|
# the client will set `response.next_request`.
|
||||||
self.next_request: typing.Optional[Request] = None
|
self.next_request: Request | None = None
|
||||||
|
|
||||||
self.extensions = {} 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
|
||||||
@ -498,7 +568,7 @@ class Response:
|
|||||||
|
|
||||||
self._num_bytes_downloaded = 0
|
self._num_bytes_downloaded = 0
|
||||||
|
|
||||||
def _prepare(self, default_headers: typing.Dict[str, str]) -> None:
|
def _prepare(self, default_headers: dict[str, str]) -> None:
|
||||||
for key, value in default_headers.items():
|
for key, value in default_headers.items():
|
||||||
# Ignore Transfer-Encoding if the Content-Length has been set explicitly.
|
# Ignore Transfer-Encoding if the Content-Length has been set explicitly.
|
||||||
if key.lower() == "transfer-encoding" and "content-length" in self.headers:
|
if key.lower() == "transfer-encoding" and "content-length" in self.headers:
|
||||||
@ -580,7 +650,7 @@ class Response:
|
|||||||
return self._text
|
return self._text
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def encoding(self) -> typing.Optional[str]:
|
def encoding(self) -> str | None:
|
||||||
"""
|
"""
|
||||||
Return an encoding to use for decoding the byte content into text.
|
Return an encoding to use for decoding the byte content into text.
|
||||||
The priority for determining this is given by...
|
The priority for determining this is given by...
|
||||||
@ -593,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"):
|
||||||
@ -603,10 +673,20 @@ class Response:
|
|||||||
|
|
||||||
@encoding.setter
|
@encoding.setter
|
||||||
def encoding(self, value: str) -> None:
|
def encoding(self, value: str) -> None:
|
||||||
|
"""
|
||||||
|
Set the encoding to use for decoding the byte content into text.
|
||||||
|
|
||||||
|
If the `text` attribute has been accessed, attempting to set the
|
||||||
|
encoding will throw a ValueError.
|
||||||
|
"""
|
||||||
|
if hasattr(self, "_text"):
|
||||||
|
raise ValueError(
|
||||||
|
"Setting encoding after `text` has been accessed is not allowed."
|
||||||
|
)
|
||||||
self._encoding = value
|
self._encoding = value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def charset_encoding(self) -> typing.Optional[str]:
|
def charset_encoding(self) -> str | None:
|
||||||
"""
|
"""
|
||||||
Return the encoding, as specified by the Content-Type header.
|
Return the encoding, as specified by the Content-Type header.
|
||||||
"""
|
"""
|
||||||
@ -614,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:
|
||||||
"""
|
"""
|
||||||
@ -622,7 +702,7 @@ class Response:
|
|||||||
content, depending on the Content-Encoding used in the response.
|
content, depending on the Content-Encoding used in the response.
|
||||||
"""
|
"""
|
||||||
if not hasattr(self, "_decoder"):
|
if not hasattr(self, "_decoder"):
|
||||||
decoders: typing.List[ContentDecoder] = []
|
decoders: list[ContentDecoder] = []
|
||||||
values = self.headers.get_list("content-encoding", split_commas=True)
|
values = self.headers.get_list("content-encoding", split_commas=True)
|
||||||
for value in values:
|
for value in values:
|
||||||
value = value.strip().lower()
|
value = value.strip().lower()
|
||||||
@ -711,7 +791,7 @@ class Response:
|
|||||||
and "Location" in self.headers
|
and "Location" in self.headers
|
||||||
)
|
)
|
||||||
|
|
||||||
def raise_for_status(self) -> None:
|
def raise_for_status(self) -> Response:
|
||||||
"""
|
"""
|
||||||
Raise the `HTTPStatusError` if one occurred.
|
Raise the `HTTPStatusError` if one occurred.
|
||||||
"""
|
"""
|
||||||
@ -723,18 +803,18 @@ class Response:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if self.is_success:
|
if self.is_success:
|
||||||
return
|
return self
|
||||||
|
|
||||||
if self.has_redirect_location:
|
if self.has_redirect_location:
|
||||||
message = (
|
message = (
|
||||||
"{error_type} '{0.status_code} {0.reason_phrase}' for url '{0.url}'\n"
|
"{error_type} '{0.status_code} {0.reason_phrase}' for url '{0.url}'\n"
|
||||||
"Redirect location: '{0.headers[location]}'\n"
|
"Redirect location: '{0.headers[location]}'\n"
|
||||||
"For more information check: https://httpstatuses.com/{0.status_code}"
|
"For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/{0.status_code}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
message = (
|
message = (
|
||||||
"{error_type} '{0.status_code} {0.reason_phrase}' for url '{0.url}'\n"
|
"{error_type} '{0.status_code} {0.reason_phrase}' for url '{0.url}'\n"
|
||||||
"For more information check: https://httpstatuses.com/{0.status_code}"
|
"For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/{0.status_code}"
|
||||||
)
|
)
|
||||||
|
|
||||||
status_class = self.status_code // 100
|
status_class = self.status_code // 100
|
||||||
@ -749,32 +829,28 @@ class Response:
|
|||||||
raise HTTPStatusError(message, request=request, response=self)
|
raise HTTPStatusError(message, request=request, response=self)
|
||||||
|
|
||||||
def json(self, **kwargs: typing.Any) -> typing.Any:
|
def json(self, **kwargs: typing.Any) -> typing.Any:
|
||||||
if self.charset_encoding is None and self.content and len(self.content) > 3:
|
return jsonlib.loads(self.content, **kwargs)
|
||||||
encoding = guess_json_utf(self.content)
|
|
||||||
if encoding is not None:
|
|
||||||
return jsonlib.loads(self.content.decode(encoding), **kwargs)
|
|
||||||
return jsonlib.loads(self.text, **kwargs)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cookies(self) -> "Cookies":
|
def cookies(self) -> Cookies:
|
||||||
if not hasattr(self, "_cookies"):
|
if not hasattr(self, "_cookies"):
|
||||||
self._cookies = Cookies()
|
self._cookies = Cookies()
|
||||||
self._cookies.extract_cookies(self)
|
self._cookies.extract_cookies(self)
|
||||||
return self._cookies
|
return self._cookies
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def links(self) -> typing.Dict[typing.Optional[str], typing.Dict[str, str]]:
|
def links(self) -> dict[str | None, dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
Returns the parsed header links of the response, if any
|
Returns the parsed header links of the response, if any
|
||||||
"""
|
"""
|
||||||
header = self.headers.get("link")
|
header = self.headers.get("link")
|
||||||
ldict = {}
|
if header is None:
|
||||||
if header:
|
return {}
|
||||||
links = parse_header_links(header)
|
|
||||||
for link in links:
|
return {
|
||||||
key = link.get("rel") or link.get("url")
|
(link.get("rel") or link.get("url")): link
|
||||||
ldict[key] = link
|
for link in _parse_header_links(header)
|
||||||
return ldict
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def num_bytes_downloaded(self) -> int:
|
def num_bytes_downloaded(self) -> int:
|
||||||
@ -783,14 +859,14 @@ class Response:
|
|||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<Response [{self.status_code} {self.reason_phrase}]>"
|
return f"<Response [{self.status_code} {self.reason_phrase}]>"
|
||||||
|
|
||||||
def __getstate__(self) -> typing.Dict[str, typing.Any]:
|
def __getstate__(self) -> dict[str, typing.Any]:
|
||||||
return {
|
return {
|
||||||
name: value
|
name: value
|
||||||
for name, value in self.__dict__.items()
|
for name, value in self.__dict__.items()
|
||||||
if name not in ["extensions", "stream", "is_closed", "_decoder"]
|
if name not in ["extensions", "stream", "is_closed", "_decoder"]
|
||||||
}
|
}
|
||||||
|
|
||||||
def __setstate__(self, state: typing.Dict[str, typing.Any]) -> None:
|
def __setstate__(self, state: dict[str, typing.Any]) -> None:
|
||||||
for name, value in state.items():
|
for name, value in state.items():
|
||||||
setattr(self, name, value)
|
setattr(self, name, value)
|
||||||
self.is_closed = True
|
self.is_closed = True
|
||||||
@ -805,12 +881,10 @@ class Response:
|
|||||||
self._content = b"".join(self.iter_bytes())
|
self._content = b"".join(self.iter_bytes())
|
||||||
return self._content
|
return self._content
|
||||||
|
|
||||||
def iter_bytes(
|
def iter_bytes(self, chunk_size: int | None = None) -> typing.Iterator[bytes]:
|
||||||
self, chunk_size: typing.Optional[int] = 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
|
||||||
@ -830,9 +904,7 @@ class Response:
|
|||||||
for chunk in chunker.flush():
|
for chunk in chunker.flush():
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|
||||||
def iter_text(
|
def iter_text(self, chunk_size: int | None = None) -> typing.Iterator[str]:
|
||||||
self, chunk_size: typing.Optional[int] = None
|
|
||||||
) -> typing.Iterator[str]:
|
|
||||||
"""
|
"""
|
||||||
A str-iterator over the decoded response content
|
A str-iterator over the decoded response content
|
||||||
that handles both gzip, deflate, etc but also detects the content's
|
that handles both gzip, deflate, etc but also detects the content's
|
||||||
@ -847,7 +919,7 @@ class Response:
|
|||||||
yield chunk
|
yield chunk
|
||||||
text_content = decoder.flush()
|
text_content = decoder.flush()
|
||||||
for chunk in chunker.decode(text_content):
|
for chunk in chunker.decode(text_content):
|
||||||
yield chunk
|
yield chunk # pragma: no cover
|
||||||
for chunk in chunker.flush():
|
for chunk in chunker.flush():
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|
||||||
@ -860,9 +932,7 @@ class Response:
|
|||||||
for line in decoder.flush():
|
for line in decoder.flush():
|
||||||
yield line
|
yield line
|
||||||
|
|
||||||
def iter_raw(
|
def iter_raw(self, chunk_size: int | None = None) -> typing.Iterator[bytes]:
|
||||||
self, chunk_size: typing.Optional[int] = None
|
|
||||||
) -> typing.Iterator[bytes]:
|
|
||||||
"""
|
"""
|
||||||
A byte-iterator over the raw response content.
|
A byte-iterator over the raw response content.
|
||||||
"""
|
"""
|
||||||
@ -894,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
|
||||||
@ -910,11 +980,11 @@ class Response:
|
|||||||
return self._content
|
return self._content
|
||||||
|
|
||||||
async def aiter_bytes(
|
async def aiter_bytes(
|
||||||
self, chunk_size: typing.Optional[int] = None
|
self, chunk_size: int | None = None
|
||||||
) -> 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
|
||||||
@ -935,7 +1005,7 @@ class Response:
|
|||||||
yield chunk
|
yield chunk
|
||||||
|
|
||||||
async def aiter_text(
|
async def aiter_text(
|
||||||
self, chunk_size: typing.Optional[int] = None
|
self, chunk_size: int | None = None
|
||||||
) -> typing.AsyncIterator[str]:
|
) -> typing.AsyncIterator[str]:
|
||||||
"""
|
"""
|
||||||
A str-iterator over the decoded response content
|
A str-iterator over the decoded response content
|
||||||
@ -951,7 +1021,7 @@ class Response:
|
|||||||
yield chunk
|
yield chunk
|
||||||
text_content = decoder.flush()
|
text_content = decoder.flush()
|
||||||
for chunk in chunker.decode(text_content):
|
for chunk in chunker.decode(text_content):
|
||||||
yield chunk
|
yield chunk # pragma: no cover
|
||||||
for chunk in chunker.flush():
|
for chunk in chunker.flush():
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|
||||||
@ -965,7 +1035,7 @@ class Response:
|
|||||||
yield line
|
yield line
|
||||||
|
|
||||||
async def aiter_raw(
|
async def aiter_raw(
|
||||||
self, chunk_size: typing.Optional[int] = None
|
self, chunk_size: int | None = None
|
||||||
) -> typing.AsyncIterator[bytes]:
|
) -> typing.AsyncIterator[bytes]:
|
||||||
"""
|
"""
|
||||||
A byte-iterator over the raw response content.
|
A byte-iterator over the raw response content.
|
||||||
@ -975,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
|
||||||
@ -998,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
|
||||||
@ -1011,7 +1081,7 @@ class Cookies(typing.MutableMapping[str, str]):
|
|||||||
HTTP Cookies, as a mutable mapping.
|
HTTP Cookies, as a mutable mapping.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, cookies: typing.Optional[CookieTypes] = None) -> None:
|
def __init__(self, cookies: CookieTypes | None = None) -> None:
|
||||||
if cookies is None or isinstance(cookies, dict):
|
if cookies is None or isinstance(cookies, dict):
|
||||||
self.jar = CookieJar()
|
self.jar = CookieJar()
|
||||||
if isinstance(cookies, dict):
|
if isinstance(cookies, dict):
|
||||||
@ -1073,10 +1143,10 @@ class Cookies(typing.MutableMapping[str, str]):
|
|||||||
def get( # type: ignore
|
def get( # type: ignore
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
default: typing.Optional[str] = None,
|
default: str | None = None,
|
||||||
domain: typing.Optional[str] = None,
|
domain: str | None = None,
|
||||||
path: typing.Optional[str] = None,
|
path: str | None = None,
|
||||||
) -> typing.Optional[str]:
|
) -> str | None:
|
||||||
"""
|
"""
|
||||||
Get a cookie by name. May optionally include domain and path
|
Get a cookie by name. May optionally include domain and path
|
||||||
in order to specify exactly which cookie to retrieve.
|
in order to specify exactly which cookie to retrieve.
|
||||||
@ -1098,8 +1168,8 @@ class Cookies(typing.MutableMapping[str, str]):
|
|||||||
def delete(
|
def delete(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
domain: typing.Optional[str] = None,
|
domain: str | None = None,
|
||||||
path: typing.Optional[str] = None,
|
path: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Delete a cookie by name. May optionally include domain and path
|
Delete a cookie by name. May optionally include domain and path
|
||||||
@ -1119,9 +1189,7 @@ class Cookies(typing.MutableMapping[str, str]):
|
|||||||
for cookie in remove:
|
for cookie in remove:
|
||||||
self.jar.clear(cookie.domain, cookie.path, cookie.name)
|
self.jar.clear(cookie.domain, cookie.path, cookie.name)
|
||||||
|
|
||||||
def clear(
|
def clear(self, domain: str | None = None, path: str | None = None) -> None:
|
||||||
self, domain: typing.Optional[str] = None, path: typing.Optional[str] = None
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
Delete all cookies. Optionally include a domain and path in
|
Delete all cookies. Optionally include a domain and path in
|
||||||
order to only delete a subset of all the cookies.
|
order to only delete a subset of all the cookies.
|
||||||
@ -1134,7 +1202,7 @@ class Cookies(typing.MutableMapping[str, str]):
|
|||||||
args.append(path)
|
args.append(path)
|
||||||
self.jar.clear(*args)
|
self.jar.clear(*args)
|
||||||
|
|
||||||
def update(self, cookies: typing.Optional[CookieTypes] = None) -> None: # type: ignore
|
def update(self, cookies: CookieTypes | None = None) -> None: # type: ignore
|
||||||
cookies = Cookies(cookies)
|
cookies = Cookies(cookies)
|
||||||
for cookie in cookies.jar:
|
for cookie in cookies.jar:
|
||||||
self.jar.set_cookie(cookie)
|
self.jar.set_cookie(cookie)
|
||||||
@ -1196,7 +1264,7 @@ class Cookies(typing.MutableMapping[str, str]):
|
|||||||
for use with `CookieJar` operations.
|
for use with `CookieJar` operations.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, response: Response):
|
def __init__(self, response: Response) -> None:
|
||||||
self.response = response
|
self.response = response
|
||||||
|
|
||||||
def info(self) -> email.message.Message:
|
def info(self) -> email.message.Message:
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import binascii
|
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
|
||||||
|
|
||||||
@ -13,17 +16,46 @@ 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: typing.Optional[bytes],
|
content_type: bytes | None,
|
||||||
) -> typing.Optional[bytes]:
|
) -> bytes | None:
|
||||||
if not content_type or not content_type.startswith(b"multipart/form-data"):
|
if not content_type or not content_type.startswith(b"multipart/form-data"):
|
||||||
return None
|
return None
|
||||||
# parse boundary according to
|
# parse boundary according to
|
||||||
@ -40,25 +72,24 @@ class DataField:
|
|||||||
A single form field item, within a multipart form field.
|
A single form field item, within a multipart form field.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, name: str, value: str | bytes | int | float | None) -> None:
|
||||||
self, name: str, value: typing.Union[str, bytes, int, float, None]
|
|
||||||
) -> None:
|
|
||||||
if not isinstance(name, str):
|
if not isinstance(name, str):
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
f"Invalid type for name. Expected str, got {type(name)}: {name!r}"
|
f"Invalid type for name. Expected str, got {type(name)}: {name!r}"
|
||||||
)
|
)
|
||||||
if value is not None and not isinstance(value, (str, bytes, int, float)):
|
if value is not None and not isinstance(value, (str, bytes, int, float)):
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
f"Invalid type for value. Expected primitive type, got {type(value)}: {value!r}"
|
"Invalid type for value. Expected primitive type,"
|
||||||
|
f" got {type(value)}: {value!r}"
|
||||||
)
|
)
|
||||||
self.name = name
|
self.name = name
|
||||||
self.value: typing.Union[str, bytes] = (
|
self.value: str | bytes = (
|
||||||
value if isinstance(value, bytes) else primitive_value_to_str(value)
|
value if isinstance(value, bytes) else primitive_value_to_str(value)
|
||||||
)
|
)
|
||||||
|
|
||||||
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"]
|
||||||
)
|
)
|
||||||
@ -93,18 +124,20 @@ class FileField:
|
|||||||
|
|
||||||
fileobj: FileContent
|
fileobj: FileContent
|
||||||
|
|
||||||
headers: typing.Dict[str, str] = {}
|
headers: dict[str, str] = {}
|
||||||
content_type: typing.Optional[str] = None
|
content_type: str | None = None
|
||||||
|
|
||||||
# This large tuple based API largely mirror's requests' API
|
# This large tuple based API largely mirror's requests' API
|
||||||
# It would be good to think of better APIs for this that we could include in httpx 2.0
|
# It would be good to think of better APIs for this that we could
|
||||||
# since variable length tuples (especially of 4 elements) are quite unwieldly
|
# include in httpx 2.0 since variable length tuples(especially of 4 elements)
|
||||||
|
# are quite unwieldly
|
||||||
if isinstance(value, tuple):
|
if isinstance(value, tuple):
|
||||||
if len(value) == 2:
|
if len(value) == 2:
|
||||||
# neither the 3rd parameter (content_type) nor the 4th (headers) was included
|
# neither the 3rd parameter (content_type) nor the 4th (headers)
|
||||||
filename, fileobj = value # type: ignore
|
# was included
|
||||||
|
filename, fileobj = value
|
||||||
elif len(value) == 3:
|
elif len(value) == 3:
|
||||||
filename, fileobj, content_type = value # type: ignore
|
filename, fileobj, content_type = value
|
||||||
else:
|
else:
|
||||||
# all 4 parameters included
|
# all 4 parameters included
|
||||||
filename, fileobj, content_type, headers = value # type: ignore
|
filename, fileobj, content_type, headers = value # type: ignore
|
||||||
@ -113,13 +146,13 @@ 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:
|
||||||
# note that unlike requests, we ignore the content_type
|
# note that unlike requests, we ignore the content_type provided in the 3rd
|
||||||
# provided in the 3rd tuple element if it is also included in the headers
|
# tuple element if it is also included in the headers requests does
|
||||||
# requests does the opposite (it overwrites the header with the 3rd tuple element)
|
# the opposite (it overwrites the headerwith the 3rd tuple element)
|
||||||
headers["Content-Type"] = content_type
|
headers["Content-Type"] = content_type
|
||||||
|
|
||||||
if isinstance(fileobj, io.StringIO):
|
if isinstance(fileobj, io.StringIO):
|
||||||
@ -135,7 +168,7 @@ class FileField:
|
|||||||
self.file = fileobj
|
self.file = fileobj
|
||||||
self.headers = headers
|
self.headers = headers
|
||||||
|
|
||||||
def get_length(self) -> typing.Optional[int]:
|
def get_length(self) -> int | None:
|
||||||
headers = self.render_headers()
|
headers = self.render_headers()
|
||||||
|
|
||||||
if isinstance(self.file, (str, bytes)):
|
if isinstance(self.file, (str, bytes)):
|
||||||
@ -154,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()
|
||||||
@ -197,10 +230,10 @@ class MultipartStream(SyncByteStream, AsyncByteStream):
|
|||||||
self,
|
self,
|
||||||
data: RequestData,
|
data: RequestData,
|
||||||
files: RequestFiles,
|
files: RequestFiles,
|
||||||
boundary: typing.Optional[bytes] = None,
|
boundary: bytes | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if boundary is None:
|
if boundary is None:
|
||||||
boundary = binascii.hexlify(os.urandom(16))
|
boundary = os.urandom(16).hex().encode("ascii")
|
||||||
|
|
||||||
self.boundary = boundary
|
self.boundary = boundary
|
||||||
self.content_type = "multipart/form-data; boundary=%s" % boundary.decode(
|
self.content_type = "multipart/form-data; boundary=%s" % boundary.decode(
|
||||||
@ -210,7 +243,7 @@ class MultipartStream(SyncByteStream, AsyncByteStream):
|
|||||||
|
|
||||||
def _iter_fields(
|
def _iter_fields(
|
||||||
self, data: RequestData, files: RequestFiles
|
self, data: RequestData, files: RequestFiles
|
||||||
) -> typing.Iterator[typing.Union[FileField, DataField]]:
|
) -> typing.Iterator[FileField | DataField]:
|
||||||
for name, value in data.items():
|
for name, value in data.items():
|
||||||
if isinstance(value, (tuple, list)):
|
if isinstance(value, (tuple, list)):
|
||||||
for item in value:
|
for item in value:
|
||||||
@ -229,7 +262,7 @@ class MultipartStream(SyncByteStream, AsyncByteStream):
|
|||||||
yield b"\r\n"
|
yield b"\r\n"
|
||||||
yield b"--%s--\r\n" % self.boundary
|
yield b"--%s--\r\n" % self.boundary
|
||||||
|
|
||||||
def get_content_length(self) -> typing.Optional[int]:
|
def get_content_length(self) -> int | None:
|
||||||
"""
|
"""
|
||||||
Return the length of the multipart encoded content, or `None` if
|
Return the length of the multipart encoded content, or `None` if
|
||||||
any of the files have a length that cannot be determined upfront.
|
any of the files have a length that cannot be determined upfront.
|
||||||
@ -251,7 +284,7 @@ class MultipartStream(SyncByteStream, AsyncByteStream):
|
|||||||
|
|
||||||
# Content stream interface.
|
# Content stream interface.
|
||||||
|
|
||||||
def get_headers(self) -> typing.Dict[str, str]:
|
def get_headers(self) -> dict[str, str]:
|
||||||
content_length = self.get_content_length()
|
content_length = self.get_content_length()
|
||||||
content_type = self.content_type
|
content_type = self.content_type
|
||||||
if content_length is None:
|
if content_length is None:
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
|
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
|
||||||
@ -21,7 +25,7 @@ class codes(IntEnum):
|
|||||||
* RFC 8470: Using Early Data in HTTP
|
* RFC 8470: Using Early Data in HTTP
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __new__(cls, value: int, phrase: str = "") -> "codes":
|
def __new__(cls, value: int, phrase: str = "") -> codes:
|
||||||
obj = int.__new__(cls, value)
|
obj = int.__new__(cls, value)
|
||||||
obj._value_ = value
|
obj._value_ = value
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
]
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import typing
|
from __future__ import annotations
|
||||||
|
|
||||||
import sniffio
|
import typing
|
||||||
|
|
||||||
from .._models import Request, Response
|
from .._models import Request, Response
|
||||||
from .._types import AsyncByteStream
|
from .._types import AsyncByteStream
|
||||||
@ -14,29 +14,46 @@ 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 create_event() -> "Event":
|
|
||||||
if sniffio.current_async_library() == "trio":
|
def is_running_trio() -> bool:
|
||||||
|
try:
|
||||||
|
# sniffio is a dependency of trio.
|
||||||
|
|
||||||
|
# See https://github.com/python-trio/trio/issues/2802
|
||||||
|
import sniffio
|
||||||
|
|
||||||
|
if sniffio.current_async_library() == "trio":
|
||||||
|
return True
|
||||||
|
except ImportError: # pragma: nocover
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def create_event() -> Event:
|
||||||
|
if 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):
|
||||||
def __init__(self, body: typing.List[bytes]) -> None:
|
def __init__(self, body: list[bytes]) -> None:
|
||||||
self._body = body
|
self._body = body
|
||||||
|
|
||||||
async def __aiter__(self) -> typing.AsyncIterator[bytes]:
|
async def __aiter__(self) -> typing.AsyncIterator[bytes]:
|
||||||
@ -46,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",
|
||||||
@ -81,7 +89,7 @@ class ASGITransport(AsyncBaseTransport):
|
|||||||
app: _ASGIApp,
|
app: _ASGIApp,
|
||||||
raise_app_exceptions: bool = True,
|
raise_app_exceptions: bool = True,
|
||||||
root_path: str = "",
|
root_path: str = "",
|
||||||
client: typing.Tuple[str, int] = ("127.0.0.1", 123),
|
client: tuple[str, int] = ("127.0.0.1", 123),
|
||||||
) -> None:
|
) -> None:
|
||||||
self.app = app
|
self.app = app
|
||||||
self.raise_app_exceptions = raise_app_exceptions
|
self.raise_app_exceptions = raise_app_exceptions
|
||||||
@ -103,7 +111,7 @@ class ASGITransport(AsyncBaseTransport):
|
|||||||
"headers": [(k.lower(), v) for (k, v) in request.headers.raw],
|
"headers": [(k.lower(), v) for (k, v) in request.headers.raw],
|
||||||
"scheme": request.url.scheme,
|
"scheme": request.url.scheme,
|
||||||
"path": request.url.path,
|
"path": request.url.path,
|
||||||
"raw_path": request.url.raw_path,
|
"raw_path": request.url.raw_path.split(b"?")[0],
|
||||||
"query_string": request.url.query,
|
"query_string": request.url.query,
|
||||||
"server": (request.url.host, request.url.port),
|
"server": (request.url.host, request.url.port),
|
||||||
"client": self.client,
|
"client": self.client,
|
||||||
@ -123,7 +131,7 @@ class ASGITransport(AsyncBaseTransport):
|
|||||||
|
|
||||||
# ASGI callables.
|
# ASGI callables.
|
||||||
|
|
||||||
async def receive() -> typing.Dict[str, typing.Any]:
|
async def receive() -> dict[str, typing.Any]:
|
||||||
nonlocal request_complete
|
nonlocal request_complete
|
||||||
|
|
||||||
if request_complete:
|
if request_complete:
|
||||||
@ -137,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: typing.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":
|
||||||
@ -161,9 +169,15 @@ class ASGITransport(AsyncBaseTransport):
|
|||||||
try:
|
try:
|
||||||
await self.app(scope, receive, send)
|
await self.app(scope, receive, send)
|
||||||
except Exception: # noqa: PIE-786
|
except Exception: # noqa: PIE-786
|
||||||
if self.raise_app_exceptions or not response_complete.is_set():
|
if self.raise_app_exceptions:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
response_complete.set()
|
||||||
|
if status_code is None:
|
||||||
|
status_code = 500
|
||||||
|
if response_headers is None:
|
||||||
|
response_headers = {}
|
||||||
|
|
||||||
assert response_complete.is_set()
|
assert response_complete.is_set()
|
||||||
assert status_code is not None
|
assert status_code is not None
|
||||||
assert response_headers is not None
|
assert response_headers is not None
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
from types import TracebackType
|
from types import TracebackType
|
||||||
|
|
||||||
@ -6,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:
|
||||||
@ -13,9 +17,9 @@ class BaseTransport:
|
|||||||
|
|
||||||
def __exit__(
|
def __exit__(
|
||||||
self,
|
self,
|
||||||
exc_type: typing.Optional[typing.Type[BaseException]] = None,
|
exc_type: type[BaseException] | None = None,
|
||||||
exc_value: typing.Optional[BaseException] = None,
|
exc_value: BaseException | None = None,
|
||||||
traceback: typing.Optional[TracebackType] = None,
|
traceback: TracebackType | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
@ -64,9 +68,9 @@ class AsyncBaseTransport:
|
|||||||
|
|
||||||
async def __aexit__(
|
async def __aexit__(
|
||||||
self,
|
self,
|
||||||
exc_type: typing.Optional[typing.Type[BaseException]] = None,
|
exc_type: type[BaseException] | None = None,
|
||||||
exc_value: typing.Optional[BaseException] = None,
|
exc_value: BaseException | None = None,
|
||||||
traceback: typing.Optional[TracebackType] = None,
|
traceback: TracebackType | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
await self.aclose()
|
await self.aclose()
|
||||||
|
|
||||||
|
|||||||
@ -23,11 +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
|
||||||
|
|
||||||
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 (
|
||||||
@ -47,18 +53,53 @@ from .._exceptions import (
|
|||||||
WriteTimeout,
|
WriteTimeout,
|
||||||
)
|
)
|
||||||
from .._models import Request, Response
|
from .._models import Request, Response
|
||||||
from .._types import AsyncByteStream, CertTypes, SyncByteStream, VerifyTypes
|
from .._types import AsyncByteStream, CertTypes, ProxyTypes, SyncByteStream
|
||||||
|
from .._urls import URL
|
||||||
from .base import AsyncBaseTransport, BaseTransport
|
from .base import AsyncBaseTransport, BaseTransport
|
||||||
|
|
||||||
T = typing.TypeVar("T", bound="HTTPTransport")
|
T = typing.TypeVar("T", bound="HTTPTransport")
|
||||||
A = typing.TypeVar("A", bound="AsyncHTTPTransport")
|
A = typing.TypeVar("A", bound="AsyncHTTPTransport")
|
||||||
|
|
||||||
|
SOCKET_OPTION = typing.Union[
|
||||||
|
typing.Tuple[int, int, int],
|
||||||
|
typing.Tuple[int, int, typing.Union[bytes, bytearray]],
|
||||||
|
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: # noqa: PIE-786
|
except Exception as exc:
|
||||||
mapped_exc = None
|
mapped_exc = None
|
||||||
|
|
||||||
for from_exc, to_exc in HTTPCORE_EXC_MAP.items():
|
for from_exc, to_exc in HTTPCORE_EXC_MAP.items():
|
||||||
@ -77,26 +118,8 @@ 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]):
|
def __init__(self, httpcore_stream: typing.Iterable[bytes]) -> None:
|
||||||
self._httpcore_stream = httpcore_stream
|
self._httpcore_stream = httpcore_stream
|
||||||
|
|
||||||
def __iter__(self) -> typing.Iterator[bytes]:
|
def __iter__(self) -> typing.Iterator[bytes]:
|
||||||
@ -112,17 +135,21 @@ 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: typing.Optional[CertTypes] = 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: typing.Optional[Proxy] = None,
|
uds: str | None = None,
|
||||||
uds: typing.Optional[str] = None,
|
local_address: str | None = None,
|
||||||
local_address: typing.Optional[str] = None,
|
|
||||||
retries: int = 0,
|
retries: int = 0,
|
||||||
|
socket_options: typing.Iterable[SOCKET_OPTION] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
import httpcore
|
||||||
|
|
||||||
|
proxy = Proxy(url=proxy) if isinstance(proxy, (str, URL)) else proxy
|
||||||
ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env)
|
ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env)
|
||||||
|
|
||||||
if proxy is None:
|
if proxy is None:
|
||||||
@ -136,6 +163,7 @@ class HTTPTransport(BaseTransport):
|
|||||||
uds=uds,
|
uds=uds,
|
||||||
local_address=local_address,
|
local_address=local_address,
|
||||||
retries=retries,
|
retries=retries,
|
||||||
|
socket_options=socket_options,
|
||||||
)
|
)
|
||||||
elif proxy.url.scheme in ("http", "https"):
|
elif proxy.url.scheme in ("http", "https"):
|
||||||
self._pool = httpcore.HTTPProxy(
|
self._pool = httpcore.HTTPProxy(
|
||||||
@ -148,13 +176,15 @@ class HTTPTransport(BaseTransport):
|
|||||||
proxy_auth=proxy.raw_auth,
|
proxy_auth=proxy.raw_auth,
|
||||||
proxy_headers=proxy.headers.raw,
|
proxy_headers=proxy.headers.raw,
|
||||||
ssl_context=ssl_context,
|
ssl_context=ssl_context,
|
||||||
|
proxy_ssl_context=proxy.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,
|
||||||
keepalive_expiry=limits.keepalive_expiry,
|
keepalive_expiry=limits.keepalive_expiry,
|
||||||
http1=http1,
|
http1=http1,
|
||||||
http2=http2,
|
http2=http2,
|
||||||
|
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
|
||||||
@ -180,7 +210,8 @@ class HTTPTransport(BaseTransport):
|
|||||||
)
|
)
|
||||||
else: # pragma: no cover
|
else: # pragma: no cover
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Proxy protocol must be either 'http', 'https', or 'socks5', but got {proxy.url.scheme!r}."
|
"Proxy protocol must be either 'http', 'https', 'socks5', or 'socks5h',"
|
||||||
|
f" but got {proxy.url.scheme!r}."
|
||||||
)
|
)
|
||||||
|
|
||||||
def __enter__(self: T) -> T: # Use generics for subclass support.
|
def __enter__(self: T) -> T: # Use generics for subclass support.
|
||||||
@ -189,9 +220,9 @@ class HTTPTransport(BaseTransport):
|
|||||||
|
|
||||||
def __exit__(
|
def __exit__(
|
||||||
self,
|
self,
|
||||||
exc_type: typing.Optional[typing.Type[BaseException]] = None,
|
exc_type: type[BaseException] | None = None,
|
||||||
exc_value: typing.Optional[BaseException] = None,
|
exc_value: BaseException | None = None,
|
||||||
traceback: typing.Optional[TracebackType] = None,
|
traceback: TracebackType | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
with map_httpcore_exceptions():
|
with map_httpcore_exceptions():
|
||||||
self._pool.__exit__(exc_type, exc_value, traceback)
|
self._pool.__exit__(exc_type, exc_value, traceback)
|
||||||
@ -201,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,
|
||||||
@ -231,7 +263,7 @@ class HTTPTransport(BaseTransport):
|
|||||||
|
|
||||||
|
|
||||||
class AsyncResponseStream(AsyncByteStream):
|
class AsyncResponseStream(AsyncByteStream):
|
||||||
def __init__(self, httpcore_stream: typing.AsyncIterable[bytes]):
|
def __init__(self, httpcore_stream: typing.AsyncIterable[bytes]) -> None:
|
||||||
self._httpcore_stream = httpcore_stream
|
self._httpcore_stream = httpcore_stream
|
||||||
|
|
||||||
async def __aiter__(self) -> typing.AsyncIterator[bytes]:
|
async def __aiter__(self) -> typing.AsyncIterator[bytes]:
|
||||||
@ -247,17 +279,21 @@ 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: typing.Optional[CertTypes] = 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: typing.Optional[Proxy] = None,
|
uds: str | None = None,
|
||||||
uds: typing.Optional[str] = None,
|
local_address: str | None = None,
|
||||||
local_address: typing.Optional[str] = None,
|
|
||||||
retries: int = 0,
|
retries: int = 0,
|
||||||
|
socket_options: typing.Iterable[SOCKET_OPTION] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
import httpcore
|
||||||
|
|
||||||
|
proxy = Proxy(url=proxy) if isinstance(proxy, (str, URL)) else proxy
|
||||||
ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env)
|
ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env)
|
||||||
|
|
||||||
if proxy is None:
|
if proxy is None:
|
||||||
@ -271,6 +307,7 @@ class AsyncHTTPTransport(AsyncBaseTransport):
|
|||||||
uds=uds,
|
uds=uds,
|
||||||
local_address=local_address,
|
local_address=local_address,
|
||||||
retries=retries,
|
retries=retries,
|
||||||
|
socket_options=socket_options,
|
||||||
)
|
)
|
||||||
elif proxy.url.scheme in ("http", "https"):
|
elif proxy.url.scheme in ("http", "https"):
|
||||||
self._pool = httpcore.AsyncHTTPProxy(
|
self._pool = httpcore.AsyncHTTPProxy(
|
||||||
@ -282,14 +319,16 @@ 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,
|
||||||
keepalive_expiry=limits.keepalive_expiry,
|
keepalive_expiry=limits.keepalive_expiry,
|
||||||
http1=http1,
|
http1=http1,
|
||||||
http2=http2,
|
http2=http2,
|
||||||
|
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
|
||||||
@ -315,7 +354,8 @@ class AsyncHTTPTransport(AsyncBaseTransport):
|
|||||||
)
|
)
|
||||||
else: # pragma: no cover
|
else: # pragma: no cover
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Proxy protocol must be either 'http', 'https', or 'socks5', but got {proxy.url.scheme!r}."
|
"Proxy protocol must be either 'http', 'https', 'socks5', or 'socks5h',"
|
||||||
|
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.
|
||||||
@ -324,9 +364,9 @@ class AsyncHTTPTransport(AsyncBaseTransport):
|
|||||||
|
|
||||||
async def __aexit__(
|
async def __aexit__(
|
||||||
self,
|
self,
|
||||||
exc_type: typing.Optional[typing.Type[BaseException]] = None,
|
exc_type: type[BaseException] | None = None,
|
||||||
exc_value: typing.Optional[BaseException] = None,
|
exc_value: BaseException | None = None,
|
||||||
traceback: typing.Optional[TracebackType] = None,
|
traceback: TracebackType | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
with map_httpcore_exceptions():
|
with map_httpcore_exceptions():
|
||||||
await self._pool.__aexit__(exc_type, exc_value, traceback)
|
await self._pool.__aexit__(exc_type, exc_value, traceback)
|
||||||
@ -336,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,
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from .._models import Request, Response
|
from .._models import Request, Response
|
||||||
@ -7,8 +9,11 @@ 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: typing.Union[SyncHandler, AsyncHandler]) -> None:
|
def __init__(self, handler: SyncHandler | AsyncHandler) -> None:
|
||||||
self.handler = handler
|
self.handler = handler
|
||||||
|
|
||||||
def handle_request(
|
def handle_request(
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import itertools
|
import itertools
|
||||||
import sys
|
import sys
|
||||||
@ -14,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:
|
||||||
@ -71,11 +76,11 @@ class WSGITransport(BaseTransport):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
app: "WSGIApplication",
|
app: WSGIApplication,
|
||||||
raise_app_exceptions: bool = True,
|
raise_app_exceptions: bool = True,
|
||||||
script_name: str = "",
|
script_name: str = "",
|
||||||
remote_addr: str = "127.0.0.1",
|
remote_addr: str = "127.0.0.1",
|
||||||
wsgi_errors: typing.Optional[typing.TextIO] = None,
|
wsgi_errors: typing.TextIO | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.app = app
|
self.app = app
|
||||||
self.raise_app_exceptions = raise_app_exceptions
|
self.raise_app_exceptions = raise_app_exceptions
|
||||||
@ -102,6 +107,7 @@ class WSGITransport(BaseTransport):
|
|||||||
"QUERY_STRING": request.url.query.decode("ascii"),
|
"QUERY_STRING": request.url.query.decode("ascii"),
|
||||||
"SERVER_NAME": request.url.host,
|
"SERVER_NAME": request.url.host,
|
||||||
"SERVER_PORT": str(port),
|
"SERVER_PORT": str(port),
|
||||||
|
"SERVER_PROTOCOL": "HTTP/1.1",
|
||||||
"REMOTE_ADDR": self.remote_addr,
|
"REMOTE_ADDR": self.remote_addr,
|
||||||
}
|
}
|
||||||
for header_key, header_value in request.headers.raw:
|
for header_key, header_value in request.headers.raw:
|
||||||
@ -116,8 +122,8 @@ class WSGITransport(BaseTransport):
|
|||||||
|
|
||||||
def start_response(
|
def start_response(
|
||||||
status: str,
|
status: str,
|
||||||
response_headers: typing.List[typing.Tuple[str, str]],
|
response_headers: list[tuple[str, str]],
|
||||||
exc_info: typing.Optional["OptExcInfo"] = None,
|
exc_info: OptExcInfo | None = None,
|
||||||
) -> typing.Callable[[bytes], typing.Any]:
|
) -> typing.Callable[[bytes], typing.Any]:
|
||||||
nonlocal seen_status, seen_response_headers, seen_exc_info
|
nonlocal seen_status, seen_response_headers, seen_exc_info
|
||||||
seen_status = status
|
seen_status = status
|
||||||
|
|||||||
@ -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,7 +15,6 @@ from typing import (
|
|||||||
Iterator,
|
Iterator,
|
||||||
List,
|
List,
|
||||||
Mapping,
|
Mapping,
|
||||||
NamedTuple,
|
|
||||||
Optional,
|
Optional,
|
||||||
Sequence,
|
Sequence,
|
||||||
Tuple,
|
Tuple,
|
||||||
@ -32,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[
|
||||||
@ -63,21 +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",
|
||||||
]
|
]
|
||||||
ProxiesTypes = Union[URLTypes, "Proxy", Dict[URLTypes, Union[None, URLTypes, "Proxy"]]]
|
ProxyTypes = Union["URL", str, "Proxy"]
|
||||||
|
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]],
|
||||||
@ -106,6 +86,8 @@ RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]]
|
|||||||
|
|
||||||
RequestExtensions = Mapping[str, Any]
|
RequestExtensions = Mapping[str, Any]
|
||||||
|
|
||||||
|
__all__ = ["AsyncByteStream", "SyncByteStream"]
|
||||||
|
|
||||||
|
|
||||||
class SyncByteStream:
|
class SyncByteStream:
|
||||||
def __iter__(self) -> Iterator[bytes]:
|
def __iter__(self) -> Iterator[bytes]:
|
||||||
|
|||||||
@ -15,6 +15,9 @@ 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
|
||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import re
|
import re
|
||||||
import typing
|
import typing
|
||||||
@ -33,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)
|
||||||
@ -62,8 +126,8 @@ AUTHORITY_REGEX = re.compile(
|
|||||||
(
|
(
|
||||||
r"(?:(?P<userinfo>{userinfo})@)?" r"(?P<host>{host})" r":?(?P<port>{port})?"
|
r"(?:(?P<userinfo>{userinfo})@)?" r"(?P<host>{host})" r":?(?P<port>{port})?"
|
||||||
).format(
|
).format(
|
||||||
userinfo="[^@]*", # Any character sequence not including '@'.
|
userinfo=".*", # Any character sequence.
|
||||||
host="(\\[.*\\]|[^:]*)", # Either any character sequence not including ':',
|
host="(\\[.*\\]|[^:@]*)", # Either any character sequence excluding ':' or '@',
|
||||||
# or an IPv6 address enclosed within square brackets.
|
# or an IPv6 address enclosed within square brackets.
|
||||||
port=".*", # Any character sequence.
|
port=".*", # Any character sequence.
|
||||||
)
|
)
|
||||||
@ -87,7 +151,7 @@ COMPONENT_REGEX = {
|
|||||||
|
|
||||||
# We use these simple regexs as a first pass before handing off to
|
# We use these simple regexs as a first pass before handing off to
|
||||||
# the stdlib 'ipaddress' module for IP address validation.
|
# the stdlib 'ipaddress' module for IP address validation.
|
||||||
IPv4_STYLE_HOSTNAME = re.compile(r"^[0-9]+.[0-9]+.[0-9]+.[0-9]+$")
|
IPv4_STYLE_HOSTNAME = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$")
|
||||||
IPv6_STYLE_HOSTNAME = re.compile(r"^\[.*\]$")
|
IPv6_STYLE_HOSTNAME = re.compile(r"^\[.*\]$")
|
||||||
|
|
||||||
|
|
||||||
@ -95,10 +159,10 @@ class ParseResult(typing.NamedTuple):
|
|||||||
scheme: str
|
scheme: str
|
||||||
userinfo: str
|
userinfo: str
|
||||||
host: str
|
host: str
|
||||||
port: typing.Optional[int]
|
port: int | None
|
||||||
path: str
|
path: str
|
||||||
query: typing.Optional[str]
|
query: str | None
|
||||||
fragment: typing.Optional[str]
|
fragment: str | None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def authority(self) -> str:
|
def authority(self) -> str:
|
||||||
@ -119,7 +183,7 @@ class ParseResult(typing.NamedTuple):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
def copy_with(self, **kwargs: typing.Optional[str]) -> "ParseResult":
|
def copy_with(self, **kwargs: str | None) -> ParseResult:
|
||||||
if not kwargs:
|
if not kwargs:
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@ -146,7 +210,7 @@ class ParseResult(typing.NamedTuple):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def urlparse(url: str = "", **kwargs: typing.Optional[str]) -> ParseResult:
|
def urlparse(url: str = "", **kwargs: str | None) -> ParseResult:
|
||||||
# Initial basic checks on allowable URLs.
|
# Initial basic checks on allowable URLs.
|
||||||
# ---------------------------------------
|
# ---------------------------------------
|
||||||
|
|
||||||
@ -157,7 +221,12 @@ def urlparse(url: str = "", **kwargs: typing.Optional[str]) -> 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.
|
||||||
# ------------------------------------------------
|
# ------------------------------------------------
|
||||||
@ -174,8 +243,8 @@ def urlparse(url: str = "", **kwargs: typing.Optional[str]) -> 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".
|
||||||
@ -202,9 +271,15 @@ def urlparse(url: str = "", **kwargs: typing.Optional[str]) -> 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):
|
||||||
@ -224,7 +299,7 @@ def urlparse(url: str = "", **kwargs: typing.Optional[str]) -> 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)
|
||||||
@ -241,32 +316,21 @@ def urlparse(url: str = "", **kwargs: typing.Optional[str]) -> 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: typing.Optional[int] = normalize_port(port, scheme)
|
parsed_port: int | None = normalize_port(port, scheme)
|
||||||
|
|
||||||
has_scheme = parsed_scheme != ""
|
has_scheme = parsed_scheme != ""
|
||||||
has_authority = (
|
has_authority = (
|
||||||
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: typing.Optional[str] = (
|
|
||||||
None if query is None else quote(query, safe=SUB_DELIMS + ":/?[]@")
|
|
||||||
)
|
|
||||||
# For 'fragment' we can include all of the GEN_DELIMS set.
|
|
||||||
parsed_fragment: typing.Optional[str] = (
|
|
||||||
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.
|
||||||
@ -277,7 +341,7 @@ def urlparse(url: str = "", **kwargs: typing.Optional[str]) -> ParseResult:
|
|||||||
parsed_port,
|
parsed_port,
|
||||||
parsed_path,
|
parsed_path,
|
||||||
parsed_query,
|
parsed_query,
|
||||||
parsed_fragment,
|
parsed_frag,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -318,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:
|
||||||
@ -327,9 +392,7 @@ def encode_host(host: str) -> str:
|
|||||||
raise InvalidURL(f"Invalid IDNA hostname: {host!r}")
|
raise InvalidURL(f"Invalid IDNA hostname: {host!r}")
|
||||||
|
|
||||||
|
|
||||||
def normalize_port(
|
def normalize_port(port: str | int | None, scheme: str) -> int | None:
|
||||||
port: typing.Optional[typing.Union[str, int]], scheme: str
|
|
||||||
) -> typing.Optional[int]:
|
|
||||||
# From https://tools.ietf.org/html/rfc3986#section-3.2.3
|
# From https://tools.ietf.org/html/rfc3986#section-3.2.3
|
||||||
#
|
#
|
||||||
# "A scheme may define a default port. For example, the "http" scheme
|
# "A scheme may define a default port. For example, the "http" scheme
|
||||||
@ -358,28 +421,27 @@ def normalize_port(
|
|||||||
|
|
||||||
def validate_path(path: str, has_scheme: bool, has_authority: bool) -> None:
|
def validate_path(path: str, has_scheme: bool, has_authority: bool) -> None:
|
||||||
"""
|
"""
|
||||||
Path validation rules that depend on if the URL contains a scheme or authority component.
|
Path validation rules that depend on if the URL contains
|
||||||
|
a scheme or authority component.
|
||||||
|
|
||||||
See https://datatracker.ietf.org/doc/html/rfc3986.html#section-3.3
|
See https://datatracker.ietf.org/doc/html/rfc3986.html#section-3.3
|
||||||
"""
|
"""
|
||||||
if has_authority:
|
if has_authority:
|
||||||
# > If a URI contains an authority component, then the path component
|
# If a URI contains an authority component, then the path component
|
||||||
# > 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 a URI does not contain an authority component, then the path cannot begin
|
if not has_scheme and not has_authority:
|
||||||
# > with two slash characters ("//").
|
# If a URI does not contain an authority component, then the path cannot begin
|
||||||
|
# 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
|
# in which case the first path segment cannot contain a colon (":") character.
|
||||||
# > case the first path segment cannot contain a colon (":") character.
|
if path.startswith(":"):
|
||||||
if path.startswith(":") and not has_scheme:
|
raise InvalidURL("Relative URLs cannot have a path starting with ':'")
|
||||||
raise InvalidURL(
|
|
||||||
"URLs with no scheme component cannot have a path starting with ':'"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_path(path: str) -> str:
|
def normalize_path(path: str) -> str:
|
||||||
@ -390,9 +452,18 @@ 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("/")
|
||||||
output: typing.List[str] = []
|
|
||||||
|
# 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] = []
|
||||||
for component in components:
|
for component in components:
|
||||||
if component == ".":
|
if component == ".":
|
||||||
pass
|
pass
|
||||||
@ -404,59 +475,53 @@ 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")])
|
||||||
|
|
||||||
|
|
||||||
|
def percent_encoded(string: str, safe: str) -> str:
|
||||||
"""
|
"""
|
||||||
Replace a single character with the percent-encoded representation.
|
Use percent-encoding to quote a string.
|
||||||
|
|
||||||
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()
|
NON_ESCAPED_CHARS = UNRESERVED_CHARACTERS + safe
|
||||||
|
|
||||||
|
# Fast path for strings that don't need escaping.
|
||||||
def is_safe(string: str, safe: str = "/") -> bool:
|
if not string.rstrip(NON_ESCAPED_CHARS):
|
||||||
"""
|
|
||||||
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
|
|
||||||
|
|
||||||
# Any '%' characters must be valid '%xx' escape sequences.
|
|
||||||
return string.count("%") == len(PERCENT_ENCODED_REGEX.findall(string))
|
|
||||||
|
|
||||||
|
|
||||||
def quote(string: str, safe: str = "/") -> str:
|
|
||||||
"""
|
|
||||||
Use percent-encoding to quote a string if required.
|
|
||||||
"""
|
|
||||||
if is_safe(string, safe=safe):
|
|
||||||
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 urlencode(items: typing.List[typing.Tuple[str, str]]) -> str:
|
def quote(string: str, safe: 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.
|
Use percent-encoding to quote a string, omitting existing '%xx' escape sequences.
|
||||||
#
|
|
||||||
# https://github.com/python/cpython/blob/b2f7b2ef0b5421e01efb8c7bee2ef95d3bab77eb/Lib/urllib/parse.py#L926
|
See: https://www.rfc-editor.org/rfc/rfc3986#section-2.1
|
||||||
#
|
|
||||||
# Note that we use '%20' encoding for spaces, and treat '/' as a safe
|
* `string`: The string to be percent-escaped.
|
||||||
# character. This means our query params have the same escaping as other
|
* `safe`: A string containing characters that may be treated as safe, and do not
|
||||||
# characters in the URL path. This is slightly different to `requests`,
|
need to be escaped. Unreserved characters are always treated as safe.
|
||||||
# but is the behaviour that browsers use.
|
See: https://www.rfc-editor.org/rfc/rfc3986#section-2.3
|
||||||
#
|
"""
|
||||||
# See https://github.com/encode/httpx/issues/2536 and
|
parts = []
|
||||||
# https://docs.python.org/3/library/urllib.parse.html#urllib.parse.urlencode
|
current_position = 0
|
||||||
return "&".join([quote(k) + "=" + quote(v) for k, v in items])
|
for match in re.finditer(PERCENT_ENCODED_REGEX, string):
|
||||||
|
start_position, end_position = match.start(), match.end()
|
||||||
|
matched_text = match.group(0)
|
||||||
|
# Add any text up to the '%xx' escape sequence.
|
||||||
|
if start_position != current_position:
|
||||||
|
leading_text = string[current_position:start_position]
|
||||||
|
parts.append(percent_encoded(leading_text, safe=safe))
|
||||||
|
|
||||||
|
# Add the '%xx' escape sequence.
|
||||||
|
parts.append(matched_text)
|
||||||
|
current_position = end_position
|
||||||
|
|
||||||
|
# Add any text after the final '%xx' escape sequence.
|
||||||
|
if current_position != len(string):
|
||||||
|
trailing_text = string[current_position:]
|
||||||
|
parts.append(percent_encoded(trailing_text, safe=safe))
|
||||||
|
|
||||||
|
return "".join(parts)
|
||||||
|
|||||||
111
httpx/_urls.py
111
httpx/_urls.py
@ -1,12 +1,16 @@
|
|||||||
|
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:
|
||||||
"""
|
"""
|
||||||
@ -51,26 +55,26 @@ class URL:
|
|||||||
assert url.raw_host == b"xn--fiqs8s.icom.museum"
|
assert url.raw_host == b"xn--fiqs8s.icom.museum"
|
||||||
|
|
||||||
* `url.port` is either None or an integer. URLs that include the default port for
|
* `url.port` is either None or an integer. URLs that include the default port for
|
||||||
"http", "https", "ws", "wss", and "ftp" schemes have their port normalized to `None`.
|
"http", "https", "ws", "wss", and "ftp" schemes have their port
|
||||||
|
normalized to `None`.
|
||||||
|
|
||||||
assert httpx.URL("http://example.com") == httpx.URL("http://example.com:80")
|
assert httpx.URL("http://example.com") == httpx.URL("http://example.com:80")
|
||||||
assert httpx.URL("http://example.com").port is None
|
assert httpx.URL("http://example.com").port is None
|
||||||
assert httpx.URL("http://example.com:80").port is None
|
assert httpx.URL("http://example.com:80").port is None
|
||||||
|
|
||||||
* `url.userinfo` is raw bytes, without URL escaping. Usually you'll want to work with
|
* `url.userinfo` is raw bytes, without URL escaping. Usually you'll want to work
|
||||||
`url.username` and `url.password` instead, which handle the URL escaping.
|
with `url.username` and `url.password` instead, which handle the URL escaping.
|
||||||
|
|
||||||
* `url.raw_path` is raw bytes of both the path and query, without URL escaping.
|
* `url.raw_path` is raw bytes of both the path and query, without URL escaping.
|
||||||
This portion is used as the target when constructing HTTP requests. Usually you'll
|
This portion is used as the target when constructing HTTP requests. Usually you'll
|
||||||
want to work with `url.path` instead.
|
want to work with `url.path` instead.
|
||||||
|
|
||||||
* `url.query` is raw bytes, without URL escaping. A URL query string portion can only
|
* `url.query` is raw bytes, without URL escaping. A URL query string portion can
|
||||||
be properly URL escaped when decoding the parameter names and values themselves.
|
only be properly URL escaped when decoding the parameter names and values
|
||||||
|
themselves.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, url: URL | str = "", **kwargs: typing.Any) -> None:
|
||||||
self, url: typing.Union["URL", str] = "", **kwargs: typing.Any
|
|
||||||
) -> None:
|
|
||||||
if kwargs:
|
if kwargs:
|
||||||
allowed = {
|
allowed = {
|
||||||
"scheme": str,
|
"scheme": str,
|
||||||
@ -115,7 +119,8 @@ class URL:
|
|||||||
self._uri_reference = url._uri_reference.copy_with(**kwargs)
|
self._uri_reference = url._uri_reference.copy_with(**kwargs)
|
||||||
else:
|
else:
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
f"Invalid type for url. Expected str or httpx.URL, got {type(url)}: {url!r}"
|
"Invalid type for url. Expected str or httpx.URL,"
|
||||||
|
f" got {type(url)}: {url!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -210,7 +215,7 @@ class URL:
|
|||||||
return self._uri_reference.host.encode("ascii")
|
return self._uri_reference.host.encode("ascii")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def port(self) -> typing.Optional[int]:
|
def port(self) -> int | None:
|
||||||
"""
|
"""
|
||||||
The URL port as an integer.
|
The URL port as an integer.
|
||||||
|
|
||||||
@ -267,7 +272,7 @@ class URL:
|
|||||||
return query.encode("ascii")
|
return query.encode("ascii")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def params(self) -> "QueryParams":
|
def params(self) -> QueryParams:
|
||||||
"""
|
"""
|
||||||
The URL query parameters, neatly parsed and packaged into an immutable
|
The URL query parameters, neatly parsed and packaged into an immutable
|
||||||
multidict representation.
|
multidict representation.
|
||||||
@ -299,21 +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:
|
||||||
"""
|
"""
|
||||||
@ -334,7 +324,7 @@ class URL:
|
|||||||
"""
|
"""
|
||||||
return not self.is_absolute_url
|
return not self.is_absolute_url
|
||||||
|
|
||||||
def copy_with(self, **kwargs: typing.Any) -> "URL":
|
def copy_with(self, **kwargs: typing.Any) -> URL:
|
||||||
"""
|
"""
|
||||||
Copy this URL, returning a new URL with some components altered.
|
Copy this URL, returning a new URL with some components altered.
|
||||||
Accepts the same set of parameters as the components that are made
|
Accepts the same set of parameters as the components that are made
|
||||||
@ -342,24 +332,26 @@ class URL:
|
|||||||
|
|
||||||
For example:
|
For example:
|
||||||
|
|
||||||
url = httpx.URL("https://www.example.com").copy_with(username="jo@gmail.com", password="a secret")
|
url = httpx.URL("https://www.example.com").copy_with(
|
||||||
|
username="jo@gmail.com", password="a secret"
|
||||||
|
)
|
||||||
assert url == "https://jo%40email.com:a%20secret@www.example.com"
|
assert url == "https://jo%40email.com:a%20secret@www.example.com"
|
||||||
"""
|
"""
|
||||||
return URL(self, **kwargs)
|
return URL(self, **kwargs)
|
||||||
|
|
||||||
def copy_set_param(self, key: str, value: typing.Any = None) -> "URL":
|
def copy_set_param(self, key: str, value: typing.Any = None) -> URL:
|
||||||
return self.copy_with(params=self.params.set(key, value))
|
return self.copy_with(params=self.params.set(key, value))
|
||||||
|
|
||||||
def copy_add_param(self, key: str, value: typing.Any = None) -> "URL":
|
def copy_add_param(self, key: str, value: typing.Any = None) -> URL:
|
||||||
return self.copy_with(params=self.params.add(key, value))
|
return self.copy_with(params=self.params.add(key, value))
|
||||||
|
|
||||||
def copy_remove_param(self, key: str) -> "URL":
|
def copy_remove_param(self, key: str) -> URL:
|
||||||
return self.copy_with(params=self.params.remove(key))
|
return self.copy_with(params=self.params.remove(key))
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@ -387,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(
|
||||||
[
|
[
|
||||||
@ -408,15 +400,29 @@ 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]):
|
||||||
"""
|
"""
|
||||||
URL query parameters, as a multi-dict.
|
URL query parameters, as a multi-dict.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, *args: QueryParamTypes | None, **kwargs: typing.Any) -> None:
|
||||||
self, *args: typing.Optional[QueryParamTypes], **kwargs: typing.Any
|
|
||||||
) -> None:
|
|
||||||
assert len(args) < 2, "Too many arguments."
|
assert len(args) < 2, "Too many arguments."
|
||||||
assert not (args and kwargs), "Cannot mix named and unnamed arguments."
|
assert not (args and kwargs), "Cannot mix named and unnamed arguments."
|
||||||
|
|
||||||
@ -428,7 +434,7 @@ class QueryParams(typing.Mapping[str, str]):
|
|||||||
elif isinstance(value, QueryParams):
|
elif isinstance(value, QueryParams):
|
||||||
self._dict = {k: list(v) for k, v in value._dict.items()}
|
self._dict = {k: list(v) for k, v in value._dict.items()}
|
||||||
else:
|
else:
|
||||||
dict_value: typing.Dict[typing.Any, typing.List[typing.Any]] = {}
|
dict_value: dict[typing.Any, list[typing.Any]] = {}
|
||||||
if isinstance(value, (list, tuple)):
|
if isinstance(value, (list, tuple)):
|
||||||
# Convert list inputs like:
|
# Convert list inputs like:
|
||||||
# [("a", "123"), ("a", "456"), ("b", "789")]
|
# [("a", "123"), ("a", "456"), ("b", "789")]
|
||||||
@ -489,7 +495,7 @@ class QueryParams(typing.Mapping[str, str]):
|
|||||||
"""
|
"""
|
||||||
return {k: v[0] for k, v in self._dict.items()}.items()
|
return {k: v[0] for k, v in self._dict.items()}.items()
|
||||||
|
|
||||||
def multi_items(self) -> typing.List[typing.Tuple[str, str]]:
|
def multi_items(self) -> list[tuple[str, str]]:
|
||||||
"""
|
"""
|
||||||
Return all items in the query params. Allow duplicate keys to occur.
|
Return all items in the query params. Allow duplicate keys to occur.
|
||||||
|
|
||||||
@ -498,7 +504,7 @@ class QueryParams(typing.Mapping[str, str]):
|
|||||||
q = httpx.QueryParams("a=123&a=456&b=789")
|
q = httpx.QueryParams("a=123&a=456&b=789")
|
||||||
assert list(q.multi_items()) == [("a", "123"), ("a", "456"), ("b", "789")]
|
assert list(q.multi_items()) == [("a", "123"), ("a", "456"), ("b", "789")]
|
||||||
"""
|
"""
|
||||||
multi_items: typing.List[typing.Tuple[str, str]] = []
|
multi_items: list[tuple[str, str]] = []
|
||||||
for k, v in self._dict.items():
|
for k, v in self._dict.items():
|
||||||
multi_items.extend([(k, i) for i in v])
|
multi_items.extend([(k, i) for i in v])
|
||||||
return multi_items
|
return multi_items
|
||||||
@ -517,7 +523,7 @@ class QueryParams(typing.Mapping[str, str]):
|
|||||||
return self._dict[str(key)][0]
|
return self._dict[str(key)][0]
|
||||||
return default
|
return default
|
||||||
|
|
||||||
def get_list(self, key: str) -> typing.List[str]:
|
def get_list(self, key: str) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Get all values from the query param for a given key.
|
Get all values from the query param for a given key.
|
||||||
|
|
||||||
@ -528,7 +534,7 @@ class QueryParams(typing.Mapping[str, str]):
|
|||||||
"""
|
"""
|
||||||
return list(self._dict.get(str(key), []))
|
return list(self._dict.get(str(key), []))
|
||||||
|
|
||||||
def set(self, key: str, value: typing.Any = None) -> "QueryParams":
|
def set(self, key: str, value: typing.Any = None) -> QueryParams:
|
||||||
"""
|
"""
|
||||||
Return a new QueryParams instance, setting the value of a key.
|
Return a new QueryParams instance, setting the value of a key.
|
||||||
|
|
||||||
@ -543,7 +549,7 @@ class QueryParams(typing.Mapping[str, str]):
|
|||||||
q._dict[str(key)] = [primitive_value_to_str(value)]
|
q._dict[str(key)] = [primitive_value_to_str(value)]
|
||||||
return q
|
return q
|
||||||
|
|
||||||
def add(self, key: str, value: typing.Any = None) -> "QueryParams":
|
def add(self, key: str, value: typing.Any = None) -> QueryParams:
|
||||||
"""
|
"""
|
||||||
Return a new QueryParams instance, setting or appending the value of a key.
|
Return a new QueryParams instance, setting or appending the value of a key.
|
||||||
|
|
||||||
@ -558,7 +564,7 @@ class QueryParams(typing.Mapping[str, str]):
|
|||||||
q._dict[str(key)] = q.get_list(key) + [primitive_value_to_str(value)]
|
q._dict[str(key)] = q.get_list(key) + [primitive_value_to_str(value)]
|
||||||
return q
|
return q
|
||||||
|
|
||||||
def remove(self, key: str) -> "QueryParams":
|
def remove(self, key: str) -> QueryParams:
|
||||||
"""
|
"""
|
||||||
Return a new QueryParams instance, removing the value of a key.
|
Return a new QueryParams instance, removing the value of a key.
|
||||||
|
|
||||||
@ -573,7 +579,7 @@ class QueryParams(typing.Mapping[str, str]):
|
|||||||
q._dict.pop(str(key), None)
|
q._dict.pop(str(key), None)
|
||||||
return q
|
return q
|
||||||
|
|
||||||
def merge(self, params: typing.Optional[QueryParamTypes] = None) -> "QueryParams":
|
def merge(self, params: QueryParamTypes | None = None) -> QueryParams:
|
||||||
"""
|
"""
|
||||||
Return a new QueryParams instance, updated with.
|
Return a new QueryParams instance, updated with.
|
||||||
|
|
||||||
@ -615,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:
|
||||||
@ -629,7 +628,7 @@ class QueryParams(typing.Mapping[str, str]):
|
|||||||
query_string = str(self)
|
query_string = str(self)
|
||||||
return f"{class_name}({query_string!r})"
|
return f"{class_name}({query_string!r})"
|
||||||
|
|
||||||
def update(self, params: typing.Optional[QueryParamTypes] = None) -> None:
|
def update(self, params: QueryParamTypes | None = None) -> None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"QueryParams are immutable since 0.18.0. "
|
"QueryParams are immutable since 0.18.0. "
|
||||||
"Use `q = q.merge(...)` to create an updated copy."
|
"Use `q = q.merge(...)` to create an updated copy."
|
||||||
|
|||||||
269
httpx/_utils.py
269
httpx/_utils.py
@ -1,59 +1,18 @@
|
|||||||
import codecs
|
from __future__ import annotations
|
||||||
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", "\\": "\\\\"}
|
def primitive_value_to_str(value: PrimitiveData) -> str:
|
||||||
_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: typing.Union[str, bytes],
|
|
||||||
lower: bool,
|
|
||||||
encoding: typing.Optional[str] = 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: typing.Union[str, bytes], encoding: typing.Optional[str] = None
|
|
||||||
) -> bytes:
|
|
||||||
"""
|
|
||||||
Coerce str/bytes into a strictly byte-wise HTTP header value.
|
|
||||||
"""
|
|
||||||
if isinstance(value, bytes):
|
|
||||||
return value
|
|
||||||
return value.encode(encoding or "ascii")
|
|
||||||
|
|
||||||
|
|
||||||
def primitive_value_to_str(value: "PrimitiveData") -> str:
|
|
||||||
"""
|
"""
|
||||||
Coerce a primitive data type into a string value.
|
Coerce a primitive data type into a string value.
|
||||||
|
|
||||||
@ -68,166 +27,7 @@ def primitive_value_to_str(value: "PrimitiveData") -> str:
|
|||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
def is_known_encoding(encoding: str) -> bool:
|
def get_environment_proxies() -> dict[str, str | None]:
|
||||||
"""
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
# Null bytes; no need to recreate these on each call to guess_json_utf
|
|
||||||
_null = b"\x00"
|
|
||||||
_null2 = _null * 2
|
|
||||||
_null3 = _null * 3
|
|
||||||
|
|
||||||
|
|
||||||
def guess_json_utf(data: bytes) -> typing.Optional[str]:
|
|
||||||
# JSON always starts with two ASCII characters, so detection is as
|
|
||||||
# easy as counting the nulls and from their location and count
|
|
||||||
# determine the encoding. Also detect a BOM, if present.
|
|
||||||
sample = data[:4]
|
|
||||||
if sample in (codecs.BOM_UTF32_LE, codecs.BOM_UTF32_BE):
|
|
||||||
return "utf-32" # BOM included
|
|
||||||
if sample[:3] == codecs.BOM_UTF8:
|
|
||||||
return "utf-8-sig" # BOM included, MS style (discouraged)
|
|
||||||
if sample[:2] in (codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE):
|
|
||||||
return "utf-16" # BOM included
|
|
||||||
nullcount = sample.count(_null)
|
|
||||||
if nullcount == 0:
|
|
||||||
return "utf-8"
|
|
||||||
if nullcount == 2:
|
|
||||||
if sample[::2] == _null2: # 1st and 3rd are null
|
|
||||||
return "utf-16-be"
|
|
||||||
if sample[1::2] == _null2: # 2nd and 4th are null
|
|
||||||
return "utf-16-le"
|
|
||||||
# Did not detect 2 valid UTF-16 ascii-range characters
|
|
||||||
if nullcount == 3:
|
|
||||||
if sample[:3] == _null3:
|
|
||||||
return "utf-32-be"
|
|
||||||
if sample[1:] == _null3:
|
|
||||||
return "utf-32-le"
|
|
||||||
# Did not detect a valid UTF-32 ascii-range character
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_ca_bundle_from_env() -> typing.Optional[str]:
|
|
||||||
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) -> typing.List[typing.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: typing.List[typing.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) -> typing.Optional[str]:
|
|
||||||
# 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[typing.Tuple[typing.AnyStr, typing.AnyStr]]
|
|
||||||
) -> typing.Iterator[typing.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") -> typing.Optional[int]:
|
|
||||||
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() -> typing.Dict[str, typing.Optional[str]]:
|
|
||||||
"""Gets proxy information from the environment"""
|
"""Gets proxy information from the environment"""
|
||||||
|
|
||||||
# urllib.request.getproxies() falls back on System
|
# urllib.request.getproxies() falls back on System
|
||||||
@ -235,7 +35,7 @@ def get_environment_proxies() -> typing.Dict[str, typing.Optional[str]]:
|
|||||||
# We don't want to propagate non-HTTP proxies into
|
# We don't want to propagate non-HTTP proxies into
|
||||||
# our configuration such as 'TRAVIS_APT_PROXY'.
|
# our configuration such as 'TRAVIS_APT_PROXY'.
|
||||||
proxy_info = getproxies()
|
proxy_info = getproxies()
|
||||||
mounts: typing.Dict[str, typing.Optional[str]] = {}
|
mounts: dict[str, str | None] = {}
|
||||||
|
|
||||||
for scheme in ("http", "https", "all"):
|
for scheme in ("http", "https", "all"):
|
||||||
if proxy_info.get(scheme):
|
if proxy_info.get(scheme):
|
||||||
@ -262,7 +62,9 @@ def get_environment_proxies() -> typing.Dict[str, typing.Optional[str]]:
|
|||||||
# (But not "wwwgoogle.com")
|
# (But not "wwwgoogle.com")
|
||||||
# NO_PROXY can include domains, IPv6, IPv4 addresses and "localhost"
|
# NO_PROXY can include domains, IPv6, IPv4 addresses and "localhost"
|
||||||
# NO_PROXY=example.com,::1,localhost,192.168.0.0/16
|
# NO_PROXY=example.com,::1,localhost,192.168.0.0/16
|
||||||
if is_ipv4_hostname(hostname):
|
if "://" in hostname:
|
||||||
|
mounts[hostname] = None
|
||||||
|
elif is_ipv4_hostname(hostname):
|
||||||
mounts[f"all://{hostname}"] = None
|
mounts[f"all://{hostname}"] = None
|
||||||
elif is_ipv6_hostname(hostname):
|
elif is_ipv6_hostname(hostname):
|
||||||
mounts[f"all://[{hostname}]"] = None
|
mounts[f"all://[{hostname}]"] = None
|
||||||
@ -274,11 +76,11 @@ def get_environment_proxies() -> typing.Dict[str, typing.Optional[str]]:
|
|||||||
return mounts
|
return mounts
|
||||||
|
|
||||||
|
|
||||||
def to_bytes(value: typing.Union[str, bytes], encoding: str = "utf-8") -> bytes:
|
def to_bytes(value: str | bytes, encoding: str = "utf-8") -> bytes:
|
||||||
return value.encode(encoding) if isinstance(value, str) else value
|
return value.encode(encoding) if isinstance(value, str) else value
|
||||||
|
|
||||||
|
|
||||||
def to_str(value: typing.Union[str, bytes], encoding: str = "utf-8") -> str:
|
def to_str(value: str | bytes, encoding: str = "utf-8") -> str:
|
||||||
return value if isinstance(value, str) else value.decode(encoding)
|
return value if isinstance(value, str) else value.decode(encoding)
|
||||||
|
|
||||||
|
|
||||||
@ -290,13 +92,7 @@ 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: typing.Optional[str]) -> typing.Optional[str]:
|
def peek_filelike_length(stream: typing.Any) -> int | None:
|
||||||
if filename:
|
|
||||||
return mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def peek_filelike_length(stream: typing.Any) -> typing.Optional[int]:
|
|
||||||
"""
|
"""
|
||||||
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
|
||||||
without reading it into memory.
|
without reading it into memory.
|
||||||
@ -321,48 +117,17 @@ def peek_filelike_length(stream: typing.Any) -> typing.Optional[int]:
|
|||||||
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()
|
|
||||||
elif library == "curio": # pragma: no cover
|
|
||||||
import curio
|
|
||||||
|
|
||||||
return typing.cast(float, await curio.clock())
|
|
||||||
|
|
||||||
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...
|
||||||
|
|
||||||
# Wildcard matching...
|
# Wildcard matching...
|
||||||
>>> pattern = URLPattern("all")
|
>>> pattern = URLPattern("all://")
|
||||||
>>> pattern.matches(httpx.URL("http://example.com"))
|
>>> pattern.matches(httpx.URL("http://example.com"))
|
||||||
True
|
True
|
||||||
|
|
||||||
# Witch scheme matching...
|
# Witch scheme matching...
|
||||||
>>> pattern = URLPattern("https")
|
>>> pattern = URLPattern("https://")
|
||||||
>>> pattern.matches(httpx.URL("https://example.com"))
|
>>> pattern.matches(httpx.URL("https://example.com"))
|
||||||
True
|
True
|
||||||
>>> pattern.matches(httpx.URL("http://example.com"))
|
>>> pattern.matches(httpx.URL("http://example.com"))
|
||||||
@ -410,7 +175,7 @@ class URLPattern:
|
|||||||
self.host = "" if url.host == "*" else url.host
|
self.host = "" if url.host == "*" else url.host
|
||||||
self.port = url.port
|
self.port = url.port
|
||||||
if not url.host or url.host == "*":
|
if not url.host or url.host == "*":
|
||||||
self.host_regex: typing.Optional[typing.Pattern[str]] = None
|
self.host_regex: typing.Pattern[str] | None = None
|
||||||
elif url.host.startswith("*."):
|
elif url.host.startswith("*."):
|
||||||
# *.example.com should match "www.example.com", but not "example.com"
|
# *.example.com should match "www.example.com", but not "example.com"
|
||||||
domain = re.escape(url.host[2:])
|
domain = re.escape(url.host[2:])
|
||||||
@ -424,7 +189,7 @@ class URLPattern:
|
|||||||
domain = re.escape(url.host)
|
domain = re.escape(url.host)
|
||||||
self.host_regex = re.compile(f"^{domain}$")
|
self.host_regex = re.compile(f"^{domain}$")
|
||||||
|
|
||||||
def matches(self, other: "URL") -> bool:
|
def matches(self, other: URL) -> bool:
|
||||||
if self.scheme and self.scheme != other.scheme:
|
if self.scheme and self.scheme != other.scheme:
|
||||||
return False
|
return False
|
||||||
if (
|
if (
|
||||||
@ -438,7 +203,7 @@ class URLPattern:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def priority(self) -> typing.Tuple[int, int, int]:
|
def priority(self) -> tuple[int, int, int]:
|
||||||
"""
|
"""
|
||||||
The priority allows URLPattern instances to be sortable, so that
|
The priority allows URLPattern instances to be sortable, so that
|
||||||
we can match from most specific to least specific.
|
we can match from most specific to least specific.
|
||||||
@ -454,7 +219,7 @@ class URLPattern:
|
|||||||
def __hash__(self) -> int:
|
def __hash__(self) -> int:
|
||||||
return hash(self.pattern)
|
return hash(self.pattern)
|
||||||
|
|
||||||
def __lt__(self, other: "URLPattern") -> bool:
|
def __lt__(self, other: URLPattern) -> bool:
|
||||||
return self.priority < other.priority
|
return self.priority < other.priority
|
||||||
|
|
||||||
def __eq__(self, other: typing.Any) -> bool:
|
def __eq__(self, other: typing.Any) -> bool:
|
||||||
|
|||||||
22
mkdocs.yml
22
mkdocs.yml
@ -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)'
|
||||||
@ -16,8 +17,6 @@ theme:
|
|||||||
toggle:
|
toggle:
|
||||||
icon: 'material/lightbulb-outline'
|
icon: 'material/lightbulb-outline'
|
||||||
name: 'Switch to light mode'
|
name: 'Switch to light mode'
|
||||||
features:
|
|
||||||
- navigation.sections
|
|
||||||
|
|
||||||
repo_name: encode/httpx
|
repo_name: encode/httpx
|
||||||
repo_url: https://github.com/encode/httpx/
|
repo_url: https://github.com/encode/httpx/
|
||||||
@ -25,9 +24,18 @@ edit_uri: ""
|
|||||||
|
|
||||||
nav:
|
nav:
|
||||||
- Introduction: 'index.md'
|
- Introduction: 'index.md'
|
||||||
- Usage:
|
- QuickStart: 'quickstart.md'
|
||||||
- QuickStart: 'quickstart.md'
|
- Advanced:
|
||||||
- Advanced Usage: 'advanced.md'
|
- Clients: 'advanced/clients.md'
|
||||||
|
- Authentication: 'advanced/authentication.md'
|
||||||
|
- SSL: 'advanced/ssl.md'
|
||||||
|
- Proxies: 'advanced/proxies.md'
|
||||||
|
- Timeouts: 'advanced/timeouts.md'
|
||||||
|
- Resource Limits: 'advanced/resource-limits.md'
|
||||||
|
- Event Hooks: 'advanced/event-hooks.md'
|
||||||
|
- Transports: 'advanced/transports.md'
|
||||||
|
- Text Encodings: 'advanced/text-encodings.md'
|
||||||
|
- Extensions: 'advanced/extensions.md'
|
||||||
- Guides:
|
- Guides:
|
||||||
- Async Support: 'async.md'
|
- Async Support: 'async.md'
|
||||||
- HTTP/2 Support: 'http2.md'
|
- HTTP/2 Support: 'http2.md'
|
||||||
@ -51,7 +59,3 @@ markdown_extensions:
|
|||||||
|
|
||||||
extra_css:
|
extra_css:
|
||||||
- css/custom.css
|
- css/custom.css
|
||||||
|
|
||||||
extra_javascript:
|
|
||||||
- 'js/chat.js'
|
|
||||||
- 'js/sidecar-1.5.0.js'
|
|
||||||
|
|||||||
@ -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.7"
|
requires-python = ">=3.9"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Tom Christie", email = "tom@tomchristie.com" },
|
{ name = "Tom Christie", email = "tom@tomchristie.com" },
|
||||||
]
|
]
|
||||||
@ -20,18 +20,18 @@ 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.7",
|
|
||||||
"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.13",
|
||||||
"Topic :: Internet :: WWW/HTTP",
|
"Topic :: Internet :: WWW/HTTP",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"certifi",
|
"certifi",
|
||||||
"httpcore>=0.15.0,<0.18.0",
|
"httpcore==1.*",
|
||||||
|
"anyio",
|
||||||
"idna",
|
"idna",
|
||||||
"sniffio",
|
|
||||||
]
|
]
|
||||||
dynamic = ["readme", "version"]
|
dynamic = ["readme", "version"]
|
||||||
|
|
||||||
@ -43,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",
|
||||||
@ -51,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"
|
||||||
@ -69,6 +72,7 @@ include = [
|
|||||||
"/httpx",
|
"/httpx",
|
||||||
"/CHANGELOG.md",
|
"/CHANGELOG.md",
|
||||||
"/README.md",
|
"/README.md",
|
||||||
|
"/tests",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.hatch.metadata.hooks.fancy-pypi-readme]
|
[tool.hatch.metadata.hooks.fancy-pypi-readme]
|
||||||
@ -91,15 +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"]
|
||||||
line-length = 120
|
|
||||||
|
|
||||||
[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
|
||||||
@ -123,5 +128,5 @@ markers = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
omit = ["venv/*", "httpx/_compat.py"]
|
omit = ["venv/*"]
|
||||||
include = ["httpx/*", "tests/*"]
|
include = ["httpx/*", "tests/*"]
|
||||||
|
|||||||
@ -2,31 +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.1.0
|
chardet==5.2.0
|
||||||
types-chardet==5.0.4.5
|
|
||||||
|
|
||||||
# Documentation
|
# Documentation
|
||||||
mkdocs==1.4.2
|
mkdocs==1.6.1
|
||||||
mkautodoc==0.2.0
|
mkautodoc==0.2.0
|
||||||
mkdocs-material==9.1.5
|
mkdocs-material==9.6.18
|
||||||
|
|
||||||
# Packaging
|
# Packaging
|
||||||
build==0.10.0
|
build==1.3.0
|
||||||
twine==4.0.2
|
twine==6.1.0
|
||||||
|
|
||||||
# Tests & Linting
|
# Tests & Linting
|
||||||
black==23.3.0
|
coverage[toml]==7.10.6
|
||||||
coverage[toml]==7.2.2
|
cryptography==45.0.7
|
||||||
cryptography==40.0.2
|
mypy==1.17.1
|
||||||
mypy==1.0.1
|
pytest==8.4.1
|
||||||
types-certifi==2021.10.8.2
|
ruff==0.12.11
|
||||||
pytest==7.2.2
|
trio==0.31.0
|
||||||
ruff==0.0.260
|
trio-typing==0.10.0
|
||||||
trio==0.22.0
|
trustme==1.2.1
|
||||||
trio-typing==0.8.0
|
uvicorn==0.35.0
|
||||||
trustme==1.0.0
|
|
||||||
uvicorn==0.22.0
|
|
||||||
|
|||||||
@ -9,6 +9,6 @@ export SOURCE_FILES="httpx tests"
|
|||||||
set -x
|
set -x
|
||||||
|
|
||||||
./scripts/sync-version
|
./scripts/sync-version
|
||||||
${PREFIX}black --check --diff --target-version=py37 $SOURCE_FILES
|
${PREFIX}ruff format $SOURCE_FILES --diff
|
||||||
${PREFIX}mypy $SOURCE_FILES
|
${PREFIX}mypy $SOURCE_FILES
|
||||||
${PREFIX}ruff check $SOURCE_FILES
|
${PREFIX}ruff check $SOURCE_FILES
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
#!/bin/sh -e
|
#!/bin/sh -e
|
||||||
|
|
||||||
export PREFIX=""
|
export PREFIX=""
|
||||||
if [ -d 'venv' ] ; then
|
if [ -d 'venv' ]; then
|
||||||
export PREFIX="venv/bin/"
|
export PREFIX="venv/bin/"
|
||||||
fi
|
fi
|
||||||
export SOURCE_FILES="httpx tests"
|
export SOURCE_FILES="httpx tests"
|
||||||
|
|
||||||
set -x
|
set -x
|
||||||
|
|
||||||
${PREFIX}ruff --fix $SOURCE_FILES
|
${PREFIX}ruff check --fix $SOURCE_FILES
|
||||||
${PREFIX}black --target-version=py37 $SOURCE_FILES
|
${PREFIX}ruff format $SOURCE_FILES
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
@ -122,7 +124,7 @@ async def test_raise_for_status(server):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
assert exc_info.value.response == response
|
assert exc_info.value.response == response
|
||||||
else:
|
else:
|
||||||
assert response.raise_for_status() is None # type: ignore
|
assert response.raise_for_status() is response
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@ -181,7 +183,7 @@ async def test_100_continue(server):
|
|||||||
async def test_context_managed_transport():
|
async def test_context_managed_transport():
|
||||||
class Transport(httpx.AsyncBaseTransport):
|
class Transport(httpx.AsyncBaseTransport):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.events: typing.List[str] = []
|
self.events: list[str] = []
|
||||||
|
|
||||||
async def aclose(self):
|
async def aclose(self):
|
||||||
# The base implementation of httpx.AsyncBaseTransport just
|
# The base implementation of httpx.AsyncBaseTransport just
|
||||||
@ -212,9 +214,9 @@ async def test_context_managed_transport():
|
|||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_context_managed_transport_and_mount():
|
async def test_context_managed_transport_and_mount():
|
||||||
class Transport(httpx.AsyncBaseTransport):
|
class Transport(httpx.AsyncBaseTransport):
|
||||||
def __init__(self, name: str):
|
def __init__(self, name: str) -> None:
|
||||||
self.name: str = name
|
self.name: str = name
|
||||||
self.events: typing.List[str] = []
|
self.events: list[str] = []
|
||||||
|
|
||||||
async def aclose(self):
|
async def aclose(self):
|
||||||
# The base implementation of httpx.AsyncBaseTransport just
|
# The base implementation of httpx.AsyncBaseTransport just
|
||||||
|
|||||||
@ -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
|
||||||
@ -93,7 +94,7 @@ class RepeatAuth(httpx.Auth):
|
|||||||
|
|
||||||
requires_request_body = True
|
requires_request_body = True
|
||||||
|
|
||||||
def __init__(self, repeat: int):
|
def __init__(self, repeat: int) -> None:
|
||||||
self.repeat = repeat
|
self.repeat = repeat
|
||||||
|
|
||||||
def auth_flow(
|
def auth_flow(
|
||||||
@ -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/"
|
||||||
@ -596,7 +574,8 @@ async def test_digest_auth_resets_nonce_count_after_401() -> None:
|
|||||||
# with this we now force a 401 on a subsequent (but initial) request
|
# with this we now force a 401 on a subsequent (but initial) request
|
||||||
app.send_response_after_attempt = 2
|
app.send_response_after_attempt = 2
|
||||||
|
|
||||||
# we expect the client again to try to authenticate, i.e. the history length must be 1
|
# we expect the client again to try to authenticate,
|
||||||
|
# i.e. the history length must be 1
|
||||||
response_2 = await client.get(url, auth=auth)
|
response_2 = await client.get(url, auth=auth)
|
||||||
assert response_2.status_code == 200
|
assert response_2.status_code == 200
|
||||||
assert len(response_2.history) == 1
|
assert len(response_2.history) == 1
|
||||||
@ -741,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:
|
||||||
@ -757,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
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
@ -141,7 +143,7 @@ def test_raise_for_status(server):
|
|||||||
assert exc_info.value.response == response
|
assert exc_info.value.response == response
|
||||||
assert exc_info.value.request.url.path == f"/status/{status_code}"
|
assert exc_info.value.request.url.path == f"/status/{status_code}"
|
||||||
else:
|
else:
|
||||||
assert response.raise_for_status() is None # type: ignore
|
assert response.raise_for_status() is response
|
||||||
|
|
||||||
|
|
||||||
def test_options(server):
|
def test_options(server):
|
||||||
@ -230,7 +232,7 @@ def test_merge_relative_url_with_encoded_slashes():
|
|||||||
def test_context_managed_transport():
|
def test_context_managed_transport():
|
||||||
class Transport(httpx.BaseTransport):
|
class Transport(httpx.BaseTransport):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.events: typing.List[str] = []
|
self.events: list[str] = []
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
# The base implementation of httpx.BaseTransport just
|
# The base implementation of httpx.BaseTransport just
|
||||||
@ -260,9 +262,9 @@ def test_context_managed_transport():
|
|||||||
|
|
||||||
def test_context_managed_transport_and_mount():
|
def test_context_managed_transport_and_mount():
|
||||||
class Transport(httpx.BaseTransport):
|
class Transport(httpx.BaseTransport):
|
||||||
def __init__(self, name: str):
|
def __init__(self, name: str) -> None:
|
||||||
self.name: str = name
|
self.name: str = name
|
||||||
self.events: typing.List[str] = []
|
self.events: list[str] = []
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
# The base implementation of httpx.BaseTransport just
|
# The base implementation of httpx.BaseTransport just
|
||||||
@ -355,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
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import httpcore
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from httpx._utils import URLPattern
|
|
||||||
|
|
||||||
|
|
||||||
def url_to_origin(url: str) -> httpcore.URL:
|
def url_to_origin(url: str) -> httpcore.URL:
|
||||||
@ -14,51 +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):
|
|
||||||
client = httpx.Client(proxies=proxies)
|
|
||||||
|
|
||||||
for proxy_key, url in expected_proxies:
|
|
||||||
pattern = URLPattern(proxy_key)
|
|
||||||
assert pattern in client._mounts
|
|
||||||
proxy = client._mounts[pattern]
|
|
||||||
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():
|
def test_socks_proxy():
|
||||||
url = httpx.URL("http://www.example.com")
|
url = httpx.URL("http://www.example.com")
|
||||||
|
|
||||||
client = httpx.Client(proxies="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(proxies="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]"
|
||||||
@ -67,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),
|
||||||
@ -87,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),
|
||||||
@ -121,7 +86,9 @@ PROXY_URL = "http://[::1]"
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_transport_for_request(url, proxies, expected):
|
def test_transport_for_request(url, proxies, expected):
|
||||||
client = httpx.Client(proxies=proxies)
|
mounts = {key: httpx.HTTPTransport(proxy=value) for key, value in proxies.items()}
|
||||||
|
client = httpx.Client(mounts=mounts)
|
||||||
|
|
||||||
transport = client._transport_for_url(httpx.URL(url))
|
transport = client._transport_for_url(httpx.URL(url))
|
||||||
|
|
||||||
if expected is None:
|
if expected is None:
|
||||||
@ -136,7 +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:
|
||||||
client = httpx.AsyncClient(proxies={"https://": PROXY_URL})
|
transport = httpx.AsyncHTTPTransport(proxy=PROXY_URL)
|
||||||
|
client = httpx.AsyncClient(mounts={"https://": transport})
|
||||||
await client.get("http://example.com")
|
await client.get("http://example.com")
|
||||||
finally:
|
finally:
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
@ -145,7 +113,8 @@ async def test_async_proxy_close():
|
|||||||
@pytest.mark.network
|
@pytest.mark.network
|
||||||
def test_sync_proxy_close():
|
def test_sync_proxy_close():
|
||||||
try:
|
try:
|
||||||
client = httpx.Client(proxies={"https://": PROXY_URL})
|
transport = httpx.HTTPTransport(proxy=PROXY_URL)
|
||||||
|
client = httpx.Client(mounts={"https://": transport})
|
||||||
client.get("http://example.com")
|
client.get("http://example.com")
|
||||||
finally:
|
finally:
|
||||||
client.close()
|
client.close()
|
||||||
@ -153,7 +122,7 @@ def test_sync_proxy_close():
|
|||||||
|
|
||||||
def test_unsupported_proxy_scheme():
|
def test_unsupported_proxy_scheme():
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
httpx.Client(proxies="ftp://127.0.0.1")
|
httpx.Client(proxy="ftp://127.0.0.1")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@ -279,8 +248,18 @@ def test_proxies_environ(monkeypatch, client_class, url, env, expected):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_for_deprecated_proxy_params(proxies, is_valid):
|
def test_for_deprecated_proxy_params(proxies, is_valid):
|
||||||
|
mounts = {key: httpx.HTTPTransport(proxy=value) for key, value in proxies.items()}
|
||||||
|
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
httpx.Client(proxies=proxies)
|
httpx.Client(mounts=mounts)
|
||||||
else:
|
else:
|
||||||
httpx.Client(proxies=proxies)
|
httpx.Client(mounts=mounts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_with_mounts():
|
||||||
|
proxy_transport = httpx.HTTPTransport(proxy="http://127.0.0.1")
|
||||||
|
client = httpx.Client(mounts={"http://": proxy_transport})
|
||||||
|
|
||||||
|
transport = client._transport_for_url(httpx.URL("http://example.com"))
|
||||||
|
assert transport == proxy_transport
|
||||||
|
|||||||
@ -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"
|
||||||
|
|
||||||
|
|||||||
@ -345,7 +345,7 @@ def test_can_stream_if_no_redirect():
|
|||||||
class ConsumeBodyTransport(httpx.MockTransport):
|
class ConsumeBodyTransport(httpx.MockTransport):
|
||||||
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
||||||
assert isinstance(request.stream, httpx.SyncByteStream)
|
assert isinstance(request.stream, httpx.SyncByteStream)
|
||||||
[_ for _ in request.stream]
|
list(request.stream)
|
||||||
return self.handler(request) # type: ignore[return-value]
|
return self.handler(request) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
|
||||||
|
|||||||
@ -92,7 +92,7 @@ def test_cookies_repr():
|
|||||||
cookies.set(name="foo", value="bar", domain="http://blah.com")
|
cookies.set(name="foo", value="bar", domain="http://blah.com")
|
||||||
cookies.set(name="fizz", value="buzz", domain="http://hello.com")
|
cookies.set(name="fizz", value="buzz", domain="http://hello.com")
|
||||||
|
|
||||||
assert (
|
assert repr(cookies) == (
|
||||||
repr(cookies)
|
"<Cookies[<Cookie foo=bar for http://blah.com />,"
|
||||||
== "<Cookies[<Cookie foo=bar for http://blah.com />, <Cookie fizz=buzz for http://hello.com />]>"
|
" <Cookie fizz=buzz for http://hello.com />]>"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -82,7 +82,7 @@ def test_read_and_stream_data():
|
|||||||
request.read()
|
request.read()
|
||||||
assert request.stream is not None
|
assert request.stream is not None
|
||||||
assert isinstance(request.stream, typing.Iterable)
|
assert isinstance(request.stream, typing.Iterable)
|
||||||
content = b"".join([part for part in request.stream])
|
content = b"".join(list(request.stream))
|
||||||
assert content == request.content
|
assert content == request.content
|
||||||
|
|
||||||
|
|
||||||
@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,7 +102,7 @@ def test_raise_for_status():
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
assert str(exc_info.value) == (
|
assert str(exc_info.value) == (
|
||||||
"Informational response '101 Switching Protocols' for url 'https://example.org'\n"
|
"Informational response '101 Switching Protocols' for url 'https://example.org'\n"
|
||||||
"For more information check: https://httpstatuses.com/101"
|
"For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/101"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3xx status codes are redirections.
|
# 3xx status codes are redirections.
|
||||||
@ -114,7 +114,7 @@ def test_raise_for_status():
|
|||||||
assert str(exc_info.value) == (
|
assert str(exc_info.value) == (
|
||||||
"Redirect response '303 See Other' for url 'https://example.org'\n"
|
"Redirect response '303 See Other' for url 'https://example.org'\n"
|
||||||
"Redirect location: 'https://other.org'\n"
|
"Redirect location: 'https://other.org'\n"
|
||||||
"For more information check: https://httpstatuses.com/303"
|
"For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/303"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 4xx status codes are a client error.
|
# 4xx status codes are a client error.
|
||||||
@ -125,7 +125,7 @@ def test_raise_for_status():
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
assert str(exc_info.value) == (
|
assert str(exc_info.value) == (
|
||||||
"Client error '403 Forbidden' for url 'https://example.org'\n"
|
"Client error '403 Forbidden' for url 'https://example.org'\n"
|
||||||
"For more information check: https://httpstatuses.com/403"
|
"For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 5xx status codes are a server error.
|
# 5xx status codes are a server error.
|
||||||
@ -136,7 +136,7 @@ def test_raise_for_status():
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
assert str(exc_info.value) == (
|
assert str(exc_info.value) == (
|
||||||
"Server error '500 Internal Server Error' for url 'https://example.org'\n"
|
"Server error '500 Internal Server Error' for url 'https://example.org'\n"
|
||||||
"For more information check: https://httpstatuses.com/500"
|
"For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Calling .raise_for_status without setting a request instance is
|
# Calling .raise_for_status without setting a request instance is
|
||||||
@ -298,6 +298,23 @@ def test_response_force_encoding():
|
|||||||
assert response.encoding == "iso-8859-1"
|
assert response.encoding == "iso-8859-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_response_force_encoding_after_text_accessed():
|
||||||
|
response = httpx.Response(
|
||||||
|
200,
|
||||||
|
content=b"Hello, world!",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.reason_phrase == "OK"
|
||||||
|
assert response.text == "Hello, world!"
|
||||||
|
assert response.encoding == "utf-8"
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
response.encoding = "UTF8"
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
response.encoding = "iso-8859-1"
|
||||||
|
|
||||||
|
|
||||||
def test_read():
|
def test_read():
|
||||||
response = httpx.Response(
|
response = httpx.Response(
|
||||||
200,
|
200,
|
||||||
@ -380,19 +397,19 @@ def test_iter_raw():
|
|||||||
|
|
||||||
def test_iter_raw_with_chunksize():
|
def test_iter_raw_with_chunksize():
|
||||||
response = httpx.Response(200, content=streaming_body())
|
response = httpx.Response(200, content=streaming_body())
|
||||||
parts = [part for part in response.iter_raw(chunk_size=5)]
|
parts = list(response.iter_raw(chunk_size=5))
|
||||||
assert parts == [b"Hello", b", wor", b"ld!"]
|
assert parts == [b"Hello", b", wor", b"ld!"]
|
||||||
|
|
||||||
response = httpx.Response(200, content=streaming_body())
|
response = httpx.Response(200, content=streaming_body())
|
||||||
parts = [part for part in response.iter_raw(chunk_size=7)]
|
parts = list(response.iter_raw(chunk_size=7))
|
||||||
assert parts == [b"Hello, ", b"world!"]
|
assert parts == [b"Hello, ", b"world!"]
|
||||||
|
|
||||||
response = httpx.Response(200, content=streaming_body())
|
response = httpx.Response(200, content=streaming_body())
|
||||||
parts = [part for part in response.iter_raw(chunk_size=13)]
|
parts = list(response.iter_raw(chunk_size=13))
|
||||||
assert parts == [b"Hello, world!"]
|
assert parts == [b"Hello, world!"]
|
||||||
|
|
||||||
response = httpx.Response(200, content=streaming_body())
|
response = httpx.Response(200, content=streaming_body())
|
||||||
parts = [part for part in response.iter_raw(chunk_size=20)]
|
parts = list(response.iter_raw(chunk_size=20))
|
||||||
assert parts == [b"Hello, world!"]
|
assert parts == [b"Hello, world!"]
|
||||||
|
|
||||||
|
|
||||||
@ -405,7 +422,7 @@ def test_iter_raw_doesnt_return_empty_chunks():
|
|||||||
|
|
||||||
response = httpx.Response(200, content=streaming_body_with_empty_chunks())
|
response = httpx.Response(200, content=streaming_body_with_empty_chunks())
|
||||||
|
|
||||||
parts = [part for part in response.iter_raw()]
|
parts = list(response.iter_raw())
|
||||||
assert parts == [b"Hello, ", b"world!"]
|
assert parts == [b"Hello, ", b"world!"]
|
||||||
|
|
||||||
|
|
||||||
@ -428,7 +445,7 @@ def test_iter_raw_on_async():
|
|||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(RuntimeError):
|
with pytest.raises(RuntimeError):
|
||||||
[part for part in response.iter_raw()]
|
list(response.iter_raw())
|
||||||
|
|
||||||
|
|
||||||
def test_close_on_async():
|
def test_close_on_async():
|
||||||
@ -521,21 +538,21 @@ def test_iter_bytes():
|
|||||||
|
|
||||||
def test_iter_bytes_with_chunk_size():
|
def test_iter_bytes_with_chunk_size():
|
||||||
response = httpx.Response(200, content=streaming_body())
|
response = httpx.Response(200, content=streaming_body())
|
||||||
parts = [part for part in response.iter_bytes(chunk_size=5)]
|
parts = list(response.iter_bytes(chunk_size=5))
|
||||||
assert parts == [b"Hello", b", wor", b"ld!"]
|
assert parts == [b"Hello", b", wor", b"ld!"]
|
||||||
|
|
||||||
response = httpx.Response(200, content=streaming_body())
|
response = httpx.Response(200, content=streaming_body())
|
||||||
parts = [part for part in response.iter_bytes(chunk_size=13)]
|
parts = list(response.iter_bytes(chunk_size=13))
|
||||||
assert parts == [b"Hello, world!"]
|
assert parts == [b"Hello, world!"]
|
||||||
|
|
||||||
response = httpx.Response(200, content=streaming_body())
|
response = httpx.Response(200, content=streaming_body())
|
||||||
parts = [part for part in response.iter_bytes(chunk_size=20)]
|
parts = list(response.iter_bytes(chunk_size=20))
|
||||||
assert parts == [b"Hello, world!"]
|
assert parts == [b"Hello, world!"]
|
||||||
|
|
||||||
|
|
||||||
def test_iter_bytes_with_empty_response():
|
def test_iter_bytes_with_empty_response():
|
||||||
response = httpx.Response(200, content=b"")
|
response = httpx.Response(200, content=b"")
|
||||||
parts = [part for part in response.iter_bytes()]
|
parts = list(response.iter_bytes())
|
||||||
assert parts == []
|
assert parts == []
|
||||||
|
|
||||||
|
|
||||||
@ -548,7 +565,7 @@ def test_iter_bytes_doesnt_return_empty_chunks():
|
|||||||
|
|
||||||
response = httpx.Response(200, content=streaming_body_with_empty_chunks())
|
response = httpx.Response(200, content=streaming_body_with_empty_chunks())
|
||||||
|
|
||||||
parts = [part for part in response.iter_bytes()]
|
parts = list(response.iter_bytes())
|
||||||
assert parts == [b"Hello, ", b"world!"]
|
assert parts == [b"Hello, ", b"world!"]
|
||||||
|
|
||||||
|
|
||||||
@ -594,23 +611,23 @@ def test_iter_text():
|
|||||||
|
|
||||||
def test_iter_text_with_chunk_size():
|
def test_iter_text_with_chunk_size():
|
||||||
response = httpx.Response(200, content=b"Hello, world!")
|
response = httpx.Response(200, content=b"Hello, world!")
|
||||||
parts = [part for part in response.iter_text(chunk_size=5)]
|
parts = list(response.iter_text(chunk_size=5))
|
||||||
assert parts == ["Hello", ", wor", "ld!"]
|
assert parts == ["Hello", ", wor", "ld!"]
|
||||||
|
|
||||||
response = httpx.Response(200, content=b"Hello, world!!")
|
response = httpx.Response(200, content=b"Hello, world!!")
|
||||||
parts = [part for part in response.iter_text(chunk_size=7)]
|
parts = list(response.iter_text(chunk_size=7))
|
||||||
assert parts == ["Hello, ", "world!!"]
|
assert parts == ["Hello, ", "world!!"]
|
||||||
|
|
||||||
response = httpx.Response(200, content=b"Hello, world!")
|
response = httpx.Response(200, content=b"Hello, world!")
|
||||||
parts = [part for part in response.iter_text(chunk_size=7)]
|
parts = list(response.iter_text(chunk_size=7))
|
||||||
assert parts == ["Hello, ", "world!"]
|
assert parts == ["Hello, ", "world!"]
|
||||||
|
|
||||||
response = httpx.Response(200, content=b"Hello, world!")
|
response = httpx.Response(200, content=b"Hello, world!")
|
||||||
parts = [part for part in response.iter_text(chunk_size=13)]
|
parts = list(response.iter_text(chunk_size=13))
|
||||||
assert parts == ["Hello, world!"]
|
assert parts == ["Hello, world!"]
|
||||||
|
|
||||||
response = httpx.Response(200, content=b"Hello, world!")
|
response = httpx.Response(200, content=b"Hello, world!")
|
||||||
parts = [part for part in response.iter_text(chunk_size=20)]
|
parts = list(response.iter_text(chunk_size=20))
|
||||||
assert parts == ["Hello, world!"]
|
assert parts == ["Hello, world!"]
|
||||||
|
|
||||||
|
|
||||||
@ -647,7 +664,7 @@ def test_iter_lines():
|
|||||||
200,
|
200,
|
||||||
content=b"Hello,\nworld!",
|
content=b"Hello,\nworld!",
|
||||||
)
|
)
|
||||||
content = [line for line in response.iter_lines()]
|
content = list(response.iter_lines())
|
||||||
assert content == ["Hello,", "world!"]
|
assert content == ["Hello,", "world!"]
|
||||||
|
|
||||||
|
|
||||||
@ -994,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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
|
||||||
|
|||||||
@ -92,7 +92,8 @@ async def test_asgi_transport_no_body():
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_asgi():
|
async def test_asgi():
|
||||||
async with httpx.AsyncClient(app=hello_world) as client:
|
transport = httpx.ASGITransport(app=hello_world)
|
||||||
|
async with httpx.AsyncClient(transport=transport) as client:
|
||||||
response = await client.get("http://www.example.org/")
|
response = await client.get("http://www.example.org/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@ -101,7 +102,8 @@ async def test_asgi():
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_asgi_urlencoded_path():
|
async def test_asgi_urlencoded_path():
|
||||||
async with httpx.AsyncClient(app=echo_path) as client:
|
transport = httpx.ASGITransport(app=echo_path)
|
||||||
|
async with httpx.AsyncClient(transport=transport) as client:
|
||||||
url = httpx.URL("http://www.example.org/").copy_with(path="/user@example.org")
|
url = httpx.URL("http://www.example.org/").copy_with(path="/user@example.org")
|
||||||
response = await client.get(url)
|
response = await client.get(url)
|
||||||
|
|
||||||
@ -111,7 +113,8 @@ async def test_asgi_urlencoded_path():
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_asgi_raw_path():
|
async def test_asgi_raw_path():
|
||||||
async with httpx.AsyncClient(app=echo_raw_path) as client:
|
transport = httpx.ASGITransport(app=echo_raw_path)
|
||||||
|
async with httpx.AsyncClient(transport=transport) as client:
|
||||||
url = httpx.URL("http://www.example.org/").copy_with(path="/user@example.org")
|
url = httpx.URL("http://www.example.org/").copy_with(path="/user@example.org")
|
||||||
response = await client.get(url)
|
response = await client.get(url)
|
||||||
|
|
||||||
@ -119,9 +122,24 @@ async def test_asgi_raw_path():
|
|||||||
assert response.json() == {"raw_path": "/user@example.org"}
|
assert response.json() == {"raw_path": "/user@example.org"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_asgi_raw_path_should_not_include_querystring_portion():
|
||||||
|
"""
|
||||||
|
See https://github.com/encode/httpx/issues/2810
|
||||||
|
"""
|
||||||
|
transport = httpx.ASGITransport(app=echo_raw_path)
|
||||||
|
async with httpx.AsyncClient(transport=transport) as client:
|
||||||
|
url = httpx.URL("http://www.example.org/path?query")
|
||||||
|
response = await client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"raw_path": "/path"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_asgi_upload():
|
async def test_asgi_upload():
|
||||||
async with httpx.AsyncClient(app=echo_body) as client:
|
transport = httpx.ASGITransport(app=echo_body)
|
||||||
|
async with httpx.AsyncClient(transport=transport) as client:
|
||||||
response = await client.post("http://www.example.org/", content=b"example")
|
response = await client.post("http://www.example.org/", content=b"example")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@ -130,7 +148,8 @@ async def test_asgi_upload():
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_asgi_headers():
|
async def test_asgi_headers():
|
||||||
async with httpx.AsyncClient(app=echo_headers) as client:
|
transport = httpx.ASGITransport(app=echo_headers)
|
||||||
|
async with httpx.AsyncClient(transport=transport) as client:
|
||||||
response = await client.get("http://www.example.org/")
|
response = await client.get("http://www.example.org/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@ -138,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__}"],
|
||||||
]
|
]
|
||||||
@ -147,14 +166,16 @@ async def test_asgi_headers():
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_asgi_exc():
|
async def test_asgi_exc():
|
||||||
async with httpx.AsyncClient(app=raise_exc) as client:
|
transport = httpx.ASGITransport(app=raise_exc)
|
||||||
|
async with httpx.AsyncClient(transport=transport) as client:
|
||||||
with pytest.raises(RuntimeError):
|
with pytest.raises(RuntimeError):
|
||||||
await client.get("http://www.example.org/")
|
await client.get("http://www.example.org/")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_asgi_exc_after_response():
|
async def test_asgi_exc_after_response():
|
||||||
async with httpx.AsyncClient(app=raise_exc_after_response) as client:
|
transport = httpx.ASGITransport(app=raise_exc_after_response)
|
||||||
|
async with httpx.AsyncClient(transport=transport) as client:
|
||||||
with pytest.raises(RuntimeError):
|
with pytest.raises(RuntimeError):
|
||||||
await client.get("http://www.example.org/")
|
await client.get("http://www.example.org/")
|
||||||
|
|
||||||
@ -186,8 +207,18 @@ async def test_asgi_disconnect_after_response_complete():
|
|||||||
message = await receive()
|
message = await receive()
|
||||||
disconnect = message.get("type") == "http.disconnect"
|
disconnect = message.get("type") == "http.disconnect"
|
||||||
|
|
||||||
async with httpx.AsyncClient(app=read_body) as client:
|
transport = httpx.ASGITransport(app=read_body)
|
||||||
|
async with httpx.AsyncClient(transport=transport) as client:
|
||||||
response = await client.post("http://www.example.org/", content=b"example")
|
response = await client.post("http://www.example.org/", content=b"example")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert disconnect
|
assert disconnect
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_asgi_exc_no_raise():
|
||||||
|
transport = httpx.ASGITransport(app=raise_exc, raise_app_exceptions=False)
|
||||||
|
async with httpx.AsyncClient(transport=transport) as client:
|
||||||
|
response = await client.get("http://www.example.org/")
|
||||||
|
|
||||||
|
assert response.status_code == 500
|
||||||
|
|||||||
@ -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
|
||||||
@ -54,7 +55,7 @@ def test_digest_auth_with_401():
|
|||||||
"WWW-Authenticate": 'Digest realm="...", qop="auth", nonce="...", opaque="..."'
|
"WWW-Authenticate": 'Digest realm="...", qop="auth", nonce="...", opaque="..."'
|
||||||
}
|
}
|
||||||
response = httpx.Response(
|
response = httpx.Response(
|
||||||
content=b"Auth required", status_code=401, headers=headers
|
content=b"Auth required", status_code=401, headers=headers, request=request
|
||||||
)
|
)
|
||||||
request = flow.send(response)
|
request = flow.send(response)
|
||||||
assert request.headers["Authorization"].startswith("Digest")
|
assert request.headers["Authorization"].startswith("Digest")
|
||||||
@ -79,7 +80,7 @@ def test_digest_auth_with_401_nonce_counting():
|
|||||||
"WWW-Authenticate": 'Digest realm="...", qop="auth", nonce="...", opaque="..."'
|
"WWW-Authenticate": 'Digest realm="...", qop="auth", nonce="...", opaque="..."'
|
||||||
}
|
}
|
||||||
response = httpx.Response(
|
response = httpx.Response(
|
||||||
content=b"Auth required", status_code=401, headers=headers
|
content=b"Auth required", status_code=401, headers=headers, request=request
|
||||||
)
|
)
|
||||||
first_request = flow.send(response)
|
first_request = flow.send(response)
|
||||||
assert first_request.headers["Authorization"].startswith("Digest")
|
assert first_request.headers["Authorization"].startswith("Digest")
|
||||||
@ -101,3 +102,207 @@ def test_digest_auth_with_401_nonce_counting():
|
|||||||
response = httpx.Response(content=b"Hello, world!", status_code=200)
|
response = httpx.Response(content=b"Hello, world!", status_code=200)
|
||||||
with pytest.raises(StopIteration):
|
with pytest.raises(StopIteration):
|
||||||
flow.send(response)
|
flow.send(response)
|
||||||
|
|
||||||
|
|
||||||
|
def set_cookies(request: httpx.Request) -> httpx.Response:
|
||||||
|
headers = {
|
||||||
|
"Set-Cookie": "session=.session_value...",
|
||||||
|
"WWW-Authenticate": 'Digest realm="...", qop="auth", nonce="...", opaque="..."',
|
||||||
|
}
|
||||||
|
if request.url.path == "/auth":
|
||||||
|
return httpx.Response(
|
||||||
|
content=b"Auth required", status_code=401, headers=headers
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise NotImplementedError() # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_auth_setting_cookie_in_request():
|
||||||
|
url = "https://www.example.com/auth"
|
||||||
|
client = httpx.Client(transport=httpx.MockTransport(set_cookies))
|
||||||
|
request = client.build_request("GET", url)
|
||||||
|
|
||||||
|
auth = httpx.DigestAuth(username="user", password="pass")
|
||||||
|
flow = auth.sync_auth_flow(request)
|
||||||
|
request = next(flow)
|
||||||
|
assert "Authorization" not in request.headers
|
||||||
|
|
||||||
|
response = client.get(url)
|
||||||
|
assert len(response.cookies) > 0
|
||||||
|
assert response.cookies["session"] == ".session_value..."
|
||||||
|
|
||||||
|
request = flow.send(response)
|
||||||
|
assert request.headers["Authorization"].startswith("Digest")
|
||||||
|
assert request.headers["Cookie"] == "session=.session_value..."
|
||||||
|
|
||||||
|
# No other requests are made.
|
||||||
|
response = httpx.Response(
|
||||||
|
content=b"Hello, world!", status_code=200, request=request
|
||||||
|
)
|
||||||
|
with pytest.raises(StopIteration):
|
||||||
|
flow.send(response)
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_auth_rfc_2069():
|
||||||
|
# Example from https://datatracker.ietf.org/doc/html/rfc2069#section-2.4
|
||||||
|
# with corrected response from https://www.rfc-editor.org/errata/eid749
|
||||||
|
|
||||||
|
auth = httpx.DigestAuth(username="Mufasa", password="CircleOfLife")
|
||||||
|
request = httpx.Request("GET", "https://www.example.com/dir/index.html")
|
||||||
|
|
||||||
|
# The initial request should not include an auth header.
|
||||||
|
flow = auth.sync_auth_flow(request)
|
||||||
|
request = next(flow)
|
||||||
|
assert "Authorization" not in request.headers
|
||||||
|
|
||||||
|
# If a 401 response is returned, then a digest auth request is made.
|
||||||
|
headers = {
|
||||||
|
"WWW-Authenticate": (
|
||||||
|
'Digest realm="testrealm@host.com", '
|
||||||
|
'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", '
|
||||||
|
'opaque="5ccc069c403ebaf9f0171e9517f40e41"'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
response = httpx.Response(
|
||||||
|
content=b"Auth required", status_code=401, headers=headers, request=request
|
||||||
|
)
|
||||||
|
request = flow.send(response)
|
||||||
|
assert request.headers["Authorization"].startswith("Digest")
|
||||||
|
assert 'username="Mufasa"' in request.headers["Authorization"]
|
||||||
|
assert 'realm="testrealm@host.com"' in request.headers["Authorization"]
|
||||||
|
assert (
|
||||||
|
'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093"' in request.headers["Authorization"]
|
||||||
|
)
|
||||||
|
assert 'uri="/dir/index.html"' in request.headers["Authorization"]
|
||||||
|
assert (
|
||||||
|
'opaque="5ccc069c403ebaf9f0171e9517f40e41"' in request.headers["Authorization"]
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
'response="1949323746fe6a43ef61f9606e7febea"'
|
||||||
|
in request.headers["Authorization"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# No other requests are made.
|
||||||
|
response = httpx.Response(content=b"Hello, world!", status_code=200)
|
||||||
|
with pytest.raises(StopIteration):
|
||||||
|
flow.send(response)
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_auth_rfc_7616_md5(monkeypatch):
|
||||||
|
# Example from https://datatracker.ietf.org/doc/html/rfc7616#section-3.9.1
|
||||||
|
|
||||||
|
def mock_get_client_nonce(nonce_count: int, nonce: bytes) -> bytes:
|
||||||
|
return "f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ".encode()
|
||||||
|
|
||||||
|
auth = httpx.DigestAuth(username="Mufasa", password="Circle of Life")
|
||||||
|
monkeypatch.setattr(auth, "_get_client_nonce", mock_get_client_nonce)
|
||||||
|
|
||||||
|
request = httpx.Request("GET", "https://www.example.com/dir/index.html")
|
||||||
|
|
||||||
|
# The initial request should not include an auth header.
|
||||||
|
flow = auth.sync_auth_flow(request)
|
||||||
|
request = next(flow)
|
||||||
|
assert "Authorization" not in request.headers
|
||||||
|
|
||||||
|
# If a 401 response is returned, then a digest auth request is made.
|
||||||
|
headers = {
|
||||||
|
"WWW-Authenticate": (
|
||||||
|
'Digest realm="http-auth@example.org", '
|
||||||
|
'qop="auth, auth-int", '
|
||||||
|
"algorithm=MD5, "
|
||||||
|
'nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", '
|
||||||
|
'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
response = httpx.Response(
|
||||||
|
content=b"Auth required", status_code=401, headers=headers, request=request
|
||||||
|
)
|
||||||
|
request = flow.send(response)
|
||||||
|
assert request.headers["Authorization"].startswith("Digest")
|
||||||
|
assert 'username="Mufasa"' in request.headers["Authorization"]
|
||||||
|
assert 'realm="http-auth@example.org"' in request.headers["Authorization"]
|
||||||
|
assert 'uri="/dir/index.html"' in request.headers["Authorization"]
|
||||||
|
assert "algorithm=MD5" in request.headers["Authorization"]
|
||||||
|
assert (
|
||||||
|
'nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v"'
|
||||||
|
in request.headers["Authorization"]
|
||||||
|
)
|
||||||
|
assert "nc=00000001" in request.headers["Authorization"]
|
||||||
|
assert (
|
||||||
|
'cnonce="f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ"'
|
||||||
|
in request.headers["Authorization"]
|
||||||
|
)
|
||||||
|
assert "qop=auth" in request.headers["Authorization"]
|
||||||
|
assert (
|
||||||
|
'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"'
|
||||||
|
in request.headers["Authorization"]
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
'response="8ca523f5e9506fed4657c9700eebdbec"'
|
||||||
|
in request.headers["Authorization"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# No other requests are made.
|
||||||
|
response = httpx.Response(content=b"Hello, world!", status_code=200)
|
||||||
|
with pytest.raises(StopIteration):
|
||||||
|
flow.send(response)
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_auth_rfc_7616_sha_256(monkeypatch):
|
||||||
|
# Example from https://datatracker.ietf.org/doc/html/rfc7616#section-3.9.1
|
||||||
|
|
||||||
|
def mock_get_client_nonce(nonce_count: int, nonce: bytes) -> bytes:
|
||||||
|
return "f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ".encode()
|
||||||
|
|
||||||
|
auth = httpx.DigestAuth(username="Mufasa", password="Circle of Life")
|
||||||
|
monkeypatch.setattr(auth, "_get_client_nonce", mock_get_client_nonce)
|
||||||
|
|
||||||
|
request = httpx.Request("GET", "https://www.example.com/dir/index.html")
|
||||||
|
|
||||||
|
# The initial request should not include an auth header.
|
||||||
|
flow = auth.sync_auth_flow(request)
|
||||||
|
request = next(flow)
|
||||||
|
assert "Authorization" not in request.headers
|
||||||
|
|
||||||
|
# If a 401 response is returned, then a digest auth request is made.
|
||||||
|
headers = {
|
||||||
|
"WWW-Authenticate": (
|
||||||
|
'Digest realm="http-auth@example.org", '
|
||||||
|
'qop="auth, auth-int", '
|
||||||
|
"algorithm=SHA-256, "
|
||||||
|
'nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", '
|
||||||
|
'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
response = httpx.Response(
|
||||||
|
content=b"Auth required", status_code=401, headers=headers, request=request
|
||||||
|
)
|
||||||
|
request = flow.send(response)
|
||||||
|
assert request.headers["Authorization"].startswith("Digest")
|
||||||
|
assert 'username="Mufasa"' in request.headers["Authorization"]
|
||||||
|
assert 'realm="http-auth@example.org"' in request.headers["Authorization"]
|
||||||
|
assert 'uri="/dir/index.html"' in request.headers["Authorization"]
|
||||||
|
assert "algorithm=SHA-256" in request.headers["Authorization"]
|
||||||
|
assert (
|
||||||
|
'nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v"'
|
||||||
|
in request.headers["Authorization"]
|
||||||
|
)
|
||||||
|
assert "nc=00000001" in request.headers["Authorization"]
|
||||||
|
assert (
|
||||||
|
'cnonce="f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ"'
|
||||||
|
in request.headers["Authorization"]
|
||||||
|
)
|
||||||
|
assert "qop=auth" in request.headers["Authorization"]
|
||||||
|
assert (
|
||||||
|
'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"'
|
||||||
|
in request.headers["Authorization"]
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
'response="753927fa0e85d155564e2e272a28d1802ca10daf4496794697cf8db5856cb6c1"'
|
||||||
|
in request.headers["Authorization"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# No other requests are made.
|
||||||
|
response = httpx.Response(content=b"Hello, world!", status_code=200)
|
||||||
|
with pytest.raises(StopIteration):
|
||||||
|
flow.send(response)
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import os
|
|
||||||
import ssl
|
import ssl
|
||||||
import sys
|
import typing
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import certifi
|
import certifi
|
||||||
@ -15,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
|
||||||
|
|
||||||
@ -60,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
|
||||||
|
|
||||||
@ -71,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():
|
||||||
@ -87,22 +79,19 @@ 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
|
||||||
|
|
||||||
|
|
||||||
def test_limits_repr():
|
def test_limits_repr():
|
||||||
limits = httpx.Limits(max_connections=100)
|
limits = httpx.Limits(max_connections=100)
|
||||||
expected = "Limits(max_connections=100, max_keepalive_connections=None, keepalive_expiry=5.0)"
|
expected = (
|
||||||
|
"Limits(max_connections=100, max_keepalive_connections=None,"
|
||||||
|
" keepalive_expiry=5.0)"
|
||||||
|
)
|
||||||
assert repr(limits) == expected
|
assert repr(limits) == expected
|
||||||
|
|
||||||
|
|
||||||
@ -172,34 +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",
|
|
||||||
)
|
|
||||||
@pytest.mark.skipif(sys.version_info < (3, 8), reason="requires python3.8 or higher")
|
|
||||||
def test_ssl_config_support_for_keylog_file(tmpdir, monkeypatch): # pragma: no cover
|
|
||||||
if sys.version_info > (3, 8):
|
|
||||||
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")
|
||||||
|
|
||||||
|
|||||||
@ -15,7 +15,7 @@ async def test_empty_content():
|
|||||||
assert isinstance(request.stream, httpx.SyncByteStream)
|
assert isinstance(request.stream, httpx.SyncByteStream)
|
||||||
assert isinstance(request.stream, httpx.AsyncByteStream)
|
assert isinstance(request.stream, httpx.AsyncByteStream)
|
||||||
|
|
||||||
sync_content = b"".join([part for part in request.stream])
|
sync_content = b"".join(list(request.stream))
|
||||||
async_content = b"".join([part async for part in request.stream])
|
async_content = b"".join([part async for part in request.stream])
|
||||||
|
|
||||||
assert request.headers == {"Host": "www.example.com", "Content-Length": "0"}
|
assert request.headers == {"Host": "www.example.com", "Content-Length": "0"}
|
||||||
@ -29,7 +29,7 @@ async def test_bytes_content():
|
|||||||
assert isinstance(request.stream, typing.Iterable)
|
assert isinstance(request.stream, typing.Iterable)
|
||||||
assert isinstance(request.stream, typing.AsyncIterable)
|
assert isinstance(request.stream, typing.AsyncIterable)
|
||||||
|
|
||||||
sync_content = b"".join([part for part in request.stream])
|
sync_content = b"".join(list(request.stream))
|
||||||
async_content = b"".join([part async for part in request.stream])
|
async_content = b"".join([part async for part in request.stream])
|
||||||
|
|
||||||
assert request.headers == {"Host": "www.example.com", "Content-Length": "13"}
|
assert request.headers == {"Host": "www.example.com", "Content-Length": "13"}
|
||||||
@ -42,7 +42,7 @@ async def test_bytes_content():
|
|||||||
assert isinstance(request.stream, typing.Iterable)
|
assert isinstance(request.stream, typing.Iterable)
|
||||||
assert isinstance(request.stream, typing.AsyncIterable)
|
assert isinstance(request.stream, typing.AsyncIterable)
|
||||||
|
|
||||||
sync_content = b"".join([part for part in request.stream])
|
sync_content = b"".join(list(request.stream))
|
||||||
async_content = b"".join([part async for part in request.stream])
|
async_content = b"".join([part async for part in request.stream])
|
||||||
|
|
||||||
assert request.headers == {"Host": "www.example.com", "Content-Length": "13"}
|
assert request.headers == {"Host": "www.example.com", "Content-Length": "13"}
|
||||||
@ -56,7 +56,7 @@ async def test_bytesio_content():
|
|||||||
assert isinstance(request.stream, typing.Iterable)
|
assert isinstance(request.stream, typing.Iterable)
|
||||||
assert not isinstance(request.stream, typing.AsyncIterable)
|
assert not isinstance(request.stream, typing.AsyncIterable)
|
||||||
|
|
||||||
content = b"".join([part for part in request.stream])
|
content = b"".join(list(request.stream))
|
||||||
|
|
||||||
assert request.headers == {"Host": "www.example.com", "Content-Length": "13"}
|
assert request.headers == {"Host": "www.example.com", "Content-Length": "13"}
|
||||||
assert content == b"Hello, world!"
|
assert content == b"Hello, world!"
|
||||||
@ -100,7 +100,7 @@ async def test_iterator_content():
|
|||||||
assert isinstance(request.stream, typing.Iterable)
|
assert isinstance(request.stream, typing.Iterable)
|
||||||
assert not isinstance(request.stream, typing.AsyncIterable)
|
assert not isinstance(request.stream, typing.AsyncIterable)
|
||||||
|
|
||||||
content = b"".join([part for part in request.stream])
|
content = b"".join(list(request.stream))
|
||||||
|
|
||||||
assert request.headers == {
|
assert request.headers == {
|
||||||
"Host": "www.example.com",
|
"Host": "www.example.com",
|
||||||
@ -109,7 +109,7 @@ async def test_iterator_content():
|
|||||||
assert content == b"Hello, world!"
|
assert content == b"Hello, world!"
|
||||||
|
|
||||||
with pytest.raises(httpx.StreamConsumed):
|
with pytest.raises(httpx.StreamConsumed):
|
||||||
[part for part in request.stream]
|
list(request.stream)
|
||||||
|
|
||||||
# Support 'data' for compat with requests.
|
# Support 'data' for compat with requests.
|
||||||
with pytest.warns(DeprecationWarning):
|
with pytest.warns(DeprecationWarning):
|
||||||
@ -117,7 +117,7 @@ async def test_iterator_content():
|
|||||||
assert isinstance(request.stream, typing.Iterable)
|
assert isinstance(request.stream, typing.Iterable)
|
||||||
assert not isinstance(request.stream, typing.AsyncIterable)
|
assert not isinstance(request.stream, typing.AsyncIterable)
|
||||||
|
|
||||||
content = b"".join([part for part in request.stream])
|
content = b"".join(list(request.stream))
|
||||||
|
|
||||||
assert request.headers == {
|
assert request.headers == {
|
||||||
"Host": "www.example.com",
|
"Host": "www.example.com",
|
||||||
@ -168,16 +168,16 @@ async def test_json_content():
|
|||||||
assert isinstance(request.stream, typing.Iterable)
|
assert isinstance(request.stream, typing.Iterable)
|
||||||
assert isinstance(request.stream, typing.AsyncIterable)
|
assert isinstance(request.stream, typing.AsyncIterable)
|
||||||
|
|
||||||
sync_content = b"".join([part for part in request.stream])
|
sync_content = b"".join(list(request.stream))
|
||||||
async_content = b"".join([part async for part in request.stream])
|
async_content = b"".join([part async for part in request.stream])
|
||||||
|
|
||||||
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
|
||||||
@ -186,7 +186,7 @@ async def test_urlencoded_content():
|
|||||||
assert isinstance(request.stream, typing.Iterable)
|
assert isinstance(request.stream, typing.Iterable)
|
||||||
assert isinstance(request.stream, typing.AsyncIterable)
|
assert isinstance(request.stream, typing.AsyncIterable)
|
||||||
|
|
||||||
sync_content = b"".join([part for part in request.stream])
|
sync_content = b"".join(list(request.stream))
|
||||||
async_content = b"".join([part async for part in request.stream])
|
async_content = b"".join([part async for part in request.stream])
|
||||||
|
|
||||||
assert request.headers == {
|
assert request.headers == {
|
||||||
@ -204,7 +204,7 @@ async def test_urlencoded_boolean():
|
|||||||
assert isinstance(request.stream, typing.Iterable)
|
assert isinstance(request.stream, typing.Iterable)
|
||||||
assert isinstance(request.stream, typing.AsyncIterable)
|
assert isinstance(request.stream, typing.AsyncIterable)
|
||||||
|
|
||||||
sync_content = b"".join([part for part in request.stream])
|
sync_content = b"".join(list(request.stream))
|
||||||
async_content = b"".join([part async for part in request.stream])
|
async_content = b"".join([part async for part in request.stream])
|
||||||
|
|
||||||
assert request.headers == {
|
assert request.headers == {
|
||||||
@ -222,7 +222,7 @@ async def test_urlencoded_none():
|
|||||||
assert isinstance(request.stream, typing.Iterable)
|
assert isinstance(request.stream, typing.Iterable)
|
||||||
assert isinstance(request.stream, typing.AsyncIterable)
|
assert isinstance(request.stream, typing.AsyncIterable)
|
||||||
|
|
||||||
sync_content = b"".join([part for part in request.stream])
|
sync_content = b"".join(list(request.stream))
|
||||||
async_content = b"".join([part async for part in request.stream])
|
async_content = b"".join([part async for part in request.stream])
|
||||||
|
|
||||||
assert request.headers == {
|
assert request.headers == {
|
||||||
@ -240,7 +240,7 @@ async def test_urlencoded_list():
|
|||||||
assert isinstance(request.stream, typing.Iterable)
|
assert isinstance(request.stream, typing.Iterable)
|
||||||
assert isinstance(request.stream, typing.AsyncIterable)
|
assert isinstance(request.stream, typing.AsyncIterable)
|
||||||
|
|
||||||
sync_content = b"".join([part for part in request.stream])
|
sync_content = b"".join(list(request.stream))
|
||||||
async_content = b"".join([part async for part in request.stream])
|
async_content = b"".join([part async for part in request.stream])
|
||||||
|
|
||||||
assert request.headers == {
|
assert request.headers == {
|
||||||
@ -265,7 +265,7 @@ async def test_multipart_files_content():
|
|||||||
assert isinstance(request.stream, typing.Iterable)
|
assert isinstance(request.stream, typing.Iterable)
|
||||||
assert isinstance(request.stream, typing.AsyncIterable)
|
assert isinstance(request.stream, typing.AsyncIterable)
|
||||||
|
|
||||||
sync_content = b"".join([part for part in request.stream])
|
sync_content = b"".join(list(request.stream))
|
||||||
async_content = b"".join([part async for part in request.stream])
|
async_content = b"".join([part async for part in request.stream])
|
||||||
|
|
||||||
assert request.headers == {
|
assert request.headers == {
|
||||||
@ -304,7 +304,7 @@ async def test_multipart_data_and_files_content():
|
|||||||
assert isinstance(request.stream, typing.Iterable)
|
assert isinstance(request.stream, typing.Iterable)
|
||||||
assert isinstance(request.stream, typing.AsyncIterable)
|
assert isinstance(request.stream, typing.AsyncIterable)
|
||||||
|
|
||||||
sync_content = b"".join([part for part in request.stream])
|
sync_content = b"".join(list(request.stream))
|
||||||
async_content = b"".join([part async for part in request.stream])
|
async_content = b"".join([part async for part in request.stream])
|
||||||
|
|
||||||
assert request.headers == {
|
assert request.headers == {
|
||||||
@ -348,7 +348,7 @@ async def test_empty_request():
|
|||||||
assert isinstance(request.stream, typing.Iterable)
|
assert isinstance(request.stream, typing.Iterable)
|
||||||
assert isinstance(request.stream, typing.AsyncIterable)
|
assert isinstance(request.stream, typing.AsyncIterable)
|
||||||
|
|
||||||
sync_content = b"".join([part for part in request.stream])
|
sync_content = b"".join(list(request.stream))
|
||||||
async_content = b"".join([part async for part in request.stream])
|
async_content = b"".join([part async for part in request.stream])
|
||||||
|
|
||||||
assert request.headers == {"Host": "www.example.com", "Content-Length": "0"}
|
assert request.headers == {"Host": "www.example.com", "Content-Length": "0"}
|
||||||
@ -375,7 +375,7 @@ async def test_multipart_multiple_files_single_input_content():
|
|||||||
assert isinstance(request.stream, typing.Iterable)
|
assert isinstance(request.stream, typing.Iterable)
|
||||||
assert isinstance(request.stream, typing.AsyncIterable)
|
assert isinstance(request.stream, typing.AsyncIterable)
|
||||||
|
|
||||||
sync_content = b"".join([part for part in request.stream])
|
sync_content = b"".join(list(request.stream))
|
||||||
async_content = b"".join([part async for part in request.stream])
|
async_content = b"".join([part async for part in request.stream])
|
||||||
|
|
||||||
assert request.headers == {
|
assert request.headers == {
|
||||||
@ -421,7 +421,7 @@ async def test_response_empty_content():
|
|||||||
assert isinstance(response.stream, typing.Iterable)
|
assert isinstance(response.stream, typing.Iterable)
|
||||||
assert isinstance(response.stream, typing.AsyncIterable)
|
assert isinstance(response.stream, typing.AsyncIterable)
|
||||||
|
|
||||||
sync_content = b"".join([part for part in response.stream])
|
sync_content = b"".join(list(response.stream))
|
||||||
async_content = b"".join([part async for part in response.stream])
|
async_content = b"".join([part async for part in response.stream])
|
||||||
|
|
||||||
assert response.headers == {}
|
assert response.headers == {}
|
||||||
@ -435,7 +435,7 @@ async def test_response_bytes_content():
|
|||||||
assert isinstance(response.stream, typing.Iterable)
|
assert isinstance(response.stream, typing.Iterable)
|
||||||
assert isinstance(response.stream, typing.AsyncIterable)
|
assert isinstance(response.stream, typing.AsyncIterable)
|
||||||
|
|
||||||
sync_content = b"".join([part for part in response.stream])
|
sync_content = b"".join(list(response.stream))
|
||||||
async_content = b"".join([part async for part in response.stream])
|
async_content = b"".join([part async for part in response.stream])
|
||||||
|
|
||||||
assert response.headers == {"Content-Length": "13"}
|
assert response.headers == {"Content-Length": "13"}
|
||||||
@ -453,13 +453,13 @@ async def test_response_iterator_content():
|
|||||||
assert isinstance(response.stream, typing.Iterable)
|
assert isinstance(response.stream, typing.Iterable)
|
||||||
assert not isinstance(response.stream, typing.AsyncIterable)
|
assert not isinstance(response.stream, typing.AsyncIterable)
|
||||||
|
|
||||||
content = b"".join([part for part in response.stream])
|
content = b"".join(list(response.stream))
|
||||||
|
|
||||||
assert response.headers == {"Transfer-Encoding": "chunked"}
|
assert response.headers == {"Transfer-Encoding": "chunked"}
|
||||||
assert content == b"Hello, world!"
|
assert content == b"Hello, world!"
|
||||||
|
|
||||||
with pytest.raises(httpx.StreamConsumed):
|
with pytest.raises(httpx.StreamConsumed):
|
||||||
[part for part in response.stream]
|
list(response.stream)
|
||||||
|
|
||||||
|
|
||||||
@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,9 +1,12 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import typing
|
import typing
|
||||||
import zlib
|
import zlib
|
||||||
|
|
||||||
import brotli
|
|
||||||
import chardet
|
import chardet
|
||||||
import pytest
|
import pytest
|
||||||
|
import zstandard as zstd
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@ -61,7 +64,7 @@ def test_gzip():
|
|||||||
|
|
||||||
def test_brotli():
|
def test_brotli():
|
||||||
body = b"test 123"
|
body = b"test 123"
|
||||||
compressed_body = brotli.compress(body)
|
compressed_body = b"\x8b\x03\x80test 123\x03"
|
||||||
|
|
||||||
headers = [(b"Content-Encoding", b"br")]
|
headers = [(b"Content-Encoding", b"br")]
|
||||||
response = httpx.Response(
|
response = httpx.Response(
|
||||||
@ -72,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"
|
||||||
|
|
||||||
@ -94,7 +163,7 @@ def test_multi():
|
|||||||
|
|
||||||
def test_multi_with_identity():
|
def test_multi_with_identity():
|
||||||
body = b"test 123"
|
body = b"test 123"
|
||||||
compressed_body = brotli.compress(body)
|
compressed_body = b"\x8b\x03\x80test 123\x03"
|
||||||
|
|
||||||
headers = [(b"Content-Encoding", b"br, identity")]
|
headers = [(b"Content-Encoding", b"br, identity")]
|
||||||
response = httpx.Response(
|
response = httpx.Response(
|
||||||
@ -153,8 +222,7 @@ def test_decoders_empty_cases(header_value):
|
|||||||
@pytest.mark.parametrize("header_value", (b"deflate", b"gzip", b"br"))
|
@pytest.mark.parametrize("header_value", (b"deflate", b"gzip", b"br"))
|
||||||
def test_decoding_errors(header_value):
|
def test_decoding_errors(header_value):
|
||||||
headers = [(b"Content-Encoding", header_value)]
|
headers = [(b"Content-Encoding", header_value)]
|
||||||
body = b"test 123"
|
compressed_body = b"invalid"
|
||||||
compressed_body = brotli.compress(body)[3:]
|
|
||||||
with pytest.raises(httpx.DecodingError):
|
with pytest.raises(httpx.DecodingError):
|
||||||
request = httpx.Request("GET", "https://example.org")
|
request = httpx.Request("GET", "https://example.org")
|
||||||
httpx.Response(200, headers=headers, content=compressed_body, request=request)
|
httpx.Response(200, headers=headers, content=compressed_body, request=request)
|
||||||
@ -221,6 +289,17 @@ def test_text_decoder_empty_cases():
|
|||||||
assert response.text == ""
|
assert response.text == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
["data", "expected"],
|
||||||
|
[((b"Hello,", b" world!"), ["Hello,", " world!"])],
|
||||||
|
)
|
||||||
|
def test_streaming_text_decoder(
|
||||||
|
data: typing.Iterable[bytes], expected: list[str]
|
||||||
|
) -> None:
|
||||||
|
response = httpx.Response(200, content=iter(data))
|
||||||
|
assert list(response.iter_text()) == expected
|
||||||
|
|
||||||
|
|
||||||
def test_line_decoder_nl():
|
def test_line_decoder_nl():
|
||||||
response = httpx.Response(200, content=[b""])
|
response = httpx.Response(200, content=[b""])
|
||||||
assert list(response.iter_lines()) == []
|
assert list(response.iter_lines()) == []
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
import httpcore
|
import httpcore
|
||||||
@ -34,7 +36,7 @@ def test_httpcore_all_exceptions_mapped() -> None:
|
|||||||
pytest.fail(f"Unmapped httpcore exceptions: {unmapped_exceptions}")
|
pytest.fail(f"Unmapped httpcore exceptions: {unmapped_exceptions}")
|
||||||
|
|
||||||
|
|
||||||
def test_httpcore_exception_mapping(server: "TestServer") -> None:
|
def test_httpcore_exception_mapping(server: TestServer) -> None:
|
||||||
"""
|
"""
|
||||||
HTTPCore exception mapping works as expected.
|
HTTPCore exception mapping works as expected.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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=",
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import tempfile
|
import tempfile
|
||||||
import typing
|
import typing
|
||||||
@ -148,7 +150,7 @@ def test_multipart_file_tuple():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("file_content_type", [None, "text/plain"])
|
@pytest.mark.parametrize("file_content_type", [None, "text/plain"])
|
||||||
def test_multipart_file_tuple_headers(file_content_type: typing.Optional[str]) -> None:
|
def test_multipart_file_tuple_headers(file_content_type: str | None) -> None:
|
||||||
file_name = "test.txt"
|
file_name = "test.txt"
|
||||||
file_content = io.BytesIO(b"<file content>")
|
file_content = io.BytesIO(b"<file content>")
|
||||||
file_headers = {"Expires": "0"}
|
file_headers = {"Expires": "0"}
|
||||||
@ -174,7 +176,10 @@ def test_multipart_file_tuple_headers(file_content_type: typing.Optional[str]) -
|
|||||||
|
|
||||||
|
|
||||||
def test_multipart_headers_include_content_type() -> None:
|
def test_multipart_headers_include_content_type() -> None:
|
||||||
"""Content-Type from 4th tuple parameter (headers) should override the 3rd parameter (content_type)"""
|
"""
|
||||||
|
Content-Type from 4th tuple parameter (headers) should
|
||||||
|
override the 3rd parameter (content_type)
|
||||||
|
"""
|
||||||
file_name = "test.txt"
|
file_name = "test.txt"
|
||||||
file_content = io.BytesIO(b"<file content>")
|
file_content = io.BytesIO(b"<file content>")
|
||||||
file_content_type = "text/plain"
|
file_content_type = "text/plain"
|
||||||
@ -457,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"))
|
||||||
|
)
|
||||||
|
|||||||
@ -1,279 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse():
|
|
||||||
url = httpx.URL("https://www.example.com/")
|
|
||||||
|
|
||||||
assert url.scheme == "https"
|
|
||||||
assert url.userinfo == b""
|
|
||||||
assert url.netloc == b"www.example.com"
|
|
||||||
assert url.host == "www.example.com"
|
|
||||||
assert url.port is None
|
|
||||||
assert url.path == "/"
|
|
||||||
assert url.query == b""
|
|
||||||
assert url.fragment == ""
|
|
||||||
|
|
||||||
assert str(url) == "https://www.example.com/"
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_no_scheme():
|
|
||||||
url = httpx.URL("://example.com")
|
|
||||||
assert url.scheme == ""
|
|
||||||
assert url.host == "example.com"
|
|
||||||
assert url.path == "/"
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_no_authority():
|
|
||||||
url = httpx.URL("http://")
|
|
||||||
assert url.scheme == "http"
|
|
||||||
assert url.host == ""
|
|
||||||
assert url.path == "/"
|
|
||||||
|
|
||||||
|
|
||||||
# Tests for different host types
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_valid_host():
|
|
||||||
url = httpx.URL("https://example.com/")
|
|
||||||
assert url.host == "example.com"
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_normalized_host():
|
|
||||||
url = httpx.URL("https://EXAMPLE.com/")
|
|
||||||
assert url.host == "example.com"
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_valid_ipv4():
|
|
||||||
url = httpx.URL("https://1.2.3.4/")
|
|
||||||
assert url.host == "1.2.3.4"
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_invalid_ipv4():
|
|
||||||
with pytest.raises(httpx.InvalidURL) as exc:
|
|
||||||
httpx.URL("https://999.999.999.999/")
|
|
||||||
assert str(exc.value) == "Invalid IPv4 address: '999.999.999.999'"
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_valid_ipv6():
|
|
||||||
url = httpx.URL("https://[2001:db8::ff00:42:8329]/")
|
|
||||||
assert url.host == "2001:db8::ff00:42:8329"
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_invalid_ipv6():
|
|
||||||
with pytest.raises(httpx.InvalidURL) as exc:
|
|
||||||
httpx.URL("https://[2001]/")
|
|
||||||
assert str(exc.value) == "Invalid IPv6 address: '[2001]'"
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_unescaped_idna_host():
|
|
||||||
url = httpx.URL("https://中国.icom.museum/")
|
|
||||||
assert url.raw_host == b"xn--fiqs8s.icom.museum"
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_escaped_idna_host():
|
|
||||||
url = httpx.URL("https://xn--fiqs8s.icom.museum/")
|
|
||||||
assert url.raw_host == b"xn--fiqs8s.icom.museum"
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_invalid_idna_host():
|
|
||||||
with pytest.raises(httpx.InvalidURL) as exc:
|
|
||||||
httpx.URL("https://☃.com/")
|
|
||||||
assert str(exc.value) == "Invalid IDNA hostname: '☃.com'"
|
|
||||||
|
|
||||||
|
|
||||||
# Tests for different port types
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_valid_port():
|
|
||||||
url = httpx.URL("https://example.com:123/")
|
|
||||||
assert url.port == 123
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_normalized_port():
|
|
||||||
# If the port matches the scheme default it is normalized to None.
|
|
||||||
url = httpx.URL("https://example.com:443/")
|
|
||||||
assert url.port is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_invalid_port():
|
|
||||||
with pytest.raises(httpx.InvalidURL) as exc:
|
|
||||||
httpx.URL("https://example.com:abc/")
|
|
||||||
assert str(exc.value) == "Invalid port: 'abc'"
|
|
||||||
|
|
||||||
|
|
||||||
# Tests for path handling
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_normalized_path():
|
|
||||||
url = httpx.URL("https://example.com/abc/def/../ghi/./jkl")
|
|
||||||
assert url.path == "/abc/ghi/jkl"
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_escaped_path():
|
|
||||||
url = httpx.URL("https://example.com/ /🌟/")
|
|
||||||
assert url.raw_path == b"/%20/%F0%9F%8C%9F/"
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_leading_dot_prefix_on_absolute_url():
|
|
||||||
url = httpx.URL("https://example.com/../abc")
|
|
||||||
assert url.path == "/abc"
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_leading_dot_prefix_on_relative_url():
|
|
||||||
url = httpx.URL("../abc")
|
|
||||||
assert url.path == "../abc"
|
|
||||||
|
|
||||||
|
|
||||||
# Tests for optional percent encoding
|
|
||||||
|
|
||||||
|
|
||||||
def test_param_requires_encoding():
|
|
||||||
url = httpx.URL("http://webservice", params={"u": "with spaces"})
|
|
||||||
assert str(url) == "http://webservice?u=with%20spaces"
|
|
||||||
|
|
||||||
|
|
||||||
def test_param_does_not_require_encoding():
|
|
||||||
url = httpx.URL("http://webservice", params={"u": "with%20spaces"})
|
|
||||||
assert str(url) == "http://webservice?u=with%20spaces"
|
|
||||||
|
|
||||||
|
|
||||||
def test_param_with_existing_escape_requires_encoding():
|
|
||||||
url = httpx.URL("http://webservice", params={"u": "http://example.com?q=foo%2Fa"})
|
|
||||||
assert str(url) == "http://webservice?u=http%3A//example.com%3Fq%3Dfoo%252Fa"
|
|
||||||
|
|
||||||
|
|
||||||
# Tests for invalid URLs
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_excessively_long_url():
|
|
||||||
with pytest.raises(httpx.InvalidURL) as exc:
|
|
||||||
httpx.URL("https://www.example.com/" + "x" * 100_000)
|
|
||||||
assert str(exc.value) == "URL too long"
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_excessively_long_component():
|
|
||||||
with pytest.raises(httpx.InvalidURL) as exc:
|
|
||||||
httpx.URL("https://www.example.com", path="/" + "x" * 100_000)
|
|
||||||
assert str(exc.value) == "URL component 'path' too long"
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_non_printing_character_in_url():
|
|
||||||
with pytest.raises(httpx.InvalidURL) as exc:
|
|
||||||
httpx.URL("https://www.example.com/\n")
|
|
||||||
assert str(exc.value) == "Invalid non-printable ASCII character in URL"
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_non_printing_character_in_component():
|
|
||||||
with pytest.raises(httpx.InvalidURL) as exc:
|
|
||||||
httpx.URL("https://www.example.com", path="/\n")
|
|
||||||
assert (
|
|
||||||
str(exc.value)
|
|
||||||
== "Invalid non-printable ASCII character in URL component 'path'"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Test for urlparse components
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_with_components():
|
|
||||||
url = httpx.URL(scheme="https", host="www.example.com", path="/")
|
|
||||||
|
|
||||||
assert url.scheme == "https"
|
|
||||||
assert url.userinfo == b""
|
|
||||||
assert url.host == "www.example.com"
|
|
||||||
assert url.port is None
|
|
||||||
assert url.path == "/"
|
|
||||||
assert url.query == b""
|
|
||||||
assert url.fragment == ""
|
|
||||||
|
|
||||||
assert str(url) == "https://www.example.com/"
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_with_invalid_component():
|
|
||||||
with pytest.raises(TypeError) as exc:
|
|
||||||
httpx.URL(scheme="https", host="www.example.com", incorrect="/")
|
|
||||||
assert str(exc.value) == "'incorrect' is an invalid keyword argument for URL()"
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_with_invalid_scheme():
|
|
||||||
with pytest.raises(httpx.InvalidURL) as exc:
|
|
||||||
httpx.URL(scheme="~", host="www.example.com", path="/")
|
|
||||||
assert str(exc.value) == "Invalid URL component 'scheme'"
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_with_invalid_path():
|
|
||||||
with pytest.raises(httpx.InvalidURL) as exc:
|
|
||||||
httpx.URL(scheme="https", host="www.example.com", path="abc")
|
|
||||||
assert str(exc.value) == "For absolute URLs, path must be empty or begin with '/'"
|
|
||||||
|
|
||||||
with pytest.raises(httpx.InvalidURL) as exc:
|
|
||||||
httpx.URL(path="//abc")
|
|
||||||
assert (
|
|
||||||
str(exc.value)
|
|
||||||
== "URLs with no authority component cannot have a path starting with '//'"
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(httpx.InvalidURL) as exc:
|
|
||||||
httpx.URL(path=":abc")
|
|
||||||
assert (
|
|
||||||
str(exc.value)
|
|
||||||
== "URLs with no scheme component cannot have a path starting with ':'"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlparse_with_relative_path():
|
|
||||||
# This path would be invalid for an absolute URL, but is valid as a relative URL.
|
|
||||||
url = httpx.URL(path="abc")
|
|
||||||
assert url.path == "abc"
|
|
||||||
|
|
||||||
|
|
||||||
# Tests for accessing and modifying `urlparse` results.
|
|
||||||
|
|
||||||
|
|
||||||
def test_copy_with():
|
|
||||||
url = httpx.URL("https://www.example.com/")
|
|
||||||
assert str(url) == "https://www.example.com/"
|
|
||||||
|
|
||||||
url = url.copy_with()
|
|
||||||
assert str(url) == "https://www.example.com/"
|
|
||||||
|
|
||||||
url = url.copy_with(scheme="http")
|
|
||||||
assert str(url) == "http://www.example.com/"
|
|
||||||
|
|
||||||
url = url.copy_with(netloc=b"example.com")
|
|
||||||
assert str(url) == "http://example.com/"
|
|
||||||
|
|
||||||
url = url.copy_with(path="/abc")
|
|
||||||
assert str(url) == "http://example.com/abc"
|
|
||||||
|
|
||||||
|
|
||||||
# Tests for percent encoding across path, query, and fragement...
|
|
||||||
|
|
||||||
|
|
||||||
def test_path_percent_encoding():
|
|
||||||
# Test percent encoding for SUB_DELIMS ALPHA NUM and allowable GEN_DELIMS
|
|
||||||
url = httpx.URL("https://example.com/!$&'()*+,;= abc ABC 123 :/[]@")
|
|
||||||
assert url.raw_path == b"/!$&'()*+,;=%20abc%20ABC%20123%20:/[]@"
|
|
||||||
assert url.path == "/!$&'()*+,;= abc ABC 123 :/[]@"
|
|
||||||
assert url.query == b""
|
|
||||||
assert url.fragment == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_query_percent_encoding():
|
|
||||||
# Test percent encoding for SUB_DELIMS ALPHA NUM and allowable GEN_DELIMS
|
|
||||||
url = httpx.URL("https://example.com/?!$&'()*+,;= abc ABC 123 :/[]@" + "?")
|
|
||||||
assert url.raw_path == b"/?!$&'()*+,;=%20abc%20ABC%20123%20:/[]@?"
|
|
||||||
assert url.path == "/"
|
|
||||||
assert url.query == b"!$&'()*+,;=%20abc%20ABC%20123%20:/[]@?"
|
|
||||||
assert url.fragment == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_fragment_percent_encoding():
|
|
||||||
# Test percent encoding for SUB_DELIMS ALPHA NUM and allowable GEN_DELIMS
|
|
||||||
url = httpx.URL("https://example.com/#!$&'()*+,;= abc ABC 123 :/[]@" + "?#")
|
|
||||||
assert url.raw_path == b"/"
|
|
||||||
assert url.path == "/"
|
|
||||||
assert url.query == b""
|
|
||||||
assert url.fragment == "!$&'()*+,;= abc ABC 123 :/[]@?#"
|
|
||||||
@ -1,23 +1,12 @@
|
|||||||
|
import json
|
||||||
import logging
|
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,
|
|
||||||
guess_json_utf,
|
|
||||||
is_https_redirect,
|
|
||||||
obfuscate_sensitive_headers,
|
|
||||||
parse_header_links,
|
|
||||||
same_origin,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .common import TESTS_DIR
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@ -34,12 +23,16 @@ from .common import TESTS_DIR
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
def test_encoded(encoding):
|
def test_encoded(encoding):
|
||||||
data = "{}".encode(encoding)
|
content = '{"abc": 123}'.encode(encoding)
|
||||||
assert guess_json_utf(data) == encoding
|
response = httpx.Response(200, content=content)
|
||||||
|
assert response.json() == {"abc": 123}
|
||||||
|
|
||||||
|
|
||||||
def test_bad_utf_like_encoding():
|
def test_bad_utf_like_encoding():
|
||||||
assert guess_json_utf(b"\x00\x00\x00\x00") is None
|
content = b"\x00\x00\x00\x00"
|
||||||
|
response = httpx.Response(200, content=content)
|
||||||
|
with pytest.raises(json.decoder.JSONDecodeError):
|
||||||
|
response.json()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@ -52,31 +45,9 @@ def test_bad_utf_like_encoding():
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
def test_guess_by_bom(encoding, expected):
|
def test_guess_by_bom(encoding, expected):
|
||||||
data = "\ufeff{}".encode(encoding)
|
content = '\ufeff{"abc": 123}'.encode(encoding)
|
||||||
assert guess_json_utf(data) == expected
|
response = httpx.Response(200, content=content)
|
||||||
|
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):
|
|
||||||
assert parse_header_links(value) == expected
|
|
||||||
|
|
||||||
|
|
||||||
def test_logging_request(server, caplog):
|
def test_logging_request(server, caplog):
|
||||||
@ -104,7 +75,8 @@ def test_logging_redirect_chain(server, caplog):
|
|||||||
(
|
(
|
||||||
"httpx",
|
"httpx",
|
||||||
logging.INFO,
|
logging.INFO,
|
||||||
'HTTP Request: GET http://127.0.0.1:8000/redirect_301 "HTTP/1.1 301 Moved Permanently"',
|
"HTTP Request: GET http://127.0.0.1:8000/redirect_301"
|
||||||
|
' "HTTP/1.1 301 Moved Permanently"',
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"httpx",
|
"httpx",
|
||||||
@ -114,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"],
|
||||||
[
|
[
|
||||||
@ -191,6 +103,7 @@ def test_get_ssl_cert_file():
|
|||||||
({"no_proxy": "localhost"}, {"all://localhost": None}),
|
({"no_proxy": "localhost"}, {"all://localhost": None}),
|
||||||
({"no_proxy": "github.com"}, {"all://*github.com": None}),
|
({"no_proxy": "github.com"}, {"all://*github.com": None}),
|
||||||
({"no_proxy": ".github.com"}, {"all://*.github.com": None}),
|
({"no_proxy": ".github.com"}, {"all://*.github.com": None}),
|
||||||
|
({"no_proxy": "http://github.com"}, {"http://github.com": None}),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_get_environment_proxies(environment, proxies):
|
def test_get_environment_proxies(environment, proxies):
|
||||||
@ -199,51 +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):
|
|
||||||
bytes_headers = [(k.encode(), v.encode()) for k, v in headers]
|
|
||||||
bytes_output = [(k.encode(), v.encode()) for k, v in output]
|
|
||||||
assert list(obfuscate_sensitive_headers(headers)) == output
|
|
||||||
assert list(obfuscate_sensitive_headers(bytes_headers)) == bytes_output
|
|
||||||
|
|
||||||
|
|
||||||
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"],
|
||||||
[
|
[
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import typing
|
import typing
|
||||||
import wsgiref.validate
|
import wsgiref.validate
|
||||||
@ -12,7 +14,7 @@ if typing.TYPE_CHECKING: # pragma: no cover
|
|||||||
from _typeshed.wsgi import StartResponse, WSGIApplication, WSGIEnvironment
|
from _typeshed.wsgi import StartResponse, WSGIApplication, WSGIEnvironment
|
||||||
|
|
||||||
|
|
||||||
def application_factory(output: typing.Iterable[bytes]) -> "WSGIApplication":
|
def application_factory(output: typing.Iterable[bytes]) -> WSGIApplication:
|
||||||
def application(environ, start_response):
|
def application(environ, start_response):
|
||||||
status = "200 OK"
|
status = "200 OK"
|
||||||
|
|
||||||
@ -29,7 +31,7 @@ def application_factory(output: typing.Iterable[bytes]) -> "WSGIApplication":
|
|||||||
|
|
||||||
|
|
||||||
def echo_body(
|
def echo_body(
|
||||||
environ: "WSGIEnvironment", start_response: "StartResponse"
|
environ: WSGIEnvironment, start_response: StartResponse
|
||||||
) -> typing.Iterable[bytes]:
|
) -> typing.Iterable[bytes]:
|
||||||
status = "200 OK"
|
status = "200 OK"
|
||||||
output = environ["wsgi.input"].read()
|
output = environ["wsgi.input"].read()
|
||||||
@ -44,7 +46,7 @@ def echo_body(
|
|||||||
|
|
||||||
|
|
||||||
def echo_body_with_response_stream(
|
def echo_body_with_response_stream(
|
||||||
environ: "WSGIEnvironment", start_response: "StartResponse"
|
environ: WSGIEnvironment, start_response: StartResponse
|
||||||
) -> typing.Iterable[bytes]:
|
) -> typing.Iterable[bytes]:
|
||||||
status = "200 OK"
|
status = "200 OK"
|
||||||
|
|
||||||
@ -63,9 +65,9 @@ def echo_body_with_response_stream(
|
|||||||
|
|
||||||
|
|
||||||
def raise_exc(
|
def raise_exc(
|
||||||
environ: "WSGIEnvironment",
|
environ: WSGIEnvironment,
|
||||||
start_response: "StartResponse",
|
start_response: StartResponse,
|
||||||
exc: typing.Type[Exception] = ValueError,
|
exc: type[Exception] = ValueError,
|
||||||
) -> typing.Iterable[bytes]:
|
) -> typing.Iterable[bytes]:
|
||||||
status = "500 Server Error"
|
status = "500 Server Error"
|
||||||
output = b"Nope!"
|
output = b"Nope!"
|
||||||
@ -90,41 +92,47 @@ def log_to_wsgi_log_buffer(environ, start_response):
|
|||||||
|
|
||||||
|
|
||||||
def test_wsgi():
|
def test_wsgi():
|
||||||
client = httpx.Client(app=application_factory([b"Hello, World!"]))
|
transport = httpx.WSGITransport(app=application_factory([b"Hello, World!"]))
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
response = client.get("http://www.example.org/")
|
response = client.get("http://www.example.org/")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.text == "Hello, World!"
|
assert response.text == "Hello, World!"
|
||||||
|
|
||||||
|
|
||||||
def test_wsgi_upload():
|
def test_wsgi_upload():
|
||||||
client = httpx.Client(app=echo_body)
|
transport = httpx.WSGITransport(app=echo_body)
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
response = client.post("http://www.example.org/", content=b"example")
|
response = client.post("http://www.example.org/", content=b"example")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.text == "example"
|
assert response.text == "example"
|
||||||
|
|
||||||
|
|
||||||
def test_wsgi_upload_with_response_stream():
|
def test_wsgi_upload_with_response_stream():
|
||||||
client = httpx.Client(app=echo_body_with_response_stream)
|
transport = httpx.WSGITransport(app=echo_body_with_response_stream)
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
response = client.post("http://www.example.org/", content=b"example")
|
response = client.post("http://www.example.org/", content=b"example")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.text == "example"
|
assert response.text == "example"
|
||||||
|
|
||||||
|
|
||||||
def test_wsgi_exc():
|
def test_wsgi_exc():
|
||||||
client = httpx.Client(app=raise_exc)
|
transport = httpx.WSGITransport(app=raise_exc)
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
client.get("http://www.example.org/")
|
client.get("http://www.example.org/")
|
||||||
|
|
||||||
|
|
||||||
def test_wsgi_http_error():
|
def test_wsgi_http_error():
|
||||||
client = httpx.Client(app=partial(raise_exc, exc=RuntimeError))
|
transport = httpx.WSGITransport(app=partial(raise_exc, exc=RuntimeError))
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
with pytest.raises(RuntimeError):
|
with pytest.raises(RuntimeError):
|
||||||
client.get("http://www.example.org/")
|
client.get("http://www.example.org/")
|
||||||
|
|
||||||
|
|
||||||
def test_wsgi_generator():
|
def test_wsgi_generator():
|
||||||
output = [b"", b"", b"Some content", b" and more content"]
|
output = [b"", b"", b"Some content", b" and more content"]
|
||||||
client = httpx.Client(app=application_factory(output))
|
transport = httpx.WSGITransport(app=application_factory(output))
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
response = client.get("http://www.example.org/")
|
response = client.get("http://www.example.org/")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.text == "Some content and more content"
|
assert response.text == "Some content and more content"
|
||||||
@ -132,7 +140,8 @@ def test_wsgi_generator():
|
|||||||
|
|
||||||
def test_wsgi_generator_empty():
|
def test_wsgi_generator_empty():
|
||||||
output = [b"", b"", b"", b""]
|
output = [b"", b"", b"", b""]
|
||||||
client = httpx.Client(app=application_factory(output))
|
transport = httpx.WSGITransport(app=application_factory(output))
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
response = client.get("http://www.example.org/")
|
response = client.get("http://www.example.org/")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.text == ""
|
assert response.text == ""
|
||||||
@ -161,15 +170,34 @@ def test_wsgi_server_port(url: str, expected_server_port: str) -> None:
|
|||||||
SERVER_PORT is populated correctly from the requested URL.
|
SERVER_PORT is populated correctly from the requested URL.
|
||||||
"""
|
"""
|
||||||
hello_world_app = application_factory([b"Hello, World!"])
|
hello_world_app = application_factory([b"Hello, World!"])
|
||||||
server_port: typing.Optional[str] = None
|
server_port: str | None = None
|
||||||
|
|
||||||
def app(environ, start_response):
|
def app(environ, start_response):
|
||||||
nonlocal server_port
|
nonlocal server_port
|
||||||
server_port = environ["SERVER_PORT"]
|
server_port = environ["SERVER_PORT"]
|
||||||
return hello_world_app(environ, start_response)
|
return hello_world_app(environ, start_response)
|
||||||
|
|
||||||
client = httpx.Client(app=app)
|
transport = httpx.WSGITransport(app=app)
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
response = client.get(url)
|
response = client.get(url)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.text == "Hello, World!"
|
assert response.text == "Hello, World!"
|
||||||
assert server_port == expected_server_port
|
assert server_port == expected_server_port
|
||||||
|
|
||||||
|
|
||||||
|
def test_wsgi_server_protocol():
|
||||||
|
server_protocol = None
|
||||||
|
|
||||||
|
def app(environ, start_response):
|
||||||
|
nonlocal server_protocol
|
||||||
|
server_protocol = environ["SERVER_PROTOCOL"]
|
||||||
|
start_response("200 OK", [("Content-Type", "text/plain")])
|
||||||
|
return [b"success"]
|
||||||
|
|
||||||
|
transport = httpx.WSGITransport(app=app)
|
||||||
|
with httpx.Client(transport=transport, base_url="http://testserver") as client:
|
||||||
|
response = client.get("/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.text == "success"
|
||||||
|
assert server_protocol == "HTTP/1.1"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user