From a738612bc147dfde1a43d88b80a098d6b687a370 Mon Sep 17 00:00:00 2001 From: Shubham Padkonde Date: Mon, 21 Sep 2026 11:48:34 +0530 Subject: [PATCH 1/3] fix(json): preserve falsy root values in serialized content --- .../json_serialization_writer.py | 4 +- .../json/tests/unit/test_root_values.py | 41 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 packages/serialization/json/tests/unit/test_root_values.py diff --git a/packages/serialization/json/kiota_serialization_json/json_serialization_writer.py b/packages/serialization/json/kiota_serialization_json/json_serialization_writer.py index f896777c..4dc0e2cb 100644 --- a/packages/serialization/json/kiota_serialization_json/json_serialization_writer.py +++ b/packages/serialization/json/kiota_serialization_json/json_serialization_writer.py @@ -379,12 +379,12 @@ def get_serialized_content(self) -> bytes: Returns: bytes: The value of the serialized content. """ - if self.writer and self.value: + if self.writer and self.value is not None: # Json output is invalid if it has a mix of values # and key-value pairs. raise ValueError("Invalid Json output") - if self.value: + if self.value is not None: json_string = json.dumps(self.value) self.value = None else: diff --git a/packages/serialization/json/tests/unit/test_root_values.py b/packages/serialization/json/tests/unit/test_root_values.py new file mode 100644 index 00000000..6859b16d --- /dev/null +++ b/packages/serialization/json/tests/unit/test_root_values.py @@ -0,0 +1,41 @@ +import json +from unittest.mock import Mock + +import pytest +from kiota_abstractions.request_information import RequestInformation + +from kiota_serialization_json.json_serialization_writer import JsonSerializationWriter +from kiota_serialization_json.json_serialization_writer_factory import ( + JsonSerializationWriterFactory, +) + + +@pytest.mark.parametrize("value", [False, 0, 0.0, "", [], {}, True, 1, "text", [1]]) +def test_root_value_round_trip_and_reset(value): + writer = JsonSerializationWriter() + writer.write_any_value(None, value) + result = json.loads(writer.get_serialized_content()) + assert type(result) is type(value) + assert result == value + writer.write_str_value("name", "next") + assert json.loads(writer.get_serialized_content()) == {"name": "next"} + + +@pytest.mark.parametrize("value", [False, 0, 0.0, "", [], {}, True, 1, "text", [1]]) +def test_rejects_mixed_root_and_property_values(value): + writer = JsonSerializationWriter() + writer.write_any_value(None, value) + writer.write_str_value("name", "property") + with pytest.raises(ValueError, match="Invalid Json output"): + writer.get_serialized_content() + + +@pytest.mark.parametrize("value", [False, 0, 0.0, "", []]) +def test_request_content_preserves_falsy_scalar_values(value): + adapter = Mock() + adapter.get_serialization_writer_factory.return_value = JsonSerializationWriterFactory() + request = RequestInformation() + request.set_content_from_scalar(adapter, "application/json", value) + result = json.loads(request.content) + assert type(result) is type(value) + assert result == value From 92bd907b585a675ac8042f01a5935558b0416a7c Mon Sep 17 00:00:00 2001 From: Shubham Padkonde Date: Mon, 21 Sep 2026 19:09:15 +0530 Subject: [PATCH 2/3] Distinguish explicit root null from an unset JSON writer --- .../json_serialization_writer.py | 22 ++++++++++++++----- .../json/tests/unit/test_root_values.py | 13 +++++++++-- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/serialization/json/kiota_serialization_json/json_serialization_writer.py b/packages/serialization/json/kiota_serialization_json/json_serialization_writer.py index 4dc0e2cb..752bd17e 100644 --- a/packages/serialization/json/kiota_serialization_json/json_serialization_writer.py +++ b/packages/serialization/json/kiota_serialization_json/json_serialization_writer.py @@ -24,13 +24,24 @@ class JsonSerializationWriter(SerializationWriter): def __init__(self) -> None: self.writer: dict = {} - self.value: Any = None + self._value: Any = None + self._has_root_value = False self._on_start_object_serialization: Optional[Callable[[Parsable, SerializationWriter], None]] = None self._on_before_object_serialization: Optional[Callable[[Parsable], None]] = None self._on_after_object_serialization: Optional[Callable[[Parsable], None]] = None + @property + def value(self) -> Any: + """The root payload, which may itself be JSON null.""" + return self._value + + @value.setter + def value(self, value: Any) -> None: + self._value = value + self._has_root_value = True + 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. Args: @@ -321,7 +332,7 @@ def write_object_value( # Use temp_writer.value if available (for composed types like oneOf wrappers), # otherwise fall back to temp_writer.writer (for regular objects with properties) serialized_value = ( - temp_writer.value if temp_writer.value is not None else temp_writer.writer + temp_writer.value if temp_writer._has_root_value else temp_writer.writer ) if key: self.writer[key] = serialized_value @@ -379,14 +390,15 @@ def get_serialized_content(self) -> bytes: Returns: bytes: The value of the serialized content. """ - if self.writer and self.value is not None: + if self.writer and self._has_root_value: # Json output is invalid if it has a mix of values # and key-value pairs. raise ValueError("Invalid Json output") - if self.value is not None: + if self._has_root_value: json_string = json.dumps(self.value) - self.value = None + self._value = None + self._has_root_value = False else: json_string = json.dumps(self.writer) self.writer.clear() diff --git a/packages/serialization/json/tests/unit/test_root_values.py b/packages/serialization/json/tests/unit/test_root_values.py index 6859b16d..67edc8ce 100644 --- a/packages/serialization/json/tests/unit/test_root_values.py +++ b/packages/serialization/json/tests/unit/test_root_values.py @@ -10,18 +10,19 @@ ) -@pytest.mark.parametrize("value", [False, 0, 0.0, "", [], {}, True, 1, "text", [1]]) +@pytest.mark.parametrize("value", [None, False, 0, 0.0, "", [], {}, True, 1, "text", [1]]) def test_root_value_round_trip_and_reset(value): writer = JsonSerializationWriter() writer.write_any_value(None, value) result = json.loads(writer.get_serialized_content()) assert type(result) is type(value) assert result == value + # A named property after serialization verifies that root state was reset. writer.write_str_value("name", "next") assert json.loads(writer.get_serialized_content()) == {"name": "next"} -@pytest.mark.parametrize("value", [False, 0, 0.0, "", [], {}, True, 1, "text", [1]]) +@pytest.mark.parametrize("value", [None, False, 0, 0.0, "", [], {}, True, 1, "text", [1]]) def test_rejects_mixed_root_and_property_values(value): writer = JsonSerializationWriter() writer.write_any_value(None, value) @@ -39,3 +40,11 @@ def test_request_content_preserves_falsy_scalar_values(value): result = json.loads(request.content) assert type(result) is type(value) assert result == value + + +def test_composed_object_can_serialize_root_null(): + model = Mock() + model.serialize.side_effect = lambda output: output.write_null_value(None) + writer = JsonSerializationWriter() + writer.write_object_value("wrapped", model) + assert json.loads(writer.get_serialized_content()) == {"wrapped": None} From eaca9faf4221cb8bd6a1c6d4d7038f5e5e876b3a Mon Sep 17 00:00:00 2001 From: Shubham Padkonde Date: Mon, 21 Sep 2026 19:22:47 +0530 Subject: [PATCH 3/3] Keep root-value tracking internal to the JSON writer --- .../json_serialization_writer.py | 62 +++++++++---------- 1 file changed, 28 insertions(+), 34 deletions(-) diff --git a/packages/serialization/json/kiota_serialization_json/json_serialization_writer.py b/packages/serialization/json/kiota_serialization_json/json_serialization_writer.py index 752bd17e..bed4b4f7 100644 --- a/packages/serialization/json/kiota_serialization_json/json_serialization_writer.py +++ b/packages/serialization/json/kiota_serialization_json/json_serialization_writer.py @@ -24,7 +24,7 @@ class JsonSerializationWriter(SerializationWriter): def __init__(self) -> None: self.writer: dict = {} - self._value: Any = None + self.value: Any = None self._has_root_value = False self._on_start_object_serialization: Optional[Callable[[Parsable, SerializationWriter], @@ -32,14 +32,8 @@ def __init__(self) -> None: self._on_before_object_serialization: Optional[Callable[[Parsable], None]] = None self._on_after_object_serialization: Optional[Callable[[Parsable], None]] = None - @property - def value(self) -> Any: - """The root payload, which may itself be JSON null.""" - return self._value - - @value.setter - def value(self, value: Any) -> None: - self._value = value + def _write_root_value(self, value: Any) -> None: + self.value = value self._has_root_value = True def write_str_value(self, key: Optional[str], value: Optional[str]) -> None: @@ -52,7 +46,7 @@ def write_str_value(self, key: Optional[str], value: Optional[str]) -> None: if key: self.writer[key] = value else: - self.value = value + self._write_root_value(value) def write_bool_value(self, key: Optional[str], value: Optional[bool]) -> None: """Writes the specified boolean value to the stream with an optional given key. @@ -64,7 +58,7 @@ def write_bool_value(self, key: Optional[str], value: Optional[bool]) -> None: if key: self.writer[key] = value else: - self.value = value + self._write_root_value(value) def write_int_value(self, key: Optional[str], value: Optional[int]) -> None: """Writes the specified integer value to the stream with an optional given key. @@ -76,7 +70,7 @@ def write_int_value(self, key: Optional[str], value: Optional[int]) -> None: if key: self.writer[key] = value else: - self.value = value + self._write_root_value(value) def write_float_value(self, key: Optional[str], value: Optional[float]) -> None: """Writes the specified float value to the stream with an optional given key. @@ -88,7 +82,7 @@ def write_float_value(self, key: Optional[str], value: Optional[float]) -> None: if key: self.writer[key] = float(value) else: - self.value = float(value) + self._write_root_value(float(value)) def write_uuid_value(self, key: Optional[str], value: Optional[UUID]) -> None: """Writes the specified uuid value to the stream with an optional given key. @@ -100,14 +94,14 @@ def write_uuid_value(self, key: Optional[str], value: Optional[UUID]) -> None: if key: self.writer[key] = str(value) else: - self.value = str(value) + self._write_root_value(str(value)) elif isinstance(value, str): try: UUID(value) if key: self.writer[key] = value else: - self.value = value + self._write_root_value(value) except ValueError: if key: raise ValueError(f"Invalid UUID string value found for property {key}") @@ -123,14 +117,14 @@ def write_datetime_value(self, key: Optional[str], value: Optional[datetime]) -> if key: self.writer[key] = value.isoformat() else: - self.value = value.isoformat() + self._write_root_value(value.isoformat()) elif isinstance(value, str): try: datetime.fromisoformat(value) if key: self.writer[key] = value else: - self.value = value + self._write_root_value(value) except ValueError: if key: raise ValueError(f"Invalid datetime string value found for property {key}") @@ -146,14 +140,14 @@ def write_timedelta_value(self, key: Optional[str], value: Optional[timedelta]) if key: self.writer[key] = str(value) else: - self.value = str(value) + self._write_root_value(str(value)) elif isinstance(value, str): try: parse_timedelta_string(value) if key: self.writer[key] = value else: - self.value = value + self._write_root_value(value) except ValueError: if key: raise ValueError(f"Invalid timedelta string value found for property {key}") @@ -169,14 +163,14 @@ def write_date_value(self, key: Optional[str], value: Optional[date]) -> None: if key: self.writer[key] = str(value) else: - self.value = str(value) + self._write_root_value(str(value)) elif isinstance(value, str): try: date.fromisoformat(value) if key: self.writer[key] = value else: - self.value = value + self._write_root_value(value) except ValueError: if key: raise ValueError(f"Invalid date string value found for property {key}") @@ -192,14 +186,14 @@ def write_time_value(self, key: Optional[str], value: Optional[time]) -> None: if key: self.writer[key] = str(value) else: - self.value = str(value) + self._write_root_value(str(value)) elif isinstance(value, str): try: time.fromisoformat(value) if key: self.writer[key] = value else: - self.value = value + self._write_root_value(value) except ValueError: if key: raise ValueError(f"Invalid time string value found for property {key}") @@ -224,7 +218,7 @@ def write_collection_of_primitive_values( if key: self.writer[key] = result else: - self.value = result + self._write_root_value(result) def write_collection_of_object_values( self, key: Optional[str], values: Optional[list[U]] @@ -245,7 +239,7 @@ def write_collection_of_object_values( if key: self.writer[key] = obj_list else: - self.value = obj_list + self._write_root_value(obj_list) def write_collection_of_enum_values( self, key: Optional[str], values: Optional[list[K]] @@ -265,7 +259,7 @@ def write_collection_of_enum_values( if key: self.writer[key] = result else: - self.value = result + self._write_root_value(result) def __write_collection_of_dict_values( self, key: Optional[str], values: Optional[list[dict[str, Any]]] @@ -287,7 +281,7 @@ def __write_collection_of_dict_values( if key: self.writer[key] = result else: - self.value = result + self._write_root_value(result) def write_bytes_value(self, key: Optional[str], value: Optional[bytes]) -> None: """Writes the specified byte array as a base64 string to the stream with an optional @@ -302,7 +296,7 @@ def write_bytes_value(self, key: Optional[str], value: Optional[bytes]) -> None: if key: self.writer[key] = base64_string else: - self.value = base64_string + self._write_root_value(base64_string) def write_object_value( self, key: Optional[str], value: Optional[U], *additional_values_to_merge: Optional[U] @@ -337,7 +331,7 @@ def write_object_value( if key: self.writer[key] = serialized_value else: - self.value = serialized_value + self._write_root_value(serialized_value) def write_enum_value(self, key: Optional[str], value: Optional[K]) -> None: """Writes the specified enum value to the stream with an optional given key. @@ -349,7 +343,7 @@ def write_enum_value(self, key: Optional[str], value: Optional[K]) -> None: if key: self.writer[key] = value.value else: - self.value = value.value + self._write_root_value(value.value) def write_null_value(self, key: Optional[str]) -> None: """Writes a null value for the specified key. @@ -359,7 +353,7 @@ def write_null_value(self, key: Optional[str]) -> None: if key: self.writer[key] = None else: - self.value = None + self._write_root_value(None) def __write_dict_value(self, key: Optional[str], value: dict[str, Any]) -> None: """Writes the specified dictionary value to the stream with an optional given key. @@ -374,7 +368,7 @@ def __write_dict_value(self, key: Optional[str], value: dict[str, Any]) -> None: if key: self.writer[key] = temp_writer.writer else: - self.value = temp_writer.writer + self._write_root_value(temp_writer.writer) def write_additional_data_value(self, value: dict[str, Any]) -> None: """Writes the specified additional data to the stream. @@ -397,7 +391,7 @@ def get_serialized_content(self) -> bytes: if self._has_root_value: json_string = json.dumps(self.value) - self._value = None + self.value = None self._has_root_value = False else: json_string = json.dumps(self.writer) @@ -474,7 +468,7 @@ def write_non_parsable_object_value(self, key: Optional[str], value: T) -> None: if key: self.writer[key] = value.__dict__ else: - self.value = value.__dict__ + self._write_root_value(value.__dict__) def write_any_value(self, key: Optional[str], value: Any) -> Any: """Writes the specified value to the stream with an optional given key.