Repository: encode/httpx
Pull request #3050 (2024-01-11 12:57:52)

Deprecate `app=...` in favor of explicit `WSGITransport`/`ASGITransport`.
*Deprecate the `httpx.Client(app=...)` and `httpx.AsyncClient(app=...)` shortcuts.*

---

Instead of...

```python
client = httpx.Client(app=app)
```

Use this...

```python
transport = httpx.WSGITransport(app=app)
client = httpx.Client(transport=transport)
```

---

Instead of...

```python
client = httpx.AsyncClient(app=app)
```

Use this...

```python
transport = httpx.ASGITransport(app=app)
client = httpx.AsyncClient(transport=transport)
```

---

The rationale for this is described in https://github.com/encode/httpx/issues/947#issuecomment-1727389402.

**TODO**:

- [x] Docs.
- [x] `CHANGELOG.md`.
- [x] ~Ensure we have a clear section where we outline 1.0 version changes in one place.~ See https://github.com/encode/httpx/pull/3069


Kludex: This is not deprecation of `app=...`, it's a removal directly. Is it ok? We usually raise warnings when deprecating paramters, don't we?
tomchristie: > We usually raise warnings when deprecating paramters, don't we?

Yep, so... https://github.com/encode/httpx/pull/3069

There's a proposal for several related changes, that end up with a stricter simpler more consistent API.
We can land these in a 1.0, and need to take a call on what we think is best wrt. hard-change vs. soft deprecation path.
karpetrosyan: Is there any reason why we should deprecate things?
Deprecation is good when we want to allow users to use the latest features and updates for software without changing anything. HTTPX 1.0 will change a lot of things, and I think we shouldn't strive to support all the HTTPX/1.x updates for 0.x syntax.

Instead, we can have a well-defined migration guide that will help our users migrate easily.
Also, all of the changes are mainly in the client, which is very easy to migrate because they are not so much in the codebase.
tomchristie: > This is not deprecation of app=..., it's a removal directly. Is it ok? We usually raise warnings when deprecating parameters, don't we?

Based, on this I've updated the PR to be just a deprecation at this point.

Once this is merged I'll look at rolling a 0.27 version with the updated docs.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 47ac88c..7950a5f 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 2f3e006..7e0e21c 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 d54a353..089d783 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 1f2145d..e2c6702 100644
--- a/httpx/_client.py
+++ b/httpx/_client.py
@@ -672,6 +672,13 @@ class Client(BaseClient):
             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 @@ class AsyncClient(BaseClient):
             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(


Test patch:
diff --git a/tests/test_asgi.py b/tests/test_asgi.py
index 2971506..ccc5526 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 test_asgi_disconnect_after_response_complete():
         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 3565a48..0134bee 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 test_wsgi_server_port(url: str, expected_server_port: str) -> None:
         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 test_wsgi_server_protocol():
         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!"]))