Skip to content

Commit 0f1ff50

Browse files
authored
Allow custom headers in multipart/form-data requests (#1936)
* feat: allow passing multipart headers * Add test for including content-type in headers * lint * override content_type with headers * compare tuples based on length * incorporate suggestion * remove .title() on headers
1 parent 3eaf69a commit 0f1ff50

File tree

3 files changed

+80
-8
lines changed

3 files changed

+80
-8
lines changed

httpx/_multipart.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,23 +78,41 @@ def __init__(self, name: str, value: FileTypes) -> None:
7878

7979
fileobj: FileContent
8080

81+
headers: typing.Dict[str, str] = {}
82+
content_type: typing.Optional[str] = None
83+
84+
# This large tuple based API largely mirror's requests' API
85+
# It would be good to think of better APIs for this that we could include in httpx 2.0
86+
# since variable length tuples (especially of 4 elements) are quite unwieldly
8187
if isinstance(value, tuple):
82-
try:
83-
filename, fileobj, content_type = value # type: ignore
84-
except ValueError:
88+
if len(value) == 2:
89+
# neither the 3rd parameter (content_type) nor the 4th (headers) was included
8590
filename, fileobj = value # type: ignore
86-
content_type = guess_content_type(filename)
91+
elif len(value) == 3:
92+
filename, fileobj, content_type = value # type: ignore
93+
else:
94+
# all 4 parameters included
95+
filename, fileobj, content_type, headers = value # type: ignore
8796
else:
8897
filename = Path(str(getattr(value, "name", "upload"))).name
8998
fileobj = value
99+
100+
if content_type is None:
90101
content_type = guess_content_type(filename)
91102

103+
has_content_type_header = any("content-type" in key.lower() for key in headers)
104+
if content_type is not None and not has_content_type_header:
105+
# note that unlike requests, we ignore the content_type
106+
# provided in the 3rd tuple element if it is also included in the headers
107+
# requests does the opposite (it overwrites the header with the 3rd tuple element)
108+
headers["Content-Type"] = content_type
109+
92110
if isinstance(fileobj, (str, io.StringIO)):
93111
raise TypeError(f"Expected bytes or bytes-like object got: {type(fileobj)}")
94112

95113
self.filename = filename
96114
self.file = fileobj
97-
self.content_type = content_type
115+
self.headers = headers
98116
self._consumed = False
99117

100118
def get_length(self) -> int:
@@ -122,9 +140,9 @@ def render_headers(self) -> bytes:
122140
if self.filename:
123141
filename = format_form_param("filename", self.filename)
124142
parts.extend([b"; ", filename])
125-
if self.content_type is not None:
126-
content_type = self.content_type.encode()
127-
parts.extend([b"\r\nContent-Type: ", content_type])
143+
for header_name, header_value in self.headers.items():
144+
key, val = f"\r\n{header_name}: ".encode(), header_value.encode()
145+
parts.extend([key, val])
128146
parts.append(b"\r\n\r\n")
129147
self._headers = b"".join(parts)
130148

httpx/_types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@
8989
Tuple[Optional[str], FileContent],
9090
# (filename, file (or bytes), content_type)
9191
Tuple[Optional[str], FileContent, Optional[str]],
92+
# (filename, file (or bytes), content_type, headers)
93+
Tuple[Optional[str], FileContent, Optional[str], Mapping[str, str]],
9294
]
9395
RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]]
9496

tests/test_multipart.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,58 @@ def test_multipart_file_tuple():
9494
assert multipart["file"] == [b"<file content>"]
9595

9696

97+
@pytest.mark.parametrize("content_type", [None, "text/plain"])
98+
def test_multipart_file_tuple_headers(content_type: typing.Optional[str]):
99+
file_name = "test.txt"
100+
expected_content_type = "text/plain"
101+
headers = {"Expires": "0"}
102+
103+
files = {"file": (file_name, io.BytesIO(b"<file content>"), content_type, headers)}
104+
with mock.patch("os.urandom", return_value=os.urandom(16)):
105+
boundary = os.urandom(16).hex()
106+
107+
headers, stream = encode_request(data={}, files=files)
108+
assert isinstance(stream, typing.Iterable)
109+
110+
content = (
111+
f'--{boundary}\r\nContent-Disposition: form-data; name="file"; '
112+
f'filename="{file_name}"\r\nExpires: 0\r\nContent-Type: '
113+
f"{expected_content_type}\r\n\r\n<file content>\r\n--{boundary}--\r\n"
114+
"".encode("ascii")
115+
)
116+
assert headers == {
117+
"Content-Type": f"multipart/form-data; boundary={boundary}",
118+
"Content-Length": str(len(content)),
119+
}
120+
assert content == b"".join(stream)
121+
122+
123+
def test_multipart_headers_include_content_type() -> None:
124+
"""Content-Type from 4th tuple parameter (headers) should override the 3rd parameter (content_type)"""
125+
file_name = "test.txt"
126+
expected_content_type = "image/png"
127+
headers = {"Content-Type": "image/png"}
128+
129+
files = {"file": (file_name, io.BytesIO(b"<file content>"), "text_plain", headers)}
130+
with mock.patch("os.urandom", return_value=os.urandom(16)):
131+
boundary = os.urandom(16).hex()
132+
133+
headers, stream = encode_request(data={}, files=files)
134+
assert isinstance(stream, typing.Iterable)
135+
136+
content = (
137+
f'--{boundary}\r\nContent-Disposition: form-data; name="file"; '
138+
f'filename="{file_name}"\r\nContent-Type: '
139+
f"{expected_content_type}\r\n\r\n<file content>\r\n--{boundary}--\r\n"
140+
"".encode("ascii")
141+
)
142+
assert headers == {
143+
"Content-Type": f"multipart/form-data; boundary={boundary}",
144+
"Content-Length": str(len(content)),
145+
}
146+
assert content == b"".join(stream)
147+
148+
97149
def test_multipart_encode(tmp_path: typing.Any) -> None:
98150
path = str(tmp_path / "name.txt")
99151
with open(path, "wb") as f:

0 commit comments

Comments
 (0)