Compare commits
15 Commits
master
...
use-unasyn
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c81675399 | ||
|
|
948ee3691f | ||
|
|
187d035622 | ||
|
|
039baa0cad | ||
|
|
f654bddb4c | ||
|
|
9c5a2d1646 | ||
|
|
e7668b6ea0 | ||
|
|
a2fc6825a5 | ||
|
|
6baba9c1ce | ||
|
|
a803813702 | ||
|
|
b1d5fca5ea | ||
|
|
c3f1ee1203 | ||
|
|
ca6f520772 | ||
|
|
3848ad120a | ||
|
|
4965a61830 |
4
.github/workflows/publish.yml
vendored
4
.github/workflows/publish.yml
vendored
@ -15,9 +15,9 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: "actions/checkout@v4"
|
- uses: "actions/checkout@v4"
|
||||||
- uses: "actions/setup-python@v6"
|
- uses: "actions/setup-python@v5"
|
||||||
with:
|
with:
|
||||||
python-version: 3.9
|
python-version: 3.8
|
||||||
- name: "Install dependencies"
|
- name: "Install dependencies"
|
||||||
run: "scripts/install"
|
run: "scripts/install"
|
||||||
- name: "Build package & docs"
|
- name: "Build package & docs"
|
||||||
|
|||||||
4
.github/workflows/test-suite.yml
vendored
4
.github/workflows/test-suite.yml
vendored
@ -14,11 +14,11 @@ jobs:
|
|||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: "actions/checkout@v4"
|
- uses: "actions/checkout@v4"
|
||||||
- uses: "actions/setup-python@v6"
|
- uses: "actions/setup-python@v5"
|
||||||
with:
|
with:
|
||||||
python-version: "${{ matrix.python-version }}"
|
python-version: "${{ matrix.python-version }}"
|
||||||
allow-prereleases: true
|
allow-prereleases: true
|
||||||
|
|||||||
10
CHANGELOG.md
10
CHANGELOG.md
@ -4,16 +4,6 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
|
|
||||||
## [UNRELEASED]
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
* Drop support for Python 3.8
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
* Expose `FunctionAuth` from the public API. (#3699)
|
|
||||||
|
|
||||||
## 0.28.1 (6th December, 2024)
|
## 0.28.1 (6th December, 2024)
|
||||||
|
|
||||||
* Fix SSL case where `verify=False` together with client side certificates.
|
* Fix SSL case where `verify=False` together with client side certificates.
|
||||||
|
|||||||
@ -101,7 +101,7 @@ Or, to include the optional HTTP/2 support, use:
|
|||||||
$ pip install httpx[http2]
|
$ pip install httpx[http2]
|
||||||
```
|
```
|
||||||
|
|
||||||
HTTPX requires Python 3.9+.
|
HTTPX requires Python 3.8+.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|||||||
@ -29,7 +29,7 @@ import certifi
|
|||||||
import httpx
|
import httpx
|
||||||
import ssl
|
import ssl
|
||||||
|
|
||||||
# This SSL context is equivalent to the default `verify=True`.
|
# This SSL context is equivelent to the default `verify=True`.
|
||||||
ctx = ssl.create_default_context(cafile=certifi.where())
|
ctx = ssl.create_default_context(cafile=certifi.where())
|
||||||
client = httpx.Client(verify=ctx)
|
client = httpx.Client(verify=ctx)
|
||||||
```
|
```
|
||||||
@ -71,7 +71,19 @@ client = httpx.Client(verify=ctx)
|
|||||||
|
|
||||||
### Working with `SSL_CERT_FILE` and `SSL_CERT_DIR`
|
### 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).
|
Unlike `requests`, the `httpx` package does not automatically pull in [the environment variables `SSL_CERT_FILE` or `SSL_CERT_DIR`](https://www.openssl.org/docs/manmaster/man3/SSL_CTX_set_default_verify_paths.html). If you want to use these they need to be enabled explicitly.
|
||||||
|
|
||||||
|
For example...
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Use `SSL_CERT_FILE` or `SSL_CERT_DIR` if configured.
|
||||||
|
# Otherwise default to certifi.
|
||||||
|
ctx = ssl.create_default_context(
|
||||||
|
cafile=os.environ.get("SSL_CERT_FILE", certifi.where()),
|
||||||
|
capath=os.environ.get("SSL_CERT_DIR"),
|
||||||
|
)
|
||||||
|
client = httpx.Client(verify=ctx)
|
||||||
|
```
|
||||||
|
|
||||||
### Making HTTPS requests to a local server
|
### Making HTTPS requests to a local server
|
||||||
|
|
||||||
|
|||||||
@ -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.9+ with `python -m asyncio` to try this code interactively, as they support executing `async`/`await` expressions in the console.
|
Use [IPython](https://ipython.readthedocs.io/en/stable/) or Python 3.8+ with `python -m asyncio` to try this code interactively, as they support executing `async`/`await` expressions in the console.
|
||||||
|
|
||||||
## API Differences
|
## API Differences
|
||||||
|
|
||||||
|
|||||||
@ -226,7 +226,3 @@ For both query params (`params=`) and form data (`data=`), `requests` supports s
|
|||||||
In HTTPX, event hooks may access properties of requests and responses, but event hook callbacks cannot mutate the original request/response.
|
In HTTPX, event hooks may access properties of requests and responses, but event hook callbacks cannot mutate the original request/response.
|
||||||
|
|
||||||
If you are looking for more control, consider checking out [Custom Transports](advanced/transports.md#custom-transports).
|
If you are looking for more control, consider checking out [Custom Transports](advanced/transports.md#custom-transports).
|
||||||
|
|
||||||
## Exceptions and Errors
|
|
||||||
|
|
||||||
`requests` exception hierarchy is slightly different to the `httpx` exception hierarchy. `requests` exposes a top level `RequestException`, where as `httpx` exposes a top level `HTTPError`. see the exceptions exposes in requests [here](https://requests.readthedocs.io/en/latest/_modules/requests/exceptions/). See the `httpx` error hierarchy [here](https://www.python-httpx.org/exceptions/).
|
|
||||||
|
|||||||
@ -51,29 +51,3 @@ python -c "import httpx; httpx.get('http://example.com')"
|
|||||||
python -c "import httpx; httpx.get('http://127.0.0.1:5000/my-api')"
|
python -c "import httpx; httpx.get('http://127.0.0.1:5000/my-api')"
|
||||||
python -c "import httpx; httpx.get('https://www.python-httpx.org')"
|
python -c "import httpx; httpx.get('https://www.python-httpx.org')"
|
||||||
```
|
```
|
||||||
|
|
||||||
## `SSL_CERT_FILE`
|
|
||||||
|
|
||||||
Valid values: a filename
|
|
||||||
|
|
||||||
If this environment variable is set then HTTPX will load
|
|
||||||
CA certificate from the specified file instead of the default
|
|
||||||
location.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```console
|
|
||||||
SSL_CERT_FILE=/path/to/ca-certs/ca-bundle.crt python -c "import httpx; httpx.get('https://example.com')"
|
|
||||||
```
|
|
||||||
|
|
||||||
## `SSL_CERT_DIR`
|
|
||||||
|
|
||||||
Valid values: a directory following an [OpenSSL specific layout](https://www.openssl.org/docs/manmaster/man3/SSL_CTX_load_verify_locations.html).
|
|
||||||
|
|
||||||
If this environment variable is set and the directory follows an [OpenSSL specific layout](https://www.openssl.org/docs/manmaster/man3/SSL_CTX_load_verify_locations.html) (ie. you ran `c_rehash`) then HTTPX will load CA certificates from this directory instead of the default location.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```console
|
|
||||||
SSL_CERT_DIR=/path/to/ca-certs/ python -c "import httpx; httpx.get('https://example.com')"
|
|
||||||
```
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 1.9 MiB |
@ -145,6 +145,6 @@ To include the optional brotli and zstandard decoders support, use:
|
|||||||
$ pip install httpx[brotli,zstd]
|
$ pip install httpx[brotli,zstd]
|
||||||
```
|
```
|
||||||
|
|
||||||
HTTPX requires Python 3.9+
|
HTTPX requires Python 3.8+
|
||||||
|
|
||||||
[sync-support]: https://github.com/encode/httpx/issues/572
|
[sync-support]: https://github.com/encode/httpx/issues/572
|
||||||
|
|||||||
@ -20,6 +20,8 @@ httpx.get("https://www.example.com")
|
|||||||
Will send debug level output to the console, or wherever `stdout` is directed too...
|
Will send debug level output to the console, or wherever `stdout` is directed too...
|
||||||
|
|
||||||
```
|
```
|
||||||
|
DEBUG [2024-09-28 17:27:40] httpx - load_ssl_context verify=True cert=None
|
||||||
|
DEBUG [2024-09-28 17:27:40] httpx - load_verify_locations cafile='/Users/karenpetrosyan/oss/karhttpx/.venv/lib/python3.9/site-packages/certifi/cacert.pem'
|
||||||
DEBUG [2024-09-28 17:27:40] httpcore.connection - connect_tcp.started host='www.example.com' port=443 local_address=None timeout=5.0 socket_options=None
|
DEBUG [2024-09-28 17:27:40] httpcore.connection - connect_tcp.started host='www.example.com' port=443 local_address=None timeout=5.0 socket_options=None
|
||||||
DEBUG [2024-09-28 17:27:41] httpcore.connection - connect_tcp.complete return_value=<httpcore._backends.sync.SyncStream object at 0x101f1e8e0>
|
DEBUG [2024-09-28 17:27:41] httpcore.connection - connect_tcp.complete return_value=<httpcore._backends.sync.SyncStream object at 0x101f1e8e0>
|
||||||
DEBUG [2024-09-28 17:27:41] httpcore.connection - start_tls.started ssl_context=SSLContext(verify=True) server_hostname='www.example.com' timeout=5.0
|
DEBUG [2024-09-28 17:27:41] httpcore.connection - start_tls.started ssl_context=SSLContext(verify=True) server_hostname='www.example.com' timeout=5.0
|
||||||
@ -78,4 +80,4 @@ logging.config.dictConfig(LOGGING_CONFIG)
|
|||||||
httpx.get('https://www.example.com')
|
httpx.get('https://www.example.com')
|
||||||
```
|
```
|
||||||
|
|
||||||
The exact formatting of the debug logging may be subject to change across different versions of `httpx` and `httpcore`. If you need to rely on a particular format it is recommended that you pin installation of these packages to fixed versions.
|
The exact formatting of the debug logging may be subject to change across different versions of `httpx` and `httpcore`. If you need to rely on a particular format it is recommended that you pin installation of these packages to fixed versions.
|
||||||
@ -191,7 +191,7 @@ You can also explicitly set the filename and content type, by using a tuple
|
|||||||
of items for the file value:
|
of items for the file value:
|
||||||
|
|
||||||
```pycon
|
```pycon
|
||||||
>>> with open('report.xls', 'rb') as report_file:
|
>>> with open('report.xls', 'rb') report_file:
|
||||||
... files = {'upload-file': ('report.xls', report_file, 'application/vnd.ms-excel')}
|
... files = {'upload-file': ('report.xls', report_file, 'application/vnd.ms-excel')}
|
||||||
... r = httpx.post("https://httpbin.org/post", files=files)
|
... r = httpx.post("https://httpbin.org/post", files=files)
|
||||||
>>> print(r.text)
|
>>> print(r.text)
|
||||||
|
|||||||
@ -2,83 +2,37 @@
|
|||||||
|
|
||||||
As HTTPX usage grows, there is an expanding community of developers building tools and libraries that integrate with HTTPX, or depend on HTTPX. Here are some of them.
|
As HTTPX usage grows, there is an expanding community of developers building tools and libraries that integrate with HTTPX, or depend on HTTPX. Here are some of them.
|
||||||
|
|
||||||
<!-- NOTE: Entries are alphabetised. -->
|
|
||||||
|
|
||||||
## Plugins
|
## Plugins
|
||||||
|
|
||||||
### Hishel
|
|
||||||
|
|
||||||
[GitHub](https://github.com/karpetrosyan/hishel) - [Documentation](https://hishel.com/)
|
|
||||||
|
|
||||||
An elegant HTTP Cache implementation for HTTPX and HTTP Core.
|
|
||||||
|
|
||||||
### HTTPX-Auth
|
|
||||||
|
|
||||||
[GitHub](https://github.com/Colin-b/httpx_auth) - [Documentation](https://colin-b.github.io/httpx_auth/)
|
|
||||||
|
|
||||||
Provides authentication classes to be used with HTTPX's [authentication parameter](advanced/authentication.md#customizing-authentication).
|
|
||||||
|
|
||||||
### httpx-caching
|
|
||||||
|
|
||||||
[Github](https://github.com/johtso/httpx-caching)
|
|
||||||
|
|
||||||
This package adds caching functionality to HTTPX
|
|
||||||
|
|
||||||
### httpx-secure
|
|
||||||
|
|
||||||
[GitHub](https://github.com/Zaczero/httpx-secure)
|
|
||||||
|
|
||||||
Drop-in SSRF protection for httpx with DNS caching and custom validation support.
|
|
||||||
|
|
||||||
### httpx-socks
|
|
||||||
|
|
||||||
[GitHub](https://github.com/romis2012/httpx-socks)
|
|
||||||
|
|
||||||
Proxy (HTTP, SOCKS) transports for httpx.
|
|
||||||
|
|
||||||
### httpx-sse
|
|
||||||
|
|
||||||
[GitHub](https://github.com/florimondmanca/httpx-sse)
|
|
||||||
|
|
||||||
Allows consuming Server-Sent Events (SSE) with HTTPX.
|
|
||||||
|
|
||||||
### httpx-retries
|
|
||||||
|
|
||||||
[GitHub](https://github.com/will-ockmore/httpx-retries) - [Documentation](https://will-ockmore.github.io/httpx-retries/)
|
|
||||||
|
|
||||||
A retry layer for HTTPX.
|
|
||||||
|
|
||||||
### httpx-ws
|
### httpx-ws
|
||||||
|
|
||||||
[GitHub](https://github.com/frankie567/httpx-ws) - [Documentation](https://frankie567.github.io/httpx-ws/)
|
[GitHub](https://github.com/frankie567/httpx-ws) - [Documentation](https://frankie567.github.io/httpx-ws/)
|
||||||
|
|
||||||
WebSocket support for HTTPX.
|
WebSocket support for HTTPX.
|
||||||
|
|
||||||
### pytest-HTTPX
|
### httpx-socks
|
||||||
|
|
||||||
[GitHub](https://github.com/Colin-b/pytest_httpx) - [Documentation](https://colin-b.github.io/pytest_httpx/)
|
[GitHub](https://github.com/romis2012/httpx-socks)
|
||||||
|
|
||||||
Provides a [pytest](https://docs.pytest.org/en/latest/) fixture to mock HTTPX within test cases.
|
Proxy (HTTP, SOCKS) transports for httpx.
|
||||||
|
|
||||||
### RESPX
|
### httpdbg
|
||||||
|
|
||||||
[GitHub](https://github.com/lundberg/respx) - [Documentation](https://lundberg.github.io/respx/)
|
[GitHub](https://github.com/cle-b/httpdbg) - [Documentation](https://httpdbg.readthedocs.io/)
|
||||||
|
|
||||||
A utility for mocking out HTTPX.
|
A tool for Python developers to easily debug the HTTP(S) client requests in a Python program.
|
||||||
|
|
||||||
### rpc.py
|
### Hishel
|
||||||
|
|
||||||
[Github](https://github.com/abersheeran/rpc.py) - [Documentation](https://github.com/abersheeran/rpc.py#rpcpy)
|
[GitHub](https://github.com/karpetrosyan/hishel) - [Documentation](https://hishel.com/)
|
||||||
|
|
||||||
A fast and powerful RPC framework based on ASGI/WSGI. Use HTTPX as the client of the RPC service.
|
An elegant HTTP Cache implementation for HTTPX and HTTP Core.
|
||||||
|
|
||||||
## Libraries with HTTPX support
|
|
||||||
|
|
||||||
### Authlib
|
### Authlib
|
||||||
|
|
||||||
[GitHub](https://github.com/lepture/authlib) - [Documentation](https://docs.authlib.org/en/latest/)
|
[GitHub](https://github.com/lepture/authlib) - [Documentation](https://docs.authlib.org/en/latest/)
|
||||||
|
|
||||||
A python library for building OAuth and OpenID Connect clients and servers. Includes an [OAuth HTTPX client](https://docs.authlib.org/en/latest/client/httpx.html).
|
The ultimate Python library in building OAuth and OpenID Connect clients and servers. Includes an [OAuth HTTPX client](https://docs.authlib.org/en/latest/client/httpx.html).
|
||||||
|
|
||||||
### Gidgethub
|
### Gidgethub
|
||||||
|
|
||||||
@ -86,20 +40,58 @@ A python library for building OAuth and OpenID Connect clients and servers. Incl
|
|||||||
|
|
||||||
An asynchronous GitHub API library. Includes [HTTPX support](https://gidgethub.readthedocs.io/en/latest/httpx.html).
|
An asynchronous GitHub API library. Includes [HTTPX support](https://gidgethub.readthedocs.io/en/latest/httpx.html).
|
||||||
|
|
||||||
### httpdbg
|
### HTTPX-Auth
|
||||||
|
|
||||||
[GitHub](https://github.com/cle-b/httpdbg) - [Documentation](https://httpdbg.readthedocs.io/)
|
[GitHub](https://github.com/Colin-b/httpx_auth) - [Documentation](https://colin-b.github.io/httpx_auth/)
|
||||||
|
|
||||||
A tool for python developers to easily debug the HTTP(S) client requests in a python program.
|
Provides authentication classes to be used with HTTPX [authentication parameter](advanced/authentication.md#customizing-authentication).
|
||||||
|
|
||||||
|
### pytest-HTTPX
|
||||||
|
|
||||||
|
[GitHub](https://github.com/Colin-b/pytest_httpx) - [Documentation](https://colin-b.github.io/pytest_httpx/)
|
||||||
|
|
||||||
|
Provides `httpx_mock` [pytest](https://docs.pytest.org/en/latest/) fixture to mock HTTPX within test cases.
|
||||||
|
|
||||||
|
### RESPX
|
||||||
|
|
||||||
|
[GitHub](https://github.com/lundberg/respx) - [Documentation](https://lundberg.github.io/respx/)
|
||||||
|
|
||||||
|
A utility for mocking out the Python HTTPX library.
|
||||||
|
|
||||||
|
### rpc.py
|
||||||
|
|
||||||
|
[Github](https://github.com/abersheeran/rpc.py) - [Documentation](https://github.com/abersheeran/rpc.py#rpcpy)
|
||||||
|
|
||||||
|
An fast and powerful RPC framework based on ASGI/WSGI. Use HTTPX as the client of the RPC service.
|
||||||
|
|
||||||
### VCR.py
|
### VCR.py
|
||||||
|
|
||||||
[GitHub](https://github.com/kevin1024/vcrpy) - [Documentation](https://vcrpy.readthedocs.io/)
|
[GitHub](https://github.com/kevin1024/vcrpy) - [Documentation](https://vcrpy.readthedocs.io/)
|
||||||
|
|
||||||
Record and repeat requests.
|
A utility for record and repeat an http request.
|
||||||
|
|
||||||
|
### httpx-caching
|
||||||
|
|
||||||
|
[Github](https://github.com/johtso/httpx-caching)
|
||||||
|
|
||||||
|
This package adds caching functionality to HTTPX
|
||||||
|
|
||||||
|
### httpx-sse
|
||||||
|
|
||||||
|
[GitHub](https://github.com/florimondmanca/httpx-sse)
|
||||||
|
|
||||||
|
Allows consuming Server-Sent Events (SSE) with HTTPX.
|
||||||
|
|
||||||
|
### robox
|
||||||
|
|
||||||
|
[Github](https://github.com/danclaudiupop/robox)
|
||||||
|
|
||||||
|
A library for scraping the web built on top of HTTPX.
|
||||||
|
|
||||||
## Gists
|
## Gists
|
||||||
|
|
||||||
|
<!-- NOTE: this list is in alphabetical order. -->
|
||||||
|
|
||||||
### urllib3-transport
|
### urllib3-transport
|
||||||
|
|
||||||
[GitHub](https://gist.github.com/florimondmanca/d56764d78d748eb9f73165da388e546e)
|
[GitHub](https://gist.github.com/florimondmanca/d56764d78d748eb9f73165da388e546e)
|
||||||
|
|||||||
@ -50,7 +50,6 @@ __all__ = [
|
|||||||
"DecodingError",
|
"DecodingError",
|
||||||
"delete",
|
"delete",
|
||||||
"DigestAuth",
|
"DigestAuth",
|
||||||
"FunctionAuth",
|
|
||||||
"get",
|
"get",
|
||||||
"head",
|
"head",
|
||||||
"Headers",
|
"Headers",
|
||||||
|
|||||||
@ -16,7 +16,7 @@ if typing.TYPE_CHECKING: # pragma: no cover
|
|||||||
from hashlib import _Hash
|
from hashlib import _Hash
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["Auth", "BasicAuth", "DigestAuth", "FunctionAuth", "NetRCAuth"]
|
__all__ = ["Auth", "BasicAuth", "DigestAuth", "NetRCAuth"]
|
||||||
|
|
||||||
|
|
||||||
class Auth:
|
class Auth:
|
||||||
|
|||||||
@ -1005,7 +1005,7 @@ class Client(BaseClient):
|
|||||||
transport = self._transport_for_url(request.url)
|
transport = self._transport_for_url(request.url)
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
|
|
||||||
if not isinstance(request.stream, SyncByteStream):
|
if not isinstance(request.stream, SyncByteStream): # pragma: no cover
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Attempted to send an async request with a sync Client instance."
|
"Attempted to send an async request with a sync Client instance."
|
||||||
)
|
)
|
||||||
@ -1721,7 +1721,7 @@ class AsyncClient(BaseClient):
|
|||||||
transport = self._transport_for_url(request.url)
|
transport = self._transport_for_url(request.url)
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
|
|
||||||
if not isinstance(request.stream, AsyncByteStream):
|
if not isinstance(request.stream, AsyncByteStream): # pragma: no cover
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Attempted to send a sync request with an AsyncClient instance."
|
"Attempted to send a sync request with an AsyncClient instance."
|
||||||
)
|
)
|
||||||
|
|||||||
@ -331,7 +331,9 @@ class StreamClosed(StreamError):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
message = "Attempted to read or stream content, but the stream has been closed."
|
message = (
|
||||||
|
"Attempted to read or stream content, but the stream has " "been closed."
|
||||||
|
)
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -355,7 +355,7 @@ class AsyncHTTPTransport(AsyncBaseTransport):
|
|||||||
else: # pragma: no cover
|
else: # pragma: no cover
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Proxy protocol must be either 'http', 'https', 'socks5', or 'socks5h',"
|
"Proxy protocol must be either 'http', 'https', 'socks5', or 'socks5h',"
|
||||||
f" but got {proxy.url.scheme!r}."
|
" but got {proxy.url.scheme!r}."
|
||||||
)
|
)
|
||||||
|
|
||||||
async def __aenter__(self: A) -> A: # Use generics for subclass support.
|
async def __aenter__(self: A) -> A: # Use generics for subclass support.
|
||||||
|
|||||||
@ -379,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(
|
||||||
[
|
[
|
||||||
|
|||||||
@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
|||||||
name = "httpx"
|
name = "httpx"
|
||||||
description = "The next generation HTTP client."
|
description = "The next generation HTTP client."
|
||||||
license = "BSD-3-Clause"
|
license = "BSD-3-Clause"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.8"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Tom Christie", email = "tom@tomchristie.com" },
|
{ name = "Tom Christie", email = "tom@tomchristie.com" },
|
||||||
]
|
]
|
||||||
@ -20,6 +20,7 @@ classifiers = [
|
|||||||
"Operating System :: OS Independent",
|
"Operating System :: OS Independent",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3 :: Only",
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
|
"Programming Language :: Python :: 3.8",
|
||||||
"Programming Language :: Python :: 3.9",
|
"Programming Language :: Python :: 3.9",
|
||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
@ -43,7 +44,7 @@ brotli = [
|
|||||||
cli = [
|
cli = [
|
||||||
"click==8.*",
|
"click==8.*",
|
||||||
"pygments==2.*",
|
"pygments==2.*",
|
||||||
"rich>=10,<15",
|
"rich>=10,<14",
|
||||||
]
|
]
|
||||||
http2 = [
|
http2 = [
|
||||||
"h2>=3,<5",
|
"h2>=3,<5",
|
||||||
@ -95,6 +96,9 @@ 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"'
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
exclude = ["tests/client/sync"]
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "I", "B", "PIE"]
|
select = ["E", "F", "I", "B", "PIE"]
|
||||||
ignore = ["B904", "B028"]
|
ignore = ["B904", "B028"]
|
||||||
@ -128,5 +132,5 @@ markers = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
omit = ["venv/*"]
|
omit = ["venv/*", "tests/client/sync/*"]
|
||||||
include = ["httpx/*", "tests/*"]
|
include = ["httpx/*", "tests/*"]
|
||||||
|
|||||||
@ -11,19 +11,20 @@ chardet==5.2.0
|
|||||||
# Documentation
|
# Documentation
|
||||||
mkdocs==1.6.1
|
mkdocs==1.6.1
|
||||||
mkautodoc==0.2.0
|
mkautodoc==0.2.0
|
||||||
mkdocs-material==9.6.18
|
mkdocs-material==9.5.47
|
||||||
|
|
||||||
# Packaging
|
# Packaging
|
||||||
build==1.3.0
|
build==1.2.2.post1
|
||||||
twine==6.1.0
|
twine==6.0.1
|
||||||
|
|
||||||
# Tests & Linting
|
# Tests & Linting
|
||||||
coverage[toml]==7.10.6
|
coverage[toml]==7.6.1
|
||||||
cryptography==45.0.7
|
cryptography==44.0.1
|
||||||
mypy==1.17.1
|
mypy==1.13.0
|
||||||
pytest==8.4.1
|
pytest==8.3.4
|
||||||
ruff==0.12.11
|
ruff==0.8.1
|
||||||
trio==0.31.0
|
trio==0.27.0
|
||||||
trio-typing==0.10.0
|
trio-typing==0.10.0
|
||||||
trustme==1.2.1
|
trustme==1.1.0; python_version < '3.9'
|
||||||
uvicorn==0.35.0
|
trustme==1.2.0; python_version >= '3.9'
|
||||||
|
uvicorn==0.32.1
|
||||||
|
|||||||
@ -12,3 +12,4 @@ set -x
|
|||||||
${PREFIX}ruff format $SOURCE_FILES --diff
|
${PREFIX}ruff format $SOURCE_FILES --diff
|
||||||
${PREFIX}mypy $SOURCE_FILES
|
${PREFIX}mypy $SOURCE_FILES
|
||||||
${PREFIX}ruff check $SOURCE_FILES
|
${PREFIX}ruff check $SOURCE_FILES
|
||||||
|
${PREFIX}python scripts/unasync.py --check
|
||||||
|
|||||||
@ -10,3 +10,4 @@ set -x
|
|||||||
|
|
||||||
${PREFIX}ruff check --fix $SOURCE_FILES
|
${PREFIX}ruff check --fix $SOURCE_FILES
|
||||||
${PREFIX}ruff format $SOURCE_FILES
|
${PREFIX}ruff format $SOURCE_FILES
|
||||||
|
${PREFIX}python scripts/unasync.py
|
||||||
|
|||||||
92
scripts/unasync.py
Executable file
92
scripts/unasync.py
Executable file
@ -0,0 +1,92 @@
|
|||||||
|
#!venv/bin/python
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pprint import pprint
|
||||||
|
|
||||||
|
SUBS = [
|
||||||
|
# httpx specific
|
||||||
|
("AsyncByteStream", "SyncByteStream"),
|
||||||
|
("async_auth_flow", "sync_auth_flow"),
|
||||||
|
("handle_async_request", "handle_request"),
|
||||||
|
# general
|
||||||
|
("AsyncIterator", "Iterator"),
|
||||||
|
("from anyio import Lock", "from threading import Lock"),
|
||||||
|
("Async([A-Z][A-Za-z0-9_]*)", r"\2"),
|
||||||
|
("async def", "def"),
|
||||||
|
("async with", "with"),
|
||||||
|
("async for", "for"),
|
||||||
|
("await ", ""),
|
||||||
|
("aclose", "close"),
|
||||||
|
("aread", "read"),
|
||||||
|
("__aenter__", "__enter__"),
|
||||||
|
("__aexit__", "__exit__"),
|
||||||
|
("__aiter__", "__iter__"),
|
||||||
|
("@pytest.mark.anyio", ""),
|
||||||
|
]
|
||||||
|
COMPILED_SUBS = [
|
||||||
|
(re.compile(r"(^|\b)" + regex + r"($|\b)"), repl) for regex, repl in SUBS
|
||||||
|
]
|
||||||
|
|
||||||
|
USED_SUBS = set()
|
||||||
|
|
||||||
|
|
||||||
|
def unasync_line(line):
|
||||||
|
for index, (regex, repl) in enumerate(COMPILED_SUBS):
|
||||||
|
old_line = line
|
||||||
|
line = re.sub(regex, repl, line)
|
||||||
|
if old_line != line:
|
||||||
|
USED_SUBS.add(index)
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
def unasync_file(in_path, out_path):
|
||||||
|
with open(in_path, "r") as in_file:
|
||||||
|
with open(out_path, "w", newline="") as out_file:
|
||||||
|
for line in in_file.readlines():
|
||||||
|
line = unasync_line(line)
|
||||||
|
out_file.write(line)
|
||||||
|
|
||||||
|
|
||||||
|
def unasync_file_check(in_path, out_path):
|
||||||
|
with open(in_path, "r") as in_file:
|
||||||
|
with open(out_path, "r") as out_file:
|
||||||
|
for in_line, out_line in zip(in_file.readlines(), out_file.readlines()):
|
||||||
|
expected = unasync_line(in_line)
|
||||||
|
if out_line != expected:
|
||||||
|
print(f"unasync mismatch between {in_path!r} and {out_path!r}")
|
||||||
|
print(f"Async code: {in_line!r}")
|
||||||
|
print(f"Expected sync code: {expected!r}")
|
||||||
|
print(f"Actual sync code: {out_line!r}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def unasync_dir(in_dir, out_dir, check_only=False):
|
||||||
|
for dirpath, dirnames, filenames in os.walk(in_dir):
|
||||||
|
for filename in filenames:
|
||||||
|
if not filename.endswith(".py"):
|
||||||
|
continue
|
||||||
|
rel_dir = os.path.relpath(dirpath, in_dir)
|
||||||
|
in_path = os.path.normpath(os.path.join(in_dir, rel_dir, filename))
|
||||||
|
out_path = os.path.normpath(os.path.join(out_dir, rel_dir, filename))
|
||||||
|
print(in_path, "->", out_path)
|
||||||
|
if check_only:
|
||||||
|
unasync_file_check(in_path, out_path)
|
||||||
|
else:
|
||||||
|
unasync_file(in_path, out_path)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
check_only = "--check" in sys.argv
|
||||||
|
unasync_dir("tests/client/async", "tests/client/sync", check_only=check_only)
|
||||||
|
|
||||||
|
if len(USED_SUBS) != len(SUBS):
|
||||||
|
unused_subs = [SUBS[i] for i in range(len(SUBS)) if i not in USED_SUBS]
|
||||||
|
|
||||||
|
print("These patterns were not used:")
|
||||||
|
pprint(unused_subs)
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
0
tests/client/async/__init__.py
Normal file
0
tests/client/async/__init__.py
Normal file
@ -8,16 +8,15 @@ import hashlib
|
|||||||
import netrc
|
import netrc
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import threading
|
|
||||||
import typing
|
import typing
|
||||||
from urllib.request import parse_keqv_list
|
from urllib.request import parse_keqv_list
|
||||||
|
|
||||||
import anyio
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from anyio import Lock
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from ..common import FIXTURES_DIR
|
from ...common import FIXTURES_DIR
|
||||||
|
|
||||||
|
|
||||||
class App:
|
class App:
|
||||||
@ -134,28 +133,18 @@ class ResponseBodyAuth(httpx.Auth):
|
|||||||
yield request
|
yield request
|
||||||
|
|
||||||
|
|
||||||
class SyncOrAsyncAuth(httpx.Auth):
|
class AsyncAuth(httpx.Auth):
|
||||||
"""
|
"""
|
||||||
A mock authentication scheme that uses a different implementation for the
|
A mock authentication scheme that uses a different implementation for the
|
||||||
sync and async cases.
|
sync and async cases.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._lock = threading.Lock()
|
self._lock = Lock()
|
||||||
self._async_lock = anyio.Lock()
|
|
||||||
|
|
||||||
def sync_auth_flow(
|
async def async_auth_flow(self, request: httpx.Request) -> typing.Any:
|
||||||
self, request: httpx.Request
|
async with self._lock:
|
||||||
) -> typing.Generator[httpx.Request, httpx.Response, None]:
|
request.headers["Authorization"] = "auth"
|
||||||
with self._lock:
|
|
||||||
request.headers["Authorization"] = "sync-auth"
|
|
||||||
yield request
|
|
||||||
|
|
||||||
async def async_auth_flow(
|
|
||||||
self, request: httpx.Request
|
|
||||||
) -> typing.AsyncGenerator[httpx.Request, httpx.Response]:
|
|
||||||
async with self._async_lock:
|
|
||||||
request.headers["Authorization"] = "async-auth"
|
|
||||||
yield request
|
yield request
|
||||||
|
|
||||||
|
|
||||||
@ -234,7 +223,8 @@ async def test_custom_auth() -> None:
|
|||||||
assert response.json() == {"auth": "Token 123"}
|
assert response.json() == {"auth": "Token 123"}
|
||||||
|
|
||||||
|
|
||||||
def test_netrc_auth_credentials_exist() -> None:
|
@pytest.mark.anyio
|
||||||
|
async def test_netrc_auth_credentials_exist() -> None:
|
||||||
"""
|
"""
|
||||||
When netrc auth is being used and a request is made to a host that is
|
When netrc auth is being used and a request is made to a host that is
|
||||||
in the netrc file, then the relevant credentials should be applied.
|
in the netrc file, then the relevant credentials should be applied.
|
||||||
@ -244,8 +234,10 @@ def test_netrc_auth_credentials_exist() -> None:
|
|||||||
app = App()
|
app = App()
|
||||||
auth = httpx.NetRCAuth(netrc_file)
|
auth = httpx.NetRCAuth(netrc_file)
|
||||||
|
|
||||||
with httpx.Client(transport=httpx.MockTransport(app), auth=auth) as client:
|
async with httpx.AsyncClient(
|
||||||
response = client.get(url)
|
transport=httpx.MockTransport(app), auth=auth
|
||||||
|
) as client:
|
||||||
|
response = await client.get(url)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
@ -253,7 +245,8 @@ def test_netrc_auth_credentials_exist() -> None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_netrc_auth_credentials_do_not_exist() -> None:
|
@pytest.mark.anyio
|
||||||
|
async def test_netrc_auth_credentials_do_not_exist() -> None:
|
||||||
"""
|
"""
|
||||||
When netrc auth is being used and a request is made to a host that is
|
When netrc auth is being used and a request is made to a host that is
|
||||||
not in the netrc file, then no credentials should be applied.
|
not in the netrc file, then no credentials should be applied.
|
||||||
@ -263,8 +256,10 @@ def test_netrc_auth_credentials_do_not_exist() -> None:
|
|||||||
app = App()
|
app = App()
|
||||||
auth = httpx.NetRCAuth(netrc_file)
|
auth = httpx.NetRCAuth(netrc_file)
|
||||||
|
|
||||||
with httpx.Client(transport=httpx.MockTransport(app), auth=auth) as client:
|
async with httpx.AsyncClient(
|
||||||
response = client.get(url)
|
transport=httpx.MockTransport(app), auth=auth
|
||||||
|
) as client:
|
||||||
|
response = await client.get(url)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {"auth": None}
|
assert response.json() == {"auth": None}
|
||||||
@ -274,7 +269,8 @@ def test_netrc_auth_credentials_do_not_exist() -> None:
|
|||||||
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",
|
||||||
)
|
)
|
||||||
def test_netrc_auth_nopassword_parse_error() -> None: # pragma: no cover
|
@pytest.mark.anyio
|
||||||
|
async def test_netrc_auth_nopassword_parse_error() -> None: # pragma: no cover
|
||||||
"""
|
"""
|
||||||
Python has different netrc parsing behaviours with different versions.
|
Python has different netrc parsing behaviours with different versions.
|
||||||
For Python < 3.11 a netrc file with no password is invalid. In this case
|
For Python < 3.11 a netrc file with no password is invalid. In this case
|
||||||
@ -300,7 +296,8 @@ async def test_auth_disable_per_request() -> None:
|
|||||||
assert response.json() == {"auth": None}
|
assert response.json() == {"auth": None}
|
||||||
|
|
||||||
|
|
||||||
def test_auth_hidden_url() -> None:
|
@pytest.mark.anyio
|
||||||
|
async def test_auth_hidden_url() -> None:
|
||||||
url = "http://example-username:example-password@example.org/"
|
url = "http://example-username:example-password@example.org/"
|
||||||
expected = "URL('http://example-username:[secure]@example.org/')"
|
expected = "URL('http://example-username:[secure]@example.org/')"
|
||||||
assert url == httpx.URL(url)
|
assert url == httpx.URL(url)
|
||||||
@ -326,7 +323,7 @@ async def test_auth_property() -> None:
|
|||||||
async with httpx.AsyncClient(transport=httpx.MockTransport(app)) as client:
|
async with httpx.AsyncClient(transport=httpx.MockTransport(app)) as client:
|
||||||
assert client.auth is None
|
assert client.auth is None
|
||||||
|
|
||||||
client.auth = ("user", "password123")
|
client.auth = ("user", "password123") # type: ignore
|
||||||
assert isinstance(client.auth, httpx.BasicAuth)
|
assert isinstance(client.auth, httpx.BasicAuth)
|
||||||
|
|
||||||
url = "https://example.org/"
|
url = "https://example.org/"
|
||||||
@ -367,18 +364,19 @@ async def test_digest_auth_returns_no_auth_if_no_digest_header_in_response() ->
|
|||||||
assert len(response.history) == 0
|
assert len(response.history) == 0
|
||||||
|
|
||||||
|
|
||||||
def test_digest_auth_returns_no_auth_if_alternate_auth_scheme() -> None:
|
@pytest.mark.anyio
|
||||||
|
async def test_digest_auth_returns_no_auth_if_alternate_auth_scheme() -> None:
|
||||||
url = "https://example.org/"
|
url = "https://example.org/"
|
||||||
auth = httpx.DigestAuth(username="user", password="password123")
|
auth = httpx.DigestAuth(username="user", password="password123")
|
||||||
auth_header = "Token ..."
|
auth_header = "Token ..."
|
||||||
app = App(auth_header=auth_header, status_code=401)
|
app = App(auth_header=auth_header, status_code=401)
|
||||||
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(app))
|
async with httpx.AsyncClient(transport=httpx.MockTransport(app)) as client:
|
||||||
response = client.get(url, auth=auth)
|
response = await client.get(url, auth=auth)
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
assert response.json() == {"auth": None}
|
assert response.json() == {"auth": None}
|
||||||
assert len(response.history) == 0
|
assert len(response.history) == 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@ -601,7 +599,7 @@ async def test_digest_auth_resets_nonce_count_after_401() -> None:
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_async_digest_auth_raises_protocol_error_on_malformed_header(
|
async def test_digest_auth_raises_protocol_error_on_malformed_header(
|
||||||
auth_header: str,
|
auth_header: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
url = "https://example.org/"
|
url = "https://example.org/"
|
||||||
@ -613,27 +611,8 @@ async def test_async_digest_auth_raises_protocol_error_on_malformed_header(
|
|||||||
await client.get(url, auth=auth)
|
await client.get(url, auth=auth)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"auth_header",
|
|
||||||
[
|
|
||||||
'Digest realm="httpx@example.org", qop="auth"', # missing fields
|
|
||||||
'Digest realm="httpx@example.org", qop="auth,au', # malformed fields list
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_sync_digest_auth_raises_protocol_error_on_malformed_header(
|
|
||||||
auth_header: str,
|
|
||||||
) -> None:
|
|
||||||
url = "https://example.org/"
|
|
||||||
auth = httpx.DigestAuth(username="user", password="password123")
|
|
||||||
app = App(auth_header=auth_header, status_code=401)
|
|
||||||
|
|
||||||
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
|
||||||
with pytest.raises(httpx.ProtocolError):
|
|
||||||
client.get(url, auth=auth)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_async_auth_history() -> None:
|
async def test_auth_history() -> None:
|
||||||
"""
|
"""
|
||||||
Test that intermediate requests sent as part of an authentication flow
|
Test that intermediate requests sent as part of an authentication flow
|
||||||
are recorded in the response history.
|
are recorded in the response history.
|
||||||
@ -659,36 +638,11 @@ async def test_async_auth_history() -> None:
|
|||||||
assert len(resp1.history) == 0
|
assert len(resp1.history) == 0
|
||||||
|
|
||||||
|
|
||||||
def test_sync_auth_history() -> None:
|
|
||||||
"""
|
|
||||||
Test that intermediate requests sent as part of an authentication flow
|
|
||||||
are recorded in the response history.
|
|
||||||
"""
|
|
||||||
url = "https://example.org/"
|
|
||||||
auth = RepeatAuth(repeat=2)
|
|
||||||
app = App(auth_header="abc")
|
|
||||||
|
|
||||||
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
|
||||||
response = client.get(url, auth=auth)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"auth": "Repeat abc.abc"}
|
|
||||||
|
|
||||||
assert len(response.history) == 2
|
|
||||||
resp1, resp2 = response.history
|
|
||||||
assert resp1.json() == {"auth": "Repeat 0"}
|
|
||||||
assert resp2.json() == {"auth": "Repeat 1"}
|
|
||||||
|
|
||||||
assert len(resp2.history) == 1
|
|
||||||
assert resp2.history == [resp1]
|
|
||||||
|
|
||||||
assert len(resp1.history) == 0
|
|
||||||
|
|
||||||
|
|
||||||
class ConsumeBodyTransport(httpx.MockTransport):
|
class ConsumeBodyTransport(httpx.MockTransport):
|
||||||
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
||||||
assert isinstance(request.stream, httpx.AsyncByteStream)
|
assert isinstance(request.stream, httpx.AsyncByteStream)
|
||||||
[_ async for _ in request.stream]
|
async for _ in request.stream:
|
||||||
|
pass
|
||||||
return self.handler(request) # type: ignore[return-value]
|
return self.handler(request) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
@ -707,7 +661,7 @@ async def test_digest_auth_unavailable_streaming_body():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_async_auth_reads_response_body() -> None:
|
async def test_auth_reads_response_body() -> None:
|
||||||
"""
|
"""
|
||||||
Test that we can read the response body in an auth flow if `requires_response_body`
|
Test that we can read the response body in an auth flow if `requires_response_body`
|
||||||
is set.
|
is set.
|
||||||
@ -723,50 +677,19 @@ async def test_async_auth_reads_response_body() -> None:
|
|||||||
assert response.json() == {"auth": '{"auth":"xyz"}'}
|
assert response.json() == {"auth": '{"auth":"xyz"}'}
|
||||||
|
|
||||||
|
|
||||||
def test_sync_auth_reads_response_body() -> None:
|
|
||||||
"""
|
|
||||||
Test that we can read the response body in an auth flow if `requires_response_body`
|
|
||||||
is set.
|
|
||||||
"""
|
|
||||||
url = "https://example.org/"
|
|
||||||
auth = ResponseBodyAuth("xyz")
|
|
||||||
app = App()
|
|
||||||
|
|
||||||
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
|
||||||
response = client.get(url, auth=auth)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"auth": '{"auth":"xyz"}'}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_async_auth() -> None:
|
async def test_auth() -> None:
|
||||||
"""
|
"""
|
||||||
Test that we can use an auth implementation specific to the async case, to
|
Test that we can use an auth implementation specific to the async case, to
|
||||||
support cases that require performing I/O or using concurrency primitives (such
|
support cases that require performing I/O or using concurrency primitives (such
|
||||||
as checking a disk-based cache or fetching a token from a remote auth server).
|
as checking a disk-based cache or fetching a token from a remote auth server).
|
||||||
"""
|
"""
|
||||||
url = "https://example.org/"
|
url = "https://example.org/"
|
||||||
auth = SyncOrAsyncAuth()
|
auth = AsyncAuth()
|
||||||
app = App()
|
app = App()
|
||||||
|
|
||||||
async with httpx.AsyncClient(transport=httpx.MockTransport(app)) as client:
|
async with httpx.AsyncClient(transport=httpx.MockTransport(app)) as client:
|
||||||
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": "async-auth"}
|
assert response.json() == {"auth": "auth"}
|
||||||
|
|
||||||
|
|
||||||
def test_sync_auth() -> None:
|
|
||||||
"""
|
|
||||||
Test that we can use an auth implementation specific to the sync case.
|
|
||||||
"""
|
|
||||||
url = "https://example.org/"
|
|
||||||
auth = SyncOrAsyncAuth()
|
|
||||||
app = App()
|
|
||||||
|
|
||||||
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
|
||||||
response = client.get(url, auth=auth)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"auth": "sync-auth"}
|
|
||||||
@ -100,17 +100,6 @@ async def test_stream_request(server):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_cannot_stream_sync_request(server):
|
|
||||||
def hello_world() -> typing.Iterator[bytes]: # pragma: no cover
|
|
||||||
yield b"Hello, "
|
|
||||||
yield b"world!"
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
with pytest.raises(RuntimeError):
|
|
||||||
await client.post(server.url, content=hello_world())
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_raise_for_status(server):
|
async def test_raise_for_status(server):
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
@ -314,7 +303,7 @@ async def test_mounted_transport():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_async_mock_transport():
|
async def test_mock_transport():
|
||||||
async def hello_world(request: httpx.Request) -> httpx.Response:
|
async def hello_world(request: httpx.Request) -> httpx.Response:
|
||||||
return httpx.Response(200, text="Hello, world!")
|
return httpx.Response(200, text="Hello, world!")
|
||||||
|
|
||||||
182
tests/client/async/test_cookies.py
Normal file
182
tests/client/async/test_cookies.py
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
from http.cookiejar import Cookie, CookieJar
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
def get_and_set_cookies(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.url.path == "/echo_cookies":
|
||||||
|
data = {"cookies": request.headers.get("cookie")}
|
||||||
|
return httpx.Response(200, json=data)
|
||||||
|
elif request.url.path == "/set_cookie":
|
||||||
|
return httpx.Response(200, headers={"set-cookie": "example-name=example-value"})
|
||||||
|
else:
|
||||||
|
raise NotImplementedError() # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_set_cookie() -> None:
|
||||||
|
"""
|
||||||
|
Send a request including a cookie.
|
||||||
|
"""
|
||||||
|
url = "http://example.org/echo_cookies"
|
||||||
|
cookies = {"example-name": "example-value"}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
cookies=cookies, transport=httpx.MockTransport(get_and_set_cookies)
|
||||||
|
) as client:
|
||||||
|
response = await client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"cookies": "example-name=example-value"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_set_per_request_cookie_is_deprecated() -> None:
|
||||||
|
"""
|
||||||
|
Sending a request including a per-request cookie is deprecated.
|
||||||
|
"""
|
||||||
|
url = "http://example.org/echo_cookies"
|
||||||
|
cookies = {"example-name": "example-value"}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=httpx.MockTransport(get_and_set_cookies)
|
||||||
|
) as client:
|
||||||
|
with pytest.warns(DeprecationWarning):
|
||||||
|
response = await client.get(url, cookies=cookies)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"cookies": "example-name=example-value"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_set_cookie_with_cookiejar() -> None:
|
||||||
|
"""
|
||||||
|
Send a request including a cookie, using a `CookieJar` instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = "http://example.org/echo_cookies"
|
||||||
|
cookies = CookieJar()
|
||||||
|
cookie = Cookie(
|
||||||
|
version=0,
|
||||||
|
name="example-name",
|
||||||
|
value="example-value",
|
||||||
|
port=None,
|
||||||
|
port_specified=False,
|
||||||
|
domain="",
|
||||||
|
domain_specified=False,
|
||||||
|
domain_initial_dot=False,
|
||||||
|
path="/",
|
||||||
|
path_specified=True,
|
||||||
|
secure=False,
|
||||||
|
expires=None,
|
||||||
|
discard=True,
|
||||||
|
comment=None,
|
||||||
|
comment_url=None,
|
||||||
|
rest={"HttpOnly": ""},
|
||||||
|
rfc2109=False,
|
||||||
|
)
|
||||||
|
cookies.set_cookie(cookie)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
cookies=cookies, transport=httpx.MockTransport(get_and_set_cookies)
|
||||||
|
) as client:
|
||||||
|
response = await client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"cookies": "example-name=example-value"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_setting_client_cookies_to_cookiejar() -> None:
|
||||||
|
"""
|
||||||
|
Send a request including a cookie, using a `CookieJar` instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = "http://example.org/echo_cookies"
|
||||||
|
cookies = CookieJar()
|
||||||
|
cookie = Cookie(
|
||||||
|
version=0,
|
||||||
|
name="example-name",
|
||||||
|
value="example-value",
|
||||||
|
port=None,
|
||||||
|
port_specified=False,
|
||||||
|
domain="",
|
||||||
|
domain_specified=False,
|
||||||
|
domain_initial_dot=False,
|
||||||
|
path="/",
|
||||||
|
path_specified=True,
|
||||||
|
secure=False,
|
||||||
|
expires=None,
|
||||||
|
discard=True,
|
||||||
|
comment=None,
|
||||||
|
comment_url=None,
|
||||||
|
rest={"HttpOnly": ""},
|
||||||
|
rfc2109=False,
|
||||||
|
)
|
||||||
|
cookies.set_cookie(cookie)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
cookies=cookies, transport=httpx.MockTransport(get_and_set_cookies)
|
||||||
|
) as client:
|
||||||
|
response = await client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"cookies": "example-name=example-value"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_set_cookie_with_cookies_model() -> None:
|
||||||
|
"""
|
||||||
|
Send a request including a cookie, using a `Cookies` instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = "http://example.org/echo_cookies"
|
||||||
|
cookies = httpx.Cookies()
|
||||||
|
cookies["example-name"] = "example-value"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=httpx.MockTransport(get_and_set_cookies)
|
||||||
|
) as client:
|
||||||
|
client.cookies = cookies
|
||||||
|
response = await client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"cookies": "example-name=example-value"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_get_cookie() -> None:
|
||||||
|
url = "http://example.org/set_cookie"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=httpx.MockTransport(get_and_set_cookies)
|
||||||
|
) as client:
|
||||||
|
response = await client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.cookies["example-name"] == "example-value"
|
||||||
|
assert client.cookies["example-name"] == "example-value"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cookie_persistence() -> None:
|
||||||
|
"""
|
||||||
|
Ensure that Client instances persist cookies between requests.
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=httpx.MockTransport(get_and_set_cookies)
|
||||||
|
) as client:
|
||||||
|
response = await client.get("http://example.org/echo_cookies")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"cookies": None}
|
||||||
|
|
||||||
|
response = await client.get("http://example.org/set_cookie")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.cookies["example-name"] == "example-value"
|
||||||
|
assert client.cookies["example-name"] == "example-value"
|
||||||
|
|
||||||
|
response = await client.get("http://example.org/echo_cookies")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"cookies": "example-name=example-value"}
|
||||||
@ -13,58 +13,8 @@ def app(request: httpx.Request) -> httpx.Response:
|
|||||||
return httpx.Response(200, headers={"server": "testserver"})
|
return httpx.Response(200, headers={"server": "testserver"})
|
||||||
|
|
||||||
|
|
||||||
def test_event_hooks():
|
|
||||||
events = []
|
|
||||||
|
|
||||||
def on_request(request):
|
|
||||||
events.append({"event": "request", "headers": dict(request.headers)})
|
|
||||||
|
|
||||||
def on_response(response):
|
|
||||||
events.append({"event": "response", "headers": dict(response.headers)})
|
|
||||||
|
|
||||||
event_hooks = {"request": [on_request], "response": [on_response]}
|
|
||||||
|
|
||||||
with httpx.Client(
|
|
||||||
event_hooks=event_hooks, transport=httpx.MockTransport(app)
|
|
||||||
) as http:
|
|
||||||
http.get("http://127.0.0.1:8000/", auth=("username", "password"))
|
|
||||||
|
|
||||||
assert events == [
|
|
||||||
{
|
|
||||||
"event": "request",
|
|
||||||
"headers": {
|
|
||||||
"host": "127.0.0.1:8000",
|
|
||||||
"user-agent": f"python-httpx/{httpx.__version__}",
|
|
||||||
"accept": "*/*",
|
|
||||||
"accept-encoding": "gzip, deflate, br, zstd",
|
|
||||||
"connection": "keep-alive",
|
|
||||||
"authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"event": "response",
|
|
||||||
"headers": {"server": "testserver"},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_event_hooks_raising_exception(server):
|
|
||||||
def raise_on_4xx_5xx(response):
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
event_hooks = {"response": [raise_on_4xx_5xx]}
|
|
||||||
|
|
||||||
with httpx.Client(
|
|
||||||
event_hooks=event_hooks, transport=httpx.MockTransport(app)
|
|
||||||
) as http:
|
|
||||||
try:
|
|
||||||
http.get("http://127.0.0.1:8000/status/400")
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
assert exc.response.is_closed
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_async_event_hooks():
|
async def test_event_hooks():
|
||||||
events = []
|
events = []
|
||||||
|
|
||||||
async def on_request(request):
|
async def on_request(request):
|
||||||
@ -100,7 +50,7 @@ async def test_async_event_hooks():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_async_event_hooks_raising_exception():
|
async def test_event_hooks_raising_exception():
|
||||||
async def raise_on_4xx_5xx(response):
|
async def raise_on_4xx_5xx(response):
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
@ -115,64 +65,8 @@ async def test_async_event_hooks_raising_exception():
|
|||||||
assert exc.response.is_closed
|
assert exc.response.is_closed
|
||||||
|
|
||||||
|
|
||||||
def test_event_hooks_with_redirect():
|
|
||||||
"""
|
|
||||||
A redirect request should trigger additional 'request' and 'response' event hooks.
|
|
||||||
"""
|
|
||||||
|
|
||||||
events = []
|
|
||||||
|
|
||||||
def on_request(request):
|
|
||||||
events.append({"event": "request", "headers": dict(request.headers)})
|
|
||||||
|
|
||||||
def on_response(response):
|
|
||||||
events.append({"event": "response", "headers": dict(response.headers)})
|
|
||||||
|
|
||||||
event_hooks = {"request": [on_request], "response": [on_response]}
|
|
||||||
|
|
||||||
with httpx.Client(
|
|
||||||
event_hooks=event_hooks,
|
|
||||||
transport=httpx.MockTransport(app),
|
|
||||||
follow_redirects=True,
|
|
||||||
) as http:
|
|
||||||
http.get("http://127.0.0.1:8000/redirect", auth=("username", "password"))
|
|
||||||
|
|
||||||
assert events == [
|
|
||||||
{
|
|
||||||
"event": "request",
|
|
||||||
"headers": {
|
|
||||||
"host": "127.0.0.1:8000",
|
|
||||||
"user-agent": f"python-httpx/{httpx.__version__}",
|
|
||||||
"accept": "*/*",
|
|
||||||
"accept-encoding": "gzip, deflate, br, zstd",
|
|
||||||
"connection": "keep-alive",
|
|
||||||
"authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"event": "response",
|
|
||||||
"headers": {"location": "/", "server": "testserver"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"event": "request",
|
|
||||||
"headers": {
|
|
||||||
"host": "127.0.0.1:8000",
|
|
||||||
"user-agent": f"python-httpx/{httpx.__version__}",
|
|
||||||
"accept": "*/*",
|
|
||||||
"accept-encoding": "gzip, deflate, br, zstd",
|
|
||||||
"connection": "keep-alive",
|
|
||||||
"authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"event": "response",
|
|
||||||
"headers": {"server": "testserver"},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_async_event_hooks_with_redirect():
|
async def test_event_hooks_with_redirect():
|
||||||
"""
|
"""
|
||||||
A redirect request should trigger additional 'request' and 'response' event hooks.
|
A redirect request should trigger additional 'request' and 'response' event hooks.
|
||||||
"""
|
"""
|
||||||
318
tests/client/async/test_headers.py
Executable file
318
tests/client/async/test_headers.py
Executable file
@ -0,0 +1,318 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
def echo_headers(request: httpx.Request) -> httpx.Response:
|
||||||
|
data = {"headers": dict(request.headers)}
|
||||||
|
return httpx.Response(200, json=data)
|
||||||
|
|
||||||
|
|
||||||
|
def echo_repeated_headers_multi_items(request: httpx.Request) -> httpx.Response:
|
||||||
|
data = {"headers": list(request.headers.multi_items())}
|
||||||
|
return httpx.Response(200, json=data)
|
||||||
|
|
||||||
|
|
||||||
|
def echo_repeated_headers_items(request: httpx.Request) -> httpx.Response:
|
||||||
|
data = {"headers": list(request.headers.items())}
|
||||||
|
return httpx.Response(200, json=data)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_client_header():
|
||||||
|
"""
|
||||||
|
Set a header in the Client.
|
||||||
|
"""
|
||||||
|
url = "http://example.org/echo_headers"
|
||||||
|
headers = {"Example-Header": "example-value"}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=httpx.MockTransport(echo_headers), headers=headers
|
||||||
|
) as client:
|
||||||
|
response = await client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"headers": {
|
||||||
|
"accept": "*/*",
|
||||||
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
|
"connection": "keep-alive",
|
||||||
|
"example-header": "example-value",
|
||||||
|
"host": "example.org",
|
||||||
|
"user-agent": f"python-httpx/{httpx.__version__}",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_header_merge():
|
||||||
|
url = "http://example.org/echo_headers"
|
||||||
|
client_headers = {"User-Agent": "python-myclient/0.2.1"}
|
||||||
|
request_headers = {"X-Auth-Token": "FooBarBazToken"}
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=httpx.MockTransport(echo_headers), headers=client_headers
|
||||||
|
) as client:
|
||||||
|
response = await client.get(url, headers=request_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"headers": {
|
||||||
|
"accept": "*/*",
|
||||||
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
|
"connection": "keep-alive",
|
||||||
|
"host": "example.org",
|
||||||
|
"user-agent": "python-myclient/0.2.1",
|
||||||
|
"x-auth-token": "FooBarBazToken",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_header_merge_conflicting_headers():
|
||||||
|
url = "http://example.org/echo_headers"
|
||||||
|
client_headers = {"X-Auth-Token": "FooBar"}
|
||||||
|
request_headers = {"X-Auth-Token": "BazToken"}
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=httpx.MockTransport(echo_headers), headers=client_headers
|
||||||
|
) as client:
|
||||||
|
response = await client.get(url, headers=request_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"headers": {
|
||||||
|
"accept": "*/*",
|
||||||
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
|
"connection": "keep-alive",
|
||||||
|
"host": "example.org",
|
||||||
|
"user-agent": f"python-httpx/{httpx.__version__}",
|
||||||
|
"x-auth-token": "BazToken",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_header_update():
|
||||||
|
url = "http://example.org/echo_headers"
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(echo_headers)) as client:
|
||||||
|
first_response = await client.get(url)
|
||||||
|
client.headers.update(
|
||||||
|
{"User-Agent": "python-myclient/0.2.1", "Another-Header": "AThing"}
|
||||||
|
)
|
||||||
|
second_response = await client.get(url)
|
||||||
|
|
||||||
|
assert first_response.status_code == 200
|
||||||
|
assert first_response.json() == {
|
||||||
|
"headers": {
|
||||||
|
"accept": "*/*",
|
||||||
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
|
"connection": "keep-alive",
|
||||||
|
"host": "example.org",
|
||||||
|
"user-agent": f"python-httpx/{httpx.__version__}",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert second_response.status_code == 200
|
||||||
|
assert second_response.json() == {
|
||||||
|
"headers": {
|
||||||
|
"accept": "*/*",
|
||||||
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
|
"another-header": "AThing",
|
||||||
|
"connection": "keep-alive",
|
||||||
|
"host": "example.org",
|
||||||
|
"user-agent": "python-myclient/0.2.1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_header_repeated_items():
|
||||||
|
url = "http://example.org/echo_headers"
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=httpx.MockTransport(echo_repeated_headers_items)
|
||||||
|
) as client:
|
||||||
|
response = await client.get(
|
||||||
|
url, headers=[("x-header", "1"), ("x-header", "2,3")]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
echoed_headers = response.json()["headers"]
|
||||||
|
# as per RFC 7230, the whitespace after a comma is insignificant
|
||||||
|
# so we split and strip here so that we can do a safe comparison
|
||||||
|
assert ["x-header", ["1", "2", "3"]] in [
|
||||||
|
[k, [subv.lstrip() for subv in v.split(",")]] for k, v in echoed_headers
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_header_repeated_multi_items():
|
||||||
|
url = "http://example.org/echo_headers"
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=httpx.MockTransport(echo_repeated_headers_multi_items)
|
||||||
|
) as client:
|
||||||
|
response = await client.get(
|
||||||
|
url, headers=[("x-header", "1"), ("x-header", "2,3")]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
echoed_headers = response.json()["headers"]
|
||||||
|
assert ["x-header", "1"] in echoed_headers
|
||||||
|
assert ["x-header", "2,3"] in echoed_headers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_remove_default_header():
|
||||||
|
"""
|
||||||
|
Remove a default header from the Client.
|
||||||
|
"""
|
||||||
|
url = "http://example.org/echo_headers"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(echo_headers)) as client:
|
||||||
|
del client.headers["User-Agent"]
|
||||||
|
|
||||||
|
response = await client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"headers": {
|
||||||
|
"accept": "*/*",
|
||||||
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
|
"connection": "keep-alive",
|
||||||
|
"host": "example.org",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_header_does_not_exist():
|
||||||
|
headers = httpx.Headers({"foo": "bar"})
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
del headers["baz"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async 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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_host_with_auth_and_port_in_url():
|
||||||
|
"""
|
||||||
|
The Host header should only include the hostname, or hostname:port
|
||||||
|
(for non-default ports only). Any userinfo or default port should not
|
||||||
|
be present.
|
||||||
|
"""
|
||||||
|
url = "http://username:password@example.org:80/echo_headers"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(echo_headers)) as client:
|
||||||
|
response = await client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"headers": {
|
||||||
|
"accept": "*/*",
|
||||||
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
|
"connection": "keep-alive",
|
||||||
|
"host": "example.org",
|
||||||
|
"user-agent": f"python-httpx/{httpx.__version__}",
|
||||||
|
"authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_host_with_non_default_port_in_url():
|
||||||
|
"""
|
||||||
|
If the URL includes a non-default port, then it should be included in
|
||||||
|
the Host header.
|
||||||
|
"""
|
||||||
|
url = "http://username:password@example.org:123/echo_headers"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(echo_headers)) as client:
|
||||||
|
response = await client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"headers": {
|
||||||
|
"accept": "*/*",
|
||||||
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
|
"connection": "keep-alive",
|
||||||
|
"host": "example.org:123",
|
||||||
|
"user-agent": f"python-httpx/{httpx.__version__}",
|
||||||
|
"authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_request_auto_headers():
|
||||||
|
request = httpx.Request("GET", "https://www.example.org/")
|
||||||
|
assert "host" in request.headers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_same_origin():
|
||||||
|
origin = httpx.URL("https://example.com")
|
||||||
|
request = httpx.Request("GET", "HTTPS://EXAMPLE.COM:443")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
headers = client._redirect_headers(request, origin, "GET")
|
||||||
|
|
||||||
|
assert headers["Host"] == request.url.netloc.decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_not_same_origin():
|
||||||
|
origin = httpx.URL("https://example.com")
|
||||||
|
request = httpx.Request("GET", "HTTP://EXAMPLE.COM:80")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
headers = client._redirect_headers(request, origin, "GET")
|
||||||
|
|
||||||
|
assert headers["Host"] == origin.netloc.decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_is_https_redirect():
|
||||||
|
url = httpx.URL("https://example.com")
|
||||||
|
request = httpx.Request(
|
||||||
|
"GET", "http://example.com", headers={"Authorization": "empty"}
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
headers = client._redirect_headers(request, url, "GET")
|
||||||
|
|
||||||
|
assert "Authorization" in headers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_is_not_https_redirect():
|
||||||
|
url = httpx.URL("https://www.example.com")
|
||||||
|
request = httpx.Request(
|
||||||
|
"GET", "http://example.com", headers={"Authorization": "empty"}
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
headers = client._redirect_headers(request, url, "GET")
|
||||||
|
|
||||||
|
assert "Authorization" not in headers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async 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"}
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
headers = client._redirect_headers(request, url, "GET")
|
||||||
|
|
||||||
|
assert "Authorization" not in headers
|
||||||
77
tests/client/async/test_properties.py
Normal file
77
tests/client/async/test_properties.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_client_base_url():
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
client.base_url = "https://www.example.org/" # type: ignore
|
||||||
|
assert isinstance(client.base_url, httpx.URL)
|
||||||
|
assert client.base_url == "https://www.example.org/"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_client_base_url_without_trailing_slash():
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
client.base_url = "https://www.example.org/path" # type: ignore
|
||||||
|
assert isinstance(client.base_url, httpx.URL)
|
||||||
|
assert client.base_url == "https://www.example.org/path/"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_client_base_url_with_trailing_slash():
|
||||||
|
client = httpx.Client()
|
||||||
|
client.base_url = "https://www.example.org/path/" # type: ignore
|
||||||
|
assert isinstance(client.base_url, httpx.URL)
|
||||||
|
assert client.base_url == "https://www.example.org/path/"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_client_headers():
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
client.headers = {"a": "b"} # type: ignore
|
||||||
|
assert isinstance(client.headers, httpx.Headers)
|
||||||
|
assert client.headers["A"] == "b"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_client_cookies():
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
client.cookies = {"a": "b"} # type: ignore
|
||||||
|
assert isinstance(client.cookies, httpx.Cookies)
|
||||||
|
mycookies = list(client.cookies.jar)
|
||||||
|
assert len(mycookies) == 1
|
||||||
|
assert mycookies[0].name == "a" and mycookies[0].value == "b"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_client_timeout():
|
||||||
|
expected_timeout = 12.0
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
client.timeout = expected_timeout # type: ignore
|
||||||
|
|
||||||
|
assert isinstance(client.timeout, httpx.Timeout)
|
||||||
|
assert client.timeout.connect == expected_timeout
|
||||||
|
assert client.timeout.read == expected_timeout
|
||||||
|
assert client.timeout.write == expected_timeout
|
||||||
|
assert client.timeout.pool == expected_timeout
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_client_event_hooks():
|
||||||
|
def on_request(request):
|
||||||
|
pass # pragma: no cover
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
client.event_hooks = {"request": [on_request]}
|
||||||
|
assert client.event_hooks == {"request": [on_request], "response": []}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_client_trust_env():
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
assert client.trust_env
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(trust_env=False) as client:
|
||||||
|
assert not client.trust_env
|
||||||
258
tests/client/async/test_proxies.py
Normal file
258
tests/client/async/test_proxies.py
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
import httpcore
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
def url_to_origin(url: str) -> httpcore.URL:
|
||||||
|
"""
|
||||||
|
Given a URL string, return the origin in the raw tuple format that
|
||||||
|
`httpcore` uses for it's representation.
|
||||||
|
"""
|
||||||
|
u = httpx.URL(url)
|
||||||
|
return httpcore.URL(scheme=u.raw_scheme, host=u.raw_host, port=u.port, target="/")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_socks_proxy():
|
||||||
|
url = httpx.URL("http://www.example.com")
|
||||||
|
|
||||||
|
for proxy in ("socks5://localhost/", "socks5h://localhost/"):
|
||||||
|
async with httpx.AsyncClient(proxy=proxy) as client:
|
||||||
|
transport = client._transport_for_url(url)
|
||||||
|
assert isinstance(transport, httpx.AsyncHTTPTransport)
|
||||||
|
assert isinstance(transport._pool, httpcore.AsyncSOCKSProxy)
|
||||||
|
|
||||||
|
|
||||||
|
PROXY_URL = "http://[::1]"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
["url", "proxies", "expected"],
|
||||||
|
[
|
||||||
|
("http://example.com", {}, None),
|
||||||
|
("http://example.com", {"https://": PROXY_URL}, None),
|
||||||
|
("http://example.com", {"http://example.net": PROXY_URL}, None),
|
||||||
|
# Using "*" should match any domain name.
|
||||||
|
("http://example.com", {"http://*": PROXY_URL}, PROXY_URL),
|
||||||
|
("https://example.com", {"http://*": PROXY_URL}, None),
|
||||||
|
# Using "example.com" should match example.com, but not www.example.com
|
||||||
|
("http://example.com", {"http://example.com": PROXY_URL}, PROXY_URL),
|
||||||
|
("http://www.example.com", {"http://example.com": PROXY_URL}, None),
|
||||||
|
# Using "*.example.com" should match www.example.com, but not example.com
|
||||||
|
("http://example.com", {"http://*.example.com": PROXY_URL}, None),
|
||||||
|
("http://www.example.com", {"http://*.example.com": PROXY_URL}, PROXY_URL),
|
||||||
|
# Using "*example.com" should match example.com and www.example.com
|
||||||
|
("http://example.com", {"http://*example.com": PROXY_URL}, PROXY_URL),
|
||||||
|
("http://www.example.com", {"http://*example.com": PROXY_URL}, PROXY_URL),
|
||||||
|
("http://wwwexample.com", {"http://*example.com": PROXY_URL}, None),
|
||||||
|
# ...
|
||||||
|
("http://example.com:443", {"http://example.com": PROXY_URL}, PROXY_URL),
|
||||||
|
("http://example.com", {"all://": 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", {"http://example.com": PROXY_URL}, PROXY_URL),
|
||||||
|
("http://example.com", {"http://example.com:80": PROXY_URL}, PROXY_URL),
|
||||||
|
("http://example.com:8080", {"http://example.com:8080": PROXY_URL}, PROXY_URL),
|
||||||
|
("http://example.com:8080", {"http://example.com": PROXY_URL}, PROXY_URL),
|
||||||
|
(
|
||||||
|
"http://example.com",
|
||||||
|
{
|
||||||
|
"all://": PROXY_URL + ":1",
|
||||||
|
"http://": PROXY_URL + ":2",
|
||||||
|
"all://example.com": PROXY_URL + ":3",
|
||||||
|
"http://example.com": PROXY_URL + ":4",
|
||||||
|
},
|
||||||
|
PROXY_URL + ":4",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"http://example.com",
|
||||||
|
{
|
||||||
|
"all://": PROXY_URL + ":1",
|
||||||
|
"http://": PROXY_URL + ":2",
|
||||||
|
"all://example.com": PROXY_URL + ":3",
|
||||||
|
},
|
||||||
|
PROXY_URL + ":3",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"http://example.com",
|
||||||
|
{"all://": PROXY_URL + ":1", "http://": PROXY_URL + ":2"},
|
||||||
|
PROXY_URL + ":2",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_transport_for_request(url, proxies, expected):
|
||||||
|
mounts = {
|
||||||
|
key: httpx.AsyncHTTPTransport(proxy=value) for key, value in proxies.items()
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(mounts=mounts) as client:
|
||||||
|
transport = client._transport_for_url(httpx.URL(url))
|
||||||
|
|
||||||
|
if expected is None:
|
||||||
|
assert transport is client._transport
|
||||||
|
else:
|
||||||
|
assert isinstance(transport, httpx.AsyncHTTPTransport)
|
||||||
|
assert isinstance(transport._pool, httpcore.AsyncHTTPProxy)
|
||||||
|
assert transport._pool._proxy_url == url_to_origin(expected)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.network
|
||||||
|
async def test_proxy_close():
|
||||||
|
try:
|
||||||
|
transport = httpx.AsyncHTTPTransport(proxy=PROXY_URL)
|
||||||
|
client = httpx.AsyncClient(mounts={"https://": transport})
|
||||||
|
await client.get("http://example.com")
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_unsupported_proxy_scheme():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
httpx.AsyncClient(proxy="ftp://127.0.0.1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
["url", "env", "expected"],
|
||||||
|
[
|
||||||
|
("http://google.com", {}, None),
|
||||||
|
(
|
||||||
|
"http://google.com",
|
||||||
|
{"HTTP_PROXY": "http://example.com"},
|
||||||
|
"http://example.com",
|
||||||
|
),
|
||||||
|
# Auto prepend http scheme
|
||||||
|
("http://google.com", {"HTTP_PROXY": "example.com"}, "http://example.com"),
|
||||||
|
(
|
||||||
|
"http://google.com",
|
||||||
|
{"HTTP_PROXY": "http://example.com", "NO_PROXY": "google.com"},
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
# Everything proxied when NO_PROXY is empty/unset
|
||||||
|
(
|
||||||
|
"http://127.0.0.1",
|
||||||
|
{"ALL_PROXY": "http://localhost:123", "NO_PROXY": ""},
|
||||||
|
"http://localhost:123",
|
||||||
|
),
|
||||||
|
# Not proxied if NO_PROXY matches URL.
|
||||||
|
(
|
||||||
|
"http://127.0.0.1",
|
||||||
|
{"ALL_PROXY": "http://localhost:123", "NO_PROXY": "127.0.0.1"},
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
# Proxied if NO_PROXY scheme does not match URL.
|
||||||
|
(
|
||||||
|
"http://127.0.0.1",
|
||||||
|
{"ALL_PROXY": "http://localhost:123", "NO_PROXY": "https://127.0.0.1"},
|
||||||
|
"http://localhost:123",
|
||||||
|
),
|
||||||
|
# Proxied if NO_PROXY scheme does not match host.
|
||||||
|
(
|
||||||
|
"http://127.0.0.1",
|
||||||
|
{"ALL_PROXY": "http://localhost:123", "NO_PROXY": "1.1.1.1"},
|
||||||
|
"http://localhost:123",
|
||||||
|
),
|
||||||
|
# Not proxied if NO_PROXY matches host domain suffix.
|
||||||
|
(
|
||||||
|
"http://courses.mit.edu",
|
||||||
|
{"ALL_PROXY": "http://localhost:123", "NO_PROXY": "mit.edu"},
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
# Proxied even though NO_PROXY matches host domain *prefix*.
|
||||||
|
(
|
||||||
|
"https://mit.edu.info",
|
||||||
|
{"ALL_PROXY": "http://localhost:123", "NO_PROXY": "mit.edu"},
|
||||||
|
"http://localhost:123",
|
||||||
|
),
|
||||||
|
# Not proxied if one item in NO_PROXY case matches host domain suffix.
|
||||||
|
(
|
||||||
|
"https://mit.edu.info",
|
||||||
|
{"ALL_PROXY": "http://localhost:123", "NO_PROXY": "mit.edu,edu.info"},
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
# Not proxied if one item in NO_PROXY case matches host domain suffix.
|
||||||
|
# May include whitespace.
|
||||||
|
(
|
||||||
|
"https://mit.edu.info",
|
||||||
|
{"ALL_PROXY": "http://localhost:123", "NO_PROXY": "mit.edu, edu.info"},
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
# Proxied if no items in NO_PROXY match.
|
||||||
|
(
|
||||||
|
"https://mit.edu.info",
|
||||||
|
{"ALL_PROXY": "http://localhost:123", "NO_PROXY": "mit.edu,mit.info"},
|
||||||
|
"http://localhost:123",
|
||||||
|
),
|
||||||
|
# Proxied if NO_PROXY domain doesn't match.
|
||||||
|
(
|
||||||
|
"https://foo.example.com",
|
||||||
|
{"ALL_PROXY": "http://localhost:123", "NO_PROXY": "www.example.com"},
|
||||||
|
"http://localhost:123",
|
||||||
|
),
|
||||||
|
# Not proxied for subdomains matching NO_PROXY, with a leading ".".
|
||||||
|
(
|
||||||
|
"https://www.example1.com",
|
||||||
|
{"ALL_PROXY": "http://localhost:123", "NO_PROXY": ".example1.com"},
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
# Proxied, because NO_PROXY subdomains only match if "." separated.
|
||||||
|
(
|
||||||
|
"https://www.example2.com",
|
||||||
|
{"ALL_PROXY": "http://localhost:123", "NO_PROXY": "ample2.com"},
|
||||||
|
"http://localhost:123",
|
||||||
|
),
|
||||||
|
# No requests are proxied if NO_PROXY="*" is set.
|
||||||
|
(
|
||||||
|
"https://www.example3.com",
|
||||||
|
{"ALL_PROXY": "http://localhost:123", "NO_PROXY": "*"},
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_proxies_environ(monkeypatch, url, env, expected):
|
||||||
|
for name, value in env.items():
|
||||||
|
monkeypatch.setenv(name, value)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
transport = client._transport_for_url(httpx.URL(url))
|
||||||
|
|
||||||
|
if expected is None:
|
||||||
|
assert transport == client._transport
|
||||||
|
else:
|
||||||
|
assert transport._pool._proxy_url == url_to_origin(expected) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
["proxies", "is_valid"],
|
||||||
|
[
|
||||||
|
({"http": "http://127.0.0.1"}, False),
|
||||||
|
({"https": "http://127.0.0.1"}, False),
|
||||||
|
({"all": "http://127.0.0.1"}, False),
|
||||||
|
({"http://": "http://127.0.0.1"}, True),
|
||||||
|
({"https://": "http://127.0.0.1"}, True),
|
||||||
|
({"all://": "http://127.0.0.1"}, True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_for_deprecated_proxy_params(proxies, is_valid):
|
||||||
|
mounts = {
|
||||||
|
key: httpx.AsyncHTTPTransport(proxy=value) for key, value in proxies.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
if not is_valid:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
httpx.AsyncClient(mounts=mounts)
|
||||||
|
else:
|
||||||
|
httpx.AsyncClient(mounts=mounts)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_proxy_with_mounts():
|
||||||
|
proxy_transport = httpx.AsyncHTTPTransport(proxy="http://127.0.0.1")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(mounts={"http://": proxy_transport}) as client:
|
||||||
|
transport = client._transport_for_url(httpx.URL("http://example.com"))
|
||||||
|
assert transport == proxy_transport
|
||||||
42
tests/client/async/test_queryparams.py
Normal file
42
tests/client/async/test_queryparams.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
def hello_world(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, text="Hello, world")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_client_queryparams():
|
||||||
|
client = httpx.Client(params={"a": "b"})
|
||||||
|
assert isinstance(client.params, httpx.QueryParams)
|
||||||
|
assert client.params["a"] == "b"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_client_queryparams_string():
|
||||||
|
async with httpx.AsyncClient(params="a=b") as client:
|
||||||
|
assert isinstance(client.params, httpx.QueryParams)
|
||||||
|
assert client.params["a"] == "b"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
client.params = "a=b" # type: ignore
|
||||||
|
assert isinstance(client.params, httpx.QueryParams)
|
||||||
|
assert client.params["a"] == "b"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_client_queryparams_echo():
|
||||||
|
url = "http://example.org/echo_queryparams"
|
||||||
|
client_queryparams = "first=str"
|
||||||
|
request_queryparams = {"second": "dict"}
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=httpx.MockTransport(hello_world), params=client_queryparams
|
||||||
|
) as client:
|
||||||
|
response = await client.get(url, params=request_queryparams)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert (
|
||||||
|
response.url == "http://example.org/echo_queryparams?first=str&second=dict"
|
||||||
|
)
|
||||||
456
tests/client/async/test_redirects.py
Normal file
456
tests/client/async/test_redirects.py
Normal file
@ -0,0 +1,456 @@
|
|||||||
|
import typing
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
def redirects(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.url.scheme not in ("http", "https"):
|
||||||
|
raise httpx.UnsupportedProtocol(f"Scheme {request.url.scheme!r} not supported.")
|
||||||
|
|
||||||
|
if request.url.path == "/redirect_301":
|
||||||
|
status_code = httpx.codes.MOVED_PERMANENTLY
|
||||||
|
content = b"<a href='https://example.org/'>here</a>"
|
||||||
|
headers = {"location": "https://example.org/"}
|
||||||
|
return httpx.Response(status_code, headers=headers, content=content)
|
||||||
|
|
||||||
|
elif request.url.path == "/redirect_302":
|
||||||
|
status_code = httpx.codes.FOUND
|
||||||
|
headers = {"location": "https://example.org/"}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
elif request.url.path == "/redirect_303":
|
||||||
|
status_code = httpx.codes.SEE_OTHER
|
||||||
|
headers = {"location": "https://example.org/"}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
elif request.url.path == "/relative_redirect":
|
||||||
|
status_code = httpx.codes.SEE_OTHER
|
||||||
|
headers = {"location": "/"}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
elif request.url.path == "/malformed_redirect":
|
||||||
|
status_code = httpx.codes.SEE_OTHER
|
||||||
|
headers = {"location": "https://:443/"}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
elif request.url.path == "/invalid_redirect":
|
||||||
|
status_code = httpx.codes.SEE_OTHER
|
||||||
|
raw_headers = [(b"location", "https://😇/".encode("utf-8"))]
|
||||||
|
return httpx.Response(status_code, headers=raw_headers)
|
||||||
|
|
||||||
|
elif request.url.path == "/no_scheme_redirect":
|
||||||
|
status_code = httpx.codes.SEE_OTHER
|
||||||
|
headers = {"location": "//example.org/"}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
elif request.url.path == "/multiple_redirects":
|
||||||
|
params = httpx.QueryParams(request.url.query)
|
||||||
|
count = int(params.get("count", "0"))
|
||||||
|
redirect_count = count - 1
|
||||||
|
status_code = httpx.codes.SEE_OTHER if count else httpx.codes.OK
|
||||||
|
if count:
|
||||||
|
location = "/multiple_redirects"
|
||||||
|
if redirect_count:
|
||||||
|
location += f"?count={redirect_count}"
|
||||||
|
headers = {"location": location}
|
||||||
|
else:
|
||||||
|
headers = {}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
if request.url.path == "/redirect_loop":
|
||||||
|
status_code = httpx.codes.SEE_OTHER
|
||||||
|
headers = {"location": "/redirect_loop"}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
elif request.url.path == "/cross_domain":
|
||||||
|
status_code = httpx.codes.SEE_OTHER
|
||||||
|
headers = {"location": "https://example.org/cross_domain_target"}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
elif request.url.path == "/cross_domain_target":
|
||||||
|
status_code = httpx.codes.OK
|
||||||
|
data = {
|
||||||
|
"body": request.content.decode("ascii"),
|
||||||
|
"headers": dict(request.headers),
|
||||||
|
}
|
||||||
|
return httpx.Response(status_code, json=data)
|
||||||
|
|
||||||
|
elif request.url.path == "/redirect_body":
|
||||||
|
status_code = httpx.codes.PERMANENT_REDIRECT
|
||||||
|
headers = {"location": "/redirect_body_target"}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
elif request.url.path == "/redirect_no_body":
|
||||||
|
status_code = httpx.codes.SEE_OTHER
|
||||||
|
headers = {"location": "/redirect_body_target"}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
elif request.url.path == "/redirect_body_target":
|
||||||
|
data = {
|
||||||
|
"body": request.content.decode("ascii"),
|
||||||
|
"headers": dict(request.headers),
|
||||||
|
}
|
||||||
|
return httpx.Response(200, json=data)
|
||||||
|
|
||||||
|
elif request.url.path == "/cross_subdomain":
|
||||||
|
if request.headers["Host"] != "www.example.org":
|
||||||
|
status_code = httpx.codes.PERMANENT_REDIRECT
|
||||||
|
headers = {"location": "https://www.example.org/cross_subdomain"}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
else:
|
||||||
|
return httpx.Response(200, text="Hello, world!")
|
||||||
|
|
||||||
|
elif request.url.path == "/redirect_custom_scheme":
|
||||||
|
status_code = httpx.codes.MOVED_PERMANENTLY
|
||||||
|
headers = {"location": "market://details?id=42"}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
if request.method == "HEAD":
|
||||||
|
return httpx.Response(200)
|
||||||
|
|
||||||
|
return httpx.Response(200, html="<html><body>Hello, world!</body></html>")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_redirect_301():
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
response = await client.post(
|
||||||
|
"https://example.org/redirect_301", follow_redirects=True
|
||||||
|
)
|
||||||
|
assert response.status_code == httpx.codes.OK
|
||||||
|
assert response.url == "https://example.org/"
|
||||||
|
assert len(response.history) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_redirect_302():
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
response = await client.post(
|
||||||
|
"https://example.org/redirect_302", follow_redirects=True
|
||||||
|
)
|
||||||
|
assert response.status_code == httpx.codes.OK
|
||||||
|
assert response.url == "https://example.org/"
|
||||||
|
assert len(response.history) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_redirect_303():
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
response = await client.get(
|
||||||
|
"https://example.org/redirect_303", follow_redirects=True
|
||||||
|
)
|
||||||
|
assert response.status_code == httpx.codes.OK
|
||||||
|
assert response.url == "https://example.org/"
|
||||||
|
assert len(response.history) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_next_request():
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
request = client.build_request("POST", "https://example.org/redirect_303")
|
||||||
|
response = await client.send(request, follow_redirects=False)
|
||||||
|
assert response.status_code == httpx.codes.SEE_OTHER
|
||||||
|
assert response.url == "https://example.org/redirect_303"
|
||||||
|
assert response.next_request is not None
|
||||||
|
|
||||||
|
response = await client.send(response.next_request, follow_redirects=False)
|
||||||
|
assert response.status_code == httpx.codes.OK
|
||||||
|
assert response.url == "https://example.org/"
|
||||||
|
assert response.next_request is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_head_redirect():
|
||||||
|
"""
|
||||||
|
Contrary to Requests, redirects remain enabled by default for HEAD requests.
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
response = await client.head(
|
||||||
|
"https://example.org/redirect_302", follow_redirects=True
|
||||||
|
)
|
||||||
|
assert response.status_code == httpx.codes.OK
|
||||||
|
assert response.url == "https://example.org/"
|
||||||
|
assert response.request.method == "HEAD"
|
||||||
|
assert len(response.history) == 1
|
||||||
|
assert response.text == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_relative_redirect():
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
response = await client.get(
|
||||||
|
"https://example.org/relative_redirect", follow_redirects=True
|
||||||
|
)
|
||||||
|
assert response.status_code == httpx.codes.OK
|
||||||
|
assert response.url == "https://example.org/"
|
||||||
|
assert len(response.history) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_malformed_redirect():
|
||||||
|
# https://github.com/encode/httpx/issues/771
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
response = await client.get(
|
||||||
|
"http://example.org/malformed_redirect", follow_redirects=True
|
||||||
|
)
|
||||||
|
assert response.status_code == httpx.codes.OK
|
||||||
|
assert response.url == "https://example.org:443/"
|
||||||
|
assert len(response.history) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_no_scheme_redirect():
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
response = await client.get(
|
||||||
|
"https://example.org/no_scheme_redirect", follow_redirects=True
|
||||||
|
)
|
||||||
|
assert response.status_code == httpx.codes.OK
|
||||||
|
assert response.url == "https://example.org/"
|
||||||
|
assert len(response.history) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_fragment_redirect():
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
response = await client.get(
|
||||||
|
"https://example.org/relative_redirect#fragment", follow_redirects=True
|
||||||
|
)
|
||||||
|
assert response.status_code == httpx.codes.OK
|
||||||
|
assert response.url == "https://example.org/#fragment"
|
||||||
|
assert len(response.history) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_multiple_redirects():
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
response = await client.get(
|
||||||
|
"https://example.org/multiple_redirects?count=20", follow_redirects=True
|
||||||
|
)
|
||||||
|
assert response.status_code == httpx.codes.OK
|
||||||
|
assert response.url == "https://example.org/multiple_redirects"
|
||||||
|
assert len(response.history) == 20
|
||||||
|
assert (
|
||||||
|
response.history[0].url == "https://example.org/multiple_redirects?count=20"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
response.history[1].url == "https://example.org/multiple_redirects?count=19"
|
||||||
|
)
|
||||||
|
assert len(response.history[0].history) == 0
|
||||||
|
assert len(response.history[1].history) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_too_many_redirects():
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
with pytest.raises(httpx.TooManyRedirects):
|
||||||
|
await client.get(
|
||||||
|
"https://example.org/multiple_redirects?count=21", follow_redirects=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_redirect_loop():
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
with pytest.raises(httpx.TooManyRedirects):
|
||||||
|
await client.get("https://example.org/redirect_loop", follow_redirects=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cross_domain_redirect_with_auth_header():
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
url = "https://example.com/cross_domain"
|
||||||
|
headers = {"Authorization": "abc"}
|
||||||
|
response = await client.get(url, headers=headers, follow_redirects=True)
|
||||||
|
assert response.url == "https://example.org/cross_domain_target"
|
||||||
|
assert "authorization" not in response.json()["headers"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cross_domain_https_redirect_with_auth_header():
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
url = "http://example.com/cross_domain"
|
||||||
|
headers = {"Authorization": "abc"}
|
||||||
|
response = await client.get(url, headers=headers, follow_redirects=True)
|
||||||
|
assert response.url == "https://example.org/cross_domain_target"
|
||||||
|
assert "authorization" not in response.json()["headers"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cross_domain_redirect_with_auth():
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
url = "https://example.com/cross_domain"
|
||||||
|
response = await client.get(url, auth=("user", "pass"), follow_redirects=True)
|
||||||
|
assert response.url == "https://example.org/cross_domain_target"
|
||||||
|
assert "authorization" not in response.json()["headers"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_same_domain_redirect():
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
url = "https://example.org/cross_domain"
|
||||||
|
headers = {"Authorization": "abc"}
|
||||||
|
response = await client.get(url, headers=headers, follow_redirects=True)
|
||||||
|
assert response.url == "https://example.org/cross_domain_target"
|
||||||
|
assert response.json()["headers"]["authorization"] == "abc"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_same_domain_https_redirect_with_auth_header():
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
url = "http://example.org/cross_domain"
|
||||||
|
headers = {"Authorization": "abc"}
|
||||||
|
response = await client.get(url, headers=headers, follow_redirects=True)
|
||||||
|
assert response.url == "https://example.org/cross_domain_target"
|
||||||
|
assert response.json()["headers"]["authorization"] == "abc"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_body_redirect():
|
||||||
|
"""
|
||||||
|
A 308 redirect should preserve the request body.
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
url = "https://example.org/redirect_body"
|
||||||
|
content = b"Example request body"
|
||||||
|
response = await client.post(url, content=content, follow_redirects=True)
|
||||||
|
assert response.url == "https://example.org/redirect_body_target"
|
||||||
|
assert response.json()["body"] == "Example request body"
|
||||||
|
assert "content-length" in response.json()["headers"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_no_body_redirect():
|
||||||
|
"""
|
||||||
|
A 303 redirect should remove the request body.
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
url = "https://example.org/redirect_no_body"
|
||||||
|
content = b"Example request body"
|
||||||
|
response = await client.post(url, content=content, follow_redirects=True)
|
||||||
|
assert response.url == "https://example.org/redirect_body_target"
|
||||||
|
assert response.json()["body"] == ""
|
||||||
|
assert "content-length" not in response.json()["headers"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_can_stream_if_no_redirect():
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
url = "https://example.org/redirect_301"
|
||||||
|
async with client.stream("GET", url, follow_redirects=False) as response:
|
||||||
|
pass
|
||||||
|
assert response.status_code == httpx.codes.MOVED_PERMANENTLY
|
||||||
|
assert response.headers["location"] == "https://example.org/"
|
||||||
|
|
||||||
|
|
||||||
|
class ConsumeBodyTransport(httpx.MockTransport):
|
||||||
|
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
||||||
|
assert isinstance(request.stream, httpx.AsyncByteStream)
|
||||||
|
async for _ in request.stream:
|
||||||
|
pass
|
||||||
|
return self.handler(request) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cannot_redirect_streaming_body():
|
||||||
|
async with httpx.AsyncClient(transport=ConsumeBodyTransport(redirects)) as client:
|
||||||
|
url = "https://example.org/redirect_body"
|
||||||
|
|
||||||
|
async def streaming_body() -> typing.AsyncIterator[bytes]:
|
||||||
|
yield b"Example request body" # pragma: no cover
|
||||||
|
|
||||||
|
with pytest.raises(httpx.StreamConsumed):
|
||||||
|
await client.post(url, content=streaming_body(), follow_redirects=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cross_subdomain_redirect():
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
url = "https://example.com/cross_subdomain"
|
||||||
|
response = await client.get(url, follow_redirects=True)
|
||||||
|
assert response.url == "https://www.example.org/cross_subdomain"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def cookie_sessions(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.url.path == "/":
|
||||||
|
cookie = request.headers.get("Cookie")
|
||||||
|
if cookie is not None:
|
||||||
|
content = b"Logged in"
|
||||||
|
else:
|
||||||
|
content = b"Not logged in"
|
||||||
|
return httpx.Response(200, content=content)
|
||||||
|
|
||||||
|
elif request.url.path == "/login":
|
||||||
|
status_code = httpx.codes.SEE_OTHER
|
||||||
|
headers = {
|
||||||
|
"location": "/",
|
||||||
|
"set-cookie": (
|
||||||
|
"session=eyJ1c2VybmFtZSI6ICJ0b21; path=/; Max-Age=1209600; "
|
||||||
|
"httponly; samesite=lax"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
else:
|
||||||
|
assert request.url.path == "/logout"
|
||||||
|
status_code = httpx.codes.SEE_OTHER
|
||||||
|
headers = {
|
||||||
|
"location": "/",
|
||||||
|
"set-cookie": (
|
||||||
|
"session=null; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; "
|
||||||
|
"httponly; samesite=lax"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_redirect_cookie_behavior():
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=httpx.MockTransport(cookie_sessions), follow_redirects=True
|
||||||
|
) as client:
|
||||||
|
# The client is not logged in.
|
||||||
|
response = await client.get("https://example.com/")
|
||||||
|
assert response.url == "https://example.com/"
|
||||||
|
assert response.text == "Not logged in"
|
||||||
|
|
||||||
|
# Login redirects to the homepage, setting a session cookie.
|
||||||
|
response = await client.post("https://example.com/login")
|
||||||
|
assert response.url == "https://example.com/"
|
||||||
|
assert response.text == "Logged in"
|
||||||
|
|
||||||
|
# The client is logged in.
|
||||||
|
response = await client.get("https://example.com/")
|
||||||
|
assert response.url == "https://example.com/"
|
||||||
|
assert response.text == "Logged in"
|
||||||
|
|
||||||
|
# Logout redirects to the homepage, expiring the session cookie.
|
||||||
|
response = await client.post("https://example.com/logout")
|
||||||
|
assert response.url == "https://example.com/"
|
||||||
|
assert response.text == "Not logged in"
|
||||||
|
|
||||||
|
# The client is not logged in.
|
||||||
|
response = await client.get("https://example.com/")
|
||||||
|
assert response.url == "https://example.com/"
|
||||||
|
assert response.text == "Not logged in"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_redirect_custom_scheme():
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
with pytest.raises(httpx.UnsupportedProtocol) as e:
|
||||||
|
await client.post(
|
||||||
|
"https://example.org/redirect_custom_scheme", follow_redirects=True
|
||||||
|
)
|
||||||
|
assert str(e.value) == "Scheme 'market' not supported."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_invalid_redirect():
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
with pytest.raises(httpx.RemoteProtocolError):
|
||||||
|
await client.get(
|
||||||
|
"http://example.org/invalid_redirect", follow_redirects=True
|
||||||
|
)
|
||||||
0
tests/client/sync/__init__.py
Normal file
0
tests/client/sync/__init__.py
Normal file
695
tests/client/sync/test_auth.py
Normal file
695
tests/client/sync/test_auth.py
Normal file
@ -0,0 +1,695 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for authentication.
|
||||||
|
|
||||||
|
Unit tests for auth classes also exist in tests/test_auth.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import netrc
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import typing
|
||||||
|
from urllib.request import parse_keqv_list
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ...common import FIXTURES_DIR
|
||||||
|
|
||||||
|
|
||||||
|
class App:
|
||||||
|
"""
|
||||||
|
A mock app to test auth credentials.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, auth_header: str = "", status_code: int = 200) -> None:
|
||||||
|
self.auth_header = auth_header
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||||
|
headers = {"www-authenticate": self.auth_header} if self.auth_header else {}
|
||||||
|
data = {"auth": request.headers.get("Authorization")}
|
||||||
|
return httpx.Response(self.status_code, headers=headers, json=data)
|
||||||
|
|
||||||
|
|
||||||
|
class DigestApp:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
algorithm: str = "SHA-256",
|
||||||
|
send_response_after_attempt: int = 1,
|
||||||
|
qop: str = "auth",
|
||||||
|
regenerate_nonce: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self.algorithm = algorithm
|
||||||
|
self.send_response_after_attempt = send_response_after_attempt
|
||||||
|
self.qop = qop
|
||||||
|
self._regenerate_nonce = regenerate_nonce
|
||||||
|
self._response_count = 0
|
||||||
|
|
||||||
|
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||||
|
if self._response_count < self.send_response_after_attempt:
|
||||||
|
return self.challenge_send(request)
|
||||||
|
|
||||||
|
data = {"auth": request.headers.get("Authorization")}
|
||||||
|
return httpx.Response(200, json=data)
|
||||||
|
|
||||||
|
def challenge_send(self, request: httpx.Request) -> httpx.Response:
|
||||||
|
self._response_count += 1
|
||||||
|
nonce = (
|
||||||
|
hashlib.sha256(os.urandom(8)).hexdigest()
|
||||||
|
if self._regenerate_nonce
|
||||||
|
else "ee96edced2a0b43e4869e96ebe27563f369c1205a049d06419bb51d8aeddf3d3"
|
||||||
|
)
|
||||||
|
challenge_data = {
|
||||||
|
"nonce": nonce,
|
||||||
|
"qop": self.qop,
|
||||||
|
"opaque": (
|
||||||
|
"ee6378f3ee14ebfd2fff54b70a91a7c9390518047f242ab2271380db0e14bda1"
|
||||||
|
),
|
||||||
|
"algorithm": self.algorithm,
|
||||||
|
"stale": "FALSE",
|
||||||
|
}
|
||||||
|
challenge_str = ", ".join(
|
||||||
|
'{}="{}"'.format(key, value)
|
||||||
|
for key, value in challenge_data.items()
|
||||||
|
if value
|
||||||
|
)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"www-authenticate": f'Digest realm="httpx@example.org", {challenge_str}',
|
||||||
|
}
|
||||||
|
return httpx.Response(401, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
class RepeatAuth(httpx.Auth):
|
||||||
|
"""
|
||||||
|
A mock authentication scheme that requires clients to send
|
||||||
|
the request a fixed number of times, and then send a last request containing
|
||||||
|
an aggregation of nonces that the server sent in 'WWW-Authenticate' headers
|
||||||
|
of intermediate responses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
requires_request_body = True
|
||||||
|
|
||||||
|
def __init__(self, repeat: int) -> None:
|
||||||
|
self.repeat = repeat
|
||||||
|
|
||||||
|
def auth_flow(
|
||||||
|
self, request: httpx.Request
|
||||||
|
) -> typing.Generator[httpx.Request, httpx.Response, None]:
|
||||||
|
nonces = []
|
||||||
|
|
||||||
|
for index in range(self.repeat):
|
||||||
|
request.headers["Authorization"] = f"Repeat {index}"
|
||||||
|
response = yield request
|
||||||
|
nonces.append(response.headers["www-authenticate"])
|
||||||
|
|
||||||
|
key = ".".join(nonces)
|
||||||
|
request.headers["Authorization"] = f"Repeat {key}"
|
||||||
|
yield request
|
||||||
|
|
||||||
|
|
||||||
|
class ResponseBodyAuth(httpx.Auth):
|
||||||
|
"""
|
||||||
|
A mock authentication scheme that requires clients to send an 'Authorization'
|
||||||
|
header, then send back the contents of the response in the 'Authorization'
|
||||||
|
header.
|
||||||
|
"""
|
||||||
|
|
||||||
|
requires_response_body = True
|
||||||
|
|
||||||
|
def __init__(self, token: str) -> None:
|
||||||
|
self.token = token
|
||||||
|
|
||||||
|
def auth_flow(
|
||||||
|
self, request: httpx.Request
|
||||||
|
) -> typing.Generator[httpx.Request, httpx.Response, None]:
|
||||||
|
request.headers["Authorization"] = self.token
|
||||||
|
response = yield request
|
||||||
|
data = response.text
|
||||||
|
request.headers["Authorization"] = data
|
||||||
|
yield request
|
||||||
|
|
||||||
|
|
||||||
|
class Auth(httpx.Auth):
|
||||||
|
"""
|
||||||
|
A mock authentication scheme that uses a different implementation for the
|
||||||
|
sync and async cases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = Lock()
|
||||||
|
|
||||||
|
def sync_auth_flow(self, request: httpx.Request) -> typing.Any:
|
||||||
|
with self._lock:
|
||||||
|
request.headers["Authorization"] = "auth"
|
||||||
|
yield request
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_auth() -> None:
|
||||||
|
url = "https://example.org/"
|
||||||
|
auth = ("user", "password123")
|
||||||
|
app = App()
|
||||||
|
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
||||||
|
response = client.get(url, auth=auth)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"auth": "Basic dXNlcjpwYXNzd29yZDEyMw=="}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_auth_with_stream() -> None:
|
||||||
|
"""
|
||||||
|
See: https://github.com/encode/httpx/pull/1312
|
||||||
|
"""
|
||||||
|
url = "https://example.org/"
|
||||||
|
auth = ("user", "password123")
|
||||||
|
app = App()
|
||||||
|
|
||||||
|
with httpx.Client(
|
||||||
|
transport=httpx.MockTransport(app), auth=auth
|
||||||
|
) as client:
|
||||||
|
with client.stream("GET", url) as response:
|
||||||
|
response.read()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"auth": "Basic dXNlcjpwYXNzd29yZDEyMw=="}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_auth_in_url() -> None:
|
||||||
|
url = "https://user:password123@example.org/"
|
||||||
|
app = App()
|
||||||
|
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
||||||
|
response = client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"auth": "Basic dXNlcjpwYXNzd29yZDEyMw=="}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_auth_on_session() -> None:
|
||||||
|
url = "https://example.org/"
|
||||||
|
auth = ("user", "password123")
|
||||||
|
app = App()
|
||||||
|
|
||||||
|
with httpx.Client(
|
||||||
|
transport=httpx.MockTransport(app), auth=auth
|
||||||
|
) as client:
|
||||||
|
response = client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"auth": "Basic dXNlcjpwYXNzd29yZDEyMw=="}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_auth() -> None:
|
||||||
|
url = "https://example.org/"
|
||||||
|
app = App()
|
||||||
|
|
||||||
|
def auth(request: httpx.Request) -> httpx.Request:
|
||||||
|
request.headers["Authorization"] = "Token 123"
|
||||||
|
return request
|
||||||
|
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
||||||
|
response = client.get(url, auth=auth)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"auth": "Token 123"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_netrc_auth_credentials_exist() -> None:
|
||||||
|
"""
|
||||||
|
When netrc auth is being used and a request is made to a host that is
|
||||||
|
in the netrc file, then the relevant credentials should be applied.
|
||||||
|
"""
|
||||||
|
netrc_file = str(FIXTURES_DIR / ".netrc")
|
||||||
|
url = "http://netrcexample.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": "Basic ZXhhbXBsZS11c2VybmFtZTpleGFtcGxlLXBhc3N3b3Jk"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_netrc_auth_credentials_do_not_exist() -> None:
|
||||||
|
"""
|
||||||
|
When netrc auth is being used and a request is made to a host that is
|
||||||
|
not in the netrc file, then no credentials should be applied.
|
||||||
|
"""
|
||||||
|
netrc_file = str(FIXTURES_DIR / ".netrc")
|
||||||
|
url = "http://example.org"
|
||||||
|
app = App()
|
||||||
|
auth = httpx.NetRCAuth(netrc_file)
|
||||||
|
|
||||||
|
with httpx.Client(
|
||||||
|
transport=httpx.MockTransport(app), auth=auth
|
||||||
|
) as client:
|
||||||
|
response = client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"auth": None}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
sys.version_info >= (3, 11),
|
||||||
|
reason="netrc files without a password are valid from Python >= 3.11",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_netrc_auth_nopassword_parse_error() -> None: # pragma: no cover
|
||||||
|
"""
|
||||||
|
Python has different netrc parsing behaviours with different versions.
|
||||||
|
For Python < 3.11 a netrc file with no password is invalid. In this case
|
||||||
|
we want to allow the parse error to be raised.
|
||||||
|
"""
|
||||||
|
netrc_file = str(FIXTURES_DIR / ".netrc-nopassword")
|
||||||
|
with pytest.raises(netrc.NetrcParseError):
|
||||||
|
httpx.NetRCAuth(netrc_file)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_disable_per_request() -> None:
|
||||||
|
url = "https://example.org/"
|
||||||
|
auth = ("user", "password123")
|
||||||
|
app = App()
|
||||||
|
|
||||||
|
with httpx.Client(
|
||||||
|
transport=httpx.MockTransport(app), auth=auth
|
||||||
|
) as client:
|
||||||
|
response = client.get(url, auth=None)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"auth": None}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_hidden_url() -> None:
|
||||||
|
url = "http://example-username:example-password@example.org/"
|
||||||
|
expected = "URL('http://example-username:[secure]@example.org/')"
|
||||||
|
assert url == httpx.URL(url)
|
||||||
|
assert expected == repr(httpx.URL(url))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_hidden_header() -> None:
|
||||||
|
url = "https://example.org/"
|
||||||
|
auth = ("example-username", "example-password")
|
||||||
|
app = App()
|
||||||
|
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
||||||
|
response = client.get(url, auth=auth)
|
||||||
|
|
||||||
|
assert "'authorization': '[secure]'" in str(response.request.headers)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_property() -> None:
|
||||||
|
app = App()
|
||||||
|
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
||||||
|
assert client.auth is None
|
||||||
|
|
||||||
|
client.auth = ("user", "password123") # type: ignore
|
||||||
|
assert isinstance(client.auth, httpx.BasicAuth)
|
||||||
|
|
||||||
|
url = "https://example.org/"
|
||||||
|
response = client.get(url)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"auth": "Basic dXNlcjpwYXNzd29yZDEyMw=="}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_invalid_type() -> None:
|
||||||
|
app = App()
|
||||||
|
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
client = httpx.Client(
|
||||||
|
transport=httpx.MockTransport(app),
|
||||||
|
auth="not a tuple, not a callable", # type: ignore
|
||||||
|
)
|
||||||
|
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
client.get(auth="not a tuple, not a callable") # type: ignore
|
||||||
|
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
client.auth = "not a tuple, not a callable" # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_auth_returns_no_auth_if_no_digest_header_in_response() -> None:
|
||||||
|
url = "https://example.org/"
|
||||||
|
auth = httpx.DigestAuth(username="user", password="password123")
|
||||||
|
app = App()
|
||||||
|
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
||||||
|
response = client.get(url, auth=auth)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"auth": None}
|
||||||
|
assert len(response.history) == 0
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_auth_returns_no_auth_if_alternate_auth_scheme() -> None:
|
||||||
|
url = "https://example.org/"
|
||||||
|
auth = httpx.DigestAuth(username="user", password="password123")
|
||||||
|
auth_header = "Token ..."
|
||||||
|
app = App(auth_header=auth_header, status_code=401)
|
||||||
|
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
||||||
|
response = client.get(url, auth=auth)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json() == {"auth": None}
|
||||||
|
assert len(response.history) == 0
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_auth_200_response_including_digest_auth_header() -> None:
|
||||||
|
url = "https://example.org/"
|
||||||
|
auth = httpx.DigestAuth(username="user", password="password123")
|
||||||
|
auth_header = 'Digest realm="realm@host.com",qop="auth",nonce="abc",opaque="xyz"'
|
||||||
|
app = App(auth_header=auth_header, status_code=200)
|
||||||
|
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
||||||
|
response = client.get(url, auth=auth)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"auth": None}
|
||||||
|
assert len(response.history) == 0
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_auth_401_response_without_digest_auth_header() -> None:
|
||||||
|
url = "https://example.org/"
|
||||||
|
auth = httpx.DigestAuth(username="user", password="password123")
|
||||||
|
app = App(auth_header="", status_code=401)
|
||||||
|
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
||||||
|
response = client.get(url, auth=auth)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json() == {"auth": None}
|
||||||
|
assert len(response.history) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"algorithm,expected_hash_length,expected_response_length",
|
||||||
|
[
|
||||||
|
("MD5", 64, 32),
|
||||||
|
("MD5-SESS", 64, 32),
|
||||||
|
("SHA", 64, 40),
|
||||||
|
("SHA-SESS", 64, 40),
|
||||||
|
("SHA-256", 64, 64),
|
||||||
|
("SHA-256-SESS", 64, 64),
|
||||||
|
("SHA-512", 64, 128),
|
||||||
|
("SHA-512-SESS", 64, 128),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_digest_auth(
|
||||||
|
algorithm: str, expected_hash_length: int, expected_response_length: int
|
||||||
|
) -> None:
|
||||||
|
url = "https://example.org/"
|
||||||
|
auth = httpx.DigestAuth(username="user", password="password123")
|
||||||
|
app = DigestApp(algorithm=algorithm)
|
||||||
|
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
||||||
|
response = client.get(url, auth=auth)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(response.history) == 1
|
||||||
|
|
||||||
|
authorization = typing.cast(typing.Dict[str, typing.Any], response.json())["auth"]
|
||||||
|
scheme, _, fields = authorization.partition(" ")
|
||||||
|
assert scheme == "Digest"
|
||||||
|
|
||||||
|
response_fields = [field.strip() for field in fields.split(",")]
|
||||||
|
digest_data = dict(field.split("=") for field in response_fields)
|
||||||
|
|
||||||
|
assert digest_data["username"] == '"user"'
|
||||||
|
assert digest_data["realm"] == '"httpx@example.org"'
|
||||||
|
assert "nonce" in digest_data
|
||||||
|
assert digest_data["uri"] == '"/"'
|
||||||
|
assert len(digest_data["response"]) == expected_response_length + 2 # extra quotes
|
||||||
|
assert len(digest_data["opaque"]) == expected_hash_length + 2
|
||||||
|
assert digest_data["algorithm"] == algorithm
|
||||||
|
assert digest_data["qop"] == "auth"
|
||||||
|
assert digest_data["nc"] == "00000001"
|
||||||
|
assert len(digest_data["cnonce"]) == 16 + 2
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_auth_no_specified_qop() -> None:
|
||||||
|
url = "https://example.org/"
|
||||||
|
auth = httpx.DigestAuth(username="user", password="password123")
|
||||||
|
app = DigestApp(qop="")
|
||||||
|
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
||||||
|
response = client.get(url, auth=auth)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(response.history) == 1
|
||||||
|
|
||||||
|
authorization = typing.cast(typing.Dict[str, typing.Any], response.json())["auth"]
|
||||||
|
scheme, _, fields = authorization.partition(" ")
|
||||||
|
assert scheme == "Digest"
|
||||||
|
|
||||||
|
response_fields = [field.strip() for field in fields.split(",")]
|
||||||
|
digest_data = dict(field.split("=") for field in response_fields)
|
||||||
|
|
||||||
|
assert "qop" not in digest_data
|
||||||
|
assert "nc" not in digest_data
|
||||||
|
assert "cnonce" not in digest_data
|
||||||
|
assert digest_data["username"] == '"user"'
|
||||||
|
assert digest_data["realm"] == '"httpx@example.org"'
|
||||||
|
assert len(digest_data["nonce"]) == 64 + 2 # extra quotes
|
||||||
|
assert digest_data["uri"] == '"/"'
|
||||||
|
assert len(digest_data["response"]) == 64 + 2
|
||||||
|
assert len(digest_data["opaque"]) == 64 + 2
|
||||||
|
assert digest_data["algorithm"] == "SHA-256"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("qop", ("auth, auth-int", "auth,auth-int", "unknown,auth"))
|
||||||
|
|
||||||
|
def test_digest_auth_qop_including_spaces_and_auth_returns_auth(qop: str) -> None:
|
||||||
|
url = "https://example.org/"
|
||||||
|
auth = httpx.DigestAuth(username="user", password="password123")
|
||||||
|
app = DigestApp(qop=qop)
|
||||||
|
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
||||||
|
response = client.get(url, auth=auth)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(response.history) == 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_auth_qop_auth_int_not_implemented() -> None:
|
||||||
|
url = "https://example.org/"
|
||||||
|
auth = httpx.DigestAuth(username="user", password="password123")
|
||||||
|
app = DigestApp(qop="auth-int")
|
||||||
|
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
client.get(url, auth=auth)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_auth_qop_must_be_auth_or_auth_int() -> None:
|
||||||
|
url = "https://example.org/"
|
||||||
|
auth = httpx.DigestAuth(username="user", password="password123")
|
||||||
|
app = DigestApp(qop="not-auth")
|
||||||
|
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
||||||
|
with pytest.raises(httpx.ProtocolError):
|
||||||
|
client.get(url, auth=auth)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_auth_incorrect_credentials() -> None:
|
||||||
|
url = "https://example.org/"
|
||||||
|
auth = httpx.DigestAuth(username="user", password="password123")
|
||||||
|
app = DigestApp(send_response_after_attempt=2)
|
||||||
|
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
||||||
|
response = client.get(url, auth=auth)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert len(response.history) == 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_auth_reuses_challenge() -> None:
|
||||||
|
url = "https://example.org/"
|
||||||
|
auth = httpx.DigestAuth(username="user", password="password123")
|
||||||
|
app = DigestApp()
|
||||||
|
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
||||||
|
response_1 = client.get(url, auth=auth)
|
||||||
|
response_2 = client.get(url, auth=auth)
|
||||||
|
|
||||||
|
assert response_1.status_code == 200
|
||||||
|
assert response_2.status_code == 200
|
||||||
|
|
||||||
|
assert len(response_1.history) == 1
|
||||||
|
assert len(response_2.history) == 0
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_auth_resets_nonce_count_after_401() -> None:
|
||||||
|
url = "https://example.org/"
|
||||||
|
auth = httpx.DigestAuth(username="user", password="password123")
|
||||||
|
app = DigestApp()
|
||||||
|
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
||||||
|
response_1 = client.get(url, auth=auth)
|
||||||
|
assert response_1.status_code == 200
|
||||||
|
assert len(response_1.history) == 1
|
||||||
|
|
||||||
|
first_nonce = parse_keqv_list(
|
||||||
|
response_1.request.headers["Authorization"].split(", ")
|
||||||
|
)["nonce"]
|
||||||
|
first_nc = parse_keqv_list(
|
||||||
|
response_1.request.headers["Authorization"].split(", ")
|
||||||
|
)["nc"]
|
||||||
|
|
||||||
|
# with this we now force a 401 on a subsequent (but initial) request
|
||||||
|
app.send_response_after_attempt = 2
|
||||||
|
|
||||||
|
# we expect the client again to try to authenticate,
|
||||||
|
# i.e. the history length must be 1
|
||||||
|
response_2 = client.get(url, auth=auth)
|
||||||
|
assert response_2.status_code == 200
|
||||||
|
assert len(response_2.history) == 1
|
||||||
|
|
||||||
|
second_nonce = parse_keqv_list(
|
||||||
|
response_2.request.headers["Authorization"].split(", ")
|
||||||
|
)["nonce"]
|
||||||
|
second_nc = parse_keqv_list(
|
||||||
|
response_2.request.headers["Authorization"].split(", ")
|
||||||
|
)["nc"]
|
||||||
|
|
||||||
|
assert first_nonce != second_nonce # ensures that the auth challenge was reset
|
||||||
|
assert (
|
||||||
|
first_nc == second_nc
|
||||||
|
) # ensures the nonce count is reset when the authentication failed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"auth_header",
|
||||||
|
[
|
||||||
|
'Digest realm="httpx@example.org", qop="auth"', # missing fields
|
||||||
|
'Digest realm="httpx@example.org", qop="auth,au', # malformed fields list
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_digest_auth_raises_protocol_error_on_malformed_header(
|
||||||
|
auth_header: str,
|
||||||
|
) -> None:
|
||||||
|
url = "https://example.org/"
|
||||||
|
auth = httpx.DigestAuth(username="user", password="password123")
|
||||||
|
app = App(auth_header=auth_header, status_code=401)
|
||||||
|
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
||||||
|
with pytest.raises(httpx.ProtocolError):
|
||||||
|
client.get(url, auth=auth)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_history() -> None:
|
||||||
|
"""
|
||||||
|
Test that intermediate requests sent as part of an authentication flow
|
||||||
|
are recorded in the response history.
|
||||||
|
"""
|
||||||
|
url = "https://example.org/"
|
||||||
|
auth = RepeatAuth(repeat=2)
|
||||||
|
app = App(auth_header="abc")
|
||||||
|
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
||||||
|
response = client.get(url, auth=auth)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"auth": "Repeat abc.abc"}
|
||||||
|
|
||||||
|
assert len(response.history) == 2
|
||||||
|
resp1, resp2 = response.history
|
||||||
|
assert resp1.json() == {"auth": "Repeat 0"}
|
||||||
|
assert resp2.json() == {"auth": "Repeat 1"}
|
||||||
|
|
||||||
|
assert len(resp2.history) == 1
|
||||||
|
assert resp2.history == [resp1]
|
||||||
|
|
||||||
|
assert len(resp1.history) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class ConsumeBodyTransport(httpx.MockTransport):
|
||||||
|
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
||||||
|
assert isinstance(request.stream, httpx.SyncByteStream)
|
||||||
|
for _ in request.stream:
|
||||||
|
pass
|
||||||
|
return self.handler(request) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_auth_unavailable_streaming_body():
|
||||||
|
url = "https://example.org/"
|
||||||
|
auth = httpx.DigestAuth(username="user", password="password123")
|
||||||
|
app = DigestApp()
|
||||||
|
|
||||||
|
def streaming_body() -> typing.Iterator[bytes]:
|
||||||
|
yield b"Example request body" # pragma: no cover
|
||||||
|
|
||||||
|
with httpx.Client(transport=ConsumeBodyTransport(app)) as client:
|
||||||
|
with pytest.raises(httpx.StreamConsumed):
|
||||||
|
client.post(url, content=streaming_body(), auth=auth)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_reads_response_body() -> None:
|
||||||
|
"""
|
||||||
|
Test that we can read the response body in an auth flow if `requires_response_body`
|
||||||
|
is set.
|
||||||
|
"""
|
||||||
|
url = "https://example.org/"
|
||||||
|
auth = ResponseBodyAuth("xyz")
|
||||||
|
app = App()
|
||||||
|
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
||||||
|
response = client.get(url, auth=auth)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"auth": '{"auth":"xyz"}'}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth() -> None:
|
||||||
|
"""
|
||||||
|
Test that we can use an auth implementation specific to the async case, to
|
||||||
|
support cases that require performing I/O or using concurrency primitives (such
|
||||||
|
as checking a disk-based cache or fetching a token from a remote auth server).
|
||||||
|
"""
|
||||||
|
url = "https://example.org/"
|
||||||
|
auth = Auth()
|
||||||
|
app = App()
|
||||||
|
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(app)) as client:
|
||||||
|
response = client.get(url, auth=auth)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"auth": "auth"}
|
||||||
364
tests/client/sync/test_client.py
Normal file
364
tests/client/sync/test_client.py
Normal file
@ -0,0 +1,364 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import typing
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_get(server):
|
||||||
|
url = server.url
|
||||||
|
with httpx.Client(http2=True) as client:
|
||||||
|
response = client.get(url)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.text == "Hello, world!"
|
||||||
|
assert response.http_version == "HTTP/1.1"
|
||||||
|
assert response.headers
|
||||||
|
assert repr(response) == "<Response [200 OK]>"
|
||||||
|
assert response.elapsed > timedelta(seconds=0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"url",
|
||||||
|
[
|
||||||
|
pytest.param("invalid://example.org", id="scheme-not-http(s)"),
|
||||||
|
pytest.param("://example.org", id="no-scheme"),
|
||||||
|
pytest.param("http://", id="no-host"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_invalid_url(server, url):
|
||||||
|
with httpx.Client() as client:
|
||||||
|
with pytest.raises((httpx.UnsupportedProtocol, httpx.LocalProtocolError)):
|
||||||
|
client.get(url)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_request(server):
|
||||||
|
url = server.url.copy_with(path="/echo_headers")
|
||||||
|
headers = {"Custom-header": "value"}
|
||||||
|
with httpx.Client() as client:
|
||||||
|
request = client.build_request("GET", url)
|
||||||
|
request.headers.update(headers)
|
||||||
|
response = client.send(request)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.url == url
|
||||||
|
|
||||||
|
assert response.json()["Custom-header"] == "value"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_post(server):
|
||||||
|
url = server.url
|
||||||
|
with httpx.Client() as client:
|
||||||
|
response = client.post(url, content=b"Hello, world!")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_json(server):
|
||||||
|
url = server.url
|
||||||
|
with httpx.Client() as client:
|
||||||
|
response = client.post(url, json={"text": "Hello, world!"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_response(server):
|
||||||
|
with httpx.Client() as client:
|
||||||
|
with client.stream("GET", server.url) as response:
|
||||||
|
body = response.read()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert body == b"Hello, world!"
|
||||||
|
assert response.content == b"Hello, world!"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_content_stream_response(server):
|
||||||
|
with httpx.Client() as client:
|
||||||
|
with client.stream("GET", server.url) as response:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
with pytest.raises(httpx.ResponseNotRead):
|
||||||
|
response.content # noqa: B018
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_request(server):
|
||||||
|
def hello_world() -> typing.Iterator[bytes]:
|
||||||
|
yield b"Hello, "
|
||||||
|
yield b"world!"
|
||||||
|
|
||||||
|
with httpx.Client() as client:
|
||||||
|
response = client.post(server.url, content=hello_world())
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_raise_for_status(server):
|
||||||
|
with httpx.Client() as client:
|
||||||
|
for status_code in (200, 400, 404, 500, 505):
|
||||||
|
response = client.request(
|
||||||
|
"GET", server.url.copy_with(path=f"/status/{status_code}")
|
||||||
|
)
|
||||||
|
|
||||||
|
if 400 <= status_code < 600:
|
||||||
|
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
||||||
|
response.raise_for_status()
|
||||||
|
assert exc_info.value.response == response
|
||||||
|
else:
|
||||||
|
assert response.raise_for_status() is response
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_options(server):
|
||||||
|
with httpx.Client() as client:
|
||||||
|
response = client.options(server.url)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.text == "Hello, world!"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_head(server):
|
||||||
|
with httpx.Client() as client:
|
||||||
|
response = client.head(server.url)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.text == ""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_put(server):
|
||||||
|
with httpx.Client() as client:
|
||||||
|
response = client.put(server.url, content=b"Hello, world!")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch(server):
|
||||||
|
with httpx.Client() as client:
|
||||||
|
response = client.patch(server.url, content=b"Hello, world!")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete(server):
|
||||||
|
with httpx.Client() as client:
|
||||||
|
response = client.delete(server.url)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.text == "Hello, world!"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_100_continue(server):
|
||||||
|
headers = {"Expect": "100-continue"}
|
||||||
|
content = b"Echo request body"
|
||||||
|
|
||||||
|
with httpx.Client() as client:
|
||||||
|
response = client.post(
|
||||||
|
server.url.copy_with(path="/echo_body"), headers=headers, content=content
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.content == content
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_managed_transport():
|
||||||
|
class Transport(httpx.BaseTransport):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.events: list[str] = []
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
# The base implementation of httpx.BaseTransport just
|
||||||
|
# calls into `.close`, so simple transport cases can just override
|
||||||
|
# this method for any cleanup, where more complex cases
|
||||||
|
# might want to additionally override `__enter__`/`__exit__`.
|
||||||
|
self.events.append("transport.close")
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
super().__enter__()
|
||||||
|
self.events.append("transport.__enter__")
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
super().__exit__(*args)
|
||||||
|
self.events.append("transport.__exit__")
|
||||||
|
|
||||||
|
transport = Transport()
|
||||||
|
with httpx.Client(transport=transport):
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert transport.events == [
|
||||||
|
"transport.__enter__",
|
||||||
|
"transport.close",
|
||||||
|
"transport.__exit__",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_managed_transport_and_mount():
|
||||||
|
class Transport(httpx.BaseTransport):
|
||||||
|
def __init__(self, name: str) -> None:
|
||||||
|
self.name: str = name
|
||||||
|
self.events: list[str] = []
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
# The base implementation of httpx.BaseTransport just
|
||||||
|
# calls into `.close`, so simple transport cases can just override
|
||||||
|
# this method for any cleanup, where more complex cases
|
||||||
|
# might want to additionally override `__enter__`/`__exit__`.
|
||||||
|
self.events.append(f"{self.name}.close")
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
super().__enter__()
|
||||||
|
self.events.append(f"{self.name}.__enter__")
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
super().__exit__(*args)
|
||||||
|
self.events.append(f"{self.name}.__exit__")
|
||||||
|
|
||||||
|
transport = Transport(name="transport")
|
||||||
|
mounted = Transport(name="mounted")
|
||||||
|
with httpx.Client(
|
||||||
|
transport=transport, mounts={"http://www.example.org": mounted}
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert transport.events == [
|
||||||
|
"transport.__enter__",
|
||||||
|
"transport.close",
|
||||||
|
"transport.__exit__",
|
||||||
|
]
|
||||||
|
assert mounted.events == [
|
||||||
|
"mounted.__enter__",
|
||||||
|
"mounted.close",
|
||||||
|
"mounted.__exit__",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def hello_world(request):
|
||||||
|
return httpx.Response(200, text="Hello, world!")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_closed_state_using_implicit_open():
|
||||||
|
client = httpx.Client(transport=httpx.MockTransport(hello_world))
|
||||||
|
|
||||||
|
assert not client.is_closed
|
||||||
|
client.get("http://example.com")
|
||||||
|
|
||||||
|
assert not client.is_closed
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
assert client.is_closed
|
||||||
|
# Once we're close we cannot make any more requests.
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
client.get("http://example.com")
|
||||||
|
|
||||||
|
# Once we're closed we cannot reopen the client.
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
with client:
|
||||||
|
pass # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_closed_state_using_with_block():
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(hello_world)) as client:
|
||||||
|
assert not client.is_closed
|
||||||
|
client.get("http://example.com")
|
||||||
|
|
||||||
|
assert client.is_closed
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
client.get("http://example.com")
|
||||||
|
|
||||||
|
|
||||||
|
def unmounted(request: httpx.Request) -> httpx.Response:
|
||||||
|
data = {"app": "unmounted"}
|
||||||
|
return httpx.Response(200, json=data)
|
||||||
|
|
||||||
|
|
||||||
|
def mounted(request: httpx.Request) -> httpx.Response:
|
||||||
|
data = {"app": "mounted"}
|
||||||
|
return httpx.Response(200, json=data)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_mounted_transport():
|
||||||
|
transport = httpx.MockTransport(unmounted)
|
||||||
|
mounts = {"custom://": httpx.MockTransport(mounted)}
|
||||||
|
|
||||||
|
with httpx.Client(transport=transport, mounts=mounts) as client:
|
||||||
|
response = client.get("https://www.example.com")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"app": "unmounted"}
|
||||||
|
|
||||||
|
response = client.get("custom://www.example.com")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"app": "mounted"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_mock_transport():
|
||||||
|
def hello_world(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, text="Hello, world!")
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(hello_world)
|
||||||
|
|
||||||
|
with httpx.Client(transport=transport) as client:
|
||||||
|
response = client.get("https://www.example.com")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.text == "Hello, world!"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancellation_during_stream():
|
||||||
|
"""
|
||||||
|
If any BaseException is raised during streaming the response, then the
|
||||||
|
stream should be closed.
|
||||||
|
|
||||||
|
This includes:
|
||||||
|
|
||||||
|
* `asyncio.CancelledError` (A subclass of BaseException from Python 3.8 onwards.)
|
||||||
|
* `trio.Cancelled`
|
||||||
|
* `KeyboardInterrupt`
|
||||||
|
* `SystemExit`
|
||||||
|
|
||||||
|
See https://github.com/encode/httpx/issues/2139
|
||||||
|
"""
|
||||||
|
stream_was_closed = False
|
||||||
|
|
||||||
|
def response_with_cancel_during_stream(request):
|
||||||
|
class CancelledStream(httpx.SyncByteStream):
|
||||||
|
def __iter__(self) -> typing.Iterator[bytes]:
|
||||||
|
yield b"Hello"
|
||||||
|
raise KeyboardInterrupt()
|
||||||
|
yield b", world" # pragma: no cover
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
nonlocal stream_was_closed
|
||||||
|
stream_was_closed = True
|
||||||
|
|
||||||
|
return httpx.Response(
|
||||||
|
200, headers={"Content-Length": "12"}, stream=CancelledStream()
|
||||||
|
)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(response_with_cancel_during_stream)
|
||||||
|
|
||||||
|
with httpx.Client(transport=transport) as client:
|
||||||
|
with pytest.raises(KeyboardInterrupt):
|
||||||
|
client.get("https://www.example.com")
|
||||||
|
assert stream_was_closed
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_extensions(server):
|
||||||
|
url = server.url
|
||||||
|
with httpx.Client(http2=True) as client:
|
||||||
|
response = client.get(url)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.extensions["http_version"] == b"HTTP/1.1"
|
||||||
@ -15,6 +15,7 @@ def get_and_set_cookies(request: httpx.Request) -> httpx.Response:
|
|||||||
raise NotImplementedError() # pragma: no cover
|
raise NotImplementedError() # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_cookie() -> None:
|
def test_set_cookie() -> None:
|
||||||
"""
|
"""
|
||||||
Send a request including a cookie.
|
Send a request including a cookie.
|
||||||
@ -22,13 +23,14 @@ def test_set_cookie() -> None:
|
|||||||
url = "http://example.org/echo_cookies"
|
url = "http://example.org/echo_cookies"
|
||||||
cookies = {"example-name": "example-value"}
|
cookies = {"example-name": "example-value"}
|
||||||
|
|
||||||
client = httpx.Client(
|
with httpx.Client(
|
||||||
cookies=cookies, transport=httpx.MockTransport(get_and_set_cookies)
|
cookies=cookies, transport=httpx.MockTransport(get_and_set_cookies)
|
||||||
)
|
) as client:
|
||||||
response = client.get(url)
|
response = client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"cookies": "example-name=example-value"}
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"cookies": "example-name=example-value"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_per_request_cookie_is_deprecated() -> None:
|
def test_set_per_request_cookie_is_deprecated() -> None:
|
||||||
@ -38,12 +40,15 @@ def test_set_per_request_cookie_is_deprecated() -> None:
|
|||||||
url = "http://example.org/echo_cookies"
|
url = "http://example.org/echo_cookies"
|
||||||
cookies = {"example-name": "example-value"}
|
cookies = {"example-name": "example-value"}
|
||||||
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(get_and_set_cookies))
|
with httpx.Client(
|
||||||
with pytest.warns(DeprecationWarning):
|
transport=httpx.MockTransport(get_and_set_cookies)
|
||||||
response = client.get(url, cookies=cookies)
|
) as client:
|
||||||
|
with pytest.warns(DeprecationWarning):
|
||||||
|
response = client.get(url, cookies=cookies)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"cookies": "example-name=example-value"}
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"cookies": "example-name=example-value"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_cookie_with_cookiejar() -> None:
|
def test_set_cookie_with_cookiejar() -> None:
|
||||||
@ -74,13 +79,14 @@ def test_set_cookie_with_cookiejar() -> None:
|
|||||||
)
|
)
|
||||||
cookies.set_cookie(cookie)
|
cookies.set_cookie(cookie)
|
||||||
|
|
||||||
client = httpx.Client(
|
with httpx.Client(
|
||||||
cookies=cookies, transport=httpx.MockTransport(get_and_set_cookies)
|
cookies=cookies, transport=httpx.MockTransport(get_and_set_cookies)
|
||||||
)
|
) as client:
|
||||||
response = client.get(url)
|
response = client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"cookies": "example-name=example-value"}
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"cookies": "example-name=example-value"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_setting_client_cookies_to_cookiejar() -> None:
|
def test_setting_client_cookies_to_cookiejar() -> None:
|
||||||
@ -111,13 +117,14 @@ def test_setting_client_cookies_to_cookiejar() -> None:
|
|||||||
)
|
)
|
||||||
cookies.set_cookie(cookie)
|
cookies.set_cookie(cookie)
|
||||||
|
|
||||||
client = httpx.Client(
|
with httpx.Client(
|
||||||
cookies=cookies, transport=httpx.MockTransport(get_and_set_cookies)
|
cookies=cookies, transport=httpx.MockTransport(get_and_set_cookies)
|
||||||
)
|
) as client:
|
||||||
response = client.get(url)
|
response = client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"cookies": "example-name=example-value"}
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"cookies": "example-name=example-value"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_cookie_with_cookies_model() -> None:
|
def test_set_cookie_with_cookies_model() -> None:
|
||||||
@ -129,40 +136,47 @@ def test_set_cookie_with_cookies_model() -> None:
|
|||||||
cookies = httpx.Cookies()
|
cookies = httpx.Cookies()
|
||||||
cookies["example-name"] = "example-value"
|
cookies["example-name"] = "example-value"
|
||||||
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(get_and_set_cookies))
|
with httpx.Client(
|
||||||
client.cookies = cookies
|
transport=httpx.MockTransport(get_and_set_cookies)
|
||||||
response = client.get(url)
|
) as client:
|
||||||
|
client.cookies = cookies
|
||||||
|
response = client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"cookies": "example-name=example-value"}
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"cookies": "example-name=example-value"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_cookie() -> None:
|
def test_get_cookie() -> None:
|
||||||
url = "http://example.org/set_cookie"
|
url = "http://example.org/set_cookie"
|
||||||
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(get_and_set_cookies))
|
with httpx.Client(
|
||||||
response = client.get(url)
|
transport=httpx.MockTransport(get_and_set_cookies)
|
||||||
|
) as client:
|
||||||
|
response = client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.cookies["example-name"] == "example-value"
|
||||||
|
assert client.cookies["example-name"] == "example-value"
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.cookies["example-name"] == "example-value"
|
|
||||||
assert client.cookies["example-name"] == "example-value"
|
|
||||||
|
|
||||||
|
|
||||||
def test_cookie_persistence() -> None:
|
def test_cookie_persistence() -> None:
|
||||||
"""
|
"""
|
||||||
Ensure that Client instances persist cookies between requests.
|
Ensure that Client instances persist cookies between requests.
|
||||||
"""
|
"""
|
||||||
client = httpx.Client(transport=httpx.MockTransport(get_and_set_cookies))
|
with httpx.Client(
|
||||||
|
transport=httpx.MockTransport(get_and_set_cookies)
|
||||||
|
) as client:
|
||||||
|
response = client.get("http://example.org/echo_cookies")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"cookies": None}
|
||||||
|
|
||||||
response = client.get("http://example.org/echo_cookies")
|
response = client.get("http://example.org/set_cookie")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {"cookies": None}
|
assert response.cookies["example-name"] == "example-value"
|
||||||
|
assert client.cookies["example-name"] == "example-value"
|
||||||
|
|
||||||
response = client.get("http://example.org/set_cookie")
|
response = client.get("http://example.org/echo_cookies")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.cookies["example-name"] == "example-value"
|
assert response.json() == {"cookies": "example-name=example-value"}
|
||||||
assert client.cookies["example-name"] == "example-value"
|
|
||||||
|
|
||||||
response = client.get("http://example.org/echo_cookies")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"cookies": "example-name=example-value"}
|
|
||||||
122
tests/client/sync/test_event_hooks.py
Normal file
122
tests/client/sync/test_event_hooks.py
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
def app(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.url.path == "/redirect":
|
||||||
|
return httpx.Response(303, headers={"server": "testserver", "location": "/"})
|
||||||
|
elif request.url.path.startswith("/status/"):
|
||||||
|
status_code = int(request.url.path[-3:])
|
||||||
|
return httpx.Response(status_code, headers={"server": "testserver"})
|
||||||
|
|
||||||
|
return httpx.Response(200, headers={"server": "testserver"})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_hooks():
|
||||||
|
events = []
|
||||||
|
|
||||||
|
def on_request(request):
|
||||||
|
events.append({"event": "request", "headers": dict(request.headers)})
|
||||||
|
|
||||||
|
def on_response(response):
|
||||||
|
events.append({"event": "response", "headers": dict(response.headers)})
|
||||||
|
|
||||||
|
event_hooks = {"request": [on_request], "response": [on_response]}
|
||||||
|
|
||||||
|
with httpx.Client(
|
||||||
|
event_hooks=event_hooks, transport=httpx.MockTransport(app)
|
||||||
|
) as http:
|
||||||
|
http.get("http://127.0.0.1:8000/", auth=("username", "password"))
|
||||||
|
|
||||||
|
assert events == [
|
||||||
|
{
|
||||||
|
"event": "request",
|
||||||
|
"headers": {
|
||||||
|
"host": "127.0.0.1:8000",
|
||||||
|
"user-agent": f"python-httpx/{httpx.__version__}",
|
||||||
|
"accept": "*/*",
|
||||||
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
|
"connection": "keep-alive",
|
||||||
|
"authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "response",
|
||||||
|
"headers": {"server": "testserver"},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_hooks_raising_exception():
|
||||||
|
def raise_on_4xx_5xx(response):
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
event_hooks = {"response": [raise_on_4xx_5xx]}
|
||||||
|
|
||||||
|
with httpx.Client(
|
||||||
|
event_hooks=event_hooks, transport=httpx.MockTransport(app)
|
||||||
|
) as http:
|
||||||
|
try:
|
||||||
|
http.get("http://127.0.0.1:8000/status/400")
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
assert exc.response.is_closed
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_hooks_with_redirect():
|
||||||
|
"""
|
||||||
|
A redirect request should trigger additional 'request' and 'response' event hooks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
events = []
|
||||||
|
|
||||||
|
def on_request(request):
|
||||||
|
events.append({"event": "request", "headers": dict(request.headers)})
|
||||||
|
|
||||||
|
def on_response(response):
|
||||||
|
events.append({"event": "response", "headers": dict(response.headers)})
|
||||||
|
|
||||||
|
event_hooks = {"request": [on_request], "response": [on_response]}
|
||||||
|
|
||||||
|
with httpx.Client(
|
||||||
|
event_hooks=event_hooks,
|
||||||
|
transport=httpx.MockTransport(app),
|
||||||
|
follow_redirects=True,
|
||||||
|
) as http:
|
||||||
|
http.get("http://127.0.0.1:8000/redirect", auth=("username", "password"))
|
||||||
|
|
||||||
|
assert events == [
|
||||||
|
{
|
||||||
|
"event": "request",
|
||||||
|
"headers": {
|
||||||
|
"host": "127.0.0.1:8000",
|
||||||
|
"user-agent": f"python-httpx/{httpx.__version__}",
|
||||||
|
"accept": "*/*",
|
||||||
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
|
"connection": "keep-alive",
|
||||||
|
"authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "response",
|
||||||
|
"headers": {"location": "/", "server": "testserver"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "request",
|
||||||
|
"headers": {
|
||||||
|
"host": "127.0.0.1:8000",
|
||||||
|
"user-agent": f"python-httpx/{httpx.__version__}",
|
||||||
|
"accept": "*/*",
|
||||||
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
|
"connection": "keep-alive",
|
||||||
|
"authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "response",
|
||||||
|
"headers": {"server": "testserver"},
|
||||||
|
},
|
||||||
|
]
|
||||||
318
tests/client/sync/test_headers.py
Normal file
318
tests/client/sync/test_headers.py
Normal file
@ -0,0 +1,318 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
def echo_headers(request: httpx.Request) -> httpx.Response:
|
||||||
|
data = {"headers": dict(request.headers)}
|
||||||
|
return httpx.Response(200, json=data)
|
||||||
|
|
||||||
|
|
||||||
|
def echo_repeated_headers_multi_items(request: httpx.Request) -> httpx.Response:
|
||||||
|
data = {"headers": list(request.headers.multi_items())}
|
||||||
|
return httpx.Response(200, json=data)
|
||||||
|
|
||||||
|
|
||||||
|
def echo_repeated_headers_items(request: httpx.Request) -> httpx.Response:
|
||||||
|
data = {"headers": list(request.headers.items())}
|
||||||
|
return httpx.Response(200, json=data)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_header():
|
||||||
|
"""
|
||||||
|
Set a header in the Client.
|
||||||
|
"""
|
||||||
|
url = "http://example.org/echo_headers"
|
||||||
|
headers = {"Example-Header": "example-value"}
|
||||||
|
|
||||||
|
with httpx.Client(
|
||||||
|
transport=httpx.MockTransport(echo_headers), headers=headers
|
||||||
|
) as client:
|
||||||
|
response = client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"headers": {
|
||||||
|
"accept": "*/*",
|
||||||
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
|
"connection": "keep-alive",
|
||||||
|
"example-header": "example-value",
|
||||||
|
"host": "example.org",
|
||||||
|
"user-agent": f"python-httpx/{httpx.__version__}",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_merge():
|
||||||
|
url = "http://example.org/echo_headers"
|
||||||
|
client_headers = {"User-Agent": "python-myclient/0.2.1"}
|
||||||
|
request_headers = {"X-Auth-Token": "FooBarBazToken"}
|
||||||
|
with httpx.Client(
|
||||||
|
transport=httpx.MockTransport(echo_headers), headers=client_headers
|
||||||
|
) as client:
|
||||||
|
response = client.get(url, headers=request_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"headers": {
|
||||||
|
"accept": "*/*",
|
||||||
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
|
"connection": "keep-alive",
|
||||||
|
"host": "example.org",
|
||||||
|
"user-agent": "python-myclient/0.2.1",
|
||||||
|
"x-auth-token": "FooBarBazToken",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_merge_conflicting_headers():
|
||||||
|
url = "http://example.org/echo_headers"
|
||||||
|
client_headers = {"X-Auth-Token": "FooBar"}
|
||||||
|
request_headers = {"X-Auth-Token": "BazToken"}
|
||||||
|
with httpx.Client(
|
||||||
|
transport=httpx.MockTransport(echo_headers), headers=client_headers
|
||||||
|
) as client:
|
||||||
|
response = client.get(url, headers=request_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"headers": {
|
||||||
|
"accept": "*/*",
|
||||||
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
|
"connection": "keep-alive",
|
||||||
|
"host": "example.org",
|
||||||
|
"user-agent": f"python-httpx/{httpx.__version__}",
|
||||||
|
"x-auth-token": "BazToken",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_update():
|
||||||
|
url = "http://example.org/echo_headers"
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(echo_headers)) as client:
|
||||||
|
first_response = client.get(url)
|
||||||
|
client.headers.update(
|
||||||
|
{"User-Agent": "python-myclient/0.2.1", "Another-Header": "AThing"}
|
||||||
|
)
|
||||||
|
second_response = client.get(url)
|
||||||
|
|
||||||
|
assert first_response.status_code == 200
|
||||||
|
assert first_response.json() == {
|
||||||
|
"headers": {
|
||||||
|
"accept": "*/*",
|
||||||
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
|
"connection": "keep-alive",
|
||||||
|
"host": "example.org",
|
||||||
|
"user-agent": f"python-httpx/{httpx.__version__}",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert second_response.status_code == 200
|
||||||
|
assert second_response.json() == {
|
||||||
|
"headers": {
|
||||||
|
"accept": "*/*",
|
||||||
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
|
"another-header": "AThing",
|
||||||
|
"connection": "keep-alive",
|
||||||
|
"host": "example.org",
|
||||||
|
"user-agent": "python-myclient/0.2.1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_repeated_items():
|
||||||
|
url = "http://example.org/echo_headers"
|
||||||
|
with httpx.Client(
|
||||||
|
transport=httpx.MockTransport(echo_repeated_headers_items)
|
||||||
|
) as client:
|
||||||
|
response = client.get(
|
||||||
|
url, headers=[("x-header", "1"), ("x-header", "2,3")]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
echoed_headers = response.json()["headers"]
|
||||||
|
# as per RFC 7230, the whitespace after a comma is insignificant
|
||||||
|
# so we split and strip here so that we can do a safe comparison
|
||||||
|
assert ["x-header", ["1", "2", "3"]] in [
|
||||||
|
[k, [subv.lstrip() for subv in v.split(",")]] for k, v in echoed_headers
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_repeated_multi_items():
|
||||||
|
url = "http://example.org/echo_headers"
|
||||||
|
with httpx.Client(
|
||||||
|
transport=httpx.MockTransport(echo_repeated_headers_multi_items)
|
||||||
|
) as client:
|
||||||
|
response = client.get(
|
||||||
|
url, headers=[("x-header", "1"), ("x-header", "2,3")]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
echoed_headers = response.json()["headers"]
|
||||||
|
assert ["x-header", "1"] in echoed_headers
|
||||||
|
assert ["x-header", "2,3"] in echoed_headers
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_default_header():
|
||||||
|
"""
|
||||||
|
Remove a default header from the Client.
|
||||||
|
"""
|
||||||
|
url = "http://example.org/echo_headers"
|
||||||
|
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(echo_headers)) as client:
|
||||||
|
del client.headers["User-Agent"]
|
||||||
|
|
||||||
|
response = client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"headers": {
|
||||||
|
"accept": "*/*",
|
||||||
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
|
"connection": "keep-alive",
|
||||||
|
"host": "example.org",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_does_not_exist():
|
||||||
|
headers = httpx.Headers({"foo": "bar"})
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
del headers["baz"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_with_incorrect_value():
|
||||||
|
with pytest.raises(
|
||||||
|
TypeError,
|
||||||
|
match=f"Header value must be str or bytes, not {type(None)}",
|
||||||
|
):
|
||||||
|
httpx.Headers({"foo": None}) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_host_with_auth_and_port_in_url():
|
||||||
|
"""
|
||||||
|
The Host header should only include the hostname, or hostname:port
|
||||||
|
(for non-default ports only). Any userinfo or default port should not
|
||||||
|
be present.
|
||||||
|
"""
|
||||||
|
url = "http://username:password@example.org:80/echo_headers"
|
||||||
|
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(echo_headers)) as client:
|
||||||
|
response = client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"headers": {
|
||||||
|
"accept": "*/*",
|
||||||
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
|
"connection": "keep-alive",
|
||||||
|
"host": "example.org",
|
||||||
|
"user-agent": f"python-httpx/{httpx.__version__}",
|
||||||
|
"authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_host_with_non_default_port_in_url():
|
||||||
|
"""
|
||||||
|
If the URL includes a non-default port, then it should be included in
|
||||||
|
the Host header.
|
||||||
|
"""
|
||||||
|
url = "http://username:password@example.org:123/echo_headers"
|
||||||
|
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(echo_headers)) as client:
|
||||||
|
response = client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"headers": {
|
||||||
|
"accept": "*/*",
|
||||||
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
|
"connection": "keep-alive",
|
||||||
|
"host": "example.org:123",
|
||||||
|
"user-agent": f"python-httpx/{httpx.__version__}",
|
||||||
|
"authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_auto_headers():
|
||||||
|
request = httpx.Request("GET", "https://www.example.org/")
|
||||||
|
assert "host" in request.headers
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_origin():
|
||||||
|
origin = httpx.URL("https://example.com")
|
||||||
|
request = httpx.Request("GET", "HTTPS://EXAMPLE.COM:443")
|
||||||
|
|
||||||
|
with httpx.Client() as 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")
|
||||||
|
|
||||||
|
with httpx.Client() as 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"}
|
||||||
|
)
|
||||||
|
|
||||||
|
with httpx.Client() as 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"}
|
||||||
|
)
|
||||||
|
|
||||||
|
with httpx.Client() as 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"}
|
||||||
|
)
|
||||||
|
|
||||||
|
with httpx.Client() as client:
|
||||||
|
headers = client._redirect_headers(request, url, "GET")
|
||||||
|
|
||||||
|
assert "Authorization" not in headers
|
||||||
77
tests/client/sync/test_properties.py
Normal file
77
tests/client/sync/test_properties.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_base_url():
|
||||||
|
with httpx.Client() as client:
|
||||||
|
client.base_url = "https://www.example.org/" # type: ignore
|
||||||
|
assert isinstance(client.base_url, httpx.URL)
|
||||||
|
assert client.base_url == "https://www.example.org/"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_base_url_without_trailing_slash():
|
||||||
|
with httpx.Client() as client:
|
||||||
|
client.base_url = "https://www.example.org/path" # type: ignore
|
||||||
|
assert isinstance(client.base_url, httpx.URL)
|
||||||
|
assert client.base_url == "https://www.example.org/path/"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_base_url_with_trailing_slash():
|
||||||
|
client = httpx.Client()
|
||||||
|
client.base_url = "https://www.example.org/path/" # type: ignore
|
||||||
|
assert isinstance(client.base_url, httpx.URL)
|
||||||
|
assert client.base_url == "https://www.example.org/path/"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_headers():
|
||||||
|
with httpx.Client() as client:
|
||||||
|
client.headers = {"a": "b"} # type: ignore
|
||||||
|
assert isinstance(client.headers, httpx.Headers)
|
||||||
|
assert client.headers["A"] == "b"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_cookies():
|
||||||
|
with httpx.Client() as client:
|
||||||
|
client.cookies = {"a": "b"} # type: ignore
|
||||||
|
assert isinstance(client.cookies, httpx.Cookies)
|
||||||
|
mycookies = list(client.cookies.jar)
|
||||||
|
assert len(mycookies) == 1
|
||||||
|
assert mycookies[0].name == "a" and mycookies[0].value == "b"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_timeout():
|
||||||
|
expected_timeout = 12.0
|
||||||
|
with httpx.Client() as client:
|
||||||
|
client.timeout = expected_timeout # type: ignore
|
||||||
|
|
||||||
|
assert isinstance(client.timeout, httpx.Timeout)
|
||||||
|
assert client.timeout.connect == expected_timeout
|
||||||
|
assert client.timeout.read == expected_timeout
|
||||||
|
assert client.timeout.write == expected_timeout
|
||||||
|
assert client.timeout.pool == expected_timeout
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_event_hooks():
|
||||||
|
def on_request(request):
|
||||||
|
pass # pragma: no cover
|
||||||
|
|
||||||
|
with httpx.Client() as client:
|
||||||
|
client.event_hooks = {"request": [on_request]}
|
||||||
|
assert client.event_hooks == {"request": [on_request], "response": []}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_trust_env():
|
||||||
|
with httpx.Client() as client:
|
||||||
|
assert client.trust_env
|
||||||
|
|
||||||
|
with httpx.Client(trust_env=False) as client:
|
||||||
|
assert not client.trust_env
|
||||||
@ -13,19 +13,15 @@ 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="/")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def test_socks_proxy():
|
def test_socks_proxy():
|
||||||
url = httpx.URL("http://www.example.com")
|
url = httpx.URL("http://www.example.com")
|
||||||
|
|
||||||
for proxy in ("socks5://localhost/", "socks5h://localhost/"):
|
for proxy in ("socks5://localhost/", "socks5h://localhost/"):
|
||||||
client = httpx.Client(proxy=proxy)
|
with httpx.Client(proxy=proxy) as client:
|
||||||
transport = client._transport_for_url(url)
|
transport = client._transport_for_url(url)
|
||||||
assert isinstance(transport, httpx.HTTPTransport)
|
assert isinstance(transport, httpx.HTTPTransport)
|
||||||
assert isinstance(transport._pool, httpcore.SOCKSProxy)
|
assert isinstance(transport._pool, httpcore.SOCKSProxy)
|
||||||
|
|
||||||
async_client = httpx.AsyncClient(proxy=proxy)
|
|
||||||
async_transport = async_client._transport_for_url(url)
|
|
||||||
assert isinstance(async_transport, httpx.AsyncHTTPTransport)
|
|
||||||
assert isinstance(async_transport._pool, httpcore.AsyncSOCKSProxy)
|
|
||||||
|
|
||||||
|
|
||||||
PROXY_URL = "http://[::1]"
|
PROXY_URL = "http://[::1]"
|
||||||
@ -85,33 +81,25 @@ PROXY_URL = "http://[::1]"
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_transport_for_request(url, proxies, expected):
|
def test_transport_for_request(url, proxies, expected):
|
||||||
mounts = {key: httpx.HTTPTransport(proxy=value) for key, value in proxies.items()}
|
mounts = {
|
||||||
client = httpx.Client(mounts=mounts)
|
key: httpx.HTTPTransport(proxy=value) for key, value in proxies.items()
|
||||||
|
}
|
||||||
|
with httpx.Client(mounts=mounts) as client:
|
||||||
|
transport = client._transport_for_url(httpx.URL(url))
|
||||||
|
|
||||||
transport = client._transport_for_url(httpx.URL(url))
|
if expected is None:
|
||||||
|
assert transport is client._transport
|
||||||
|
else:
|
||||||
|
assert isinstance(transport, httpx.HTTPTransport)
|
||||||
|
assert isinstance(transport._pool, httpcore.HTTPProxy)
|
||||||
|
assert transport._pool._proxy_url == url_to_origin(expected)
|
||||||
|
|
||||||
if expected is None:
|
|
||||||
assert transport is client._transport
|
|
||||||
else:
|
|
||||||
assert isinstance(transport, httpx.HTTPTransport)
|
|
||||||
assert isinstance(transport._pool, httpcore.HTTPProxy)
|
|
||||||
assert transport._pool._proxy_url == url_to_origin(expected)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
@pytest.mark.network
|
|
||||||
async def test_async_proxy_close():
|
|
||||||
try:
|
|
||||||
transport = httpx.AsyncHTTPTransport(proxy=PROXY_URL)
|
|
||||||
client = httpx.AsyncClient(mounts={"https://": transport})
|
|
||||||
await client.get("http://example.com")
|
|
||||||
finally:
|
|
||||||
await client.aclose()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.network
|
@pytest.mark.network
|
||||||
def test_sync_proxy_close():
|
def test_proxy_close():
|
||||||
try:
|
try:
|
||||||
transport = httpx.HTTPTransport(proxy=PROXY_URL)
|
transport = httpx.HTTPTransport(proxy=PROXY_URL)
|
||||||
client = httpx.Client(mounts={"https://": transport})
|
client = httpx.Client(mounts={"https://": transport})
|
||||||
@ -120,6 +108,7 @@ def test_sync_proxy_close():
|
|||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def test_unsupported_proxy_scheme():
|
def test_unsupported_proxy_scheme():
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
httpx.Client(proxy="ftp://127.0.0.1")
|
httpx.Client(proxy="ftp://127.0.0.1")
|
||||||
@ -222,18 +211,18 @@ def test_unsupported_proxy_scheme():
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@pytest.mark.parametrize("client_class", [httpx.Client, httpx.AsyncClient])
|
|
||||||
def test_proxies_environ(monkeypatch, client_class, url, env, expected):
|
def test_proxies_environ(monkeypatch, url, env, expected):
|
||||||
for name, value in env.items():
|
for name, value in env.items():
|
||||||
monkeypatch.setenv(name, value)
|
monkeypatch.setenv(name, value)
|
||||||
|
|
||||||
client = client_class()
|
with httpx.Client() as client:
|
||||||
transport = client._transport_for_url(httpx.URL(url))
|
transport = client._transport_for_url(httpx.URL(url))
|
||||||
|
|
||||||
if expected is None:
|
if expected is None:
|
||||||
assert transport == client._transport
|
assert transport == client._transport
|
||||||
else:
|
else:
|
||||||
assert transport._pool._proxy_url == url_to_origin(expected)
|
assert transport._pool._proxy_url == url_to_origin(expected) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@ -247,8 +236,11 @@ def test_proxies_environ(monkeypatch, client_class, url, env, expected):
|
|||||||
({"all://": "http://127.0.0.1"}, True),
|
({"all://": "http://127.0.0.1"}, True),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
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()}
|
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):
|
||||||
@ -257,9 +249,10 @@ def test_for_deprecated_proxy_params(proxies, is_valid):
|
|||||||
httpx.Client(mounts=mounts)
|
httpx.Client(mounts=mounts)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def test_proxy_with_mounts():
|
def test_proxy_with_mounts():
|
||||||
proxy_transport = httpx.HTTPTransport(proxy="http://127.0.0.1")
|
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"))
|
with httpx.Client(mounts={"http://": proxy_transport}) as client:
|
||||||
assert transport == proxy_transport
|
transport = client._transport_for_url(httpx.URL("http://example.com"))
|
||||||
|
assert transport == proxy_transport
|
||||||
42
tests/client/sync/test_queryparams.py
Normal file
42
tests/client/sync/test_queryparams.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
def hello_world(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, text="Hello, world")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_queryparams():
|
||||||
|
client = httpx.Client(params={"a": "b"})
|
||||||
|
assert isinstance(client.params, httpx.QueryParams)
|
||||||
|
assert client.params["a"] == "b"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_queryparams_string():
|
||||||
|
with httpx.Client(params="a=b") as client:
|
||||||
|
assert isinstance(client.params, httpx.QueryParams)
|
||||||
|
assert client.params["a"] == "b"
|
||||||
|
|
||||||
|
with httpx.Client() as client:
|
||||||
|
client.params = "a=b" # type: ignore
|
||||||
|
assert isinstance(client.params, httpx.QueryParams)
|
||||||
|
assert client.params["a"] == "b"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_queryparams_echo():
|
||||||
|
url = "http://example.org/echo_queryparams"
|
||||||
|
client_queryparams = "first=str"
|
||||||
|
request_queryparams = {"second": "dict"}
|
||||||
|
with httpx.Client(
|
||||||
|
transport=httpx.MockTransport(hello_world), params=client_queryparams
|
||||||
|
) as client:
|
||||||
|
response = client.get(url, params=request_queryparams)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert (
|
||||||
|
response.url == "http://example.org/echo_queryparams?first=str&second=dict"
|
||||||
|
)
|
||||||
456
tests/client/sync/test_redirects.py
Normal file
456
tests/client/sync/test_redirects.py
Normal file
@ -0,0 +1,456 @@
|
|||||||
|
import typing
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
def redirects(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.url.scheme not in ("http", "https"):
|
||||||
|
raise httpx.UnsupportedProtocol(f"Scheme {request.url.scheme!r} not supported.")
|
||||||
|
|
||||||
|
if request.url.path == "/redirect_301":
|
||||||
|
status_code = httpx.codes.MOVED_PERMANENTLY
|
||||||
|
content = b"<a href='https://example.org/'>here</a>"
|
||||||
|
headers = {"location": "https://example.org/"}
|
||||||
|
return httpx.Response(status_code, headers=headers, content=content)
|
||||||
|
|
||||||
|
elif request.url.path == "/redirect_302":
|
||||||
|
status_code = httpx.codes.FOUND
|
||||||
|
headers = {"location": "https://example.org/"}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
elif request.url.path == "/redirect_303":
|
||||||
|
status_code = httpx.codes.SEE_OTHER
|
||||||
|
headers = {"location": "https://example.org/"}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
elif request.url.path == "/relative_redirect":
|
||||||
|
status_code = httpx.codes.SEE_OTHER
|
||||||
|
headers = {"location": "/"}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
elif request.url.path == "/malformed_redirect":
|
||||||
|
status_code = httpx.codes.SEE_OTHER
|
||||||
|
headers = {"location": "https://:443/"}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
elif request.url.path == "/invalid_redirect":
|
||||||
|
status_code = httpx.codes.SEE_OTHER
|
||||||
|
raw_headers = [(b"location", "https://😇/".encode("utf-8"))]
|
||||||
|
return httpx.Response(status_code, headers=raw_headers)
|
||||||
|
|
||||||
|
elif request.url.path == "/no_scheme_redirect":
|
||||||
|
status_code = httpx.codes.SEE_OTHER
|
||||||
|
headers = {"location": "//example.org/"}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
elif request.url.path == "/multiple_redirects":
|
||||||
|
params = httpx.QueryParams(request.url.query)
|
||||||
|
count = int(params.get("count", "0"))
|
||||||
|
redirect_count = count - 1
|
||||||
|
status_code = httpx.codes.SEE_OTHER if count else httpx.codes.OK
|
||||||
|
if count:
|
||||||
|
location = "/multiple_redirects"
|
||||||
|
if redirect_count:
|
||||||
|
location += f"?count={redirect_count}"
|
||||||
|
headers = {"location": location}
|
||||||
|
else:
|
||||||
|
headers = {}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
if request.url.path == "/redirect_loop":
|
||||||
|
status_code = httpx.codes.SEE_OTHER
|
||||||
|
headers = {"location": "/redirect_loop"}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
elif request.url.path == "/cross_domain":
|
||||||
|
status_code = httpx.codes.SEE_OTHER
|
||||||
|
headers = {"location": "https://example.org/cross_domain_target"}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
elif request.url.path == "/cross_domain_target":
|
||||||
|
status_code = httpx.codes.OK
|
||||||
|
data = {
|
||||||
|
"body": request.content.decode("ascii"),
|
||||||
|
"headers": dict(request.headers),
|
||||||
|
}
|
||||||
|
return httpx.Response(status_code, json=data)
|
||||||
|
|
||||||
|
elif request.url.path == "/redirect_body":
|
||||||
|
status_code = httpx.codes.PERMANENT_REDIRECT
|
||||||
|
headers = {"location": "/redirect_body_target"}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
elif request.url.path == "/redirect_no_body":
|
||||||
|
status_code = httpx.codes.SEE_OTHER
|
||||||
|
headers = {"location": "/redirect_body_target"}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
elif request.url.path == "/redirect_body_target":
|
||||||
|
data = {
|
||||||
|
"body": request.content.decode("ascii"),
|
||||||
|
"headers": dict(request.headers),
|
||||||
|
}
|
||||||
|
return httpx.Response(200, json=data)
|
||||||
|
|
||||||
|
elif request.url.path == "/cross_subdomain":
|
||||||
|
if request.headers["Host"] != "www.example.org":
|
||||||
|
status_code = httpx.codes.PERMANENT_REDIRECT
|
||||||
|
headers = {"location": "https://www.example.org/cross_subdomain"}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
else:
|
||||||
|
return httpx.Response(200, text="Hello, world!")
|
||||||
|
|
||||||
|
elif request.url.path == "/redirect_custom_scheme":
|
||||||
|
status_code = httpx.codes.MOVED_PERMANENTLY
|
||||||
|
headers = {"location": "market://details?id=42"}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
if request.method == "HEAD":
|
||||||
|
return httpx.Response(200)
|
||||||
|
|
||||||
|
return httpx.Response(200, html="<html><body>Hello, world!</body></html>")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_redirect_301():
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
response = client.post(
|
||||||
|
"https://example.org/redirect_301", follow_redirects=True
|
||||||
|
)
|
||||||
|
assert response.status_code == httpx.codes.OK
|
||||||
|
assert response.url == "https://example.org/"
|
||||||
|
assert len(response.history) == 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_redirect_302():
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
response = client.post(
|
||||||
|
"https://example.org/redirect_302", follow_redirects=True
|
||||||
|
)
|
||||||
|
assert response.status_code == httpx.codes.OK
|
||||||
|
assert response.url == "https://example.org/"
|
||||||
|
assert len(response.history) == 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_redirect_303():
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
response = client.get(
|
||||||
|
"https://example.org/redirect_303", follow_redirects=True
|
||||||
|
)
|
||||||
|
assert response.status_code == httpx.codes.OK
|
||||||
|
assert response.url == "https://example.org/"
|
||||||
|
assert len(response.history) == 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_request():
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
request = client.build_request("POST", "https://example.org/redirect_303")
|
||||||
|
response = client.send(request, follow_redirects=False)
|
||||||
|
assert response.status_code == httpx.codes.SEE_OTHER
|
||||||
|
assert response.url == "https://example.org/redirect_303"
|
||||||
|
assert response.next_request is not None
|
||||||
|
|
||||||
|
response = client.send(response.next_request, follow_redirects=False)
|
||||||
|
assert response.status_code == httpx.codes.OK
|
||||||
|
assert response.url == "https://example.org/"
|
||||||
|
assert response.next_request is None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_head_redirect():
|
||||||
|
"""
|
||||||
|
Contrary to Requests, redirects remain enabled by default for HEAD requests.
|
||||||
|
"""
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
response = client.head(
|
||||||
|
"https://example.org/redirect_302", follow_redirects=True
|
||||||
|
)
|
||||||
|
assert response.status_code == httpx.codes.OK
|
||||||
|
assert response.url == "https://example.org/"
|
||||||
|
assert response.request.method == "HEAD"
|
||||||
|
assert len(response.history) == 1
|
||||||
|
assert response.text == ""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_redirect():
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
response = client.get(
|
||||||
|
"https://example.org/relative_redirect", follow_redirects=True
|
||||||
|
)
|
||||||
|
assert response.status_code == httpx.codes.OK
|
||||||
|
assert response.url == "https://example.org/"
|
||||||
|
assert len(response.history) == 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_redirect():
|
||||||
|
# https://github.com/encode/httpx/issues/771
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
response = client.get(
|
||||||
|
"http://example.org/malformed_redirect", follow_redirects=True
|
||||||
|
)
|
||||||
|
assert response.status_code == httpx.codes.OK
|
||||||
|
assert response.url == "https://example.org:443/"
|
||||||
|
assert len(response.history) == 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_scheme_redirect():
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
response = client.get(
|
||||||
|
"https://example.org/no_scheme_redirect", follow_redirects=True
|
||||||
|
)
|
||||||
|
assert response.status_code == httpx.codes.OK
|
||||||
|
assert response.url == "https://example.org/"
|
||||||
|
assert len(response.history) == 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_fragment_redirect():
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
response = client.get(
|
||||||
|
"https://example.org/relative_redirect#fragment", follow_redirects=True
|
||||||
|
)
|
||||||
|
assert response.status_code == httpx.codes.OK
|
||||||
|
assert response.url == "https://example.org/#fragment"
|
||||||
|
assert len(response.history) == 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_redirects():
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
response = client.get(
|
||||||
|
"https://example.org/multiple_redirects?count=20", follow_redirects=True
|
||||||
|
)
|
||||||
|
assert response.status_code == httpx.codes.OK
|
||||||
|
assert response.url == "https://example.org/multiple_redirects"
|
||||||
|
assert len(response.history) == 20
|
||||||
|
assert (
|
||||||
|
response.history[0].url == "https://example.org/multiple_redirects?count=20"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
response.history[1].url == "https://example.org/multiple_redirects?count=19"
|
||||||
|
)
|
||||||
|
assert len(response.history[0].history) == 0
|
||||||
|
assert len(response.history[1].history) == 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_too_many_redirects():
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
with pytest.raises(httpx.TooManyRedirects):
|
||||||
|
client.get(
|
||||||
|
"https://example.org/multiple_redirects?count=21", follow_redirects=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_redirect_loop():
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
with pytest.raises(httpx.TooManyRedirects):
|
||||||
|
client.get("https://example.org/redirect_loop", follow_redirects=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_domain_redirect_with_auth_header():
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
url = "https://example.com/cross_domain"
|
||||||
|
headers = {"Authorization": "abc"}
|
||||||
|
response = client.get(url, headers=headers, follow_redirects=True)
|
||||||
|
assert response.url == "https://example.org/cross_domain_target"
|
||||||
|
assert "authorization" not in response.json()["headers"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_domain_https_redirect_with_auth_header():
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
url = "http://example.com/cross_domain"
|
||||||
|
headers = {"Authorization": "abc"}
|
||||||
|
response = client.get(url, headers=headers, follow_redirects=True)
|
||||||
|
assert response.url == "https://example.org/cross_domain_target"
|
||||||
|
assert "authorization" not in response.json()["headers"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_domain_redirect_with_auth():
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
url = "https://example.com/cross_domain"
|
||||||
|
response = client.get(url, auth=("user", "pass"), follow_redirects=True)
|
||||||
|
assert response.url == "https://example.org/cross_domain_target"
|
||||||
|
assert "authorization" not in response.json()["headers"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_domain_redirect():
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
url = "https://example.org/cross_domain"
|
||||||
|
headers = {"Authorization": "abc"}
|
||||||
|
response = client.get(url, headers=headers, follow_redirects=True)
|
||||||
|
assert response.url == "https://example.org/cross_domain_target"
|
||||||
|
assert response.json()["headers"]["authorization"] == "abc"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_domain_https_redirect_with_auth_header():
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
url = "http://example.org/cross_domain"
|
||||||
|
headers = {"Authorization": "abc"}
|
||||||
|
response = client.get(url, headers=headers, follow_redirects=True)
|
||||||
|
assert response.url == "https://example.org/cross_domain_target"
|
||||||
|
assert response.json()["headers"]["authorization"] == "abc"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_body_redirect():
|
||||||
|
"""
|
||||||
|
A 308 redirect should preserve the request body.
|
||||||
|
"""
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
url = "https://example.org/redirect_body"
|
||||||
|
content = b"Example request body"
|
||||||
|
response = client.post(url, content=content, follow_redirects=True)
|
||||||
|
assert response.url == "https://example.org/redirect_body_target"
|
||||||
|
assert response.json()["body"] == "Example request body"
|
||||||
|
assert "content-length" in response.json()["headers"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_body_redirect():
|
||||||
|
"""
|
||||||
|
A 303 redirect should remove the request body.
|
||||||
|
"""
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
url = "https://example.org/redirect_no_body"
|
||||||
|
content = b"Example request body"
|
||||||
|
response = client.post(url, content=content, follow_redirects=True)
|
||||||
|
assert response.url == "https://example.org/redirect_body_target"
|
||||||
|
assert response.json()["body"] == ""
|
||||||
|
assert "content-length" not in response.json()["headers"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_stream_if_no_redirect():
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
url = "https://example.org/redirect_301"
|
||||||
|
with client.stream("GET", url, follow_redirects=False) as response:
|
||||||
|
pass
|
||||||
|
assert response.status_code == httpx.codes.MOVED_PERMANENTLY
|
||||||
|
assert response.headers["location"] == "https://example.org/"
|
||||||
|
|
||||||
|
|
||||||
|
class ConsumeBodyTransport(httpx.MockTransport):
|
||||||
|
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
||||||
|
assert isinstance(request.stream, httpx.SyncByteStream)
|
||||||
|
for _ in request.stream:
|
||||||
|
pass
|
||||||
|
return self.handler(request) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_cannot_redirect_streaming_body():
|
||||||
|
with httpx.Client(transport=ConsumeBodyTransport(redirects)) as client:
|
||||||
|
url = "https://example.org/redirect_body"
|
||||||
|
|
||||||
|
def streaming_body() -> typing.Iterator[bytes]:
|
||||||
|
yield b"Example request body" # pragma: no cover
|
||||||
|
|
||||||
|
with pytest.raises(httpx.StreamConsumed):
|
||||||
|
client.post(url, content=streaming_body(), follow_redirects=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_subdomain_redirect():
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
url = "https://example.com/cross_subdomain"
|
||||||
|
response = client.get(url, follow_redirects=True)
|
||||||
|
assert response.url == "https://www.example.org/cross_subdomain"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def cookie_sessions(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.url.path == "/":
|
||||||
|
cookie = request.headers.get("Cookie")
|
||||||
|
if cookie is not None:
|
||||||
|
content = b"Logged in"
|
||||||
|
else:
|
||||||
|
content = b"Not logged in"
|
||||||
|
return httpx.Response(200, content=content)
|
||||||
|
|
||||||
|
elif request.url.path == "/login":
|
||||||
|
status_code = httpx.codes.SEE_OTHER
|
||||||
|
headers = {
|
||||||
|
"location": "/",
|
||||||
|
"set-cookie": (
|
||||||
|
"session=eyJ1c2VybmFtZSI6ICJ0b21; path=/; Max-Age=1209600; "
|
||||||
|
"httponly; samesite=lax"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
else:
|
||||||
|
assert request.url.path == "/logout"
|
||||||
|
status_code = httpx.codes.SEE_OTHER
|
||||||
|
headers = {
|
||||||
|
"location": "/",
|
||||||
|
"set-cookie": (
|
||||||
|
"session=null; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; "
|
||||||
|
"httponly; samesite=lax"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return httpx.Response(status_code, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_redirect_cookie_behavior():
|
||||||
|
with httpx.Client(
|
||||||
|
transport=httpx.MockTransport(cookie_sessions), follow_redirects=True
|
||||||
|
) as client:
|
||||||
|
# The client is not logged in.
|
||||||
|
response = client.get("https://example.com/")
|
||||||
|
assert response.url == "https://example.com/"
|
||||||
|
assert response.text == "Not logged in"
|
||||||
|
|
||||||
|
# Login redirects to the homepage, setting a session cookie.
|
||||||
|
response = client.post("https://example.com/login")
|
||||||
|
assert response.url == "https://example.com/"
|
||||||
|
assert response.text == "Logged in"
|
||||||
|
|
||||||
|
# The client is logged in.
|
||||||
|
response = client.get("https://example.com/")
|
||||||
|
assert response.url == "https://example.com/"
|
||||||
|
assert response.text == "Logged in"
|
||||||
|
|
||||||
|
# Logout redirects to the homepage, expiring the session cookie.
|
||||||
|
response = client.post("https://example.com/logout")
|
||||||
|
assert response.url == "https://example.com/"
|
||||||
|
assert response.text == "Not logged in"
|
||||||
|
|
||||||
|
# The client is not logged in.
|
||||||
|
response = client.get("https://example.com/")
|
||||||
|
assert response.url == "https://example.com/"
|
||||||
|
assert response.text == "Not logged in"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_redirect_custom_scheme():
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
with pytest.raises(httpx.UnsupportedProtocol) as e:
|
||||||
|
client.post(
|
||||||
|
"https://example.org/redirect_custom_scheme", follow_redirects=True
|
||||||
|
)
|
||||||
|
assert str(e.value) == "Scheme 'market' not supported."
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_redirect():
|
||||||
|
with httpx.Client(transport=httpx.MockTransport(redirects)) as client:
|
||||||
|
with pytest.raises(httpx.RemoteProtocolError):
|
||||||
|
client.get(
|
||||||
|
"http://example.org/invalid_redirect", follow_redirects=True
|
||||||
|
)
|
||||||
@ -1,462 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
import chardet
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
|
|
||||||
def autodetect(content):
|
|
||||||
return chardet.detect(content).get("encoding")
|
|
||||||
|
|
||||||
|
|
||||||
def test_get(server):
|
|
||||||
url = server.url
|
|
||||||
with httpx.Client(http2=True) as http:
|
|
||||||
response = http.get(url)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.url == url
|
|
||||||
assert response.content == b"Hello, world!"
|
|
||||||
assert response.text == "Hello, world!"
|
|
||||||
assert response.http_version == "HTTP/1.1"
|
|
||||||
assert response.encoding == "utf-8"
|
|
||||||
assert response.request.url == url
|
|
||||||
assert response.headers
|
|
||||||
assert response.is_redirect is False
|
|
||||||
assert repr(response) == "<Response [200 OK]>"
|
|
||||||
assert response.elapsed > timedelta(0)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"url",
|
|
||||||
[
|
|
||||||
pytest.param("invalid://example.org", id="scheme-not-http(s)"),
|
|
||||||
pytest.param("://example.org", id="no-scheme"),
|
|
||||||
pytest.param("http://", id="no-host"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_get_invalid_url(server, url):
|
|
||||||
with httpx.Client() as client:
|
|
||||||
with pytest.raises((httpx.UnsupportedProtocol, httpx.LocalProtocolError)):
|
|
||||||
client.get(url)
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_request(server):
|
|
||||||
url = server.url.copy_with(path="/echo_headers")
|
|
||||||
headers = {"Custom-header": "value"}
|
|
||||||
|
|
||||||
with httpx.Client() as client:
|
|
||||||
request = client.build_request("GET", url)
|
|
||||||
request.headers.update(headers)
|
|
||||||
response = client.send(request)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.url == url
|
|
||||||
|
|
||||||
assert response.json()["Custom-header"] == "value"
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_post_request(server):
|
|
||||||
url = server.url.copy_with(path="/echo_headers")
|
|
||||||
headers = {"Custom-header": "value"}
|
|
||||||
|
|
||||||
with httpx.Client() as client:
|
|
||||||
request = client.build_request("POST", url)
|
|
||||||
request.headers.update(headers)
|
|
||||||
response = client.send(request)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.url == url
|
|
||||||
|
|
||||||
assert response.json()["Content-length"] == "0"
|
|
||||||
assert response.json()["Custom-header"] == "value"
|
|
||||||
|
|
||||||
|
|
||||||
def test_post(server):
|
|
||||||
with httpx.Client() as client:
|
|
||||||
response = client.post(server.url, content=b"Hello, world!")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.reason_phrase == "OK"
|
|
||||||
|
|
||||||
|
|
||||||
def test_post_json(server):
|
|
||||||
with httpx.Client() as client:
|
|
||||||
response = client.post(server.url, json={"text": "Hello, world!"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.reason_phrase == "OK"
|
|
||||||
|
|
||||||
|
|
||||||
def test_stream_response(server):
|
|
||||||
with httpx.Client() as client:
|
|
||||||
with client.stream("GET", server.url) as response:
|
|
||||||
content = response.read()
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert content == b"Hello, world!"
|
|
||||||
|
|
||||||
|
|
||||||
def test_stream_iterator(server):
|
|
||||||
body = b""
|
|
||||||
|
|
||||||
with httpx.Client() as client:
|
|
||||||
with client.stream("GET", server.url) as response:
|
|
||||||
for chunk in response.iter_bytes():
|
|
||||||
body += chunk
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert body == b"Hello, world!"
|
|
||||||
|
|
||||||
|
|
||||||
def test_raw_iterator(server):
|
|
||||||
body = b""
|
|
||||||
|
|
||||||
with httpx.Client() as client:
|
|
||||||
with client.stream("GET", server.url) as response:
|
|
||||||
for chunk in response.iter_raw():
|
|
||||||
body += chunk
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert body == b"Hello, world!"
|
|
||||||
|
|
||||||
|
|
||||||
def test_cannot_stream_async_request(server):
|
|
||||||
async def hello_world() -> typing.AsyncIterator[bytes]: # pragma: no cover
|
|
||||||
yield b"Hello, "
|
|
||||||
yield b"world!"
|
|
||||||
|
|
||||||
with httpx.Client() as client:
|
|
||||||
with pytest.raises(RuntimeError):
|
|
||||||
client.post(server.url, content=hello_world())
|
|
||||||
|
|
||||||
|
|
||||||
def test_raise_for_status(server):
|
|
||||||
with httpx.Client() as client:
|
|
||||||
for status_code in (200, 400, 404, 500, 505):
|
|
||||||
response = client.request(
|
|
||||||
"GET", server.url.copy_with(path=f"/status/{status_code}")
|
|
||||||
)
|
|
||||||
if 400 <= status_code < 600:
|
|
||||||
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
|
||||||
response.raise_for_status()
|
|
||||||
assert exc_info.value.response == response
|
|
||||||
assert exc_info.value.request.url.path == f"/status/{status_code}"
|
|
||||||
else:
|
|
||||||
assert response.raise_for_status() is response
|
|
||||||
|
|
||||||
|
|
||||||
def test_options(server):
|
|
||||||
with httpx.Client() as client:
|
|
||||||
response = client.options(server.url)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.reason_phrase == "OK"
|
|
||||||
|
|
||||||
|
|
||||||
def test_head(server):
|
|
||||||
with httpx.Client() as client:
|
|
||||||
response = client.head(server.url)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.reason_phrase == "OK"
|
|
||||||
|
|
||||||
|
|
||||||
def test_put(server):
|
|
||||||
with httpx.Client() as client:
|
|
||||||
response = client.put(server.url, content=b"Hello, world!")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.reason_phrase == "OK"
|
|
||||||
|
|
||||||
|
|
||||||
def test_patch(server):
|
|
||||||
with httpx.Client() as client:
|
|
||||||
response = client.patch(server.url, content=b"Hello, world!")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.reason_phrase == "OK"
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete(server):
|
|
||||||
with httpx.Client() as client:
|
|
||||||
response = client.delete(server.url)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.reason_phrase == "OK"
|
|
||||||
|
|
||||||
|
|
||||||
def test_base_url(server):
|
|
||||||
base_url = server.url
|
|
||||||
with httpx.Client(base_url=base_url) as client:
|
|
||||||
response = client.get("/")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.url == base_url
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_absolute_url():
|
|
||||||
client = httpx.Client(base_url="https://www.example.com/")
|
|
||||||
request = client.build_request("GET", "http://www.example.com/")
|
|
||||||
assert request.url == "http://www.example.com/"
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_relative_url():
|
|
||||||
client = httpx.Client(base_url="https://www.example.com/")
|
|
||||||
request = client.build_request("GET", "/testing/123")
|
|
||||||
assert request.url == "https://www.example.com/testing/123"
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_relative_url_with_path():
|
|
||||||
client = httpx.Client(base_url="https://www.example.com/some/path")
|
|
||||||
request = client.build_request("GET", "/testing/123")
|
|
||||||
assert request.url == "https://www.example.com/some/path/testing/123"
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_relative_url_with_dotted_path():
|
|
||||||
client = httpx.Client(base_url="https://www.example.com/some/path")
|
|
||||||
request = client.build_request("GET", "../testing/123")
|
|
||||||
assert request.url == "https://www.example.com/some/testing/123"
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_relative_url_with_path_including_colon():
|
|
||||||
client = httpx.Client(base_url="https://www.example.com/some/path")
|
|
||||||
request = client.build_request("GET", "/testing:123")
|
|
||||||
assert request.url == "https://www.example.com/some/path/testing:123"
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_relative_url_with_encoded_slashes():
|
|
||||||
client = httpx.Client(base_url="https://www.example.com/")
|
|
||||||
request = client.build_request("GET", "/testing%2F123")
|
|
||||||
assert request.url == "https://www.example.com/testing%2F123"
|
|
||||||
|
|
||||||
client = httpx.Client(base_url="https://www.example.com/base%2Fpath")
|
|
||||||
request = client.build_request("GET", "/testing")
|
|
||||||
assert request.url == "https://www.example.com/base%2Fpath/testing"
|
|
||||||
|
|
||||||
|
|
||||||
def test_context_managed_transport():
|
|
||||||
class Transport(httpx.BaseTransport):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.events: list[str] = []
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
# The base implementation of httpx.BaseTransport just
|
|
||||||
# calls into `.close`, so simple transport cases can just override
|
|
||||||
# this method for any cleanup, where more complex cases
|
|
||||||
# might want to additionally override `__enter__`/`__exit__`.
|
|
||||||
self.events.append("transport.close")
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
super().__enter__()
|
|
||||||
self.events.append("transport.__enter__")
|
|
||||||
|
|
||||||
def __exit__(self, *args):
|
|
||||||
super().__exit__(*args)
|
|
||||||
self.events.append("transport.__exit__")
|
|
||||||
|
|
||||||
transport = Transport()
|
|
||||||
with httpx.Client(transport=transport):
|
|
||||||
pass
|
|
||||||
|
|
||||||
assert transport.events == [
|
|
||||||
"transport.__enter__",
|
|
||||||
"transport.close",
|
|
||||||
"transport.__exit__",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_context_managed_transport_and_mount():
|
|
||||||
class Transport(httpx.BaseTransport):
|
|
||||||
def __init__(self, name: str) -> None:
|
|
||||||
self.name: str = name
|
|
||||||
self.events: list[str] = []
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
# The base implementation of httpx.BaseTransport just
|
|
||||||
# calls into `.close`, so simple transport cases can just override
|
|
||||||
# this method for any cleanup, where more complex cases
|
|
||||||
# might want to additionally override `__enter__`/`__exit__`.
|
|
||||||
self.events.append(f"{self.name}.close")
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
super().__enter__()
|
|
||||||
self.events.append(f"{self.name}.__enter__")
|
|
||||||
|
|
||||||
def __exit__(self, *args):
|
|
||||||
super().__exit__(*args)
|
|
||||||
self.events.append(f"{self.name}.__exit__")
|
|
||||||
|
|
||||||
transport = Transport(name="transport")
|
|
||||||
mounted = Transport(name="mounted")
|
|
||||||
with httpx.Client(transport=transport, mounts={"http://www.example.org": mounted}):
|
|
||||||
pass
|
|
||||||
|
|
||||||
assert transport.events == [
|
|
||||||
"transport.__enter__",
|
|
||||||
"transport.close",
|
|
||||||
"transport.__exit__",
|
|
||||||
]
|
|
||||||
assert mounted.events == [
|
|
||||||
"mounted.__enter__",
|
|
||||||
"mounted.close",
|
|
||||||
"mounted.__exit__",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def hello_world(request):
|
|
||||||
return httpx.Response(200, text="Hello, world!")
|
|
||||||
|
|
||||||
|
|
||||||
def test_client_closed_state_using_implicit_open():
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(hello_world))
|
|
||||||
|
|
||||||
assert not client.is_closed
|
|
||||||
client.get("http://example.com")
|
|
||||||
|
|
||||||
assert not client.is_closed
|
|
||||||
client.close()
|
|
||||||
|
|
||||||
assert client.is_closed
|
|
||||||
|
|
||||||
# Once we're close we cannot make any more requests.
|
|
||||||
with pytest.raises(RuntimeError):
|
|
||||||
client.get("http://example.com")
|
|
||||||
|
|
||||||
# Once we're closed we cannot reopen the client.
|
|
||||||
with pytest.raises(RuntimeError):
|
|
||||||
with client:
|
|
||||||
pass # pragma: no cover
|
|
||||||
|
|
||||||
|
|
||||||
def test_client_closed_state_using_with_block():
|
|
||||||
with httpx.Client(transport=httpx.MockTransport(hello_world)) as client:
|
|
||||||
assert not client.is_closed
|
|
||||||
client.get("http://example.com")
|
|
||||||
|
|
||||||
assert client.is_closed
|
|
||||||
with pytest.raises(RuntimeError):
|
|
||||||
client.get("http://example.com")
|
|
||||||
|
|
||||||
|
|
||||||
def echo_raw_headers(request: httpx.Request) -> httpx.Response:
|
|
||||||
data = [
|
|
||||||
(name.decode("ascii"), value.decode("ascii"))
|
|
||||||
for name, value in request.headers.raw
|
|
||||||
]
|
|
||||||
return httpx.Response(200, json=data)
|
|
||||||
|
|
||||||
|
|
||||||
def test_raw_client_header():
|
|
||||||
"""
|
|
||||||
Set a header in the Client.
|
|
||||||
"""
|
|
||||||
url = "http://example.org/echo_headers"
|
|
||||||
headers = {"Example-Header": "example-value"}
|
|
||||||
|
|
||||||
client = httpx.Client(
|
|
||||||
transport=httpx.MockTransport(echo_raw_headers), headers=headers
|
|
||||||
)
|
|
||||||
response = client.get(url)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == [
|
|
||||||
["Host", "example.org"],
|
|
||||||
["Accept", "*/*"],
|
|
||||||
["Accept-Encoding", "gzip, deflate, br, zstd"],
|
|
||||||
["Connection", "keep-alive"],
|
|
||||||
["User-Agent", f"python-httpx/{httpx.__version__}"],
|
|
||||||
["Example-Header", "example-value"],
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def unmounted(request: httpx.Request) -> httpx.Response:
|
|
||||||
data = {"app": "unmounted"}
|
|
||||||
return httpx.Response(200, json=data)
|
|
||||||
|
|
||||||
|
|
||||||
def mounted(request: httpx.Request) -> httpx.Response:
|
|
||||||
data = {"app": "mounted"}
|
|
||||||
return httpx.Response(200, json=data)
|
|
||||||
|
|
||||||
|
|
||||||
def test_mounted_transport():
|
|
||||||
transport = httpx.MockTransport(unmounted)
|
|
||||||
mounts = {"custom://": httpx.MockTransport(mounted)}
|
|
||||||
|
|
||||||
client = httpx.Client(transport=transport, mounts=mounts)
|
|
||||||
|
|
||||||
response = client.get("https://www.example.com")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"app": "unmounted"}
|
|
||||||
|
|
||||||
response = client.get("custom://www.example.com")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"app": "mounted"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_all_mounted_transport():
|
|
||||||
mounts = {"all://": httpx.MockTransport(mounted)}
|
|
||||||
|
|
||||||
client = httpx.Client(mounts=mounts)
|
|
||||||
|
|
||||||
response = client.get("https://www.example.com")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"app": "mounted"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_server_extensions(server):
|
|
||||||
url = server.url.copy_with(path="/http_version_2")
|
|
||||||
with httpx.Client(http2=True) as client:
|
|
||||||
response = client.get(url)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.extensions["http_version"] == b"HTTP/1.1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_client_decode_text_using_autodetect():
|
|
||||||
# Ensure that a 'default_encoding=autodetect' on the response allows for
|
|
||||||
# encoding autodetection to be used when no "Content-Type: text/plain; charset=..."
|
|
||||||
# info is present.
|
|
||||||
#
|
|
||||||
# Here we have some french text encoded with ISO-8859-1, rather than UTF-8.
|
|
||||||
text = (
|
|
||||||
"Non-seulement Despréaux ne se trompait pas, mais de tous les écrivains "
|
|
||||||
"que la France a produits, sans excepter Voltaire lui-même, imprégné de "
|
|
||||||
"l'esprit anglais par son séjour à Londres, c'est incontestablement "
|
|
||||||
"Molière ou Poquelin qui reproduit avec l'exactitude la plus vive et la "
|
|
||||||
"plus complète le fond du génie français."
|
|
||||||
)
|
|
||||||
|
|
||||||
def cp1252_but_no_content_type(request):
|
|
||||||
content = text.encode("ISO-8859-1")
|
|
||||||
return httpx.Response(200, content=content)
|
|
||||||
|
|
||||||
transport = httpx.MockTransport(cp1252_but_no_content_type)
|
|
||||||
with httpx.Client(transport=transport, default_encoding=autodetect) as client:
|
|
||||||
response = client.get("http://www.example.com")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.reason_phrase == "OK"
|
|
||||||
assert response.encoding == "ISO-8859-1"
|
|
||||||
assert response.text == text
|
|
||||||
|
|
||||||
|
|
||||||
def test_client_decode_text_using_explicit_encoding():
|
|
||||||
# Ensure that a 'default_encoding="..."' on the response is used for text decoding
|
|
||||||
# when no "Content-Type: text/plain; charset=..."" info is present.
|
|
||||||
#
|
|
||||||
# Here we have some french text encoded with ISO-8859-1, rather than UTF-8.
|
|
||||||
text = (
|
|
||||||
"Non-seulement Despréaux ne se trompait pas, mais de tous les écrivains "
|
|
||||||
"que la France a produits, sans excepter Voltaire lui-même, imprégné de "
|
|
||||||
"l'esprit anglais par son séjour à Londres, c'est incontestablement "
|
|
||||||
"Molière ou Poquelin qui reproduit avec l'exactitude la plus vive et la "
|
|
||||||
"plus complète le fond du génie français."
|
|
||||||
)
|
|
||||||
|
|
||||||
def cp1252_but_no_content_type(request):
|
|
||||||
content = text.encode("ISO-8859-1")
|
|
||||||
return httpx.Response(200, content=content)
|
|
||||||
|
|
||||||
transport = httpx.MockTransport(cp1252_but_no_content_type)
|
|
||||||
with httpx.Client(transport=transport, default_encoding=autodetect) as client:
|
|
||||||
response = client.get("http://www.example.com")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.reason_phrase == "OK"
|
|
||||||
assert response.encoding == "ISO-8859-1"
|
|
||||||
assert response.text == text
|
|
||||||
@ -1,293 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
|
|
||||||
def echo_headers(request: httpx.Request) -> httpx.Response:
|
|
||||||
data = {"headers": dict(request.headers)}
|
|
||||||
return httpx.Response(200, json=data)
|
|
||||||
|
|
||||||
|
|
||||||
def echo_repeated_headers_multi_items(request: httpx.Request) -> httpx.Response:
|
|
||||||
data = {"headers": list(request.headers.multi_items())}
|
|
||||||
return httpx.Response(200, json=data)
|
|
||||||
|
|
||||||
|
|
||||||
def echo_repeated_headers_items(request: httpx.Request) -> httpx.Response:
|
|
||||||
data = {"headers": list(request.headers.items())}
|
|
||||||
return httpx.Response(200, json=data)
|
|
||||||
|
|
||||||
|
|
||||||
def test_client_header():
|
|
||||||
"""
|
|
||||||
Set a header in the Client.
|
|
||||||
"""
|
|
||||||
url = "http://example.org/echo_headers"
|
|
||||||
headers = {"Example-Header": "example-value"}
|
|
||||||
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(echo_headers), headers=headers)
|
|
||||||
response = client.get(url)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {
|
|
||||||
"headers": {
|
|
||||||
"accept": "*/*",
|
|
||||||
"accept-encoding": "gzip, deflate, br, zstd",
|
|
||||||
"connection": "keep-alive",
|
|
||||||
"example-header": "example-value",
|
|
||||||
"host": "example.org",
|
|
||||||
"user-agent": f"python-httpx/{httpx.__version__}",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_header_merge():
|
|
||||||
url = "http://example.org/echo_headers"
|
|
||||||
client_headers = {"User-Agent": "python-myclient/0.2.1"}
|
|
||||||
request_headers = {"X-Auth-Token": "FooBarBazToken"}
|
|
||||||
client = httpx.Client(
|
|
||||||
transport=httpx.MockTransport(echo_headers), headers=client_headers
|
|
||||||
)
|
|
||||||
response = client.get(url, headers=request_headers)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {
|
|
||||||
"headers": {
|
|
||||||
"accept": "*/*",
|
|
||||||
"accept-encoding": "gzip, deflate, br, zstd",
|
|
||||||
"connection": "keep-alive",
|
|
||||||
"host": "example.org",
|
|
||||||
"user-agent": "python-myclient/0.2.1",
|
|
||||||
"x-auth-token": "FooBarBazToken",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_header_merge_conflicting_headers():
|
|
||||||
url = "http://example.org/echo_headers"
|
|
||||||
client_headers = {"X-Auth-Token": "FooBar"}
|
|
||||||
request_headers = {"X-Auth-Token": "BazToken"}
|
|
||||||
client = httpx.Client(
|
|
||||||
transport=httpx.MockTransport(echo_headers), headers=client_headers
|
|
||||||
)
|
|
||||||
response = client.get(url, headers=request_headers)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {
|
|
||||||
"headers": {
|
|
||||||
"accept": "*/*",
|
|
||||||
"accept-encoding": "gzip, deflate, br, zstd",
|
|
||||||
"connection": "keep-alive",
|
|
||||||
"host": "example.org",
|
|
||||||
"user-agent": f"python-httpx/{httpx.__version__}",
|
|
||||||
"x-auth-token": "BazToken",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_header_update():
|
|
||||||
url = "http://example.org/echo_headers"
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(echo_headers))
|
|
||||||
first_response = client.get(url)
|
|
||||||
client.headers.update(
|
|
||||||
{"User-Agent": "python-myclient/0.2.1", "Another-Header": "AThing"}
|
|
||||||
)
|
|
||||||
second_response = client.get(url)
|
|
||||||
|
|
||||||
assert first_response.status_code == 200
|
|
||||||
assert first_response.json() == {
|
|
||||||
"headers": {
|
|
||||||
"accept": "*/*",
|
|
||||||
"accept-encoding": "gzip, deflate, br, zstd",
|
|
||||||
"connection": "keep-alive",
|
|
||||||
"host": "example.org",
|
|
||||||
"user-agent": f"python-httpx/{httpx.__version__}",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert second_response.status_code == 200
|
|
||||||
assert second_response.json() == {
|
|
||||||
"headers": {
|
|
||||||
"accept": "*/*",
|
|
||||||
"accept-encoding": "gzip, deflate, br, zstd",
|
|
||||||
"another-header": "AThing",
|
|
||||||
"connection": "keep-alive",
|
|
||||||
"host": "example.org",
|
|
||||||
"user-agent": "python-myclient/0.2.1",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_header_repeated_items():
|
|
||||||
url = "http://example.org/echo_headers"
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(echo_repeated_headers_items))
|
|
||||||
response = client.get(url, headers=[("x-header", "1"), ("x-header", "2,3")])
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
echoed_headers = response.json()["headers"]
|
|
||||||
# as per RFC 7230, the whitespace after a comma is insignificant
|
|
||||||
# so we split and strip here so that we can do a safe comparison
|
|
||||||
assert ["x-header", ["1", "2", "3"]] in [
|
|
||||||
[k, [subv.lstrip() for subv in v.split(",")]] for k, v in echoed_headers
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_header_repeated_multi_items():
|
|
||||||
url = "http://example.org/echo_headers"
|
|
||||||
client = httpx.Client(
|
|
||||||
transport=httpx.MockTransport(echo_repeated_headers_multi_items)
|
|
||||||
)
|
|
||||||
response = client.get(url, headers=[("x-header", "1"), ("x-header", "2,3")])
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
echoed_headers = response.json()["headers"]
|
|
||||||
assert ["x-header", "1"] in echoed_headers
|
|
||||||
assert ["x-header", "2,3"] in echoed_headers
|
|
||||||
|
|
||||||
|
|
||||||
def test_remove_default_header():
|
|
||||||
"""
|
|
||||||
Remove a default header from the Client.
|
|
||||||
"""
|
|
||||||
url = "http://example.org/echo_headers"
|
|
||||||
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(echo_headers))
|
|
||||||
del client.headers["User-Agent"]
|
|
||||||
|
|
||||||
response = client.get(url)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {
|
|
||||||
"headers": {
|
|
||||||
"accept": "*/*",
|
|
||||||
"accept-encoding": "gzip, deflate, br, zstd",
|
|
||||||
"connection": "keep-alive",
|
|
||||||
"host": "example.org",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_header_does_not_exist():
|
|
||||||
headers = httpx.Headers({"foo": "bar"})
|
|
||||||
with pytest.raises(KeyError):
|
|
||||||
del headers["baz"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_header_with_incorrect_value():
|
|
||||||
with pytest.raises(
|
|
||||||
TypeError,
|
|
||||||
match=f"Header value must be str or bytes, not {type(None)}",
|
|
||||||
):
|
|
||||||
httpx.Headers({"foo": None}) # type: ignore
|
|
||||||
|
|
||||||
|
|
||||||
def test_host_with_auth_and_port_in_url():
|
|
||||||
"""
|
|
||||||
The Host header should only include the hostname, or hostname:port
|
|
||||||
(for non-default ports only). Any userinfo or default port should not
|
|
||||||
be present.
|
|
||||||
"""
|
|
||||||
url = "http://username:password@example.org:80/echo_headers"
|
|
||||||
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(echo_headers))
|
|
||||||
response = client.get(url)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {
|
|
||||||
"headers": {
|
|
||||||
"accept": "*/*",
|
|
||||||
"accept-encoding": "gzip, deflate, br, zstd",
|
|
||||||
"connection": "keep-alive",
|
|
||||||
"host": "example.org",
|
|
||||||
"user-agent": f"python-httpx/{httpx.__version__}",
|
|
||||||
"authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_host_with_non_default_port_in_url():
|
|
||||||
"""
|
|
||||||
If the URL includes a non-default port, then it should be included in
|
|
||||||
the Host header.
|
|
||||||
"""
|
|
||||||
url = "http://username:password@example.org:123/echo_headers"
|
|
||||||
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(echo_headers))
|
|
||||||
response = client.get(url)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {
|
|
||||||
"headers": {
|
|
||||||
"accept": "*/*",
|
|
||||||
"accept-encoding": "gzip, deflate, br, zstd",
|
|
||||||
"connection": "keep-alive",
|
|
||||||
"host": "example.org:123",
|
|
||||||
"user-agent": f"python-httpx/{httpx.__version__}",
|
|
||||||
"authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_request_auto_headers():
|
|
||||||
request = httpx.Request("GET", "https://www.example.org/")
|
|
||||||
assert "host" in request.headers
|
|
||||||
|
|
||||||
|
|
||||||
def test_same_origin():
|
|
||||||
origin = httpx.URL("https://example.com")
|
|
||||||
request = httpx.Request("GET", "HTTPS://EXAMPLE.COM:443")
|
|
||||||
|
|
||||||
client = httpx.Client()
|
|
||||||
headers = client._redirect_headers(request, origin, "GET")
|
|
||||||
|
|
||||||
assert headers["Host"] == request.url.netloc.decode("ascii")
|
|
||||||
|
|
||||||
|
|
||||||
def test_not_same_origin():
|
|
||||||
origin = httpx.URL("https://example.com")
|
|
||||||
request = httpx.Request("GET", "HTTP://EXAMPLE.COM:80")
|
|
||||||
|
|
||||||
client = httpx.Client()
|
|
||||||
headers = client._redirect_headers(request, origin, "GET")
|
|
||||||
|
|
||||||
assert headers["Host"] == origin.netloc.decode("ascii")
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_https_redirect():
|
|
||||||
url = httpx.URL("https://example.com")
|
|
||||||
request = httpx.Request(
|
|
||||||
"GET", "http://example.com", headers={"Authorization": "empty"}
|
|
||||||
)
|
|
||||||
|
|
||||||
client = httpx.Client()
|
|
||||||
headers = client._redirect_headers(request, url, "GET")
|
|
||||||
|
|
||||||
assert "Authorization" in headers
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_not_https_redirect():
|
|
||||||
url = httpx.URL("https://www.example.com")
|
|
||||||
request = httpx.Request(
|
|
||||||
"GET", "http://example.com", headers={"Authorization": "empty"}
|
|
||||||
)
|
|
||||||
|
|
||||||
client = httpx.Client()
|
|
||||||
headers = client._redirect_headers(request, url, "GET")
|
|
||||||
|
|
||||||
assert "Authorization" not in headers
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_not_https_redirect_if_not_default_ports():
|
|
||||||
url = httpx.URL("https://example.com:1337")
|
|
||||||
request = httpx.Request(
|
|
||||||
"GET", "http://example.com:9999", headers={"Authorization": "empty"}
|
|
||||||
)
|
|
||||||
|
|
||||||
client = httpx.Client()
|
|
||||||
headers = client._redirect_headers(request, url, "GET")
|
|
||||||
|
|
||||||
assert "Authorization" not in headers
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
import httpx
|
|
||||||
|
|
||||||
|
|
||||||
def test_client_base_url():
|
|
||||||
client = httpx.Client()
|
|
||||||
client.base_url = "https://www.example.org/"
|
|
||||||
assert isinstance(client.base_url, httpx.URL)
|
|
||||||
assert client.base_url == "https://www.example.org/"
|
|
||||||
|
|
||||||
|
|
||||||
def test_client_base_url_without_trailing_slash():
|
|
||||||
client = httpx.Client()
|
|
||||||
client.base_url = "https://www.example.org/path"
|
|
||||||
assert isinstance(client.base_url, httpx.URL)
|
|
||||||
assert client.base_url == "https://www.example.org/path/"
|
|
||||||
|
|
||||||
|
|
||||||
def test_client_base_url_with_trailing_slash():
|
|
||||||
client = httpx.Client()
|
|
||||||
client.base_url = "https://www.example.org/path/"
|
|
||||||
assert isinstance(client.base_url, httpx.URL)
|
|
||||||
assert client.base_url == "https://www.example.org/path/"
|
|
||||||
|
|
||||||
|
|
||||||
def test_client_headers():
|
|
||||||
client = httpx.Client()
|
|
||||||
client.headers = {"a": "b"}
|
|
||||||
assert isinstance(client.headers, httpx.Headers)
|
|
||||||
assert client.headers["A"] == "b"
|
|
||||||
|
|
||||||
|
|
||||||
def test_client_cookies():
|
|
||||||
client = httpx.Client()
|
|
||||||
client.cookies = {"a": "b"}
|
|
||||||
assert isinstance(client.cookies, httpx.Cookies)
|
|
||||||
mycookies = list(client.cookies.jar)
|
|
||||||
assert len(mycookies) == 1
|
|
||||||
assert mycookies[0].name == "a" and mycookies[0].value == "b"
|
|
||||||
|
|
||||||
|
|
||||||
def test_client_timeout():
|
|
||||||
expected_timeout = 12.0
|
|
||||||
client = httpx.Client()
|
|
||||||
|
|
||||||
client.timeout = expected_timeout
|
|
||||||
|
|
||||||
assert isinstance(client.timeout, httpx.Timeout)
|
|
||||||
assert client.timeout.connect == expected_timeout
|
|
||||||
assert client.timeout.read == expected_timeout
|
|
||||||
assert client.timeout.write == expected_timeout
|
|
||||||
assert client.timeout.pool == expected_timeout
|
|
||||||
|
|
||||||
|
|
||||||
def test_client_event_hooks():
|
|
||||||
def on_request(request):
|
|
||||||
pass # pragma: no cover
|
|
||||||
|
|
||||||
client = httpx.Client()
|
|
||||||
client.event_hooks = {"request": [on_request]}
|
|
||||||
assert client.event_hooks == {"request": [on_request], "response": []}
|
|
||||||
|
|
||||||
|
|
||||||
def test_client_trust_env():
|
|
||||||
client = httpx.Client()
|
|
||||||
assert client.trust_env
|
|
||||||
|
|
||||||
client = httpx.Client(trust_env=False)
|
|
||||||
assert not client.trust_env
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
import httpx
|
|
||||||
|
|
||||||
|
|
||||||
def hello_world(request: httpx.Request) -> httpx.Response:
|
|
||||||
return httpx.Response(200, text="Hello, world")
|
|
||||||
|
|
||||||
|
|
||||||
def test_client_queryparams():
|
|
||||||
client = httpx.Client(params={"a": "b"})
|
|
||||||
assert isinstance(client.params, httpx.QueryParams)
|
|
||||||
assert client.params["a"] == "b"
|
|
||||||
|
|
||||||
|
|
||||||
def test_client_queryparams_string():
|
|
||||||
client = httpx.Client(params="a=b")
|
|
||||||
assert isinstance(client.params, httpx.QueryParams)
|
|
||||||
assert client.params["a"] == "b"
|
|
||||||
|
|
||||||
client = httpx.Client()
|
|
||||||
client.params = "a=b"
|
|
||||||
assert isinstance(client.params, httpx.QueryParams)
|
|
||||||
assert client.params["a"] == "b"
|
|
||||||
|
|
||||||
|
|
||||||
def test_client_queryparams_echo():
|
|
||||||
url = "http://example.org/echo_queryparams"
|
|
||||||
client_queryparams = "first=str"
|
|
||||||
request_queryparams = {"second": "dict"}
|
|
||||||
client = httpx.Client(
|
|
||||||
transport=httpx.MockTransport(hello_world), params=client_queryparams
|
|
||||||
)
|
|
||||||
response = client.get(url, params=request_queryparams)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.url == "http://example.org/echo_queryparams?first=str&second=dict"
|
|
||||||
@ -1,447 +0,0 @@
|
|||||||
import typing
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
|
|
||||||
def redirects(request: httpx.Request) -> httpx.Response:
|
|
||||||
if request.url.scheme not in ("http", "https"):
|
|
||||||
raise httpx.UnsupportedProtocol(f"Scheme {request.url.scheme!r} not supported.")
|
|
||||||
|
|
||||||
if request.url.path == "/redirect_301":
|
|
||||||
status_code = httpx.codes.MOVED_PERMANENTLY
|
|
||||||
content = b"<a href='https://example.org/'>here</a>"
|
|
||||||
headers = {"location": "https://example.org/"}
|
|
||||||
return httpx.Response(status_code, headers=headers, content=content)
|
|
||||||
|
|
||||||
elif request.url.path == "/redirect_302":
|
|
||||||
status_code = httpx.codes.FOUND
|
|
||||||
headers = {"location": "https://example.org/"}
|
|
||||||
return httpx.Response(status_code, headers=headers)
|
|
||||||
|
|
||||||
elif request.url.path == "/redirect_303":
|
|
||||||
status_code = httpx.codes.SEE_OTHER
|
|
||||||
headers = {"location": "https://example.org/"}
|
|
||||||
return httpx.Response(status_code, headers=headers)
|
|
||||||
|
|
||||||
elif request.url.path == "/relative_redirect":
|
|
||||||
status_code = httpx.codes.SEE_OTHER
|
|
||||||
headers = {"location": "/"}
|
|
||||||
return httpx.Response(status_code, headers=headers)
|
|
||||||
|
|
||||||
elif request.url.path == "/malformed_redirect":
|
|
||||||
status_code = httpx.codes.SEE_OTHER
|
|
||||||
headers = {"location": "https://:443/"}
|
|
||||||
return httpx.Response(status_code, headers=headers)
|
|
||||||
|
|
||||||
elif request.url.path == "/invalid_redirect":
|
|
||||||
status_code = httpx.codes.SEE_OTHER
|
|
||||||
raw_headers = [(b"location", "https://😇/".encode("utf-8"))]
|
|
||||||
return httpx.Response(status_code, headers=raw_headers)
|
|
||||||
|
|
||||||
elif request.url.path == "/no_scheme_redirect":
|
|
||||||
status_code = httpx.codes.SEE_OTHER
|
|
||||||
headers = {"location": "//example.org/"}
|
|
||||||
return httpx.Response(status_code, headers=headers)
|
|
||||||
|
|
||||||
elif request.url.path == "/multiple_redirects":
|
|
||||||
params = httpx.QueryParams(request.url.query)
|
|
||||||
count = int(params.get("count", "0"))
|
|
||||||
redirect_count = count - 1
|
|
||||||
status_code = httpx.codes.SEE_OTHER if count else httpx.codes.OK
|
|
||||||
if count:
|
|
||||||
location = "/multiple_redirects"
|
|
||||||
if redirect_count:
|
|
||||||
location += f"?count={redirect_count}"
|
|
||||||
headers = {"location": location}
|
|
||||||
else:
|
|
||||||
headers = {}
|
|
||||||
return httpx.Response(status_code, headers=headers)
|
|
||||||
|
|
||||||
if request.url.path == "/redirect_loop":
|
|
||||||
status_code = httpx.codes.SEE_OTHER
|
|
||||||
headers = {"location": "/redirect_loop"}
|
|
||||||
return httpx.Response(status_code, headers=headers)
|
|
||||||
|
|
||||||
elif request.url.path == "/cross_domain":
|
|
||||||
status_code = httpx.codes.SEE_OTHER
|
|
||||||
headers = {"location": "https://example.org/cross_domain_target"}
|
|
||||||
return httpx.Response(status_code, headers=headers)
|
|
||||||
|
|
||||||
elif request.url.path == "/cross_domain_target":
|
|
||||||
status_code = httpx.codes.OK
|
|
||||||
data = {
|
|
||||||
"body": request.content.decode("ascii"),
|
|
||||||
"headers": dict(request.headers),
|
|
||||||
}
|
|
||||||
return httpx.Response(status_code, json=data)
|
|
||||||
|
|
||||||
elif request.url.path == "/redirect_body":
|
|
||||||
status_code = httpx.codes.PERMANENT_REDIRECT
|
|
||||||
headers = {"location": "/redirect_body_target"}
|
|
||||||
return httpx.Response(status_code, headers=headers)
|
|
||||||
|
|
||||||
elif request.url.path == "/redirect_no_body":
|
|
||||||
status_code = httpx.codes.SEE_OTHER
|
|
||||||
headers = {"location": "/redirect_body_target"}
|
|
||||||
return httpx.Response(status_code, headers=headers)
|
|
||||||
|
|
||||||
elif request.url.path == "/redirect_body_target":
|
|
||||||
data = {
|
|
||||||
"body": request.content.decode("ascii"),
|
|
||||||
"headers": dict(request.headers),
|
|
||||||
}
|
|
||||||
return httpx.Response(200, json=data)
|
|
||||||
|
|
||||||
elif request.url.path == "/cross_subdomain":
|
|
||||||
if request.headers["Host"] != "www.example.org":
|
|
||||||
status_code = httpx.codes.PERMANENT_REDIRECT
|
|
||||||
headers = {"location": "https://www.example.org/cross_subdomain"}
|
|
||||||
return httpx.Response(status_code, headers=headers)
|
|
||||||
else:
|
|
||||||
return httpx.Response(200, text="Hello, world!")
|
|
||||||
|
|
||||||
elif request.url.path == "/redirect_custom_scheme":
|
|
||||||
status_code = httpx.codes.MOVED_PERMANENTLY
|
|
||||||
headers = {"location": "market://details?id=42"}
|
|
||||||
return httpx.Response(status_code, headers=headers)
|
|
||||||
|
|
||||||
if request.method == "HEAD":
|
|
||||||
return httpx.Response(200)
|
|
||||||
|
|
||||||
return httpx.Response(200, html="<html><body>Hello, world!</body></html>")
|
|
||||||
|
|
||||||
|
|
||||||
def test_redirect_301():
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(redirects))
|
|
||||||
response = client.post("https://example.org/redirect_301", follow_redirects=True)
|
|
||||||
assert response.status_code == httpx.codes.OK
|
|
||||||
assert response.url == "https://example.org/"
|
|
||||||
assert len(response.history) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_redirect_302():
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(redirects))
|
|
||||||
response = client.post("https://example.org/redirect_302", follow_redirects=True)
|
|
||||||
assert response.status_code == httpx.codes.OK
|
|
||||||
assert response.url == "https://example.org/"
|
|
||||||
assert len(response.history) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_redirect_303():
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(redirects))
|
|
||||||
response = client.get("https://example.org/redirect_303", follow_redirects=True)
|
|
||||||
assert response.status_code == httpx.codes.OK
|
|
||||||
assert response.url == "https://example.org/"
|
|
||||||
assert len(response.history) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_next_request():
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(redirects))
|
|
||||||
request = client.build_request("POST", "https://example.org/redirect_303")
|
|
||||||
response = client.send(request, follow_redirects=False)
|
|
||||||
assert response.status_code == httpx.codes.SEE_OTHER
|
|
||||||
assert response.url == "https://example.org/redirect_303"
|
|
||||||
assert response.next_request is not None
|
|
||||||
|
|
||||||
response = client.send(response.next_request, follow_redirects=False)
|
|
||||||
assert response.status_code == httpx.codes.OK
|
|
||||||
assert response.url == "https://example.org/"
|
|
||||||
assert response.next_request is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_async_next_request():
|
|
||||||
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
|
||||||
request = client.build_request("POST", "https://example.org/redirect_303")
|
|
||||||
response = await client.send(request, follow_redirects=False)
|
|
||||||
assert response.status_code == httpx.codes.SEE_OTHER
|
|
||||||
assert response.url == "https://example.org/redirect_303"
|
|
||||||
assert response.next_request is not None
|
|
||||||
|
|
||||||
response = await client.send(response.next_request, follow_redirects=False)
|
|
||||||
assert response.status_code == httpx.codes.OK
|
|
||||||
assert response.url == "https://example.org/"
|
|
||||||
assert response.next_request is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_head_redirect():
|
|
||||||
"""
|
|
||||||
Contrary to Requests, redirects remain enabled by default for HEAD requests.
|
|
||||||
"""
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(redirects))
|
|
||||||
response = client.head("https://example.org/redirect_302", follow_redirects=True)
|
|
||||||
assert response.status_code == httpx.codes.OK
|
|
||||||
assert response.url == "https://example.org/"
|
|
||||||
assert response.request.method == "HEAD"
|
|
||||||
assert len(response.history) == 1
|
|
||||||
assert response.text == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_relative_redirect():
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(redirects))
|
|
||||||
response = client.get(
|
|
||||||
"https://example.org/relative_redirect", follow_redirects=True
|
|
||||||
)
|
|
||||||
assert response.status_code == httpx.codes.OK
|
|
||||||
assert response.url == "https://example.org/"
|
|
||||||
assert len(response.history) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_malformed_redirect():
|
|
||||||
# https://github.com/encode/httpx/issues/771
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(redirects))
|
|
||||||
response = client.get(
|
|
||||||
"http://example.org/malformed_redirect", follow_redirects=True
|
|
||||||
)
|
|
||||||
assert response.status_code == httpx.codes.OK
|
|
||||||
assert response.url == "https://example.org:443/"
|
|
||||||
assert len(response.history) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_redirect():
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(redirects))
|
|
||||||
with pytest.raises(httpx.RemoteProtocolError):
|
|
||||||
client.get("http://example.org/invalid_redirect", follow_redirects=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_scheme_redirect():
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(redirects))
|
|
||||||
response = client.get(
|
|
||||||
"https://example.org/no_scheme_redirect", follow_redirects=True
|
|
||||||
)
|
|
||||||
assert response.status_code == httpx.codes.OK
|
|
||||||
assert response.url == "https://example.org/"
|
|
||||||
assert len(response.history) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_fragment_redirect():
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(redirects))
|
|
||||||
response = client.get(
|
|
||||||
"https://example.org/relative_redirect#fragment", follow_redirects=True
|
|
||||||
)
|
|
||||||
assert response.status_code == httpx.codes.OK
|
|
||||||
assert response.url == "https://example.org/#fragment"
|
|
||||||
assert len(response.history) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_multiple_redirects():
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(redirects))
|
|
||||||
response = client.get(
|
|
||||||
"https://example.org/multiple_redirects?count=20", follow_redirects=True
|
|
||||||
)
|
|
||||||
assert response.status_code == httpx.codes.OK
|
|
||||||
assert response.url == "https://example.org/multiple_redirects"
|
|
||||||
assert len(response.history) == 20
|
|
||||||
assert response.history[0].url == "https://example.org/multiple_redirects?count=20"
|
|
||||||
assert response.history[1].url == "https://example.org/multiple_redirects?count=19"
|
|
||||||
assert len(response.history[0].history) == 0
|
|
||||||
assert len(response.history[1].history) == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_async_too_many_redirects():
|
|
||||||
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
|
||||||
with pytest.raises(httpx.TooManyRedirects):
|
|
||||||
await client.get(
|
|
||||||
"https://example.org/multiple_redirects?count=21", follow_redirects=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_sync_too_many_redirects():
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(redirects))
|
|
||||||
with pytest.raises(httpx.TooManyRedirects):
|
|
||||||
client.get(
|
|
||||||
"https://example.org/multiple_redirects?count=21", follow_redirects=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_redirect_loop():
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(redirects))
|
|
||||||
with pytest.raises(httpx.TooManyRedirects):
|
|
||||||
client.get("https://example.org/redirect_loop", follow_redirects=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_cross_domain_redirect_with_auth_header():
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(redirects))
|
|
||||||
url = "https://example.com/cross_domain"
|
|
||||||
headers = {"Authorization": "abc"}
|
|
||||||
response = client.get(url, headers=headers, follow_redirects=True)
|
|
||||||
assert response.url == "https://example.org/cross_domain_target"
|
|
||||||
assert "authorization" not in response.json()["headers"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_cross_domain_https_redirect_with_auth_header():
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(redirects))
|
|
||||||
url = "http://example.com/cross_domain"
|
|
||||||
headers = {"Authorization": "abc"}
|
|
||||||
response = client.get(url, headers=headers, follow_redirects=True)
|
|
||||||
assert response.url == "https://example.org/cross_domain_target"
|
|
||||||
assert "authorization" not in response.json()["headers"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_cross_domain_redirect_with_auth():
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(redirects))
|
|
||||||
url = "https://example.com/cross_domain"
|
|
||||||
response = client.get(url, auth=("user", "pass"), follow_redirects=True)
|
|
||||||
assert response.url == "https://example.org/cross_domain_target"
|
|
||||||
assert "authorization" not in response.json()["headers"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_same_domain_redirect():
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(redirects))
|
|
||||||
url = "https://example.org/cross_domain"
|
|
||||||
headers = {"Authorization": "abc"}
|
|
||||||
response = client.get(url, headers=headers, follow_redirects=True)
|
|
||||||
assert response.url == "https://example.org/cross_domain_target"
|
|
||||||
assert response.json()["headers"]["authorization"] == "abc"
|
|
||||||
|
|
||||||
|
|
||||||
def test_same_domain_https_redirect_with_auth_header():
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(redirects))
|
|
||||||
url = "http://example.org/cross_domain"
|
|
||||||
headers = {"Authorization": "abc"}
|
|
||||||
response = client.get(url, headers=headers, follow_redirects=True)
|
|
||||||
assert response.url == "https://example.org/cross_domain_target"
|
|
||||||
assert response.json()["headers"]["authorization"] == "abc"
|
|
||||||
|
|
||||||
|
|
||||||
def test_body_redirect():
|
|
||||||
"""
|
|
||||||
A 308 redirect should preserve the request body.
|
|
||||||
"""
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(redirects))
|
|
||||||
url = "https://example.org/redirect_body"
|
|
||||||
content = b"Example request body"
|
|
||||||
response = client.post(url, content=content, follow_redirects=True)
|
|
||||||
assert response.url == "https://example.org/redirect_body_target"
|
|
||||||
assert response.json()["body"] == "Example request body"
|
|
||||||
assert "content-length" in response.json()["headers"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_body_redirect():
|
|
||||||
"""
|
|
||||||
A 303 redirect should remove the request body.
|
|
||||||
"""
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(redirects))
|
|
||||||
url = "https://example.org/redirect_no_body"
|
|
||||||
content = b"Example request body"
|
|
||||||
response = client.post(url, content=content, follow_redirects=True)
|
|
||||||
assert response.url == "https://example.org/redirect_body_target"
|
|
||||||
assert response.json()["body"] == ""
|
|
||||||
assert "content-length" not in response.json()["headers"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_can_stream_if_no_redirect():
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(redirects))
|
|
||||||
url = "https://example.org/redirect_301"
|
|
||||||
with client.stream("GET", url, follow_redirects=False) as response:
|
|
||||||
pass
|
|
||||||
assert response.status_code == httpx.codes.MOVED_PERMANENTLY
|
|
||||||
assert response.headers["location"] == "https://example.org/"
|
|
||||||
|
|
||||||
|
|
||||||
class ConsumeBodyTransport(httpx.MockTransport):
|
|
||||||
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
|
||||||
assert isinstance(request.stream, httpx.SyncByteStream)
|
|
||||||
list(request.stream)
|
|
||||||
return self.handler(request) # type: ignore[return-value]
|
|
||||||
|
|
||||||
|
|
||||||
def test_cannot_redirect_streaming_body():
|
|
||||||
client = httpx.Client(transport=ConsumeBodyTransport(redirects))
|
|
||||||
url = "https://example.org/redirect_body"
|
|
||||||
|
|
||||||
def streaming_body() -> typing.Iterator[bytes]:
|
|
||||||
yield b"Example request body" # pragma: no cover
|
|
||||||
|
|
||||||
with pytest.raises(httpx.StreamConsumed):
|
|
||||||
client.post(url, content=streaming_body(), follow_redirects=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_cross_subdomain_redirect():
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(redirects))
|
|
||||||
url = "https://example.com/cross_subdomain"
|
|
||||||
response = client.get(url, follow_redirects=True)
|
|
||||||
assert response.url == "https://www.example.org/cross_subdomain"
|
|
||||||
|
|
||||||
|
|
||||||
def cookie_sessions(request: httpx.Request) -> httpx.Response:
|
|
||||||
if request.url.path == "/":
|
|
||||||
cookie = request.headers.get("Cookie")
|
|
||||||
if cookie is not None:
|
|
||||||
content = b"Logged in"
|
|
||||||
else:
|
|
||||||
content = b"Not logged in"
|
|
||||||
return httpx.Response(200, content=content)
|
|
||||||
|
|
||||||
elif request.url.path == "/login":
|
|
||||||
status_code = httpx.codes.SEE_OTHER
|
|
||||||
headers = {
|
|
||||||
"location": "/",
|
|
||||||
"set-cookie": (
|
|
||||||
"session=eyJ1c2VybmFtZSI6ICJ0b21; path=/; Max-Age=1209600; "
|
|
||||||
"httponly; samesite=lax"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
return httpx.Response(status_code, headers=headers)
|
|
||||||
|
|
||||||
else:
|
|
||||||
assert request.url.path == "/logout"
|
|
||||||
status_code = httpx.codes.SEE_OTHER
|
|
||||||
headers = {
|
|
||||||
"location": "/",
|
|
||||||
"set-cookie": (
|
|
||||||
"session=null; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; "
|
|
||||||
"httponly; samesite=lax"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
return httpx.Response(status_code, headers=headers)
|
|
||||||
|
|
||||||
|
|
||||||
def test_redirect_cookie_behavior():
|
|
||||||
client = httpx.Client(
|
|
||||||
transport=httpx.MockTransport(cookie_sessions), follow_redirects=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# The client is not logged in.
|
|
||||||
response = client.get("https://example.com/")
|
|
||||||
assert response.url == "https://example.com/"
|
|
||||||
assert response.text == "Not logged in"
|
|
||||||
|
|
||||||
# Login redirects to the homepage, setting a session cookie.
|
|
||||||
response = client.post("https://example.com/login")
|
|
||||||
assert response.url == "https://example.com/"
|
|
||||||
assert response.text == "Logged in"
|
|
||||||
|
|
||||||
# The client is logged in.
|
|
||||||
response = client.get("https://example.com/")
|
|
||||||
assert response.url == "https://example.com/"
|
|
||||||
assert response.text == "Logged in"
|
|
||||||
|
|
||||||
# Logout redirects to the homepage, expiring the session cookie.
|
|
||||||
response = client.post("https://example.com/logout")
|
|
||||||
assert response.url == "https://example.com/"
|
|
||||||
assert response.text == "Not logged in"
|
|
||||||
|
|
||||||
# The client is not logged in.
|
|
||||||
response = client.get("https://example.com/")
|
|
||||||
assert response.url == "https://example.com/"
|
|
||||||
assert response.text == "Not logged in"
|
|
||||||
|
|
||||||
|
|
||||||
def test_redirect_custom_scheme():
|
|
||||||
client = httpx.Client(transport=httpx.MockTransport(redirects))
|
|
||||||
with pytest.raises(httpx.UnsupportedProtocol) as e:
|
|
||||||
client.post("https://example.org/redirect_custom_scheme", follow_redirects=True)
|
|
||||||
assert str(e.value) == "Scheme 'market' not supported."
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_async_invalid_redirect():
|
|
||||||
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
|
|
||||||
with pytest.raises(httpx.RemoteProtocolError):
|
|
||||||
await client.get(
|
|
||||||
"http://example.org/invalid_redirect", follow_redirects=True
|
|
||||||
)
|
|
||||||
@ -1011,10 +1011,7 @@ def test_response_decode_text_using_autodetect():
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.reason_phrase == "OK"
|
assert response.reason_phrase == "OK"
|
||||||
# The encoded byte string is consistent with either ISO-8859-1 or
|
assert response.encoding == "ISO-8859-1"
|
||||||
# WINDOWS-1252. Versions <6.0 of chardet claim the former, while chardet
|
|
||||||
# 6.0 detects the latter.
|
|
||||||
assert response.encoding in ("ISO-8859-1", "WINDOWS-1252")
|
|
||||||
assert response.text == text
|
assert response.text == text
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -489,18 +489,18 @@ def test_response_invalid_argument():
|
|||||||
def test_ensure_ascii_false_with_french_characters():
|
def test_ensure_ascii_false_with_french_characters():
|
||||||
data = {"greeting": "Bonjour, ça va ?"}
|
data = {"greeting": "Bonjour, ça va ?"}
|
||||||
response = httpx.Response(200, json=data)
|
response = httpx.Response(200, json=data)
|
||||||
assert "ça va" in response.text, (
|
assert (
|
||||||
"ensure_ascii=False should preserve French accented characters"
|
"ça va" in response.text
|
||||||
)
|
), "ensure_ascii=False should preserve French accented characters"
|
||||||
assert response.headers["Content-Type"] == "application/json"
|
assert response.headers["Content-Type"] == "application/json"
|
||||||
|
|
||||||
|
|
||||||
def test_separators_for_compact_json():
|
def test_separators_for_compact_json():
|
||||||
data = {"clé": "valeur", "liste": [1, 2, 3]}
|
data = {"clé": "valeur", "liste": [1, 2, 3]}
|
||||||
response = httpx.Response(200, json=data)
|
response = httpx.Response(200, json=data)
|
||||||
assert response.text == '{"clé":"valeur","liste":[1,2,3]}', (
|
assert (
|
||||||
"separators=(',', ':') should produce a compact representation"
|
response.text == '{"clé":"valeur","liste":[1,2,3]}'
|
||||||
)
|
), "separators=(',', ':') should produce a compact representation"
|
||||||
assert response.headers["Content-Type"] == "application/json"
|
assert response.headers["Content-Type"] == "application/json"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user