diff --git a/.chronus/changes/copilot-add-xml-deserialization-test-2026-03-04-05-46-13.md b/.chronus/changes/copilot-add-xml-deserialization-test-2026-03-04-05-46-13.md new file mode 100644 index 00000000000..e006e732936 --- /dev/null +++ b/.chronus/changes/copilot-add-xml-deserialization-test-2026-03-04-05-46-13.md @@ -0,0 +1,6 @@ +--- +changeKind: fix +packages: + - "@typespec/http-client-python" +--- +Return empty list instead of None for non-optional unwrapped XML list fields during deserialization diff --git a/.chronus/changes/http-client-python-xml-enumeration-results-test-2026-2-23-22-33-2.md b/.chronus/changes/http-client-python-xml-enumeration-results-test-2026-2-23-22-33-2.md new file mode 100644 index 00000000000..c4a7ac65651 --- /dev/null +++ b/.chronus/changes/http-client-python-xml-enumeration-results-test-2026-2-23-22-33-2.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "@typespec/http-client-python" +--- + +Add unit test for deserializing Azure Blob Storage EnumerationResults XML payload with attributes, empty list element, and empty string element. diff --git a/packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2 index c4c58fe89e5..253b0c1c1d8 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2 @@ -652,6 +652,9 @@ class Model(_MyMutableMapping): if len(items) > 0: existed_attr_keys.append(xml_name) dict_to_pass[rf._rest_name] = _deserialize(rf._type, items) + elif not rf._is_optional: + existed_attr_keys.append(xml_name) + dict_to_pass[rf._rest_name] = [] continue # text element is primitive type @@ -930,6 +933,8 @@ def _get_deserialize_callable_from_annotation( # pylint: disable=too-many-retur # is it optional? try: if any(a is _NONE_TYPE for a in annotation.__args__): # pyright: ignore + if rf: + rf._is_optional = True if len(annotation.__args__) <= 2: # pyright: ignore if_obj_deserializer = _get_deserialize_callable_from_annotation( next(a for a in annotation.__args__ if a is not _NONE_TYPE), module, rf # pyright: ignore @@ -1090,9 +1095,7 @@ def _failsafe_deserialize_xml( return None -{% if code_model.has_padded_model_property %} # pylint: disable=too-many-instance-attributes -{% endif %} class _RestField: def __init__( self, @@ -1115,6 +1118,7 @@ class _RestField: self._is_discriminator = is_discriminator self._visibility = visibility self._is_model = False + self._is_optional = False self._default = default self._format = format self._is_multipart_file_input = is_multipart_file_input diff --git a/packages/http-client-python/generator/test/unittests/test_model_base_xml_serialization.py b/packages/http-client-python/generator/test/unittests/test_model_base_xml_serialization.py index 27529cc5263..f026fa6afd5 100644 --- a/packages/http-client-python/generator/test/unittests/test_model_base_xml_serialization.py +++ b/packages/http-client-python/generator/test/unittests/test_model_base_xml_serialization.py @@ -4,7 +4,7 @@ # ------------------------------------ import xml.etree.ElementTree as ET -from typing import Literal +from typing import Literal, Optional from specialwords._utils.model_base import ( _get_element, @@ -165,7 +165,7 @@ def __init__(self, *args, **kwargs): _xml = {"name": "Data"} result = _deserialize_xml(XmlModel, basic_xml) - assert result.age is None + assert result.age == [] def test_list_wrapped_items_name_basic_types(self): """Test XML list and wrap, items is basic type and there is itemsName.""" @@ -515,6 +515,248 @@ def __init__(self, *args, **kwargs): assert isinstance(result.filter, CorrelationFilter) assert result.filter.correlation_id == 12 + def test_enumeration_results(self): + """Test deserializing an Azure Blob Storage EnumerationResults XML payload.""" + xml_payload = '/' + + class EnumerationResults(Model): + service_endpoint: str = rest_field( + name="ServiceEndpoint", xml={"name": "ServiceEndpoint", "attribute": True} + ) + container_name: str = rest_field(name="ContainerName", xml={"name": "ContainerName", "attribute": True}) + delimiter: str = rest_field(name="Delimiter", xml={"name": "Delimiter"}) + blobs: list[str] = rest_field(name="Blobs", xml={"name": "Blobs"}) + next_marker: str = rest_field(name="NextMarker", xml={"name": "NextMarker"}) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + _xml = {"name": "EnumerationResults"} + + result = _deserialize_xml(EnumerationResults, xml_payload) + + assert result.service_endpoint == "https://service.blob.core.windows.net/" + assert result.container_name == "my-container-108f32e8" + assert result.delimiter == "/" + assert result.blobs == [] + assert result.next_marker == "" + + def test_enumeration_results_nested_empty_list(self): + """Test deserializing XML where a container element holds a nested empty list (e.g. Blobs/BlobPrefixes).""" + xml_payload = '/' + + class BlobPrefix(Model): + name: str = rest_field(name="Name", xml={"name": "Name"}) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + _xml = {"name": "BlobPrefix"} + + class BlobsSegment(Model): + blob_prefixes: list[BlobPrefix] = rest_field( + name="BlobPrefixes", xml={"name": "BlobPrefixes", "itemsName": "BlobPrefix"} + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + _xml = {"name": "Blobs"} + + class EnumerationResults(Model): + service_endpoint: str = rest_field( + name="ServiceEndpoint", xml={"name": "ServiceEndpoint", "attribute": True} + ) + container_name: str = rest_field(name="ContainerName", xml={"name": "ContainerName", "attribute": True}) + delimiter: str = rest_field(name="Delimiter", xml={"name": "Delimiter"}) + blobs: BlobsSegment = rest_field(name="Blobs", xml={"name": "Blobs"}) + next_marker: str = rest_field(name="NextMarker", xml={"name": "NextMarker"}) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + _xml = {"name": "EnumerationResults"} + + result = _deserialize_xml(EnumerationResults, xml_payload) + + assert result.service_endpoint == "https://service.blob.core.windows.net/" + assert result.container_name == "my-container" + assert result.delimiter == "/" + assert result.blobs.blob_prefixes == [] + assert result.next_marker == "" + + def test_enumeration_results_azure_sdk_pattern(self): + """Test the real Azure SDK model pattern where BlobsSegment has two unwrapped list fields.""" + # Both blob_prefixes and blob_items are unwrapped lists (items appear directly in ). + # With , no matching children are found so both are None. + xml_payload = '/' + + class BlobPrefix(Model): + name: str = rest_field(name="Name", xml={"name": "Name"}) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + _xml = {"name": "BlobPrefix"} + + class BlobItem(Model): + name: str = rest_field(name="Name", xml={"name": "Name"}) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + _xml = {"name": "Blob"} + + class BlobsSegment(Model): + blob_prefixes: list[BlobPrefix] = rest_field( + name="blob_prefixes", xml={"name": "BlobPrefix", "unwrapped": True} + ) + blob_items: list[BlobItem] = rest_field(name="blob_items", xml={"name": "Blob", "unwrapped": True}) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + _xml = {"name": "Blobs"} + + class EnumerationResults(Model): + service_endpoint: str = rest_field( + name="ServiceEndpoint", xml={"name": "ServiceEndpoint", "attribute": True} + ) + container_name: str = rest_field(name="ContainerName", xml={"name": "ContainerName", "attribute": True}) + delimiter: str = rest_field(name="Delimiter", xml={"name": "Delimiter"}) + blobs: BlobsSegment = rest_field(name="Blobs", xml={"name": "Blobs"}) + next_marker: str = rest_field(name="NextMarker", xml={"name": "NextMarker"}) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + _xml = {"name": "EnumerationResults"} + + result = _deserialize_xml(EnumerationResults, xml_payload) + + assert result.service_endpoint == "https://service.blob.core.windows.net/" + assert result.container_name == "my-container" + assert result.delimiter == "/" + assert isinstance(result.blobs, BlobsSegment) + # With , no or children exist → unwrapped non-optional lists default to [] + assert result.blobs.blob_prefixes == [] + assert result.blobs.blob_items == [] + assert result.next_marker == "" + + def test_enumeration_results_azure_sdk_pattern_optional(self): + """Test the Azure SDK pattern where unwrapped list fields are Optional[list[X]]. + + When the type is Optional[list[X]], empty unwrapped lists should stay None + (the element is absent, and None is a valid value for the optional type). + """ + xml_payload = '/' + + class BlobPrefix(Model): + name: str = rest_field(name="Name", xml={"name": "Name"}) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + _xml = {"name": "BlobPrefix"} + + class BlobItem(Model): + name: str = rest_field(name="Name", xml={"name": "Name"}) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + _xml = {"name": "Blob"} + + class BlobsSegment(Model): + blob_prefixes: Optional[list[BlobPrefix]] = rest_field( + name="blob_prefixes", xml={"name": "BlobPrefix", "unwrapped": True} + ) + blob_items: Optional[list[BlobItem]] = rest_field( + name="blob_items", xml={"name": "Blob", "unwrapped": True} + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + _xml = {"name": "Blobs"} + + class EnumerationResults(Model): + service_endpoint: str = rest_field( + name="ServiceEndpoint", xml={"name": "ServiceEndpoint", "attribute": True} + ) + container_name: str = rest_field(name="ContainerName", xml={"name": "ContainerName", "attribute": True}) + delimiter: str = rest_field(name="Delimiter", xml={"name": "Delimiter"}) + blobs: BlobsSegment = rest_field(name="Blobs", xml={"name": "Blobs"}) + next_marker: str = rest_field(name="NextMarker", xml={"name": "NextMarker"}) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + _xml = {"name": "EnumerationResults"} + + result = _deserialize_xml(EnumerationResults, xml_payload) + + assert result.service_endpoint == "https://service.blob.core.windows.net/" + assert result.container_name == "my-container" + assert result.delimiter == "/" + assert isinstance(result.blobs, BlobsSegment) + # With , no or children exist → Optional lists stay None + assert result.blobs.blob_prefixes is None + assert result.blobs.blob_items is None + assert result.next_marker == "" + + def test_enumeration_results_blobs_unwrapped(self): + """Test what happens when the blobs field itself is declared with unwrapped=True.""" + # When a non-list model field uses unwrapped=True, the matching XML elements are collected + # as a list and stored as-is (the field receives a list of ET.Element objects). + xml_payload = '/' + + class BlobPrefix(Model): + name: str = rest_field(name="Name", xml={"name": "Name"}) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + _xml = {"name": "BlobPrefix"} + + class BlobsSegment(Model): + blob_prefixes: list[BlobPrefix] = rest_field( + name="BlobPrefixes", xml={"name": "BlobPrefixes", "itemsName": "BlobPrefix"} + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + _xml = {"name": "Blobs"} + + class EnumerationResults(Model): + service_endpoint: str = rest_field( + name="ServiceEndpoint", xml={"name": "ServiceEndpoint", "attribute": True} + ) + container_name: str = rest_field(name="ContainerName", xml={"name": "ContainerName", "attribute": True}) + delimiter: str = rest_field(name="Delimiter", xml={"name": "Delimiter"}) + # unwrapped=True on a model-typed field: the deserialization collects matching XML + # elements as a list (rather than deserializing them into the model). + blobs: BlobsSegment = rest_field(name="Blobs", xml={"name": "Blobs", "unwrapped": True}) + next_marker: str = rest_field(name="NextMarker", xml={"name": "NextMarker"}) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + _xml = {"name": "EnumerationResults"} + + result = _deserialize_xml(EnumerationResults, xml_payload) + + assert result.service_endpoint == "https://service.blob.core.windows.net/" + assert result.container_name == "my-container" + assert result.delimiter == "/" + # unwrapped=True on a model field collects matching elements; is found so it + # returns a list containing the raw ET.Element instead of a deserialized BlobsSegment. + assert isinstance(result.blobs, list) + assert len(result.blobs) == 1 + assert isinstance(result.blobs[0], ET.Element) + assert result.next_marker == "" + class TestXmlSerialization: def test_basic(self):