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

### Removed

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this PR is version 1.0 proposal, why don't you track its changelog in #3069 ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

@T-256 T-256 Jan 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then do we need to re-mention it in this PR?
All other open PRs with 1.0 proposal tag don't include changelog independently.

Copy link
Member Author

@tomchristie tomchristie Jan 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They should do before approval.

(We should include CHANGELOG entries with the PRs as usual.)


### 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).
22 changes: 2 additions & 20 deletions httpx/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,8 @@
)
from ._models import Cookies, Headers, Request, Response
from ._status_codes import codes
from ._transports.asgi import ASGITransport
from ._transports.base import AsyncBaseTransport, BaseTransport
from ._transports.default import AsyncHTTPTransport, HTTPTransport
from ._transports.wsgi import WSGITransport
from ._types import (
AsyncByteStream,
AuthTypes,
Expand Down Expand Up @@ -606,8 +604,6 @@ class Client(BaseClient):
request URLs.
* **transport** - *(optional)* A transport class to use for sending requests
over the network.
* **app** - *(optional)* An WSGI application to send requests to,
rather than sending actual network requests.
* **trust_env** - *(optional)* Enables or disables usage of environment
variables for configuration.
* **default_encoding** - *(optional)* The default encoding to use for decoding
Expand Down Expand Up @@ -636,7 +632,6 @@ def __init__(
event_hooks: None | (typing.Mapping[str, list[EventHook]]) = None,
base_url: URLTypes = "",
transport: BaseTransport | None = None,
app: typing.Callable[..., typing.Any] | None = None,
trust_env: bool = True,
default_encoding: str | typing.Callable[[bytes], str] = "utf-8",
) -> None:
Expand Down Expand Up @@ -672,7 +667,7 @@ 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
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 All @@ -682,7 +677,6 @@ def __init__(
http2=http2,
limits=limits,
transport=transport,
app=app,
trust_env=trust_env,
)
self._mounts: dict[URLPattern, BaseTransport | None] = {
Expand Down Expand Up @@ -714,15 +708,11 @@ def _init_transport(
http2: bool = False,
limits: Limits = DEFAULT_LIMITS,
transport: BaseTransport | None = None,
app: typing.Callable[..., typing.Any] | None = None,
trust_env: bool = True,
) -> BaseTransport:
if transport is not None:
return transport

if app is not None:
return WSGITransport(app=app)

return HTTPTransport(
verify=verify,
cert=cert,
Expand Down Expand Up @@ -1344,8 +1334,6 @@ class AsyncClient(BaseClient):
request URLs.
* **transport** - *(optional)* A transport class to use for sending requests
over the network.
* **app** - *(optional)* An ASGI application to send requests to,
rather than sending actual network requests.
* **trust_env** - *(optional)* Enables or disables usage of environment
variables for configuration.
* **default_encoding** - *(optional)* The default encoding to use for decoding
Expand Down Expand Up @@ -1375,7 +1363,6 @@ def __init__(
| (typing.Mapping[str, list[typing.Callable[..., typing.Any]]]) = None,
base_url: URLTypes = "",
transport: AsyncBaseTransport | None = None,
app: typing.Callable[..., typing.Any] | None = None,
trust_env: bool = True,
default_encoding: str | typing.Callable[[bytes], str] = "utf-8",
) -> None:
Expand Down Expand Up @@ -1411,7 +1398,7 @@ 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
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 All @@ -1421,7 +1408,6 @@ def __init__(
http2=http2,
limits=limits,
transport=transport,
app=app,
trust_env=trust_env,
)

Expand Down Expand Up @@ -1453,15 +1439,11 @@ def _init_transport(
http2: bool = False,
limits: Limits = DEFAULT_LIMITS,
transport: AsyncBaseTransport | None = None,
app: typing.Callable[..., typing.Any] | None = None,
trust_env: bool = True,
) -> AsyncBaseTransport:
if transport is not None:
return transport

if app is not None:
return ASGITransport(app=app)

return AsyncHTTPTransport(
verify=verify,
cert=cert,
Expand Down
27 changes: 18 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 Down
Loading