Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Deprecate app=... in favor of explicit WSGITransport/ASGITransport. #3050

Merged
merged 16 commits into from
Feb 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## Unreleased

### Deprecated

* The `app=...` shortcut has been deprecated. Use the explicit style of `transport=httpx.WSGITransport()` or `transport=httpx.ASGITransport()` instead.

### Fixed

* Respect the `http1` argument while configuring proxy transports. (#3023)
Expand Down
72 changes: 70 additions & 2 deletions docs/advanced/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ You can configure an `httpx` client to call directly into a Python web applicati
This is particularly useful for two main use-cases:

* Using `httpx` as a client inside test cases.
* Mocking out external services during tests or in dev/staging environments.
* Mocking out external services during tests or in dev or staging environments.

### Example

Here's an example of integrating against a Flask application:

Expand All @@ -57,12 +59,15 @@ app = Flask(__name__)
def hello():
return "Hello World!"

with httpx.Client(app=app, base_url="http://testserver") as client:
transport = httpx.WSGITransport(app=app)
with httpx.Client(transport=transport, base_url="http://testserver") as client:
r = client.get("/")
assert r.status_code == 200
assert r.text == "Hello World!"
```

### Configuration

For some more complex cases you might need to customize the WSGI transport. This allows you to:

* Inspect 500 error responses rather than raise exceptions by setting `raise_app_exceptions=False`.
Expand All @@ -78,6 +83,69 @@ with httpx.Client(transport=transport, base_url="http://testserver") as client:
...
```

## ASGITransport

You can configure an `httpx` client to call directly into an async Python web application using the ASGI protocol.

This is particularly useful for two main use-cases:

* Using `httpx` as a client inside test cases.
* Mocking out external services during tests or in dev or staging environments.

### Example

Let's take this Starlette application as an example:

```python
from starlette.applications import Starlette
from starlette.responses import HTMLResponse
from starlette.routing import Route


async def hello(request):
return HTMLResponse("Hello World!")


app = Starlette(routes=[Route("/", hello)])
```

We can make requests directly against the application, like so:

```python
transport = httpx.ASGITransport(app=app)

async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
r = await client.get("/")
assert r.status_code == 200
assert r.text == "Hello World!"
```

### Configuration

For some more complex cases you might need to customise the ASGI transport. This allows you to:

* Inspect 500 error responses rather than raise exceptions by setting `raise_app_exceptions=False`.
* Mount the ASGI application at a subpath by setting `root_path`.
* Use a given client address for requests by setting `client`.

For example:

```python
# Instantiate a client that makes ASGI requests with a client IP of "1.2.3.4",
# on port 123.
transport = httpx.ASGITransport(app=app, client=("1.2.3.4", 123))
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
...
```

See [the ASGI documentation](https://asgi.readthedocs.io/en/latest/specs/www.html#connection-scope) for more details on the `client` and `root_path` keys.

### ASGI startup and shutdown

It is not in the scope of HTTPX to trigger ASGI lifespan events of your app.

However it is suggested to use `LifespanManager` from [asgi-lifespan](https://github.com/florimondmanca/asgi-lifespan#usage) in pair with `AsyncClient`.

## Custom transports

A transport instance must implement the low-level Transport API, which deals
Expand Down
52 changes: 1 addition & 51 deletions docs/async.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,54 +191,4 @@ anyio.run(main, backend='trio')

## Calling into Python Web Apps

Just as `httpx.Client` allows you to call directly into WSGI web applications,
the `httpx.AsyncClient` class allows you to call directly into ASGI web applications.

Let's take this Starlette application as an example:

```python
from starlette.applications import Starlette
from starlette.responses import HTMLResponse
from starlette.routing import Route


async def hello(request):
return HTMLResponse("Hello World!")


app = Starlette(routes=[Route("/", hello)])
```

We can make requests directly against the application, like so:

```pycon
>>> import httpx
>>> async with httpx.AsyncClient(app=app, base_url="http://testserver") as client:
... r = await client.get("/")
... assert r.status_code == 200
... assert r.text == "Hello World!"
```

For some more complex cases you might need to customise the ASGI transport. This allows you to:

* Inspect 500 error responses rather than raise exceptions by setting `raise_app_exceptions=False`.
* Mount the ASGI application at a subpath by setting `root_path`.
* Use a given client address for requests by setting `client`.

For example:

```python
# Instantiate a client that makes ASGI requests with a client IP of "1.2.3.4",
# on port 123.
transport = httpx.ASGITransport(app=app, client=("1.2.3.4", 123))
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
...
```

See [the ASGI documentation](https://asgi.readthedocs.io/en/latest/specs/www.html#connection-scope) for more details on the `client` and `root_path` keys.

## Startup/shutdown of ASGI apps

It is not in the scope of HTTPX to trigger lifespan events of your app.

However it is suggested to use `LifespanManager` from [asgi-lifespan](https://github.com/florimondmanca/asgi-lifespan#usage) in pair with `AsyncClient`.
For details on calling directly into ASGI applications, see [the `ASGITransport` docs](../advanced/transports#asgitransport).
16 changes: 15 additions & 1 deletion httpx/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,13 @@ def __init__(
if proxy:
raise RuntimeError("Use either `proxy` or 'proxies', not both.")

if app:
message = (
"The 'app' shortcut is now deprecated."
" Use the explicit style 'transport=WSGITransport(app=...)' instead."
)
warnings.warn(message, DeprecationWarning)

allow_env_proxies = trust_env and app is None and transport is None
proxy_map = self._get_proxy_map(proxies or proxy, allow_env_proxies)

Expand Down Expand Up @@ -1411,7 +1418,14 @@ def __init__(
if proxy:
raise RuntimeError("Use either `proxy` or 'proxies', not both.")

allow_env_proxies = trust_env and app is None and transport is None
if app:
message = (
"The 'app' shortcut is now deprecated."
" Use the explicit style 'transport=ASGITransport(app=...)' instead."
)
warnings.warn(message, DeprecationWarning)

allow_env_proxies = trust_env and transport is None
proxy_map = self._get_proxy_map(proxies or proxy, allow_env_proxies)

self._transport = self._init_transport(
Expand Down
37 changes: 28 additions & 9 deletions tests/test_asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ async def test_asgi_transport_no_body():

@pytest.mark.anyio
async def test_asgi():
async with httpx.AsyncClient(app=hello_world) as client:
transport = httpx.ASGITransport(app=hello_world)
async with httpx.AsyncClient(transport=transport) as client:
response = await client.get("http://www.example.org/")

assert response.status_code == 200
Expand All @@ -101,7 +102,8 @@ async def test_asgi():

@pytest.mark.anyio
async def test_asgi_urlencoded_path():
async with httpx.AsyncClient(app=echo_path) as client:
transport = httpx.ASGITransport(app=echo_path)
async with httpx.AsyncClient(transport=transport) as client:
url = httpx.URL("http://www.example.org/").copy_with(path="/user@example.org")
response = await client.get(url)

Expand All @@ -111,7 +113,8 @@ async def test_asgi_urlencoded_path():

@pytest.mark.anyio
async def test_asgi_raw_path():
async with httpx.AsyncClient(app=echo_raw_path) as client:
transport = httpx.ASGITransport(app=echo_raw_path)
async with httpx.AsyncClient(transport=transport) as client:
url = httpx.URL("http://www.example.org/").copy_with(path="/user@example.org")
response = await client.get(url)

Expand All @@ -124,7 +127,8 @@ async def test_asgi_raw_path_should_not_include_querystring_portion():
"""
See https://github.com/encode/httpx/issues/2810
"""
async with httpx.AsyncClient(app=echo_raw_path) as client:
transport = httpx.ASGITransport(app=echo_raw_path)
async with httpx.AsyncClient(transport=transport) as client:
url = httpx.URL("http://www.example.org/path?query")
response = await client.get(url)

Expand All @@ -134,7 +138,8 @@ async def test_asgi_raw_path_should_not_include_querystring_portion():

@pytest.mark.anyio
async def test_asgi_upload():
async with httpx.AsyncClient(app=echo_body) as client:
transport = httpx.ASGITransport(app=echo_body)
async with httpx.AsyncClient(transport=transport) as client:
response = await client.post("http://www.example.org/", content=b"example")

assert response.status_code == 200
Expand All @@ -143,7 +148,8 @@ async def test_asgi_upload():

@pytest.mark.anyio
async def test_asgi_headers():
async with httpx.AsyncClient(app=echo_headers) as client:
transport = httpx.ASGITransport(app=echo_headers)
async with httpx.AsyncClient(transport=transport) as client:
response = await client.get("http://www.example.org/")

assert response.status_code == 200
Expand All @@ -160,14 +166,16 @@ async def test_asgi_headers():

@pytest.mark.anyio
async def test_asgi_exc():
async with httpx.AsyncClient(app=raise_exc) as client:
transport = httpx.ASGITransport(app=raise_exc)
async with httpx.AsyncClient(transport=transport) as client:
with pytest.raises(RuntimeError):
await client.get("http://www.example.org/")


@pytest.mark.anyio
async def test_asgi_exc_after_response():
async with httpx.AsyncClient(app=raise_exc_after_response) as client:
transport = httpx.ASGITransport(app=raise_exc_after_response)
async with httpx.AsyncClient(transport=transport) as client:
with pytest.raises(RuntimeError):
await client.get("http://www.example.org/")

Expand Down Expand Up @@ -199,7 +207,8 @@ async def read_body(scope, receive, send):
message = await receive()
disconnect = message.get("type") == "http.disconnect"

async with httpx.AsyncClient(app=read_body) as client:
transport = httpx.ASGITransport(app=read_body)
async with httpx.AsyncClient(transport=transport) as client:
response = await client.post("http://www.example.org/", content=b"example")

assert response.status_code == 200
Expand All @@ -213,3 +222,13 @@ async def test_asgi_exc_no_raise():
response = await client.get("http://www.example.org/")

assert response.status_code == 500


@pytest.mark.anyio
async def test_deprecated_shortcut():
"""
The `app=...` shortcut is now deprecated.
Use the explicit transport style instead.
"""
with pytest.warns(DeprecationWarning):
httpx.AsyncClient(app=hello_world)
36 changes: 27 additions & 9 deletions tests/test_wsgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,49 +92,56 @@ def log_to_wsgi_log_buffer(environ, start_response):


def test_wsgi():
client = httpx.Client(app=application_factory([b"Hello, World!"]))
transport = httpx.WSGITransport(app=application_factory([b"Hello, World!"]))
client = httpx.Client(transport=transport)
response = client.get("http://www.example.org/")
assert response.status_code == 200
assert response.text == "Hello, World!"


def test_wsgi_upload():
client = httpx.Client(app=echo_body)
transport = httpx.WSGITransport(app=echo_body)
client = httpx.Client(transport=transport)
response = client.post("http://www.example.org/", content=b"example")
assert response.status_code == 200
assert response.text == "example"


def test_wsgi_upload_with_response_stream():
client = httpx.Client(app=echo_body_with_response_stream)
transport = httpx.WSGITransport(app=echo_body_with_response_stream)
client = httpx.Client(transport=transport)
response = client.post("http://www.example.org/", content=b"example")
assert response.status_code == 200
assert response.text == "example"


def test_wsgi_exc():
client = httpx.Client(app=raise_exc)
transport = httpx.WSGITransport(app=raise_exc)
client = httpx.Client(transport=transport)
with pytest.raises(ValueError):
client.get("http://www.example.org/")


def test_wsgi_http_error():
client = httpx.Client(app=partial(raise_exc, exc=RuntimeError))
transport = httpx.WSGITransport(app=partial(raise_exc, exc=RuntimeError))
client = httpx.Client(transport=transport)
with pytest.raises(RuntimeError):
client.get("http://www.example.org/")


def test_wsgi_generator():
output = [b"", b"", b"Some content", b" and more content"]
client = httpx.Client(app=application_factory(output))
transport = httpx.WSGITransport(app=application_factory(output))
client = httpx.Client(transport=transport)
response = client.get("http://www.example.org/")
assert response.status_code == 200
assert response.text == "Some content and more content"


def test_wsgi_generator_empty():
output = [b"", b"", b"", b""]
client = httpx.Client(app=application_factory(output))
transport = httpx.WSGITransport(app=application_factory(output))
client = httpx.Client(transport=transport)
response = client.get("http://www.example.org/")
assert response.status_code == 200
assert response.text == ""
Expand Down Expand Up @@ -170,7 +177,8 @@ def app(environ, start_response):
server_port = environ["SERVER_PORT"]
return hello_world_app(environ, start_response)

client = httpx.Client(app=app)
transport = httpx.WSGITransport(app=app)
client = httpx.Client(transport=transport)
response = client.get(url)
assert response.status_code == 200
assert response.text == "Hello, World!"
Expand All @@ -186,9 +194,19 @@ def app(environ, start_response):
start_response("200 OK", [("Content-Type", "text/plain")])
return [b"success"]

with httpx.Client(app=app, base_url="http://testserver") as client:
transport = httpx.WSGITransport(app=app)
with httpx.Client(transport=transport, base_url="http://testserver") as client:
response = client.get("/")

assert response.status_code == 200
assert response.text == "success"
assert server_protocol == "HTTP/1.1"


def test_deprecated_shortcut():
"""
The `app=...` shortcut is now deprecated.
Use the explicit transport style instead.
"""
with pytest.warns(DeprecationWarning):
httpx.Client(app=application_factory([b"Hello, World!"]))