Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .chronus/changes/fix-multipart-filename-2026-5-29-17-50-0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: fix
packages:
- "@typespec/http-client-python"
---

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.
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ def need_utils_utils_file(self) -> str:
ImportType.LOCAL,
)
file_import.add_import("json", ImportType.STDLIB)
file_import.add_import("os", ImportType.STDLIB)

return template.render(
code_model=self.code_model,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,23 +76,60 @@ def serialize_multipart_data_entry(data_entry: Any) -> Any:
return json.dumps(data_entry, cls=SdkJSONEncoder, exclude_readonly=True)
return data_entry

def _normalize_multipart_file_entry(field_name: str, entry: Any, index: int) -> Any:
"""Ensure a multipart file entry carries a filename for Content-Disposition.

Servers distinguish file parts from plain form fields by the presence of
``filename=`` in the ``Content-Disposition`` header. When callers pass
bare bytes/str/IO the HTTP client omits the filename and the server may
reject the upload. This helper wraps bare values into a (filename, content)
tuple, deriving the name from IO.name when available.

:param str field_name: The multipart field name used as a filename fallback.
:param entry: The user-provided file entry (tuple, bytes, str, or IO).
:type entry: any
:param int index: The positional index of the entry within the field, used
to disambiguate fallback filenames when multiple entries are provided.
:return: Either the original tuple entry, or a ``(filename, content)`` tuple
wrapping the bare value.
:rtype: any
"""
if isinstance(entry, tuple):
return entry
filename: Optional[str] = None
name_attr = getattr(entry, "name", None)
if isinstance(name_attr, str) and name_attr:
filename = os.path.basename(name_attr)
if not filename:
filename = f"{field_name}_{index}" if index else field_name

# Return a 3-tuple with an explicit "application/octet-stream" content type.
# A 2-tuple (filename, content) would leave the part's Content-Type unset, and
# the sdk core library only defaults to "application/octet-stream" for bare
# (non-tuple) values - a tuple bypasses that default and falls back to the
# HTTP "text/plain" default instead. Setting it explicitly preserves the
# pre-existing behavior for bare bytes/IO across all transports.
return (filename, entry, "application/octet-stream")
Comment thread
iscai-msft marked this conversation as resolved.

def prepare_multipart_form_data(
body: Mapping[str, Any], multipart_fields: list[str], data_fields: list[str]
) -> list[FileType]:
files: list[FileType] = []
for multipart_field in multipart_fields:
multipart_entry = body.get(multipart_field)
if isinstance(multipart_entry, list):
files.extend([(multipart_field, e) for e in multipart_entry ])
elif multipart_entry:
files.append((multipart_field, multipart_entry))

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

for multipart_field in multipart_fields:
multipart_entry = body.get(multipart_field)
if isinstance(multipart_entry, list):
for idx, e in enumerate(multipart_entry):
files.append((multipart_field, _normalize_multipart_file_entry(multipart_field, e, idx)))
elif multipart_entry is not None:
files.append((multipart_field, _normalize_multipart_file_entry(multipart_field, multipart_entry, 0)))

return files
{% endif %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# -------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# --------------------------------------------------------------------------
"""Offline unit tests for ``prepare_multipart_form_data``.

Verify that every concrete variant of the ``FileType`` union produces a
multipart-equivalent normalized entry — i.e. the same field name, filename,
and content payload. These tests run entirely offline (no network, no mock
server) and operate directly on the generated helper.
"""

import io
from pathlib import Path

import pytest

from payload.multipart._utils.utils import prepare_multipart_form_data

FILENAME = "image.jpg"
CONTENT = b"\xff\xd8\xff\xe0 fake jpeg"
FIELD = "profileImage"


def _read(value):
"""Return raw bytes regardless of whether *value* is bytes or IO."""
if hasattr(value, "read"):
try:
value.seek(0)
except Exception: # pylint: disable=broad-except
pass
return value.read()
return value


def _canonicalize(prepared, field=FIELD):
"""Extract the first entry for *field* as (field, filename, bytes)."""
for f, entry in prepared:
if f == field:
assert isinstance(entry, tuple), f"helper must wrap entry as a tuple, got {entry!r}"
filename = entry[0]
content = _read(entry[1])
return (f, filename, content)
raise AssertionError(f"field {field!r} not found in {prepared!r}")


# ── Variant helpers ──────────────────────────────────────────────────────


def _io_from_disk(tmp_path):
p = tmp_path / FILENAME
p.write_bytes(CONTENT)
return p.open("rb")


# ── Tests ────────────────────────────────────────────────────────────────


class TestNormalizeBareInputs:
"""Bare bytes / IO must be wrapped with a synthesized filename."""

def test_bare_io_gets_filename_from_name_attr(self, tmp_path):
"""IO objects with a .name attribute use basename as filename."""
body = {FIELD: _io_from_disk(tmp_path)}
result = prepare_multipart_form_data(body, [FIELD], [])
field, filename, content = _canonicalize(result)
assert field == FIELD
assert filename == FILENAME
assert content == CONTENT

def test_bare_bytes_gets_field_name_as_filename(self):
"""Bare bytes without .name fall back to the field name."""
body = {FIELD: CONTENT}
result = prepare_multipart_form_data(body, [FIELD], [])
field, filename, content = _canonicalize(result)
assert field == FIELD
assert filename == FIELD # fallback
assert content == CONTENT

def test_bare_bytes_io_gets_field_name_as_filename(self):
"""BytesIO without .name falls back to the field name."""
body = {FIELD: io.BytesIO(CONTENT)}
result = prepare_multipart_form_data(body, [FIELD], [])
field, filename, content = _canonicalize(result)
assert field == FIELD
assert filename == FIELD # BytesIO.name is not a real path
assert content == CONTENT


class TestTuplePassthrough:
"""Tuple variants of FileType must pass through unchanged."""

def test_two_tuple(self):
body = {FIELD: (FILENAME, CONTENT)}
result = prepare_multipart_form_data(body, [FIELD], [])
_, entry = result[0]
assert entry == (FILENAME, CONTENT)

def test_three_tuple(self):
body = {FIELD: (FILENAME, CONTENT, "image/jpeg")}
result = prepare_multipart_form_data(body, [FIELD], [])
_, entry = result[0]
assert entry == (FILENAME, CONTENT, "image/jpeg")


class TestListEntries:
"""List-valued file fields normalize each element independently."""

def test_list_of_bare_bytes(self):
body = {FIELD: [b"file0", b"file1"]}
result = prepare_multipart_form_data(body, [FIELD], [])
assert len(result) == 2
_, entry0 = result[0]
_, entry1 = result[1]
# index 0 → field name (no suffix), index 1+ → field_N
assert entry0[0] == FIELD
assert entry1[0] == f"{FIELD}_1"

def test_list_of_tuples(self):
body = {FIELD: [("a.jpg", b"a"), ("b.jpg", b"b")]}
result = prepare_multipart_form_data(body, [FIELD], [])
assert len(result) == 2
_, entry0 = result[0]
_, entry1 = result[1]
assert entry0 == ("a.jpg", b"a")
assert entry1 == ("b.jpg", b"b")


class TestDataFieldOrdering:
"""Data fields must appear before file fields."""

def test_data_precedes_files(self, tmp_path):
body = {"id": "123", FIELD: _io_from_disk(tmp_path)}
result = prepare_multipart_form_data(body, [FIELD], ["id"])
fields = [f for f, _ in result]
assert fields == ["id", FIELD]


class TestEdgeCases:
"""Edge cases: None values, empty content."""

def test_none_value_skipped(self):
body = {FIELD: None}
result = prepare_multipart_form_data(body, [FIELD], [])
assert len(result) == 0

def test_missing_field_skipped(self):
body = {}
result = prepare_multipart_form_data(body, [FIELD], [])
assert len(result) == 0
Loading