Repository: encode/httpx

Issue:
The code in the document does not work
Hi. The code in the [document](https://www.python-httpx.org/advanced/#:~:text=files%20%3D%20%7B%27upload%2Dfile%27%3A%20(None%2C%20%27text%20content%27%2C%20%27text/plain%27)%7D) does not work

```
import httpx
files = {'upload-file': (None, 'text content', 'text/plain')}
r = httpx.post("https://httpbin.org/post", files=files)
print(r.text)
```
The error is 
```
TypeError: Expected bytes or bytes-like object got: <class 'str'>
```
I found [this problem](https://github.com/encode/httpx/issues/481) has been raised but not solved

If the string is modified with b, it works fine
```
import httpx
files = {'upload-file': (None, b'text content', 'text/plain')}
r = httpx.post("https://httpbin.org/post", files=files)
print(r.text)
```
Compared to requests
```
import requests
import httpx

files = {'upload-file': (None, 'text content', 'text/plain')}
httpx.post("https://httpbin.org/post", files=files)  # error
requests.post("https://httpbin.org/post", files=files)  # works

files = {'upload-file': (None, b'text content', 'text/plain')}
httpx.post("https://httpbin.org/post", files=files)  # works
requests.post("https://httpbin.org/post", files=files)  # works
```


Discussion:
Thanks for raising this @imsgj - you're absolutely correct.

We made a change here so that only binary content is accepted. However I'm not sure we really want to do that or not. The *necessary* case was that we only accept *files opened in binary mode*. But we *could* still accept the plain ol' `str` case if we wanted to.

We've got two options here.

* Accept that we've got sensible behaviour, and update the docs to match the expected usage.
* Change the behaviour so that plain `str` cases *are* accepted. (But files opened in text mode are not.)


If we decide to keep the behavior -- should we also try to sniff "have we got str content?" early on enough so that we display a friendlier error message, also explaining _why_ we only accept `bytes`? I don't know where the `TypeError` comes from, probably not from us directly. I assume it might be a frequent enough attempt to pass `str` here assuming it would be accepted.
FWIW I am hopeful that we can allow non-files for two reasons:
1. It would be consistent with `requests`
2. I just ran into exactly this issue because the Cloudflare Images API recently fixed a long-standing issue, but in doing so changed to require `multipart/form-data` requests (see https://developers.cloudflare.com/images/cloudflare-images/upload-images/direct-creator-upload/).  This used to take urlencoded JSON, so my client instantly broke.  I worked around it by faking the files argument with an empty pydantic instance:

```
async with httpx.AsyncClient() as client:
    cloudflare_response = await client.post(
        url=url,
        data=data_dict,
        files=pydantic.BaseModel(),  # truthy+iterable+empty to fake httpx into doing multipart encoding for v2 API without actually sending any files
        headers={
            # 'Content-Type': application/json',  # only used for v1 of the API
            'Authorization': f"Bearer {api_token}"
        })
```
> FWIW I am hopeful that we can allow non-files for two reasons:
> 
> 1. It would be consistent with `requests`
> 2. I just ran into exactly this issue because the Cloudflare Images API recently fixed a long-standing issue, but in doing so changed to require `multipart/form-data` requests (see https://developers.cloudflare.com/images/cloudflare-images/upload-images/direct-creator-upload/).  This used to take urlencoded JSON, so my client instantly broke.  I worked around it by faking the files argument with an empty pydantic instance:
> 
> ```
> async with httpx.AsyncClient() as client:
>     cloudflare_response = await client.post(
>         url=url,
>         data=data_dict,
>         files=pydantic.BaseModel(),  # truthy+iterable+empty to fake httpx into doing multipart encoding for v2 API without actually sending any files
>         headers={
>             # 'Content-Type': application/json',  # only used for v1 of the API
>             'Authorization': f"Bearer {api_token}"
>         })
> ```

You can use : 
```py
payload = (
    ('foo', (None, b'bar')),
    ('variable', (None, str.encode(variable))),
    ('bar', (None, b'foo')),
)
client.post("https://.../api/post", headers=headers, files=payload)
```
This will work.

The other problem that I have is that we are not able to set a "WebKitFormBoundary" for the form, this could be really useful because almost every website using multipart are using a boundary

Resolving patch:
diff --git a/httpx/_multipart.py b/httpx/_multipart.py
--- a/httpx/_multipart.py
+++ b/httpx/_multipart.py
@@ -122,8 +122,14 @@ def __init__(self, name: str, value: FileTypes) -> None:
             # requests does the opposite (it overwrites the header with the 3rd tuple element)
             headers["Content-Type"] = content_type
 
-        if isinstance(fileobj, (str, io.StringIO)):
-            raise TypeError(f"Expected bytes or bytes-like object got: {type(fileobj)}")
+        if "b" not in getattr(fileobj, "mode", "b"):
+            raise TypeError(
+                "Multipart file uploads must be opened in binary mode, not text mode."
+            )
+        if isinstance(fileobj, io.StringIO):
+            raise TypeError(
+                "Multipart file uploads require 'io.BytesIO', not 'io.StringIO'."
+            )
 
         self.filename = filename
         self.file = fileobj
diff --git a/httpx/_types.py b/httpx/_types.py
--- a/httpx/_types.py
+++ b/httpx/_types.py
@@ -80,7 +80,7 @@
 
 RequestData = Mapping[str, Any]
 
-FileContent = Union[IO[bytes], bytes]
+FileContent = Union[IO[bytes], bytes, str]
 FileTypes = Union[
     # file (or bytes)
     FileContent,


Test patch:
diff --git a/tests/test_multipart.py b/tests/test_multipart.py
--- a/tests/test_multipart.py
+++ b/tests/test_multipart.py
@@ -339,18 +339,37 @@ def test_multipart_encode_files_allows_bytes_content() -> None:
         assert content == b"".join(stream)
 
 
-def test_multipart_encode_files_raises_exception_with_str_content() -> None:
-    files = {"file": ("test.txt", "<bytes content>", "text/plain")}
+def test_multipart_encode_files_allows_str_content() -> None:
+    files = {"file": ("test.txt", "<str content>", "text/plain")}
     with mock.patch("os.urandom", return_value=os.urandom(16)):
+        boundary = os.urandom(16).hex()
 
-        with pytest.raises(TypeError):
-            encode_request(data={}, files=files)  # type: ignore
+        headers, stream = encode_request(data={}, files=files)
+        assert isinstance(stream, typing.Iterable)
+
+        content = (
+            '--{0}\r\nContent-Disposition: form-data; name="file"; '
+            'filename="test.txt"\r\n'
+            "Content-Type: text/plain\r\n\r\n<str content>\r\n"
+            "--{0}--\r\n"
+            "".format(boundary).encode("ascii")
+        )
+        assert headers == {
+            "Content-Type": f"multipart/form-data; boundary={boundary}",
+            "Content-Length": str(len(content)),
+        }
+        assert content == b"".join(stream)
 
 
 def test_multipart_encode_files_raises_exception_with_StringIO_content() -> None:
     files = {"file": ("test.txt", io.StringIO("content"), "text/plain")}
-    with mock.patch("os.urandom", return_value=os.urandom(16)):
+    with pytest.raises(TypeError):
+        encode_request(data={}, files=files)  # type: ignore
+
 
+def test_multipart_encode_files_raises_exception_with_text_mode_file() -> None:
+    with tempfile.TemporaryFile(mode="w") as upload:
+        files = {"file": ("test.txt", upload, "text/plain")}
         with pytest.raises(TypeError):
             encode_request(data={}, files=files)  # type: ignore