From cabd1c095e52e2f67ab63ec17941d3a539d7c877 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 2 Feb 2024 13:29:41 +0000 Subject: [PATCH] Deprecate `app=...` in favor of explicit `WSGITransport`/`ASGITransport`. (#3050) * Deprecate app=... in favour of explicit WSGITransport/ASGITransport * Linting * Linting * Update WSGITransport and ASGITransport docs * Deprecate app * Drop deprecation tests * Add CHANGELOG * Deprecate 'app=...' shortcut, rather than removing it. * Update CHANGELOG * Fix test_asgi.test_deprecated_shortcut --- CHANGELOG.md | 4 +++ docs/advanced/transports.md | 72 +++++++++++++++++++++++++++++++++++-- docs/async.md | 52 +-------------------------- httpx/_client.py | 16 ++++++++- tests/test_asgi.py | 37 ++++++++++++++----- tests/test_wsgi.py | 36 ++++++++++++++----- 6 files changed, 145 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47ac88c834..7950a5f320 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/docs/advanced/transports.md b/docs/advanced/transports.md index 2f3e00690d..7e0e21c6f9 100644 --- a/docs/advanced/transports.md +++ b/docs/advanced/transports.md @@ -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: @@ -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`. @@ -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 diff --git a/docs/async.md b/docs/async.md index d54a353d62..089d783191 100644 --- a/docs/async.md +++ b/docs/async.md @@ -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). \ No newline at end of file diff --git a/httpx/_client.py b/httpx/_client.py index 1f2145d12e..e2c6702e0c 100644 --- a/httpx/_client.py +++ b/httpx/_client.py @@ -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) @@ -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( diff --git a/tests/test_asgi.py b/tests/test_asgi.py index 2971506097..ccc5526678 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -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 @@ -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) @@ -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) @@ -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) @@ -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 @@ -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 @@ -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/") @@ -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 @@ -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) diff --git a/tests/test_wsgi.py b/tests/test_wsgi.py index 3565a48c92..0134bee854 100644 --- a/tests/test_wsgi.py +++ b/tests/test_wsgi.py @@ -92,41 +92,47 @@ 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" @@ -134,7 +140,8 @@ def test_wsgi_generator(): 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 == "" @@ -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!" @@ -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!"]))