Repository: encode/httpx

Issue:
Displaying binary responses in the command line client.
Using the command line client to access a binary URL...

```shell
$ httpx https://cataas.com/cat
HTTP/1.1 200 OK
Server: nginx/1.12.2
Date: Thu, 27 Jan 2022 10:41:25 GMT
Content-Type: image/jpeg
Content-Length: 35300
Connection: keep-alive
Access-Control-Allow-Origin: *
'�C�.�h�&�.\)��;՗s�-l�m/gN�<Ws��F...
```

This behaviour isn't really ideal. The HTTP response is giving us enough information in the `Content-Type` to indicate that the response is binary, not text, so we could be doing better than this.

I'd much prefer for us to have better handling for this kind of case.

For example...

```shell
$ httpx https://cataas.com/cat
HTTP/1.1 200 OK
Server: nginx/1.12.2
Date: Thu, 27 Jan 2022 10:41:25 GMT
Content-Type: image/jpeg
Content-Length: 35300
Connection: keep-alive
Access-Control-Allow-Origin: *

<35300 bytes of binary data>
```

Or...

```shell
$ httpx https://cataas.com/cat
HTTP/1.1 200 OK
Server: nginx/1.12.2
Date: Thu, 27 Jan 2022 10:41:25 GMT
Content-Type: image/jpeg
Content-Length: 35300
Connection: keep-alive
Access-Control-Allow-Origin: *

<35300 bytes of binary data>
00000000:  ff d8 ff e0 00 10 4a 46  49 46 00 01-01 01 00 48  ......JF IF.....H
00000010:  00 48 00 00 ff db 00 43  00 08 06 06 07 06 05 08  .H.....C ........
00000020:  07 07 07 09 09 08 0a 0c  14 0d 0c 0b 0b 0c 19 12  ........ ........
00000030:  13 0f 14 1d 1a 1f 1e 1d  1a 1c 1c 20 24 2e 27 20  ........ ....$.'.
00000040:  22 2c 23 1c 1c 28 37 29  2c 30 31 34 34 34 1f 27  ",#..(7) ,01444.'
00000050:  39 3d 38 32 3c 2e 33 34  32 ff db 00 43 01 09 09  9=82<.34 2...C...
00000060:  09 0c 0b 0c 18 0d 0d 18  32 21 1c 21 32 32 32 32  ........ 2!.!2222
00000070:  32 32 32 32 32 32 32 32  32 32 32 32 32 32 32 32  22222222 22222222
00000080:  32 32 32 32 32 32 32 32  32 32 32 32 32 32 32 32  22222222 22222222
00000090:  32 32 32 32 32 32 32 32  32 32 32 32 32 32 ff c0  22222222 222222..
```

I've not thought through what flags we'd want for controlling this display, but it seems like an obvious improvement for us to make, and I'm sure we could do a very nice job of how it's displayed with `rich`.


Discussion:
Jotting down a couple of possibly useful resources...

* Gist: [jynik / rich_hexdump.py](https://gist.github.com/jynik/8c49d7f630775e55bf6d784823325fb7)
* Repo: [Jadhusan-S / Python3-Hex-Dump](https://github.com/Jadhusan-S/Python3-Hex-Dump)
* [Request on twitter for feedback](https://twitter.com/starletdreaming/status/1486654722782244867) couple of links to rust based examples, that might be useful for figuring out what kind of colorisation/style we'd like.
I've played around with it and I found that larger responses will take unreasonable amount of time to print (600kb takes ~20s on my machine). What would you suggest as a way around that? As for the flags, I think we could borrow the idea from curl, where it will not print the response unless output file is specified with `--output` flag.
I gave the `hexdump` style a try, and while I thought it was neat, there's not really any way I'd *actually* be using it in depth. At least not as my default behaviour.

I think a good enough pass at this point would simply be to do the following for binary content...

```shell
$ httpx https://cataas.com/cat
HTTP/1.1 200 OK
Server: nginx/1.12.2
Date: Thu, 27 Jan 2022 10:41:25 GMT
Content-Type: image/jpeg
Content-Length: 35300
Connection: keep-alive
Access-Control-Allow-Origin: *

<35300 bytes of binary data>
```

I think we want a switch here: https://github.com/encode/httpx/blob/a4161062501a32ce258e9f990737d494bfe419aa/httpx/_main.py#L178

To output as text for `Content-Type: text/...` and output as binary otherwise.
I'll submit a PR for this

Resolving patch:
diff --git a/httpx/_main.py b/httpx/_main.py
--- a/httpx/_main.py
+++ b/httpx/_main.py
@@ -172,10 +172,11 @@ def print_response(response: Response) -> None:
                 text = response.text
         else:
             text = response.text
+
         syntax = rich.syntax.Syntax(text, lexer_name, theme="ansi_dark", word_wrap=True)
         console.print(syntax)
-    else:  # pragma: nocover
-        console.print(rich.markup.escape(response.text))
+    else:
+        console.print(f"<{len(response.content)} bytes of binary data>")
 
 
 def format_certificate(cert: dict) -> str:  # pragma: nocover


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -80,6 +80,8 @@ async def app(scope, receive, send):
         await status_code(scope, receive, send)
     elif scope["path"].startswith("/echo_body"):
         await echo_body(scope, receive, send)
+    elif scope["path"].startswith("/echo_binary"):
+        await echo_binary(scope, receive, send)
     elif scope["path"].startswith("/echo_headers"):
         await echo_headers(scope, receive, send)
     elif scope["path"].startswith("/redirect_301"):
@@ -155,6 +157,25 @@ async def echo_body(scope, receive, send):
     await send({"type": "http.response.body", "body": body})
 
 
+async def echo_binary(scope, receive, send):
+    body = b""
+    more_body = True
+
+    while more_body:
+        message = await receive()
+        body += message.get("body", b"")
+        more_body = message.get("more_body", False)
+
+    await send(
+        {
+            "type": "http.response.start",
+            "status": 200,
+            "headers": [[b"content-type", b"application/octet-stream"]],
+        }
+    )
+    await send({"type": "http.response.body", "body": body})
+
+
 async def echo_headers(scope, receive, send):
     body = {
         name.capitalize().decode(): value.decode()
diff --git a/tests/test_main.py b/tests/test_main.py
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -52,6 +52,22 @@ def test_json(server):
     ]
 
 
+def test_binary(server):
+    url = str(server.url.copy_with(path="/echo_binary"))
+    runner = CliRunner()
+    content = "Hello, world!"
+    result = runner.invoke(httpx.main, [url, "-c", content])
+    assert result.exit_code == 0
+    assert remove_date_header(splitlines(result.output)) == [
+        "HTTP/1.1 200 OK",
+        "server: uvicorn",
+        "content-type: application/octet-stream",
+        "Transfer-Encoding: chunked",
+        "",
+        f"<{len(content)} bytes of binary data>",
+    ]
+
+
 def test_redirects(server):
     url = str(server.url.copy_with(path="/redirect_301"))
     runner = CliRunner()