From f268ae5063cb4b6775e380546abc0d98b1b80ff5 Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Tue, 27 Oct 2026 13:33:53 +0530 Subject: [PATCH 1/7] test(multipart): add reproduction tests for nil string header values and null object Signed-off-by: Hardik Kaurani --- .../test_multipart_serialization_writer.py | 129 ++++++++++++------ 1 file changed, 91 insertions(+), 38 deletions(-) diff --git a/packages/serialization/multipart/tests/unit/test_multipart_serialization_writer.py b/packages/serialization/multipart/tests/unit/test_multipart_serialization_writer.py index d6593aa5..0abe3905 100644 --- a/packages/serialization/multipart/tests/unit/test_multipart_serialization_writer.py +++ b/packages/serialization/multipart/tests/unit/test_multipart_serialization_writer.py @@ -1,9 +1,13 @@ -from uuid import UUID +from datetime import date, datetime, time, timedelta from unittest.mock import Mock +from uuid import UUID + import pytest -from datetime import datetime, timedelta, date, time -from kiota_serialization_multipart.multipart_serialization_writer import MultipartSerializationWriter +from kiota_serialization_multipart.multipart_serialization_writer import ( + MultipartSerializationWriter, +) + from ..helpers import TestEntity, TestEnum @@ -33,7 +37,8 @@ def test_not_implemented(): writer.write_collection_of_object_values("users", [TestEntity()]) with pytest.raises(NotImplementedError): writer.write_collection_of_primitive_values("numbers", [1, 2.0, "3"]) - + + def test_write_string_value(): serialization_writer = MultipartSerializationWriter() serialization_writer.write_str_value("message", "Hello world") @@ -41,76 +46,124 @@ def test_write_string_value(): content_string = content.decode('utf-8') assert content_string == "message: Hello world\r\n" + def test_write_bytes_value_bytes(): serialization_writer = MultipartSerializationWriter() serialization_writer.write_bytes_value("", b"Hello world") content = serialization_writer.get_serialized_content() content_string = content.decode('utf-8') assert content_string == "Hello world" - + + def test_write_object_value_raises_exception_on_parseable_object(): serialization_writer = MultipartSerializationWriter() with pytest.raises(ValueError) as excinfo: serialization_writer.write_object_value("user", TestEntity()) assert "Expected a MultipartBody instance but got" in str(excinfo.value) - -def test_write_object_value(user_1, mock_request_adapter, mock_serialization_writer_factory, mock_multipart_body): - mock_request_adapter.get_serialization_writer_factory = Mock(return_value=mock_serialization_writer_factory) + + +def test_write_object_value( + user_1, mock_request_adapter, mock_serialization_writer_factory, mock_multipart_body +): + mock_request_adapter.get_serialization_writer_factory = Mock( + return_value=mock_serialization_writer_factory + ) mock_multipart_body.request_adapter = mock_request_adapter mock_multipart_body.add_or_replace_part("test user", "application/json", user_1) mock_multipart_body.add_or_replace_part("img", "application/octet-stream", b"Hello world") - + serialization_writer = MultipartSerializationWriter() serialization_writer.write_object_value("", mock_multipart_body, None) content = serialization_writer.get_serialized_content() content_string = content.decode('utf-8') - assert content_string == f'--{mock_multipart_body.boundary}'+'\r\nContent-Type: application/json\r\nContent-Disposition: form-data; name="test user"\r\n\r\n{"id": "eac79bd3-fd08-4abf-9df2-2565cf3a3845", "workDuration": "2:00:00", "birthDay": "2017-09-04", "startWorkTime": "00:00:00", "createdDateTime": "2022-01-27T12:59:45", "businessPhones": ["+1 412 555 0109"], "mobilePhone": null, "accountEnabled": false, "jobTitle": "Auditor", "manager": {"id": "eac79bd3-fd08-4abf-9df2-2565cf3a3845"}}\r\n'+f'--{mock_multipart_body.boundary}'+'\r\nContent-Type: application/octet-stream\r\nContent-Disposition: form-data; name="img"\r\n\r\nHello world\r\n'+f'--{mock_multipart_body.boundary}--\r\n' - -def test_write_object_value_inverted(user_1, mock_request_adapter, mock_serialization_writer_factory, mock_multipart_body): - mock_request_adapter.get_serialization_writer_factory = Mock(return_value=mock_serialization_writer_factory) + assert content_string == f'--{mock_multipart_body.boundary}' + '\r\nContent-Type: application/json\r\nContent-Disposition: form-data; name="test user"\r\n\r\n{"id": "eac79bd3-fd08-4abf-9df2-2565cf3a3845", "workDuration": "2:00:00", "birthDay": "2017-09-04", "startWorkTime": "00:00:00", "createdDateTime": "2022-01-27T12:59:45", "businessPhones": ["+1 412 555 0109"], "mobilePhone": null, "accountEnabled": false, "jobTitle": "Auditor", "manager": {"id": "eac79bd3-fd08-4abf-9df2-2565cf3a3845"}}\r\n' + f'--{mock_multipart_body.boundary}' + '\r\nContent-Type: application/octet-stream\r\nContent-Disposition: form-data; name="img"\r\n\r\nHello world\r\n' + f'--{mock_multipart_body.boundary}--\r\n' + + +def test_write_object_value_inverted( + user_1, mock_request_adapter, mock_serialization_writer_factory, mock_multipart_body +): + mock_request_adapter.get_serialization_writer_factory = Mock( + return_value=mock_serialization_writer_factory + ) mock_multipart_body.request_adapter = mock_request_adapter mock_multipart_body.add_or_replace_part("img", "application/octet-stream", b"Hello world") mock_multipart_body.add_or_replace_part("test user", "application/json", user_1) - + serialization_writer = MultipartSerializationWriter() serialization_writer.write_object_value("", mock_multipart_body, None) content = serialization_writer.get_serialized_content() content_string = content.decode('utf-8') - assert content_string == f'--{mock_multipart_body.boundary}'+'\r\nContent-Type: application/octet-stream\r\nContent-Disposition: form-data; name="img"\r\n\r\nHello world\r\n'+f'--{mock_multipart_body.boundary}'+'\r\nContent-Type: application/json\r\nContent-Disposition: form-data; name="test user"\r\n\r\n{"id": "eac79bd3-fd08-4abf-9df2-2565cf3a3845", "workDuration": "2:00:00", "birthDay": "2017-09-04", "startWorkTime": "00:00:00", "createdDateTime": "2022-01-27T12:59:45", "businessPhones": ["+1 412 555 0109"], "mobilePhone": null, "accountEnabled": false, "jobTitle": "Auditor", "manager": {"id": "eac79bd3-fd08-4abf-9df2-2565cf3a3845"}}\r\n'+f'--{mock_multipart_body.boundary}--\r\n' + assert content_string == f'--{mock_multipart_body.boundary}' + '\r\nContent-Type: application/octet-stream\r\nContent-Disposition: form-data; name="img"\r\n\r\nHello world\r\n' + f'--{mock_multipart_body.boundary}' + '\r\nContent-Type: application/json\r\nContent-Disposition: form-data; name="test user"\r\n\r\n{"id": "eac79bd3-fd08-4abf-9df2-2565cf3a3845", "workDuration": "2:00:00", "birthDay": "2017-09-04", "startWorkTime": "00:00:00", "createdDateTime": "2022-01-27T12:59:45", "businessPhones": ["+1 412 555 0109"], "mobilePhone": null, "accountEnabled": false, "jobTitle": "Auditor", "manager": {"id": "eac79bd3-fd08-4abf-9df2-2565cf3a3845"}}\r\n' + f'--{mock_multipart_body.boundary}--\r\n' -def test_write_object_value_with_filename(user_1, mock_request_adapter, mock_serialization_writer_factory, mock_multipart_body): - mock_request_adapter.get_serialization_writer_factory = Mock(return_value=mock_serialization_writer_factory) + +def test_write_object_value_with_filename( + user_1, mock_request_adapter, mock_serialization_writer_factory, mock_multipart_body +): + mock_request_adapter.get_serialization_writer_factory = Mock( + return_value=mock_serialization_writer_factory + ) mock_multipart_body.request_adapter = mock_request_adapter mock_multipart_body.add_or_replace_part("test user", "application/json", user_1) - mock_multipart_body.add_or_replace_part("file", "application/octet-stream", b"Hello world", "file.txt") + mock_multipart_body.add_or_replace_part( + "file", "application/octet-stream", b"Hello world", "file.txt" + ) + + serialization_writer = MultipartSerializationWriter() + serialization_writer.write_object_value("", mock_multipart_body, None) + content = serialization_writer.get_serialized_content() + content_string = content.decode('utf-8') + assert content_string == f'--{mock_multipart_body.boundary}' + '\r\nContent-Type: application/json\r\nContent-Disposition: form-data; name="test user"\r\n\r\n{"id": "eac79bd3-fd08-4abf-9df2-2565cf3a3845", "workDuration": "2:00:00", "birthDay": "2017-09-04", "startWorkTime": "00:00:00", "createdDateTime": "2022-01-27T12:59:45", "businessPhones": ["+1 412 555 0109"], "mobilePhone": null, "accountEnabled": false, "jobTitle": "Auditor", "manager": {"id": "eac79bd3-fd08-4abf-9df2-2565cf3a3845"}}\r\n' + f'--{mock_multipart_body.boundary}' + '\r\nContent-Type: application/octet-stream\r\nContent-Disposition: form-data; name="file"; filename="file.txt"\r\n\r\nHello world\r\n' + f'--{mock_multipart_body.boundary}--\r\n' + + +def test_write_object_value_with_string_part( + mock_request_adapter, mock_serialization_writer_factory, mock_multipart_body +): + mock_request_adapter.get_serialization_writer_factory = Mock( + return_value=mock_serialization_writer_factory + ) + mock_multipart_body.request_adapter = mock_request_adapter + mock_multipart_body.add_or_replace_part("message", "text/plain", "Hello world") serialization_writer = MultipartSerializationWriter() serialization_writer.write_object_value("", mock_multipart_body, None) content = serialization_writer.get_serialized_content() content_string = content.decode('utf-8') - assert content_string == f'--{mock_multipart_body.boundary}'+'\r\nContent-Type: application/json\r\nContent-Disposition: form-data; name="test user"\r\n\r\n{"id": "eac79bd3-fd08-4abf-9df2-2565cf3a3845", "workDuration": "2:00:00", "birthDay": "2017-09-04", "startWorkTime": "00:00:00", "createdDateTime": "2022-01-27T12:59:45", "businessPhones": ["+1 412 555 0109"], "mobilePhone": null, "accountEnabled": false, "jobTitle": "Auditor", "manager": {"id": "eac79bd3-fd08-4abf-9df2-2565cf3a3845"}}\r\n'+f'--{mock_multipart_body.boundary}'+'\r\nContent-Type: application/octet-stream\r\nContent-Disposition: form-data; name="file"; filename="file.txt"\r\n\r\nHello world\r\n'+f'--{mock_multipart_body.boundary}--\r\n' + assert content_string == f'--{mock_multipart_body.boundary}' + '\r\nContent-Type: text/plain\r\nContent-Disposition: form-data; name="message"\r\n\r\nHello world\r\n' + f'--{mock_multipart_body.boundary}--\r\n' -def test_write_object_value_with_string_part(mock_request_adapter, mock_serialization_writer_factory, mock_multipart_body): - mock_request_adapter.get_serialization_writer_factory = Mock(return_value=mock_serialization_writer_factory) - mock_multipart_body.request_adapter = mock_request_adapter - mock_multipart_body.add_or_replace_part("message", "text/plain", "Hello world") +def test_write_object_value_with_multiple_string_parts( + mock_request_adapter, mock_serialization_writer_factory, mock_multipart_body +): + mock_request_adapter.get_serialization_writer_factory = Mock( + return_value=mock_serialization_writer_factory + ) + mock_multipart_body.request_adapter = mock_request_adapter + mock_multipart_body.add_or_replace_part("message1", "text/plain", "Hello world") + mock_multipart_body.add_or_replace_part("message2", "text/plain", "Goodbye world") - serialization_writer = MultipartSerializationWriter() - serialization_writer.write_object_value("", mock_multipart_body, None) - content = serialization_writer.get_serialized_content() - content_string = content.decode('utf-8') - assert content_string == f'--{mock_multipart_body.boundary}'+'\r\nContent-Type: text/plain\r\nContent-Disposition: form-data; name="message"\r\n\r\nHello world\r\n'+f'--{mock_multipart_body.boundary}--\r\n' + serialization_writer = MultipartSerializationWriter() + serialization_writer.write_object_value("", mock_multipart_body, None) + content = serialization_writer.get_serialized_content() + content_string = content.decode('utf-8') + assert content_string == f'--{mock_multipart_body.boundary}' + '\r\nContent-Type: text/plain\r\nContent-Disposition: form-data; name="message1"\r\n\r\nHello world\r\n' + f'--{mock_multipart_body.boundary}' + '\r\nContent-Type: text/plain\r\nContent-Disposition: form-data; name="message2"\r\n\r\nGoodbye world\r\n' + f'--{mock_multipart_body.boundary}--\r\n' -def test_write_object_value_with_multiple_string_parts(mock_request_adapter, mock_serialization_writer_factory, mock_multipart_body): - mock_request_adapter.get_serialization_writer_factory = Mock(return_value=mock_serialization_writer_factory) - mock_multipart_body.request_adapter = mock_request_adapter - mock_multipart_body.add_or_replace_part("message1", "text/plain", "Hello world") - mock_multipart_body.add_or_replace_part("message2", "text/plain", "Goodbye world") + +def test_write_str_value_omits_nil_value(): + serialization_writer = MultipartSerializationWriter() + serialization_writer.write_str_value("Content-Type", None) + content = serialization_writer.get_serialized_content() + assert content == b"" - serialization_writer = MultipartSerializationWriter() - serialization_writer.write_object_value("", mock_multipart_body, None) - content = serialization_writer.get_serialized_content() - content_string = content.decode('utf-8') - assert content_string == f'--{mock_multipart_body.boundary}'+'\r\nContent-Type: text/plain\r\nContent-Disposition: form-data; name="message1"\r\n\r\nHello world\r\n'+f'--{mock_multipart_body.boundary}'+'\r\nContent-Type: text/plain\r\nContent-Disposition: form-data; name="message2"\r\n\r\nGoodbye world\r\n'+f'--{mock_multipart_body.boundary}--\r\n' \ No newline at end of file +def test_write_str_value_omits_both_nil(): + serialization_writer = MultipartSerializationWriter() + serialization_writer.write_str_value(None, None) + content = serialization_writer.get_serialized_content() + assert content == b"" + + +def test_write_object_value_handles_none_gracefully(): + serialization_writer = MultipartSerializationWriter() + serialization_writer.write_object_value("", None) + content = serialization_writer.get_serialized_content() + assert content == b"" From 79ca1098c8b91288b4ba14c0be715f0de44b4a93 Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Tue, 27 Oct 2026 13:34:43 +0530 Subject: [PATCH 2/7] fix(multipart): omit nil string header values and handle null objects in write_object_value Signed-off-by: Hardik Kaurani --- .../multipart_serialization_writer.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/serialization/multipart/kiota_serialization_multipart/multipart_serialization_writer.py b/packages/serialization/multipart/kiota_serialization_multipart/multipart_serialization_writer.py index b12b7763..5a22100f 100644 --- a/packages/serialization/multipart/kiota_serialization_multipart/multipart_serialization_writer.py +++ b/packages/serialization/multipart/kiota_serialization_multipart/multipart_serialization_writer.py @@ -38,6 +38,8 @@ def write_str_value(self, key: Optional[str], value: Optional[str]) -> None: key (Optional[str]): The key to be used for the written value. May be null. value (Optional[str]): The string value to be written. """ + if value is None: + return if key: self.writer.write(key) if value: @@ -170,13 +172,15 @@ def write_object_value( additional_values_to_merge (tuple[Parsable]): The additional values to merge to the main value when serializing an intersection wrapper. """ - temp_writer = self._create_new_writer() - - if not isinstance(value, MultipartBody): - raise ValueError(f"Expected a MultipartBody instance but got {type(value)}") - self._serialize_value(self, value) - if self._on_after_object_serialization: - self._on_after_object_serialization(value) + if value is None and not any(additional_values_to_merge): + return + + if value is not None: + if not isinstance(value, MultipartBody): + raise ValueError(f"Expected a MultipartBody instance but got {type(value)}") + self._serialize_value(self, value) + if self._on_after_object_serialization: + self._on_after_object_serialization(value) def write_null_value(self, key: Optional[str]) -> None: """Writes a null value for the specified key. From 946e4248a83fca822c1aab3e02c94ed654a9b4c0 Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Tue, 27 Oct 2026 13:36:31 +0530 Subject: [PATCH 3/7] feat(multipart): support additional_values_to_merge and add intersection wrapper tests Signed-off-by: Hardik Kaurani --- .../multipart_serialization_writer.py | 85 +++++++++++++++++-- .../test_multipart_serialization_writer.py | 71 ++++++++++++++++ 2 files changed, 150 insertions(+), 6 deletions(-) diff --git a/packages/serialization/multipart/kiota_serialization_multipart/multipart_serialization_writer.py b/packages/serialization/multipart/kiota_serialization_multipart/multipart_serialization_writer.py index 5a22100f..39c53615 100644 --- a/packages/serialization/multipart/kiota_serialization_multipart/multipart_serialization_writer.py +++ b/packages/serialization/multipart/kiota_serialization_multipart/multipart_serialization_writer.py @@ -4,7 +4,7 @@ from collections.abc import Callable from datetime import date, datetime, time, timedelta from enum import Enum -from typing import Any, Optional, TypeVar +from typing import Any, Optional, TypeVar, cast from uuid import UUID from kiota_abstractions.multipart_body import MultipartBody @@ -175,12 +175,85 @@ def write_object_value( if value is None and not any(additional_values_to_merge): return - if value is not None: - if not isinstance(value, MultipartBody): - raise ValueError(f"Expected a MultipartBody instance but got {type(value)}") - self._serialize_value(self, value) + if value is not None and not isinstance(value, MultipartBody): + raise ValueError(f"Expected a MultipartBody instance but got {type(value)}") + + if additional_values_to_merge: + for additional_value in additional_values_to_merge: + if additional_value is not None and not isinstance(additional_value, MultipartBody): + raise ValueError( + f"Expected a MultipartBody instance but got {type(additional_value)}" + ) + + all_values: list[MultipartBody[Any]] = [ + cast(MultipartBody[Any], v) for v in (value, *additional_values_to_merge) + if v is not None + ] + + if not all_values: + return + + if len(all_values) == 1: + self._serialize_value(self, all_values[0]) if self._on_after_object_serialization: - self._on_after_object_serialization(value) + self._on_after_object_serialization(all_values[0]) + return + + boundary = all_values[0].boundary + first_part = True + for current_body in all_values: + if ( + not current_body.request_adapter + or not current_body.request_adapter.get_serialization_writer_factory() + ): + raise ValueError("Request adapter or serialization writer factory cannot be null") + if not current_body.parts: + raise ValueError("No parts to serialize") + + if on_before := self.on_before_object_serialization: + on_before(current_body) + if on_start := self.on_start_object_serialization: + on_start(current_body, self) + + first_part = self._write_body_parts(current_body, boundary, first_part) + + if on_after := self.on_after_object_serialization: + on_after(current_body) + + self.write_str_value("", "") + self.write_str_value("", f"--{boundary}--") + + def _write_body_parts(self, body: MultipartBody[Any], boundary: str, first_part: bool) -> bool: + for part_name, part_value in body.parts.items(): + if first_part: + first_part = False + else: + self.write_str_value("", "") + + self.write_str_value("", f"--{boundary}") + self.write_str_value("Content-Type", f"{part_value[0]}") + + disposition_fn = getattr( + body, "_get_content_disposition", getattr(body, "_get_comtent_disposition", None) + ) + disposition = ( + disposition_fn(part_name, part_value) + if disposition_fn else f'form-data; name="{part_name}"' + ) + self.write_str_value("Content-Disposition", disposition) + self.write_str_value("", "") + + if isinstance(part_value[1], Parsable): + body._write_parsable(self, part_value) + elif isinstance(part_value[1], str): + self.write_bytes_value("", part_value[1].encode('utf-8')) + elif isinstance(part_value[1], bytes): + self.write_bytes_value("", part_value[1]) + elif isinstance(part_value[1], io.IOBase): + self.write_bytes_value("", part_value[1].read()) + else: + raise ValueError(f"Unsupported type {type(part_value[1])} for part {part_name}") + return first_part def write_null_value(self, key: Optional[str]) -> None: """Writes a null value for the specified key. diff --git a/packages/serialization/multipart/tests/unit/test_multipart_serialization_writer.py b/packages/serialization/multipart/tests/unit/test_multipart_serialization_writer.py index 0abe3905..cf0d11c2 100644 --- a/packages/serialization/multipart/tests/unit/test_multipart_serialization_writer.py +++ b/packages/serialization/multipart/tests/unit/test_multipart_serialization_writer.py @@ -167,3 +167,74 @@ def test_write_object_value_handles_none_gracefully(): serialization_writer.write_object_value("", None) content = serialization_writer.get_serialized_content() assert content == b"" + + +def test_write_object_value_with_additional_values_to_merge( + user_1, mock_request_adapter, mock_serialization_writer_factory +): + import email + from email.policy import default + + from kiota_abstractions.multipart_body import MultipartBody + + mock_request_adapter.get_serialization_writer_factory = Mock( + return_value=mock_serialization_writer_factory + ) + + body1 = MultipartBody() + body1.boundary = "body1-boundary" + body1.request_adapter = mock_request_adapter + body1.add_or_replace_part("part1", "text/plain", "First part") + + body2 = MultipartBody() + body2.boundary = "body2-boundary" + body2.request_adapter = mock_request_adapter + body2.add_or_replace_part("part2", "text/plain", "Second part") + + before_calls = [] + after_calls = [] + + serialization_writer = MultipartSerializationWriter() + serialization_writer.on_before_object_serialization = lambda obj: before_calls.append(obj) + serialization_writer.on_after_object_serialization = lambda obj: after_calls.append(obj) + + serialization_writer.write_object_value("", body1, body2) + raw_content = serialization_writer.get_serialized_content() + content = raw_content.decode("utf-8") + + # Assert callbacks were preserved for each object + assert before_calls == [body1, body2] + assert after_calls == [body1, body2] + + # Verify serialized under single boundary with a single closing delimiter + boundary = body1.boundary + assert f"--{boundary}\r\n" in content + assert f"--{body2.boundary}" not in content + assert content.endswith(f"--{boundary}--\r\n") + assert content.count(f"--{boundary}--") == 1 + + # Verify both parts can be parsed by MIME parser under one boundary + mime_message = email.message_from_bytes( + f"Content-Type: multipart/form-data; boundary={boundary}\r\n\r\n".encode("utf-8") + + raw_content, + policy=default, + ) + assert mime_message.is_multipart() + parts = mime_message.get_payload() + assert len(parts) == 2 + assert parts[0].get_param("name", header="content-disposition") == "part1" + assert parts[0].get_content_type() == "text/plain" + assert parts[0].get_content().strip() == "First part" + assert parts[1].get_param("name", header="content-disposition") == "part2" + assert parts[1].get_content_type() == "text/plain" + assert parts[1].get_content().strip() == "Second part" + + +def test_write_object_value_with_invalid_additional_value_raises(): + from kiota_abstractions.multipart_body import MultipartBody + + body = MultipartBody() + serialization_writer = MultipartSerializationWriter() + with pytest.raises(ValueError) as excinfo: + serialization_writer.write_object_value("", body, TestEntity()) + assert "Expected a MultipartBody instance but got" in str(excinfo.value) From dd32904e10428bf46f87747fc1060a5b6f47a073 Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Tue, 27 Oct 2026 13:38:57 +0530 Subject: [PATCH 4/7] docs(multipart): document nil header omission and clean up writer parameter naming Signed-off-by: Hardik Kaurani --- .../multipart_serialization_writer.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/serialization/multipart/kiota_serialization_multipart/multipart_serialization_writer.py b/packages/serialization/multipart/kiota_serialization_multipart/multipart_serialization_writer.py index 39c53615..8086f76f 100644 --- a/packages/serialization/multipart/kiota_serialization_multipart/multipart_serialization_writer.py +++ b/packages/serialization/multipart/kiota_serialization_multipart/multipart_serialization_writer.py @@ -34,9 +34,11 @@ def __init__(self) -> None: def write_str_value(self, key: Optional[str], value: Optional[str]) -> None: """Writes the specified string value to the stream with an optional given key. + If value is None, no output is written to avoid emitting bare header names. + Args: key (Optional[str]): The key to be used for the written value. May be null. - value (Optional[str]): The string value to be written. + value (Optional[str]): The string value to be written. If None, the value is omitted. """ if value is None: return @@ -337,13 +339,13 @@ def on_start_object_serialization( """ self._on_start_object_serialization = value - def _serialize_value(self, temp_writer: MultipartSerializationWriter, value: U) -> None: + def _serialize_value(self, writer: MultipartSerializationWriter, value: U) -> None: if on_before := self.on_before_object_serialization: on_before(value) if on_start := self.on_start_object_serialization: on_start(value, self) - value.serialize(temp_writer) + value.serialize(writer) def _create_new_writer(self) -> SerializationWriter: writer = MultipartSerializationWriter() From 5d5bd14c46a5ef8bb7de901666884433e9738e7b Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Wed, 28 Oct 2026 02:06:46 +0530 Subject: [PATCH 5/7] fix(multipart): invoke serialization callbacks before merge body validation Signed-off-by: Hardik Kaurani --- .../multipart_serialization_writer.py | 10 +++---- .../test_multipart_serialization_writer.py | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/serialization/multipart/kiota_serialization_multipart/multipart_serialization_writer.py b/packages/serialization/multipart/kiota_serialization_multipart/multipart_serialization_writer.py index 8086f76f..bf21e250 100644 --- a/packages/serialization/multipart/kiota_serialization_multipart/multipart_serialization_writer.py +++ b/packages/serialization/multipart/kiota_serialization_multipart/multipart_serialization_writer.py @@ -204,6 +204,11 @@ def write_object_value( boundary = all_values[0].boundary first_part = True for current_body in all_values: + if on_before := self.on_before_object_serialization: + on_before(current_body) + if on_start := self.on_start_object_serialization: + on_start(current_body, self) + if ( not current_body.request_adapter or not current_body.request_adapter.get_serialization_writer_factory() @@ -212,11 +217,6 @@ def write_object_value( if not current_body.parts: raise ValueError("No parts to serialize") - if on_before := self.on_before_object_serialization: - on_before(current_body) - if on_start := self.on_start_object_serialization: - on_start(current_body, self) - first_part = self._write_body_parts(current_body, boundary, first_part) if on_after := self.on_after_object_serialization: diff --git a/packages/serialization/multipart/tests/unit/test_multipart_serialization_writer.py b/packages/serialization/multipart/tests/unit/test_multipart_serialization_writer.py index cf0d11c2..85e03959 100644 --- a/packages/serialization/multipart/tests/unit/test_multipart_serialization_writer.py +++ b/packages/serialization/multipart/tests/unit/test_multipart_serialization_writer.py @@ -238,3 +238,32 @@ def test_write_object_value_with_invalid_additional_value_raises(): with pytest.raises(ValueError) as excinfo: serialization_writer.write_object_value("", body, TestEntity()) assert "Expected a MultipartBody instance but got" in str(excinfo.value) + + +def test_write_object_value_with_additional_values_callback_initialization( + mock_request_adapter, mock_serialization_writer_factory +): + from kiota_abstractions.multipart_body import MultipartBody + + mock_request_adapter.get_serialization_writer_factory = Mock( + return_value=mock_serialization_writer_factory + ) + + body1 = MultipartBody() + body1.boundary = "body1-boundary" + body2 = MultipartBody() + body2.boundary = "body2-boundary" + + def populate_body(body): + body.request_adapter = mock_request_adapter + body.add_or_replace_part(f"part_{body.boundary}", "text/plain", "content") + + serialization_writer = MultipartSerializationWriter() + serialization_writer.on_before_object_serialization = populate_body + + serialization_writer.write_object_value("", body1, body2) + raw_content = serialization_writer.get_serialized_content() + content = raw_content.decode("utf-8") + assert f"--{body1.boundary}" in content + assert "part_body1-boundary" in content + assert "part_body2-boundary" in content From bf0a82bc914ce0e95e3d697f968070de7f45605e Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Wed, 28 Oct 2026 19:16:14 +0530 Subject: [PATCH 6/7] fix(multipart): validate falsy additional values when primary is none and capture boundary after callbacks Signed-off-by: Hardik Kaurani --- .../multipart_serialization_writer.py | 10 ++-- .../test_multipart_serialization_writer.py | 49 +++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/packages/serialization/multipart/kiota_serialization_multipart/multipart_serialization_writer.py b/packages/serialization/multipart/kiota_serialization_multipart/multipart_serialization_writer.py index bf21e250..e367a7c6 100644 --- a/packages/serialization/multipart/kiota_serialization_multipart/multipart_serialization_writer.py +++ b/packages/serialization/multipart/kiota_serialization_multipart/multipart_serialization_writer.py @@ -174,7 +174,7 @@ def write_object_value( additional_values_to_merge (tuple[Parsable]): The additional values to merge to the main value when serializing an intersection wrapper. """ - if value is None and not any(additional_values_to_merge): + if value is None and not any(v is not None for v in additional_values_to_merge): return if value is not None and not isinstance(value, MultipartBody): @@ -201,14 +201,17 @@ def write_object_value( self._on_after_object_serialization(all_values[0]) return - boundary = all_values[0].boundary + boundary: Optional[str] = None first_part = True - for current_body in all_values: + for i, current_body in enumerate(all_values): if on_before := self.on_before_object_serialization: on_before(current_body) if on_start := self.on_start_object_serialization: on_start(current_body, self) + if i == 0: + boundary = current_body.boundary + if ( not current_body.request_adapter or not current_body.request_adapter.get_serialization_writer_factory() @@ -217,6 +220,7 @@ def write_object_value( if not current_body.parts: raise ValueError("No parts to serialize") + assert boundary is not None first_part = self._write_body_parts(current_body, boundary, first_part) if on_after := self.on_after_object_serialization: diff --git a/packages/serialization/multipart/tests/unit/test_multipart_serialization_writer.py b/packages/serialization/multipart/tests/unit/test_multipart_serialization_writer.py index 85e03959..ab4e93fa 100644 --- a/packages/serialization/multipart/tests/unit/test_multipart_serialization_writer.py +++ b/packages/serialization/multipart/tests/unit/test_multipart_serialization_writer.py @@ -267,3 +267,52 @@ def populate_body(body): assert f"--{body1.boundary}" in content assert "part_body1-boundary" in content assert "part_body2-boundary" in content + + +@pytest.mark.parametrize("invalid_value", ["", 0, False, [], {}, TestEntity()]) +def test_write_object_value_none_primary_with_invalid_additional_value_raises(invalid_value): + serialization_writer = MultipartSerializationWriter() + with pytest.raises(ValueError) as excinfo: + serialization_writer.write_object_value("", None, invalid_value) + assert "Expected a MultipartBody instance but got" in str(excinfo.value) + + +def test_write_object_value_none_primary_with_none_additional_values_noop(): + serialization_writer = MultipartSerializationWriter() + serialization_writer.write_object_value("", None, None, None) + assert len(serialization_writer.get_serialized_content()) == 0 + + +def test_write_object_value_captures_boundary_after_callbacks( + mock_request_adapter, mock_serialization_writer_factory +): + from kiota_abstractions.multipart_body import MultipartBody + + mock_request_adapter.get_serialization_writer_factory = Mock( + return_value=mock_serialization_writer_factory + ) + + body1 = MultipartBody() + body1.boundary = "initial-boundary" + body1.request_adapter = mock_request_adapter + body1.add_or_replace_part("part1", "text/plain", "First part") + + body2 = MultipartBody() + body2.boundary = "body2-boundary" + body2.request_adapter = mock_request_adapter + body2.add_or_replace_part("part2", "text/plain", "Second part") + + def update_boundary_in_callback(body): + if body is body1: + body.boundary = "callback-updated-boundary" + + serialization_writer = MultipartSerializationWriter() + serialization_writer.on_before_object_serialization = update_boundary_in_callback + + serialization_writer.write_object_value("", body1, body2) + raw_content = serialization_writer.get_serialized_content() + content = raw_content.decode("utf-8") + + assert "--callback-updated-boundary" in content + assert "--callback-updated-boundary--" in content + assert "--initial-boundary" not in content From ce098a21318f7aafc271b8744de7ddb80ee93075 Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Wed, 28 Oct 2026 22:35:30 +0530 Subject: [PATCH 7/7] fix(multipart): revert additional values merging and scope to nil header omission Signed-off-by: Hardik Kaurani --- .../multipart_serialization_writer.py | 95 ++---------- .../test_multipart_serialization_writer.py | 142 ------------------ 2 files changed, 10 insertions(+), 227 deletions(-) diff --git a/packages/serialization/multipart/kiota_serialization_multipart/multipart_serialization_writer.py b/packages/serialization/multipart/kiota_serialization_multipart/multipart_serialization_writer.py index e367a7c6..b2bfdaf8 100644 --- a/packages/serialization/multipart/kiota_serialization_multipart/multipart_serialization_writer.py +++ b/packages/serialization/multipart/kiota_serialization_multipart/multipart_serialization_writer.py @@ -4,7 +4,7 @@ from collections.abc import Callable from datetime import date, datetime, time, timedelta from enum import Enum -from typing import Any, Optional, TypeVar, cast +from typing import Any, Optional, TypeVar from uuid import UUID from kiota_abstractions.multipart_body import MultipartBody @@ -165,7 +165,10 @@ def write_collection_of_object_values( raise NotImplementedError() def write_object_value( - self, key: Optional[str], value: Optional[U], *additional_values_to_merge: Optional[U] + self, + key: Optional[str], + value: Optional[U], + *additional_values_to_merge: Optional[U], ) -> None: """Writes the specified model object to the stream with an optional given key. Args: @@ -174,92 +177,14 @@ def write_object_value( additional_values_to_merge (tuple[Parsable]): The additional values to merge to the main value when serializing an intersection wrapper. """ - if value is None and not any(v is not None for v in additional_values_to_merge): + if value is None: return - if value is not None and not isinstance(value, MultipartBody): + if not isinstance(value, MultipartBody): raise ValueError(f"Expected a MultipartBody instance but got {type(value)}") - - if additional_values_to_merge: - for additional_value in additional_values_to_merge: - if additional_value is not None and not isinstance(additional_value, MultipartBody): - raise ValueError( - f"Expected a MultipartBody instance but got {type(additional_value)}" - ) - - all_values: list[MultipartBody[Any]] = [ - cast(MultipartBody[Any], v) for v in (value, *additional_values_to_merge) - if v is not None - ] - - if not all_values: - return - - if len(all_values) == 1: - self._serialize_value(self, all_values[0]) - if self._on_after_object_serialization: - self._on_after_object_serialization(all_values[0]) - return - - boundary: Optional[str] = None - first_part = True - for i, current_body in enumerate(all_values): - if on_before := self.on_before_object_serialization: - on_before(current_body) - if on_start := self.on_start_object_serialization: - on_start(current_body, self) - - if i == 0: - boundary = current_body.boundary - - if ( - not current_body.request_adapter - or not current_body.request_adapter.get_serialization_writer_factory() - ): - raise ValueError("Request adapter or serialization writer factory cannot be null") - if not current_body.parts: - raise ValueError("No parts to serialize") - - assert boundary is not None - first_part = self._write_body_parts(current_body, boundary, first_part) - - if on_after := self.on_after_object_serialization: - on_after(current_body) - - self.write_str_value("", "") - self.write_str_value("", f"--{boundary}--") - - def _write_body_parts(self, body: MultipartBody[Any], boundary: str, first_part: bool) -> bool: - for part_name, part_value in body.parts.items(): - if first_part: - first_part = False - else: - self.write_str_value("", "") - - self.write_str_value("", f"--{boundary}") - self.write_str_value("Content-Type", f"{part_value[0]}") - - disposition_fn = getattr( - body, "_get_content_disposition", getattr(body, "_get_comtent_disposition", None) - ) - disposition = ( - disposition_fn(part_name, part_value) - if disposition_fn else f'form-data; name="{part_name}"' - ) - self.write_str_value("Content-Disposition", disposition) - self.write_str_value("", "") - - if isinstance(part_value[1], Parsable): - body._write_parsable(self, part_value) - elif isinstance(part_value[1], str): - self.write_bytes_value("", part_value[1].encode('utf-8')) - elif isinstance(part_value[1], bytes): - self.write_bytes_value("", part_value[1]) - elif isinstance(part_value[1], io.IOBase): - self.write_bytes_value("", part_value[1].read()) - else: - raise ValueError(f"Unsupported type {type(part_value[1])} for part {part_name}") - return first_part + self._serialize_value(self, value) + if self._on_after_object_serialization: + self._on_after_object_serialization(value) def write_null_value(self, key: Optional[str]) -> None: """Writes a null value for the specified key. diff --git a/packages/serialization/multipart/tests/unit/test_multipart_serialization_writer.py b/packages/serialization/multipart/tests/unit/test_multipart_serialization_writer.py index ab4e93fa..498f67c7 100644 --- a/packages/serialization/multipart/tests/unit/test_multipart_serialization_writer.py +++ b/packages/serialization/multipart/tests/unit/test_multipart_serialization_writer.py @@ -169,150 +169,8 @@ def test_write_object_value_handles_none_gracefully(): assert content == b"" -def test_write_object_value_with_additional_values_to_merge( - user_1, mock_request_adapter, mock_serialization_writer_factory -): - import email - from email.policy import default - - from kiota_abstractions.multipart_body import MultipartBody - - mock_request_adapter.get_serialization_writer_factory = Mock( - return_value=mock_serialization_writer_factory - ) - - body1 = MultipartBody() - body1.boundary = "body1-boundary" - body1.request_adapter = mock_request_adapter - body1.add_or_replace_part("part1", "text/plain", "First part") - - body2 = MultipartBody() - body2.boundary = "body2-boundary" - body2.request_adapter = mock_request_adapter - body2.add_or_replace_part("part2", "text/plain", "Second part") - - before_calls = [] - after_calls = [] - - serialization_writer = MultipartSerializationWriter() - serialization_writer.on_before_object_serialization = lambda obj: before_calls.append(obj) - serialization_writer.on_after_object_serialization = lambda obj: after_calls.append(obj) - - serialization_writer.write_object_value("", body1, body2) - raw_content = serialization_writer.get_serialized_content() - content = raw_content.decode("utf-8") - - # Assert callbacks were preserved for each object - assert before_calls == [body1, body2] - assert after_calls == [body1, body2] - - # Verify serialized under single boundary with a single closing delimiter - boundary = body1.boundary - assert f"--{boundary}\r\n" in content - assert f"--{body2.boundary}" not in content - assert content.endswith(f"--{boundary}--\r\n") - assert content.count(f"--{boundary}--") == 1 - - # Verify both parts can be parsed by MIME parser under one boundary - mime_message = email.message_from_bytes( - f"Content-Type: multipart/form-data; boundary={boundary}\r\n\r\n".encode("utf-8") + - raw_content, - policy=default, - ) - assert mime_message.is_multipart() - parts = mime_message.get_payload() - assert len(parts) == 2 - assert parts[0].get_param("name", header="content-disposition") == "part1" - assert parts[0].get_content_type() == "text/plain" - assert parts[0].get_content().strip() == "First part" - assert parts[1].get_param("name", header="content-disposition") == "part2" - assert parts[1].get_content_type() == "text/plain" - assert parts[1].get_content().strip() == "Second part" - - -def test_write_object_value_with_invalid_additional_value_raises(): - from kiota_abstractions.multipart_body import MultipartBody - - body = MultipartBody() - serialization_writer = MultipartSerializationWriter() - with pytest.raises(ValueError) as excinfo: - serialization_writer.write_object_value("", body, TestEntity()) - assert "Expected a MultipartBody instance but got" in str(excinfo.value) - - -def test_write_object_value_with_additional_values_callback_initialization( - mock_request_adapter, mock_serialization_writer_factory -): - from kiota_abstractions.multipart_body import MultipartBody - - mock_request_adapter.get_serialization_writer_factory = Mock( - return_value=mock_serialization_writer_factory - ) - - body1 = MultipartBody() - body1.boundary = "body1-boundary" - body2 = MultipartBody() - body2.boundary = "body2-boundary" - - def populate_body(body): - body.request_adapter = mock_request_adapter - body.add_or_replace_part(f"part_{body.boundary}", "text/plain", "content") - - serialization_writer = MultipartSerializationWriter() - serialization_writer.on_before_object_serialization = populate_body - - serialization_writer.write_object_value("", body1, body2) - raw_content = serialization_writer.get_serialized_content() - content = raw_content.decode("utf-8") - assert f"--{body1.boundary}" in content - assert "part_body1-boundary" in content - assert "part_body2-boundary" in content - - -@pytest.mark.parametrize("invalid_value", ["", 0, False, [], {}, TestEntity()]) -def test_write_object_value_none_primary_with_invalid_additional_value_raises(invalid_value): - serialization_writer = MultipartSerializationWriter() - with pytest.raises(ValueError) as excinfo: - serialization_writer.write_object_value("", None, invalid_value) - assert "Expected a MultipartBody instance but got" in str(excinfo.value) - - def test_write_object_value_none_primary_with_none_additional_values_noop(): serialization_writer = MultipartSerializationWriter() serialization_writer.write_object_value("", None, None, None) assert len(serialization_writer.get_serialized_content()) == 0 - -def test_write_object_value_captures_boundary_after_callbacks( - mock_request_adapter, mock_serialization_writer_factory -): - from kiota_abstractions.multipart_body import MultipartBody - - mock_request_adapter.get_serialization_writer_factory = Mock( - return_value=mock_serialization_writer_factory - ) - - body1 = MultipartBody() - body1.boundary = "initial-boundary" - body1.request_adapter = mock_request_adapter - body1.add_or_replace_part("part1", "text/plain", "First part") - - body2 = MultipartBody() - body2.boundary = "body2-boundary" - body2.request_adapter = mock_request_adapter - body2.add_or_replace_part("part2", "text/plain", "Second part") - - def update_boundary_in_callback(body): - if body is body1: - body.boundary = "callback-updated-boundary" - - serialization_writer = MultipartSerializationWriter() - serialization_writer.on_before_object_serialization = update_boundary_in_callback - - serialization_writer.write_object_value("", body1, body2) - raw_content = serialization_writer.get_serialized_content() - content = raw_content.decode("utf-8") - - assert "--callback-updated-boundary" in content - assert "--callback-updated-boundary--" in content - assert "--initial-boundary" not in content