Skip to content

Commit 206bccb

Browse files
stainless-botstainless-app[bot]
authored andcommitted
chore(internal): support multipart data with overlapping keys (#274)
1 parent b11dc4c commit 206bccb

File tree

2 files changed

+84
-6
lines changed

2 files changed

+84
-6
lines changed

src/finch/_base_client.py

+26-6
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
RequestOptions,
6262
ModelBuilderProtocol,
6363
)
64-
from ._utils import is_dict, is_given, is_mapping
64+
from ._utils import is_dict, is_list, is_given, is_mapping
6565
from ._compat import model_copy, model_dump
6666
from ._models import GenericModel, FinalRequestOptions, validate_type, construct_type
6767
from ._response import (
@@ -451,14 +451,18 @@ def _build_request(
451451

452452
headers = self._build_headers(options)
453453
params = _merge_mappings(self._custom_query, options.params)
454+
content_type = headers.get("Content-Type")
454455

455456
# If the given Content-Type header is multipart/form-data then it
456457
# has to be removed so that httpx can generate the header with
457458
# additional information for us as it has to be in this form
458459
# for the server to be able to correctly parse the request:
459460
# multipart/form-data; boundary=---abc--
460-
if headers.get("Content-Type") == "multipart/form-data":
461-
headers.pop("Content-Type")
461+
if content_type is not None and content_type.startswith("multipart/form-data"):
462+
if "boundary" not in content_type:
463+
# only remove the header if the boundary hasn't been explicitly set
464+
# as the caller doesn't want httpx to come up with their own boundary
465+
headers.pop("Content-Type")
462466

463467
# As we are now sending multipart/form-data instead of application/json
464468
# we need to tell httpx to use it, https://www.python-httpx.org/advanced/#multipart-file-encoding
@@ -494,9 +498,25 @@ def _serialize_multipartform(self, data: Mapping[object, object]) -> dict[str, o
494498
)
495499
serialized: dict[str, object] = {}
496500
for key, value in items:
497-
if key in serialized:
498-
raise ValueError(f"Duplicate key encountered: {key}; This behaviour is not supported")
499-
serialized[key] = value
501+
existing = serialized.get(key)
502+
503+
if not existing:
504+
serialized[key] = value
505+
continue
506+
507+
# If a value has already been set for this key then that
508+
# means we're sending data like `array[]=[1, 2, 3]` and we
509+
# need to tell httpx that we want to send multiple values with
510+
# the same key which is done by using a list or a tuple.
511+
#
512+
# Note: 2d arrays should never result in the same key at both
513+
# levels so it's safe to assume that if the value is a list,
514+
# it was because we changed it to be a list.
515+
if is_list(existing):
516+
existing.append(value)
517+
else:
518+
serialized[key] = [existing, value]
519+
500520
return serialized
501521

502522
def _maybe_override_cast_to(self, cast_to: type[ResponseT], options: FinalRequestOptions) -> type[ResponseT]:

tests/test_client.py

+58
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,35 @@ def test_request_extra_query(self) -> None:
443443
params = dict(request.url.params)
444444
assert params == {"foo": "2"}
445445

446+
def test_multipart_repeating_array(self, client: Finch) -> None:
447+
request = client._build_request(
448+
FinalRequestOptions.construct(
449+
method="get",
450+
url="/foo",
451+
headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"},
452+
json_data={"array": ["foo", "bar"]},
453+
files=[("foo.txt", b"hello world")],
454+
)
455+
)
456+
457+
assert request.read().split(b"\r\n") == [
458+
b"--6b7ba517decee4a450543ea6ae821c82",
459+
b'Content-Disposition: form-data; name="array[]"',
460+
b"",
461+
b"foo",
462+
b"--6b7ba517decee4a450543ea6ae821c82",
463+
b'Content-Disposition: form-data; name="array[]"',
464+
b"",
465+
b"bar",
466+
b"--6b7ba517decee4a450543ea6ae821c82",
467+
b'Content-Disposition: form-data; name="foo.txt"; filename="upload"',
468+
b"Content-Type: application/octet-stream",
469+
b"",
470+
b"hello world",
471+
b"--6b7ba517decee4a450543ea6ae821c82--",
472+
b"",
473+
]
474+
446475
@pytest.mark.respx(base_url=base_url)
447476
def test_basic_union_response(self, respx_mock: MockRouter) -> None:
448477
class Model1(BaseModel):
@@ -1179,6 +1208,35 @@ def test_request_extra_query(self) -> None:
11791208
params = dict(request.url.params)
11801209
assert params == {"foo": "2"}
11811210

1211+
def test_multipart_repeating_array(self, async_client: AsyncFinch) -> None:
1212+
request = async_client._build_request(
1213+
FinalRequestOptions.construct(
1214+
method="get",
1215+
url="/foo",
1216+
headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"},
1217+
json_data={"array": ["foo", "bar"]},
1218+
files=[("foo.txt", b"hello world")],
1219+
)
1220+
)
1221+
1222+
assert request.read().split(b"\r\n") == [
1223+
b"--6b7ba517decee4a450543ea6ae821c82",
1224+
b'Content-Disposition: form-data; name="array[]"',
1225+
b"",
1226+
b"foo",
1227+
b"--6b7ba517decee4a450543ea6ae821c82",
1228+
b'Content-Disposition: form-data; name="array[]"',
1229+
b"",
1230+
b"bar",
1231+
b"--6b7ba517decee4a450543ea6ae821c82",
1232+
b'Content-Disposition: form-data; name="foo.txt"; filename="upload"',
1233+
b"Content-Type: application/octet-stream",
1234+
b"",
1235+
b"hello world",
1236+
b"--6b7ba517decee4a450543ea6ae821c82--",
1237+
b"",
1238+
]
1239+
11821240
@pytest.mark.respx(base_url=base_url)
11831241
async def test_basic_union_response(self, respx_mock: MockRouter) -> None:
11841242
class Model1(BaseModel):

0 commit comments

Comments
 (0)