From ed949508a685d84d658b06807e2a9aeb5e3bedfa Mon Sep 17 00:00:00 2001 From: Mattwmaster58 Date: Sat, 30 Nov 2019 12:46:44 -0700 Subject: [PATCH] Files without a filename should not set a Content-Type in multipart data. (#520) * File upoads with no filename should not set a Content-Type in their multipart data. * Update type annotations to allow file uploads to be a string --- httpx/models.py | 15 +++++++++++---- httpx/multipart.py | 10 ++++++---- tests/test_multipart.py | 27 ++++++++++++++++++++++++--- 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/httpx/models.py b/httpx/models.py index 33cfb832..d7029035 100644 --- a/httpx/models.py +++ b/httpx/models.py @@ -81,11 +81,18 @@ RequestData = typing.Union[dict, str, bytes, typing.AsyncIterator[bytes]] 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 fcfd2569..a4642794 100644 --- a/httpx/multipart.py +++ b/httpx/multipart.py @@ -58,19 +58,21 @@ class FileField(Field): 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: diff --git a/tests/test_multipart.py b/tests/test_multipart.py index 87727020..98f8468d 100644 --- a/tests/test_multipart.py +++ b/tests/test_multipart.py @@ -141,13 +141,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\r\n" - "--{0}--\r\n" + '--{0}\r\nContent-Disposition: form-data; name="file"\r\n\r\n' + "\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""))} + 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\r\n--{boundary}--\r\n" + "".encode("ascii") + ) + + def test_multipart_encode_files_allows_str_content(): files = {"file": ("test.txt", "", "text/plain")} with mock.patch("os.urandom", return_value=os.urandom(16)): -- 2.47.3