Skip to content

Commit 8701e7f

Browse files
iscai-msftiscai-msftCopilotmsyyc
authored
fix(http-client-python): synthesize filename in multipart Content-Disposition for bare file inputs (#10843)
## Problem When callers pass bare `bytes`/`str`/`IO` (the `FileContent` variant of `FileType`) for multipart file fields, the generated `prepare_multipart_form_data` helper creates `(field_name, bare_content)`. The HTTP library interprets this as `Content-Disposition: form-data; name="field_name"` with **no `filename=` attribute**. Many servers require `filename=` in the `Content-Disposition` header to recognize file uploads (e.g., servers that use the file extension to detect package type will reject with "At least one file must be uploaded"). The tuple variants `(filename, content)` and `(filename, content, content_type)` already work correctly since they produce `filename=` in the header. ## Fix Added `_normalize_multipart_file_entry` helper in `utils.py.jinja2` that wraps bare content into a `(filename, content)` tuple: - **IO objects with `.name`**: derives filename via `os.path.basename()` (e.g., `open('path/to/image.jpg')` → `filename="image.jpg"`) - **Bare bytes/str**: falls back to the field name (e.g., `"profileImage"`) or `"field_0"`, `"field_1"` for list entries - **Existing tuples**: pass through unchanged Also changed `elif multipart_entry:` to `elif multipart_entry is not None:` to allow empty bytes (`b""`) to be uploaded. ## Files Changed - `generator/pygen/codegen/templates/utils.py.jinja2`: Added `_normalize_multipart_file_entry` helper, updated `prepare_multipart_form_data` - `generator/pygen/codegen/serializers/general_serializer.py`: Added `import os` to generated utils imports --------- Co-authored-by: iscai-msft <isabellavcai@gmail.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Yuchao Yan <yuchaoyan@microsoft.com>
1 parent 6bc7fbe commit 8701e7f

4 files changed

Lines changed: 204 additions & 8 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: fix
3+
packages:
4+
- "@typespec/http-client-python"
5+
---
6+
7+
Synthesize filename in multipart Content-Disposition for bare file inputs. When callers pass bare bytes/str/IO instead of a (filename, content) tuple for multipart file fields, the `prepare_multipart_form_data` helper now wraps them with a synthesized filename so servers that require `filename=` in the Content-Disposition header no longer reject the upload.

packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ def need_utils_utils_file(self) -> str:
242242
ImportType.LOCAL,
243243
)
244244
file_import.add_import("json", ImportType.STDLIB)
245+
file_import.add_import("os", ImportType.STDLIB)
245246

246247
return template.render(
247248
code_model=self.code_model,

packages/http-client-python/generator/pygen/codegen/templates/utils.py.jinja2

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -76,23 +76,60 @@ def serialize_multipart_data_entry(data_entry: Any) -> Any:
7676
return json.dumps(data_entry, cls=SdkJSONEncoder, exclude_readonly=True)
7777
return data_entry
7878

79+
def _normalize_multipart_file_entry(field_name: str, entry: Any, index: int) -> Any:
80+
"""Ensure a multipart file entry carries a filename for Content-Disposition.
81+
82+
Servers distinguish file parts from plain form fields by the presence of
83+
``filename=`` in the ``Content-Disposition`` header. When callers pass
84+
bare bytes/str/IO the HTTP client omits the filename and the server may
85+
reject the upload. This helper wraps bare values into a (filename, content)
86+
tuple, deriving the name from IO.name when available.
87+
88+
:param str field_name: The multipart field name used as a filename fallback.
89+
:param entry: The user-provided file entry (tuple, bytes, str, or IO).
90+
:type entry: any
91+
:param int index: The positional index of the entry within the field, used
92+
to disambiguate fallback filenames when multiple entries are provided.
93+
:return: Either the original tuple entry, or a ``(filename, content)`` tuple
94+
wrapping the bare value.
95+
:rtype: any
96+
"""
97+
if isinstance(entry, tuple):
98+
return entry
99+
filename: Optional[str] = None
100+
name_attr = getattr(entry, "name", None)
101+
if isinstance(name_attr, str) and name_attr:
102+
filename = os.path.basename(name_attr)
103+
if not filename:
104+
filename = f"{field_name}_{index}" if index else field_name
105+
106+
# Return a 3-tuple with an explicit "application/octet-stream" content type.
107+
# A 2-tuple (filename, content) would leave the part's Content-Type unset, and
108+
# the sdk core library only defaults to "application/octet-stream" for bare
109+
# (non-tuple) values - a tuple bypasses that default and falls back to the
110+
# HTTP "text/plain" default instead. Setting it explicitly preserves the
111+
# pre-existing behavior for bare bytes/IO across all transports.
112+
return (filename, entry, "application/octet-stream")
113+
79114
def prepare_multipart_form_data(
80115
body: Mapping[str, Any], multipart_fields: list[str], data_fields: list[str]
81116
) -> list[FileType]:
82117
files: list[FileType] = []
83-
for multipart_field in multipart_fields:
84-
multipart_entry = body.get(multipart_field)
85-
if isinstance(multipart_entry, list):
86-
files.extend([(multipart_field, e) for e in multipart_entry ])
87-
elif multipart_entry:
88-
files.append((multipart_field, multipart_entry))
89118

90-
# if files is empty, sdk core library can't handle multipart/form-data correctly, so
91-
# we put data fields into files with filename as None to avoid that scenario.
119+
# Data fields first so streaming server-side parsers see metadata before
120+
# binary file parts.
92121
for data_field in data_fields:
93122
data_entry = body.get(data_field)
94123
if data_entry:
95124
files.append((data_field, str(serialize_multipart_data_entry(data_entry))))
96125

126+
for multipart_field in multipart_fields:
127+
multipart_entry = body.get(multipart_field)
128+
if isinstance(multipart_entry, list):
129+
for idx, e in enumerate(multipart_entry):
130+
files.append((multipart_field, _normalize_multipart_file_entry(multipart_field, e, idx)))
131+
elif multipart_entry is not None:
132+
files.append((multipart_field, _normalize_multipart_file_entry(multipart_field, multipart_entry, 0)))
133+
97134
return files
98135
{% endif %}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# -------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for
4+
# license information.
5+
# --------------------------------------------------------------------------
6+
"""Offline unit tests for ``prepare_multipart_form_data``.
7+
8+
Verify that every concrete variant of the ``FileType`` union produces a
9+
multipart-equivalent normalized entry — i.e. the same field name, filename,
10+
and content payload. These tests run entirely offline (no network, no mock
11+
server) and operate directly on the generated helper.
12+
"""
13+
14+
import io
15+
from pathlib import Path
16+
17+
import pytest
18+
19+
from payload.multipart._utils.utils import prepare_multipart_form_data
20+
21+
FILENAME = "image.jpg"
22+
CONTENT = b"\xff\xd8\xff\xe0 fake jpeg"
23+
FIELD = "profileImage"
24+
25+
26+
def _read(value):
27+
"""Return raw bytes regardless of whether *value* is bytes or IO."""
28+
if hasattr(value, "read"):
29+
try:
30+
value.seek(0)
31+
except Exception: # pylint: disable=broad-except
32+
pass
33+
return value.read()
34+
return value
35+
36+
37+
def _canonicalize(prepared, field=FIELD):
38+
"""Extract the first entry for *field* as (field, filename, bytes)."""
39+
for f, entry in prepared:
40+
if f == field:
41+
assert isinstance(entry, tuple), f"helper must wrap entry as a tuple, got {entry!r}"
42+
filename = entry[0]
43+
content = _read(entry[1])
44+
return (f, filename, content)
45+
raise AssertionError(f"field {field!r} not found in {prepared!r}")
46+
47+
48+
# ── Variant helpers ──────────────────────────────────────────────────────
49+
50+
51+
def _io_from_disk(tmp_path):
52+
p = tmp_path / FILENAME
53+
p.write_bytes(CONTENT)
54+
return p.open("rb")
55+
56+
57+
# ── Tests ────────────────────────────────────────────────────────────────
58+
59+
60+
class TestNormalizeBareInputs:
61+
"""Bare bytes / IO must be wrapped with a synthesized filename."""
62+
63+
def test_bare_io_gets_filename_from_name_attr(self, tmp_path):
64+
"""IO objects with a .name attribute use basename as filename."""
65+
body = {FIELD: _io_from_disk(tmp_path)}
66+
result = prepare_multipart_form_data(body, [FIELD], [])
67+
field, filename, content = _canonicalize(result)
68+
assert field == FIELD
69+
assert filename == FILENAME
70+
assert content == CONTENT
71+
72+
def test_bare_bytes_gets_field_name_as_filename(self):
73+
"""Bare bytes without .name fall back to the field name."""
74+
body = {FIELD: CONTENT}
75+
result = prepare_multipart_form_data(body, [FIELD], [])
76+
field, filename, content = _canonicalize(result)
77+
assert field == FIELD
78+
assert filename == FIELD # fallback
79+
assert content == CONTENT
80+
81+
def test_bare_bytes_io_gets_field_name_as_filename(self):
82+
"""BytesIO without .name falls back to the field name."""
83+
body = {FIELD: io.BytesIO(CONTENT)}
84+
result = prepare_multipart_form_data(body, [FIELD], [])
85+
field, filename, content = _canonicalize(result)
86+
assert field == FIELD
87+
assert filename == FIELD # BytesIO.name is not a real path
88+
assert content == CONTENT
89+
90+
91+
class TestTuplePassthrough:
92+
"""Tuple variants of FileType must pass through unchanged."""
93+
94+
def test_two_tuple(self):
95+
body = {FIELD: (FILENAME, CONTENT)}
96+
result = prepare_multipart_form_data(body, [FIELD], [])
97+
_, entry = result[0]
98+
assert entry == (FILENAME, CONTENT)
99+
100+
def test_three_tuple(self):
101+
body = {FIELD: (FILENAME, CONTENT, "image/jpeg")}
102+
result = prepare_multipart_form_data(body, [FIELD], [])
103+
_, entry = result[0]
104+
assert entry == (FILENAME, CONTENT, "image/jpeg")
105+
106+
107+
class TestListEntries:
108+
"""List-valued file fields normalize each element independently."""
109+
110+
def test_list_of_bare_bytes(self):
111+
body = {FIELD: [b"file0", b"file1"]}
112+
result = prepare_multipart_form_data(body, [FIELD], [])
113+
assert len(result) == 2
114+
_, entry0 = result[0]
115+
_, entry1 = result[1]
116+
# index 0 → field name (no suffix), index 1+ → field_N
117+
assert entry0[0] == FIELD
118+
assert entry1[0] == f"{FIELD}_1"
119+
120+
def test_list_of_tuples(self):
121+
body = {FIELD: [("a.jpg", b"a"), ("b.jpg", b"b")]}
122+
result = prepare_multipart_form_data(body, [FIELD], [])
123+
assert len(result) == 2
124+
_, entry0 = result[0]
125+
_, entry1 = result[1]
126+
assert entry0 == ("a.jpg", b"a")
127+
assert entry1 == ("b.jpg", b"b")
128+
129+
130+
class TestDataFieldOrdering:
131+
"""Data fields must appear before file fields."""
132+
133+
def test_data_precedes_files(self, tmp_path):
134+
body = {"id": "123", FIELD: _io_from_disk(tmp_path)}
135+
result = prepare_multipart_form_data(body, [FIELD], ["id"])
136+
fields = [f for f, _ in result]
137+
assert fields == ["id", FIELD]
138+
139+
140+
class TestEdgeCases:
141+
"""Edge cases: None values, empty content."""
142+
143+
def test_none_value_skipped(self):
144+
body = {FIELD: None}
145+
result = prepare_multipart_form_data(body, [FIELD], [])
146+
assert len(result) == 0
147+
148+
def test_missing_field_skipped(self):
149+
body = {}
150+
result = prepare_multipart_form_data(body, [FIELD], [])
151+
assert len(result) == 0

0 commit comments

Comments
 (0)