Repository: encode/httpx (Python)

Issue:
No way to replicate specific multipart requests POST?
With the requests library, I could do something like this:
```
sub = {'field1': (None, 'test')}
requests.post('https://enygrrtwf27.x.pipedream.net', files=sub)
```
And get this result:
```
--13489f816b24eea1f1ae00720a15d8da
Content-Disposition: form-data; name="field1"

test
--13489f816b24eea1f1ae00720a15d8da--
```
<details>
<summary>
Reading through the docs, it's unclear how I would replicate this. Here are some of the methods I've tried with `httpx.post(url, files=sub)` and their results:</summary>

### Method 1
#### Input
```
sub = {'field1': (None, 'test', None)}
```
#### Ouput
```
AttributeError: 'NoneType' object has no attribute 'encode'
```

### Method 2
#### Input
```
sub = {'field1': 'test'}
```
#### Output
```
--d0eeaee30c2025a463cc2eebc6455a22
Content-Disposition: form-data; name="field1"; filename="upload"
Content-Type: application/octet-stream

test
--d0eeaee30c2025a463cc2eebc6455a22--
```

### Method 3
#### Input
```
sub = {'field1': (None, 'test')}
```
#### Output
```
--7ef3cb0ed2b1789c79c2ec46db9aa4bb
Content-Disposition: form-data; name="field1"
Content-Type: application/octet-stream

test
--7ef3cb0ed2b1789c79c2ec46db9aa4bb--
```

### Method 3
#### Input
```
sub = {'field1': (None, 'test', 'text/plain')}
```
#### Output
```
--7ef3cb0ed2b1789c79c2ec46db9aa4bb
Content-Disposition: form-data; name="field1"
Content-Type: application/octet-stream

test
--7ef3cb0ed2b1789c79c2ec46db9aa4bb--
```
</details>

I think the best way to rectify this would be to allow `None` as a `Content-Type` and then just not set any `Content-Type`


Discussion:

Patch:
diff --git a/httpx/models.py b/httpx/models.py
index 136aa41c26..3821d482aa 100644
--- a/httpx/models.py
+++ b/httpx/models.py
@@ -84,11 +84,18 @@
 RequestFiles = typing.Dict[
     str,
     typing.Union[
-        typing.IO[typing.AnyStr],  # file
-        typing.Tuple[str, typing.IO[typing.AnyStr]],  # (filename, file)
+        # file (or str)
+        typing.Union[typing.IO[typing.AnyStr], typing.AnyStr],
+        # (filename, file (or str))
         typing.Tuple[
-            str, typing.IO[typing.AnyStr], str
-        ],  # (filename, file, content_type)
+            typing.Optional[str], typing.Union[typing.IO[typing.AnyStr], typing.AnyStr],
+        ],
+        # (filename, file (or str), content_type)
+        typing.Tuple[
+            typing.Optional[str],
+            typing.Union[typing.IO[typing.AnyStr], typing.AnyStr],
+            typing.Optional[str],
+        ],
     ],
 ]
 
diff --git a/httpx/multipart.py b/httpx/multipart.py
index d850f79dcf..ea407851e4 100644
--- a/httpx/multipart.py
+++ b/httpx/multipart.py
@@ -58,19 +58,21 @@ def __init__(
                 value[2] if len(value) > 2 else self.guess_content_type()
             )
 
-    def guess_content_type(self) -> str:
+    def guess_content_type(self) -> typing.Optional[str]:
         if self.filename:
             return mimetypes.guess_type(self.filename)[0] or "application/octet-stream"
         else:
-            return "application/octet-stream"
+            return None
 
     def render_headers(self) -> bytes:
         parts = [b"Content-Disposition: form-data; ", _format_param("name", self.name)]
         if self.filename:
             filename = _format_param("filename", self.filename)
             parts.extend([b"; ", filename])
-        content_type = self.content_type.encode()
-        parts.extend([b"\r\nContent-Type: ", content_type, b"\r\n\r\n"])
+        if self.content_type is not None:
+            content_type = self.content_type.encode()
+            parts.extend([b"\r\nContent-Type: ", content_type])
+        parts.append(b"\r\n\r\n")
         return b"".join(parts)
 
     def render_data(self) -> bytes:


Test patch:
diff --git a/tests/test_multipart.py b/tests/test_multipart.py
index 41e69f7ab9..109d39c62b 100644
--- a/tests/test_multipart.py
+++ b/tests/test_multipart.py
@@ -136,13 +136,34 @@ def test_multipart_encode_files_allows_filenames_as_none():
 
         assert content_type == f"multipart/form-data; boundary={boundary}"
         assert body == (
-            '--{0}\r\nContent-Disposition: form-data; name="file"\r\n'
-            "Content-Type: application/octet-stream\r\n\r\n<file content>\r\n"
-            "--{0}--\r\n"
+            '--{0}\r\nContent-Disposition: form-data; name="file"\r\n\r\n'
+            "<file content>\r\n--{0}--\r\n"
             "".format(boundary).encode("ascii")
         )
 
 
+@pytest.mark.parametrize(
+    "file_name,expected_content_type",
+    [("example.json", "application/json"), ("example.log", "application/octet-stream")],
+)
+def test_multipart_encode_files_guesses_correct_content_type(
+    file_name, expected_content_type
+):
+    files = {"file": (file_name, io.BytesIO(b"<file content>"))}
+    with mock.patch("os.urandom", return_value=os.urandom(16)):
+        boundary = binascii.hexlify(os.urandom(16)).decode("ascii")
+
+        body, content_type = multipart.multipart_encode(data={}, files=files)
+
+        assert content_type == f"multipart/form-data; boundary={boundary}"
+        assert body == (
+            f'--{boundary}\r\nContent-Disposition: form-data; name="file"; '
+            f'filename="{file_name}"\r\nContent-Type: '
+            f"{expected_content_type}\r\n\r\n<file content>\r\n--{boundary}--\r\n"
+            "".encode("ascii")
+        )
+
+
 def test_multipart_encode_files_allows_str_content():
     files = {"file": ("test.txt", "<string content>", "text/plain")}
     with mock.patch("os.urandom", return_value=os.urandom(16)):