Skip to content
Open
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,44 @@ 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.
"""
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 (filename, entry)

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
Expand Up @@ -217,3 +217,40 @@ async def test_file_upload_file_array(client: MultiPartClient):
],
)
)


# -- Bare IO variants: verify _normalize_multipart_file_entry synthesizes
# filename from IO.name so the server receives filename= in Content-Disposition.


@pytest.mark.asyncio
async def test_file_upload_file_required_filename_bare_io(client: MultiPartClient):
"""Pass bare open() IO — filename should be derived from IO.name ('image.png')."""
await client.form_data.file.upload_file_required_filename(
file_models.UploadFileRequiredFilenameRequest(
file=open(str(PNG), "rb"),
)
)


@pytest.mark.asyncio
async def test_file_upload_file_specific_content_type_bare_io(client: MultiPartClient):
"""Pass bare open() IO — filename derived from IO.name, content type guessed."""
await client.form_data.file.upload_file_specific_content_type(
file_models.UploadFileSpecificContentTypeRequest(
file=open(str(PNG), "rb"),
)
)


@pytest.mark.asyncio
async def test_file_upload_file_array_bare_io(client: MultiPartClient):
"""Pass bare open() IO in a list — each gets filename from IO.name."""
await client.form_data.file.upload_file_array(
file_models.UploadFileArrayRequest(
files=[
open(str(PNG), "rb"),
open(str(PNG), "rb"),
],
)
)
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_bytesio_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
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,37 @@ def test_file_upload_file_array(client: MultiPartClient):
],
)
)


# -- Bare IO variants: verify _normalize_multipart_file_entry synthesizes
# filename from IO.name so the server receives filename= in Content-Disposition.


def test_file_upload_file_required_filename_bare_io(client: MultiPartClient):
"""Pass bare open() IO — filename should be derived from IO.name ('image.png')."""
client.form_data.file.upload_file_required_filename(
file_models.UploadFileRequiredFilenameRequest(
file=open(str(PNG), "rb"),
)
)


def test_file_upload_file_specific_content_type_bare_io(client: MultiPartClient):
"""Pass bare open() IO — filename derived from IO.name, content type guessed."""
client.form_data.file.upload_file_specific_content_type(
file_models.UploadFileSpecificContentTypeRequest(
file=open(str(PNG), "rb"),
)
)


def test_file_upload_file_array_bare_io(client: MultiPartClient):
"""Pass bare open() IO in a list — each gets filename from IO.name."""
client.form_data.file.upload_file_array(
file_models.UploadFileArrayRequest(
files=[
open(str(PNG), "rb"),
open(str(PNG), "rb"),
],
)
)
Loading