diff --git a/.chronus/changes/fix-multipart-filename-2026-5-29-17-50-0.md b/.chronus/changes/fix-multipart-filename-2026-5-29-17-50-0.md new file mode 100644 index 00000000000..a0bdf8c55b8 --- /dev/null +++ b/.chronus/changes/fix-multipart-filename-2026-5-29-17-50-0.md @@ -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. diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py index 282468facf2..fad25ae4eeb 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py @@ -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, diff --git a/packages/http-client-python/generator/pygen/codegen/templates/utils.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/utils.py.jinja2 index 140f80638cd..b30c0f8e843 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/utils.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/utils.py.jinja2 @@ -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") + 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 %} diff --git a/packages/http-client-python/tests/mock_api/shared/test_multipart_filetype_variants.py b/packages/http-client-python/tests/mock_api/shared/test_multipart_filetype_variants.py new file mode 100644 index 00000000000..ac48a590c92 --- /dev/null +++ b/packages/http-client-python/tests/mock_api/shared/test_multipart_filetype_variants.py @@ -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