Repository: encode/httpx

Issue:
Garbage collected responses should warn if still open.
See https://github.com/encode/httpx/issues/393#issuecomment-538370554
Reviewing the streaming API
Right now we issue streaming requests using...

```python
response = await client.get(url, stream=True)
```

And have the following stream-specific methods...

* `.read()`
* `.raw()`
* `.stream()`
* `.stream_text()`
* `.stream_lines()`
* `.close()`

For the 1.0 release I think we should consider? droping `stream=True`, as it makes it *exceptionally* easy to leave unclosed connections hanging around.

I'm interested in if we might want to use the following instead...

```python
async with client.stream('GET', url) as response:  # Can only be used as a context manager.
    ...
```

That way users never have to think about "did I call close on the response", or leave open connections hanging around accidentally.

This is related to #393, but it's a stricter API, that doesn't rely on "users *might* use a response as a context manger". And also makes it far clearer *where and when* the streaming methods should be used. Ie. they only make sense inside a `.stream()` block.

I also think we could rationalize the streaming methods, so...

* `.read()`
* `.stream_raw()`
* `.stream_bytes()`
* `.stream_text()`
* `.stream_lines()`
* `.close()`


Discussion:

Thanks for bringing this up! I remember we mentioned it briefly elsewhere but it's good it has its own issue now.

I agree with the idea of a dedicated method, because passing `stream=True` induces an important change in behavior that should be better reflected in the API.

I also agree on enforcing context manager usage.

Thinking about alternatives, the fact that we have "one method per HTTP verb" for the non-streaming case and "one method accepting the HTTP verb" for the streaming case might look a bit odd.

(Strictly speaking, users *can* use `.request("GET", ...)` for the non-streaming case, but we don't really advertise that possibility e.g. in the docs.)

The only constructive idea I might have regarding this would be…

```python
async with client.stream.get(url) as response: ...
```

But a) flat is better tha nested and b) that would require us to implement *yet another host of methods* for each HTTP verb.

So, I think `.stream("<method", url, **kwargs)` is fine; at least *much better* than `.get(url, stream=True`).

> I also think we could rationalize the streaming methods

:100:, yes.
> Thinking about alternatives, the fact that we have "one method per HTTP verb" for the non-streaming case and "one method accepting the HTTP verb" for the streaming case might look a bit odd.

Indeed yeah.

Options I can see here are...

Provide nested `.get()` and pals...

```python
async with client.stream.get(url) as response:
    ...
```

Explicit request methods, for the streaming case...

```python
async with client.stream("GET", url) as response:
    ...
```

Support *either one or two* postional args for the streaming case...

```python
async with client.stream(url) as response:  # Defaults to GET
    ...

async with client.stream("POST", url) as response:
    ...
```
Another take could be that we *enforce* that responses *must* be used as a context manager when `stream=True` is used, so...

```python
response = await client.get(url, stream=True)
response.<whatever>  # Raises an error.

async with client.get(url, stream=True) as response:
    ...  # Happy days
```

Although I. *think* that feels a bit weirdly ambiguous to me.
> Explicit request methods, for the streaming case...

That’s the most reasonable option, IMO.
Okay, so here's something *super* nice that the more constrained variant of the streaming API can potentially give us...

* We can have `.request()`, `.get()`, `.post()` and pals return a `Response` instance, which doesn't actually include the streaming methods *at all*.
* We can have the context managed `.stream()` return an `AsyncResponse` instance, which does.

Why is that a nice thing? Well, for one, we're more tightly scoping the "do some I/O with this thing" to a very particular case. You can only perform I/O on an `AsyncResponse` - a `Response` is just a plain old bit of state+interface.

This also means that `Response` is pickleable, where `AsyncResponse` is not, which is a really clear boundary-line.

The biggest thing tho' is that once we've got a `SyncClient` and an `AsyncClient` they *actually* return the same type for almost all operations, except the more snazzy "I need to control the I/O directly" case. (We won't need `SyncRequest` and `AsyncRequest`, but I won't go into that now.)

There's one other bit of API that would need finessing in order to achieve that, which would be to drop the one other bit of I/O on the response, which is `response.next()`, for calling the next redirect in a chain. Instead that ought to just be something plainer, which returns a redirect response instance, that can be sent explicitly.

We won't go into any of that initially - Let's just get the streaming API squared away first - but wanted to share what I'm thinking there.
> `AsyncResponse`

*Or*... `StreamingResponse`? If « async » there really just means « this response may still do I/O » then this would make even more sense to me.

But yeah; at the moment we have a rather strange « read the response content, unless we’re steaming » bit in the client. Switching to a clear distinction between the two will definitely make things cleaner. We might even just get the entire content from inside the actual connections, and pass plain bytes the `Response`?

Resolving patch:
diff --git a/httpx/__init__.py b/httpx/__init__.py
--- a/httpx/__init__.py
+++ b/httpx/__init__.py
@@ -1,5 +1,5 @@
 from .__version__ import __description__, __title__, __version__
-from .api import delete, get, head, options, patch, post, put, request
+from .api import delete, get, head, options, patch, post, put, request, stream
 from .auth import BasicAuth, DigestAuth
 from .client import Client
 from .concurrency.asyncio import AsyncioBackend
@@ -75,6 +75,7 @@
     "patch",
     "put",
     "request",
+    "stream",
     "BasicAuth",
     "Client",
     "DigestAuth",
diff --git a/httpx/api.py b/httpx/api.py
--- a/httpx/api.py
+++ b/httpx/api.py
@@ -1,12 +1,13 @@
 import typing
 
-from .client import Client
+from .client import Client, StreamContextManager
 from .config import DEFAULT_TIMEOUT_CONFIG, CertTypes, TimeoutTypes, VerifyTypes
 from .models import (
     AuthTypes,
     CookieTypes,
     HeaderTypes,
     QueryParamTypes,
+    Request,
     RequestData,
     RequestFiles,
     Response,
@@ -98,6 +99,44 @@ async def request(
         )
 
 
+def stream(
+    method: str,
+    url: URLTypes,
+    *,
+    params: QueryParamTypes = None,
+    data: RequestData = None,
+    files: RequestFiles = None,
+    json: typing.Any = None,
+    headers: HeaderTypes = None,
+    cookies: CookieTypes = None,
+    auth: AuthTypes = None,
+    timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
+    allow_redirects: bool = True,
+    verify: VerifyTypes = True,
+    cert: CertTypes = None,
+    trust_env: bool = True,
+) -> StreamContextManager:
+    client = Client(cert=cert, verify=verify, trust_env=trust_env)
+    request = Request(
+        method=method,
+        url=url,
+        params=params,
+        data=data,
+        files=files,
+        json=json,
+        headers=headers,
+        cookies=cookies,
+    )
+    return StreamContextManager(
+        client=client,
+        request=request,
+        auth=auth,
+        timeout=timeout,
+        allow_redirects=allow_redirects,
+        close_client=True,
+    )
+
+
 async def get(
     url: URLTypes,
     *,
diff --git a/httpx/client.py b/httpx/client.py
--- a/httpx/client.py
+++ b/httpx/client.py
@@ -1,5 +1,6 @@
 import functools
 import typing
+import warnings
 from types import TracebackType
 
 import hstspreload
@@ -222,6 +223,12 @@ async def request(
         timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
         trust_env: bool = None,
     ) -> Response:
+        if stream:
+            warnings.warn(
+                "The 'stream=True' argument is due to be deprecated. "
+                "Use 'async with client.stream(method, url, ...) as response' instead."
+            )
+
         request = self.build_request(
             method=method,
             url=url,
@@ -244,6 +251,39 @@ async def request(
         )
         return response
 
+    def stream(
+        self,
+        method: str,
+        url: URLTypes,
+        *,
+        data: RequestData = None,
+        files: RequestFiles = None,
+        json: typing.Any = None,
+        params: QueryParamTypes = None,
+        headers: HeaderTypes = None,
+        cookies: CookieTypes = None,
+        auth: AuthTypes = None,
+        allow_redirects: bool = True,
+        timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
+    ) -> "StreamContextManager":
+        request = self.build_request(
+            method=method,
+            url=url,
+            data=data,
+            files=files,
+            json=json,
+            params=params,
+            headers=headers,
+            cookies=cookies,
+        )
+        return StreamContextManager(
+            client=self,
+            request=request,
+            auth=auth,
+            allow_redirects=allow_redirects,
+            timeout=timeout,
+        )
+
     def build_request(
         self,
         method: str,
@@ -864,3 +904,42 @@ def _proxy_from_url(url: URLTypes) -> Dispatcher:
             else:
                 new_proxies[str(key)] = dispatcher_or_url
         return new_proxies
+
+
+class StreamContextManager:
+    def __init__(
+        self,
+        client: Client,
+        request: Request,
+        *,
+        auth: AuthTypes = None,
+        allow_redirects: bool = True,
+        timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
+        close_client: bool = False,
+    ) -> None:
+        self.client = client
+        self.request = request
+        self.auth = auth
+        self.allow_redirects = allow_redirects
+        self.timeout = timeout
+        self.close_client = close_client
+
+    async def __aenter__(self) -> "Response":
+        self.response = await self.client.send(
+            request=self.request,
+            auth=self.auth,
+            allow_redirects=self.allow_redirects,
+            timeout=self.timeout,
+            stream=True,
+        )
+        return self.response
+
+    async def __aexit__(
+        self,
+        exc_type: typing.Type[BaseException] = None,
+        exc_value: BaseException = None,
+        traceback: TracebackType = None,
+    ) -> None:
+        await self.response.close()
+        if self.close_client:
+            await self.client.close()


Test patch:
diff --git a/tests/client/test_async_client.py b/tests/client/test_async_client.py
--- a/tests/client/test_async_client.py
+++ b/tests/client/test_async_client.py
@@ -62,16 +62,19 @@ async def test_post_json(server, backend):
 
 async def test_stream_response(server, backend):
     async with httpx.Client(backend=backend) as client:
-        response = await client.request("GET", server.url, stream=True)
+        async with client.stream("GET", server.url) as response:
+            body = await response.read()
+
     assert response.status_code == 200
-    body = await response.read()
     assert body == b"Hello, world!"
     assert response.content == b"Hello, world!"
 
 
 async def test_access_content_stream_response(server, backend):
     async with httpx.Client(backend=backend) as client:
-        response = await client.request("GET", server.url, stream=True)
+        async with client.stream("GET", server.url) as response:
+            pass
+
     assert response.status_code == 200
     with pytest.raises(httpx.ResponseNotRead):
         response.content
diff --git a/tests/client/test_client.py b/tests/client/test_client.py
--- a/tests/client/test_client.py
+++ b/tests/client/test_client.py
@@ -59,33 +59,36 @@ async def test_post_json(server):
 @pytest.mark.asyncio
 async def test_stream_response(server):
     async with httpx.Client() as client:
-        response = await client.get(server.url, stream=True)
+        async with client.stream("GET", server.url) as response:
+            content = await response.read()
     assert response.status_code == 200
-    content = await response.read()
     assert content == b"Hello, world!"
 
 
 @pytest.mark.asyncio
 async def test_stream_iterator(server):
+    body = b""
+
     async with httpx.Client() as client:
-        response = await client.get(server.url, stream=True)
+        async with client.stream("GET", server.url) as response:
+            async for chunk in response.stream_bytes():
+                body += chunk
+
     assert response.status_code == 200
-    body = b""
-    async for chunk in response.stream_bytes():
-        body += chunk
     assert body == b"Hello, world!"
 
 
 @pytest.mark.asyncio
 async def test_raw_iterator(server):
+    body = b""
+
     async with httpx.Client() as client:
-        response = await client.get(server.url, stream=True)
+        async with client.stream("GET", server.url) as response:
+            async for chunk in response.stream_raw():
+                body += chunk
+
     assert response.status_code == 200
-    body = b""
-    async for chunk in response.stream_raw():
-        body += chunk
     assert body == b"Hello, world!"
-    await response.close()
 
 
 @pytest.mark.asyncio
@@ -174,17 +177,18 @@ def test_merge_url():
 
 @pytest.mark.asyncio
 async def test_elapsed_delay(server):
+    url = server.url.copy_with(path="/slow_response/100")
     async with httpx.Client() as client:
-        response = await client.get(server.url.copy_with(path="/slow_response/100"))
+        response = await client.get(url)
     assert response.elapsed.total_seconds() == pytest.approx(0.1, rel=0.2)
 
 
 @pytest.mark.asyncio
 async def test_elapsed_delay_ignores_read_time(server):
+    url = server.url.copy_with(path="/slow_response/100")
     async with httpx.Client() as client:
-        response = await client.get(
-            server.url.copy_with(path="/slow_response/100"), stream=True
-        )
-    sleep(0.2)
-    await response.read()
+        async with client.stream("GET", url) as response:
+            sleep(0.2)
+            await response.read()
+
     assert response.elapsed.total_seconds() == pytest.approx(0.1, rel=0.2)
diff --git a/tests/test_api.py b/tests/test_api.py
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -66,6 +66,17 @@ async def test_delete(server):
     assert response.reason_phrase == "OK"
 
 
+@pytest.mark.asyncio
+async def test_stream(server):
+    async with httpx.stream("GET", server.url) as response:
+        await response.read()
+
+    assert response.status_code == 200
+    assert response.reason_phrase == "OK"
+    assert response.text == "Hello, world!"
+    assert response.http_version == "HTTP/1.1"
+
+
 @pytest.mark.asyncio
 async def test_get_invalid_url(server):
     with pytest.raises(httpx.InvalidURL):
diff --git a/tests/test_timeouts.py b/tests/test_timeouts.py
--- a/tests/test_timeouts.py
+++ b/tests/test_timeouts.py
@@ -44,9 +44,6 @@ async def test_pool_timeout(server, backend):
     async with Client(
         pool_limits=pool_limits, timeout=timeout, backend=backend
     ) as client:
-        response = await client.get(server.url, stream=True)
-
-        with pytest.raises(PoolTimeout):
-            await client.get("http://localhost:8000/")
-
-        await response.read()
+        async with client.stream("GET", server.url):
+            with pytest.raises(PoolTimeout):
+                await client.get("http://localhost:8000/")