Compare commits

..

15 Commits

Author SHA1 Message Date
Kar Petrosyan
1c81675399
Merge branch 'master' into use-unasync-for-tests 2025-03-21 11:29:21 +04:00
Kar Petrosyan
948ee3691f
Merge branch 'master' into use-unasync-for-tests 2025-02-27 21:45:37 +04:00
Kar Petrosyan
187d035622
Merge branch 'master' into use-unasync-for-tests 2025-02-27 21:03:59 +04:00
Kar Petrosyan
039baa0cad fix cov 2025-02-27 21:01:59 +04:00
Kar Petrosyan
f654bddb4c fix typo 2025-02-27 20:59:01 +04:00
Kar Petrosyan
9c5a2d1646 ignore ruff for generated sync tests 2025-02-27 20:56:49 +04:00
Kar Petrosyan
e7668b6ea0 fix unasync 2025-02-27 20:47:41 +04:00
Kar Petrosyan
a2fc6825a5 Use unasync for tests 2025-02-27 20:38:08 +04:00
Kar Petrosyan
6baba9c1ce Make all the tests from test_headers and test_auth to be async 2025-02-27 20:01:04 +04:00
Kar Petrosyan
a803813702 Make all the tests from test_headers and test_cookies to be async 2025-02-27 19:53:57 +04:00
Kar Petrosyan
b1d5fca5ea Make all the tests from test_headers and test_event_hooks to be async 2025-02-27 19:46:52 +04:00
Kar Petrosyan
c3f1ee1203 Make all the tests from test_headers and test_properties to be async 2025-02-27 19:40:59 +04:00
Kar Petrosyan
ca6f520772 Make all the tests from test_proxies to be async 2025-02-27 19:30:49 +04:00
Kar Petrosyan
3848ad120a Make all the tests from test_queryparams to be async 2025-02-27 19:23:00 +04:00
Kar Petrosyan
4965a61830 Make all the tests from test_redirects to be async 2025-02-27 19:19:45 +04:00
51 changed files with 3746 additions and 1768 deletions

View File

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

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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/).

View File

@ -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

View File

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

View File

@ -20,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.

View File

@ -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)

View File

@ -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)

View File

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

View File

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

View File

@ -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."
) )

View File

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

View File

@ -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.

View File

@ -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(
[ [

View File

@ -6,7 +6,7 @@ build-backend = "hatchling.build"
name = "httpx" name = "httpx"
description = "The next generation HTTP client." description = "The next generation HTTP client."
license = "BSD-3-Clause" license = "BSD-3-Clause"
requires-python = ">=3.9" requires-python = ">=3.8"
authors = [ authors = [
{ name = "Tom Christie", email = "tom@tomchristie.com" }, { name = "Tom Christie", email = "tom@tomchristie.com" },
] ]
@ -20,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/*"]

View File

@ -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

View File

@ -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

View File

@ -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
View 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()

View File

View 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"}

View File

@ -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!")

View 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"}

View File

@ -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.
""" """

View 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

View 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

View 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

View 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"
)

View 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
)

View File

View 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"}

View 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"

View File

@ -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"}

View 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"},
},
]

View 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

View 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

View File

@ -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

View 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"
)

View 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
)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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
)

View File

@ -1011,10 +1011,7 @@ def test_response_decode_text_using_autodetect():
assert response.status_code == 200 assert response.status_code == 200
assert response.reason_phrase == "OK" assert response.reason_phrase == "OK"
# The encoded byte string is consistent with either ISO-8859-1 or assert response.encoding == "ISO-8859-1"
# WINDOWS-1252. Versions <6.0 of chardet claim the former, while chardet
# 6.0 detects the latter.
assert response.encoding in ("ISO-8859-1", "WINDOWS-1252")
assert response.text == text assert response.text == text

View File

@ -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"