diff --git a/.chronus/changes/python-xml-test-cases-2026-3-19-22-47-48.md b/.chronus/changes/python-xml-test-cases-2026-3-19-22-47-48.md new file mode 100644 index 00000000000..3d855bfa355 --- /dev/null +++ b/.chronus/changes/python-xml-test-cases-2026-3-19-22-47-48.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "@typespec/http-client-python" +--- + +Add mock API test cases for XML scenarios introduced in https://github.com/microsoft/typespec/pull/10063, covering: renamed property, nested model, renamed nested model, wrapped primitive with custom item names, model array variants (wrapped/unwrapped/renamed), renamed attribute, namespace, and namespace-on-properties. Fix XML serialization/deserialization bugs: unwrapped model array element naming, namespace key mismatch between DPG models and the runtime template, default-namespace propagation semantics, and handling of Python-reserved namespace prefixes. 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 253b0c1c1d8..bb940454953 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 @@ -630,7 +630,11 @@ class Model(_MyMutableMapping): for rf in self._attr_to_rest_field.values(): prop_meta = getattr(rf, "_xml", {}) xml_name = prop_meta.get("name", rf._rest_name) - xml_ns = prop_meta.get("ns", model_meta.get("ns", None)) + xml_ns = _get_xml_ns(prop_meta) + # If the property has no namespace but the model uses a default namespace + # (declared without a prefix), child elements inherit that namespace. + if xml_ns is None and not model_meta.get("prefix"): + xml_ns = _get_xml_ns(model_meta) if xml_ns: xml_name = "{" + xml_ns + "}" + xml_name @@ -645,7 +649,9 @@ class Model(_MyMutableMapping): # unwrapped array could either use prop items meta/prop meta if prop_meta.get("itemsName"): xml_name = prop_meta.get("itemsName") - xml_ns = prop_meta.get("itemNs") + _items_ns = prop_meta.get("itemsNs") + if _items_ns is not None: + xml_ns = _items_ns if xml_ns: xml_name = "{" + xml_ns + "}" + xml_name items = args[0].findall(xml_name) # pyright: ignore @@ -761,7 +767,9 @@ class Model(_MyMutableMapping): model_meta = getattr(cls, "_xml", {}) prop_meta = getattr(discriminator, "_xml", {}) xml_name = prop_meta.get("name", discriminator._rest_name) - xml_ns = prop_meta.get("ns", model_meta.get("ns", None)) + xml_ns = _get_xml_ns(prop_meta) + if xml_ns is None and not model_meta.get("prefix"): + xml_ns = _get_xml_ns(model_meta) if xml_ns: xml_name = "{" + xml_ns + "}" + xml_name @@ -1243,6 +1251,17 @@ def serialize_xml(model: Model, exclude_readonly: bool = False) -> str: return ET.tostring(_get_element(model, exclude_readonly), encoding="unicode") # type: ignore +def _get_xml_ns(meta: dict[str, typing.Any]) -> typing.Optional[str]: + """Return the XML namespace from a metadata dict, checking both 'ns' (old-style) and 'namespace' (DPG) keys. + + Returns the value of 'ns' if present (even empty string); falls back to 'namespace' otherwise. + """ + ns = meta.get("ns") + if ns is None: + ns = meta.get("namespace") + return ns + + def _get_element( o: typing.Any, exclude_readonly: bool = False, @@ -1254,10 +1273,16 @@ def _get_element( # if prop is a model, then use the prop element directly, else generate a wrapper of model if wrapped_element is None: + # When serializing as an array item (parent_meta is set), check if the parent has an + # explicit itemsName. This ensures correct element names for unwrapped arrays (where + # the element tag is the property/items name, not the model type name). + _items_name = parent_meta.get("itemsName") if parent_meta is not None else None + element_name = _items_name if _items_name else (model_meta.get("name") or o.__class__.__name__) + _model_ns = _get_xml_ns(model_meta) wrapped_element = _create_xml_element( - model_meta.get("name", o.__class__.__name__), + element_name, model_meta.get("prefix"), - model_meta.get("ns"), + _model_ns, ) readonly_props = [] @@ -1279,7 +1304,9 @@ def _get_element( # additional properties will not have rest field, use the wire name as xml name prop_meta = {"name": k} - # if no ns for prop, use model's + # Propagate model namespace to properties only for old-style "ns"-keyed models. + # DPG-generated models use the "namespace" key and explicitly declare namespace on + # each property that needs it, so propagation is intentionally skipped for them. if prop_meta.get("ns") is None and model_meta.get("ns"): prop_meta["ns"] = model_meta.get("ns") prop_meta["prefix"] = model_meta.get("prefix") @@ -1292,9 +1319,17 @@ def _get_element( wrapped_element.text = _get_primitive_type_value(v) elif prop_meta.get("attribute", False): xml_name = prop_meta.get("name", k) - if prop_meta.get("ns"): - ET.register_namespace(prop_meta.get("prefix"), prop_meta.get("ns")) # pyright: ignore - xml_name = "{" + prop_meta.get("ns") + "}" + xml_name # pyright: ignore + _attr_ns = _get_xml_ns(prop_meta) + if _attr_ns: + _attr_prefix = prop_meta.get("prefix") + if _attr_prefix: + try: + ET.register_namespace(_attr_prefix, _attr_ns) # pyright: ignore + except ValueError: + # ElementTree reserves prefixes matching "ns\\d+" and will raise ValueError. + # In that case, skip explicit registration and rely on Clark-notation only. + pass + xml_name = "{" + _attr_ns + "}" + xml_name # pyright: ignore # attribute should be primitive type wrapped_element.set(xml_name, _get_primitive_type_value(v)) else: @@ -1305,6 +1340,7 @@ def _get_element( return [_get_element(x, exclude_readonly, parent_meta) for x in o] # type: ignore if isinstance(o, dict): result = [] + _dict_ns = _get_xml_ns(parent_meta) if parent_meta else None for k, v in o.items(): result.append( _get_wrapped_element( @@ -1312,7 +1348,7 @@ def _get_element( exclude_readonly, { "name": k, - "ns": parent_meta.get("ns") if parent_meta else None, + "ns": _dict_ns, "prefix": parent_meta.get("prefix") if parent_meta else None, }, ) @@ -1321,13 +1357,16 @@ def _get_element( # primitive case need to create element based on parent_meta if parent_meta: + _items_ns = parent_meta.get("itemsNs") + if _items_ns is None: + _items_ns = _get_xml_ns(parent_meta) return _get_wrapped_element( o, exclude_readonly, { "name": parent_meta.get("itemsName", parent_meta.get("name")), "prefix": parent_meta.get("itemsPrefix", parent_meta.get("prefix")), - "ns": parent_meta.get("itemsNs", parent_meta.get("ns")), + "ns": _items_ns, }, ) @@ -1339,8 +1378,9 @@ def _get_wrapped_element( exclude_readonly: bool, meta: typing.Optional[dict[str, typing.Any]], ) -> ET.Element: + _meta_ns = _get_xml_ns(meta) if meta else None wrapped_element = _create_xml_element( - meta.get("name") if meta else None, meta.get("prefix") if meta else None, meta.get("ns") if meta else None + meta.get("name") if meta else None, meta.get("prefix") if meta else None, _meta_ns ) if isinstance(v, (dict, list)): wrapped_element.extend(_get_element(v, exclude_readonly, meta)) @@ -1365,7 +1405,18 @@ def _create_xml_element( tag: typing.Any, prefix: typing.Optional[str] = None, ns: typing.Optional[str] = None ) -> ET.Element: if prefix and ns: - ET.register_namespace(prefix, ns) + try: + ET.register_namespace(prefix, ns) + except ValueError: + # Some prefixes (e.g. 'ns2') match Python's reserved 'ns\d+' pattern used for + # auto-generated prefixes, causing register_namespace to raise ValueError. + # Fall back to directly registering in the internal namespace map so the intended + # prefix is honoured when serialising to XML. + # Note: if '_namespace_map' is absent (e.g. alternative Python implementations), + # ElementTree will silently auto-generate a prefix like 'ns0', 'ns1', etc. + _ns_map = getattr(ET, "_namespace_map", None) + if _ns_map is not None: + _ns_map[ns] = prefix if ns: return ET.Element("{" + ns + "}" + tag) return ET.Element(tag) diff --git a/packages/http-client-python/generator/test/generic_mock_api_tests/asynctests/test_payload_xml_async.py b/packages/http-client-python/generator/test/generic_mock_api_tests/asynctests/test_payload_xml_async.py index 0cfccaee38a..6cf981a0e4f 100644 --- a/packages/http-client-python/generator/test/generic_mock_api_tests/asynctests/test_payload_xml_async.py +++ b/packages/http-client-python/generator/test/generic_mock_api_tests/asynctests/test_payload_xml_async.py @@ -7,12 +7,21 @@ import pytest from payload.xml.aio import XmlClient from payload.xml.models import ( + Author, + Book, SimpleModel, ModelWithSimpleArrays, ModelWithArrayOfModel, ModelWithAttributes, ModelWithUnwrappedArray, + ModelWithUnwrappedModelArray, ModelWithRenamedArrays, + ModelWithRenamedProperty, + ModelWithRenamedAttribute, + ModelWithRenamedNestedModel, + ModelWithRenamedWrappedModelArray, + ModelWithRenamedUnwrappedModelArray, + ModelWithRenamedWrappedAndItemModelArray, ModelWithOptionalField, ModelWithRenamedFields, ModelWithEmptyArray, @@ -21,6 +30,10 @@ ModelWithEncodedNames, ModelWithEnum, ModelWithDatetime, + ModelWithNamespace, + ModelWithNamespaceOnProperties, + ModelWithNestedModel, + ModelWithWrappedPrimitiveCustomItemNames, ) @@ -37,6 +50,13 @@ async def test_simple_model(client: XmlClient): await client.simple_model_value.put(model) +@pytest.mark.asyncio +async def test_model_with_renamed_property(client: XmlClient): + model = ModelWithRenamedProperty(title="foo", author="bar") + assert await client.model_with_renamed_property_value.get() == model + await client.model_with_renamed_property_value.put(model) + + @pytest.mark.asyncio async def test_model_with_simple_arrays(client: XmlClient): model = ModelWithSimpleArrays(colors=["red", "green", "blue"], counts=[1, 2]) @@ -44,6 +64,13 @@ async def test_model_with_simple_arrays(client: XmlClient): await client.model_with_simple_arrays_value.put(model) +@pytest.mark.asyncio +async def test_model_with_wrapped_primitive_custom_item_names(client: XmlClient): + model = ModelWithWrappedPrimitiveCustomItemNames(tags=["fiction", "classic"]) + assert await client.model_with_wrapped_primitive_custom_item_names_value.get() == model + await client.model_with_wrapped_primitive_custom_item_names_value.put(model) + + @pytest.mark.asyncio async def test_model_with_array_of_model(client: XmlClient): model = ModelWithArrayOfModel( @@ -56,6 +83,54 @@ async def test_model_with_array_of_model(client: XmlClient): await client.model_with_array_of_model_value.put(model) +@pytest.mark.asyncio +async def test_model_with_unwrapped_model_array(client: XmlClient): + model = ModelWithUnwrappedModelArray( + items_property=[ + SimpleModel(name="foo", age=123), + SimpleModel(name="bar", age=456), + ] + ) + assert await client.model_with_unwrapped_model_array_value.get() == model + await client.model_with_unwrapped_model_array_value.put(model) + + +@pytest.mark.asyncio +async def test_model_with_renamed_wrapped_model_array(client: XmlClient): + model = ModelWithRenamedWrappedModelArray( + items_property=[ + SimpleModel(name="foo", age=123), + SimpleModel(name="bar", age=456), + ] + ) + assert await client.model_with_renamed_wrapped_model_array_value.get() == model + await client.model_with_renamed_wrapped_model_array_value.put(model) + + +@pytest.mark.asyncio +async def test_model_with_renamed_unwrapped_model_array(client: XmlClient): + model = ModelWithRenamedUnwrappedModelArray( + items_property=[ + SimpleModel(name="foo", age=123), + SimpleModel(name="bar", age=456), + ] + ) + assert await client.model_with_renamed_unwrapped_model_array_value.get() == model + await client.model_with_renamed_unwrapped_model_array_value.put(model) + + +@pytest.mark.asyncio +async def test_model_with_renamed_wrapped_and_item_model_array(client: XmlClient): + model = ModelWithRenamedWrappedAndItemModelArray( + books=[ + Book(title="The Great Gatsby"), + Book(title="Les Miserables"), + ] + ) + assert await client.model_with_renamed_wrapped_and_item_model_array_value.get() == model + await client.model_with_renamed_wrapped_and_item_model_array_value.put(model) + + @pytest.mark.asyncio async def test_model_with_attributes(client: XmlClient): model = ModelWithAttributes(id1=123, id2="foo", enabled=True) @@ -63,6 +138,13 @@ async def test_model_with_attributes(client: XmlClient): await client.model_with_attributes_value.put(model) +@pytest.mark.asyncio +async def test_model_with_renamed_attribute(client: XmlClient): + model = ModelWithRenamedAttribute(id=123, title="The Great Gatsby", author="F. Scott Fitzgerald") + assert await client.model_with_renamed_attribute_value.get() == model + await client.model_with_renamed_attribute_value.put(model) + + @pytest.mark.asyncio async def test_model_with_unwrapped_array(client: XmlClient): model = ModelWithUnwrappedArray(colors=["red", "green", "blue"], counts=[1, 2]) @@ -84,6 +166,20 @@ async def test_model_with_optional_field(client: XmlClient): await client.model_with_optional_field_value.put(model) +@pytest.mark.asyncio +async def test_model_with_nested_model(client: XmlClient): + model = ModelWithNestedModel(nested=SimpleModel(name="foo", age=123)) + assert await client.model_with_nested_model_value.get() == model + await client.model_with_nested_model_value.put(model) + + +@pytest.mark.asyncio +async def test_model_with_renamed_nested_model(client: XmlClient): + model = ModelWithRenamedNestedModel(author=Author(name="foo")) + assert await client.model_with_renamed_nested_model_value.get() == model + await client.model_with_renamed_nested_model_value.put(model) + + @pytest.mark.asyncio async def test_model_with_renamed_fields(client: XmlClient): model = ModelWithRenamedFields( @@ -141,6 +237,20 @@ async def test_model_with_datetime(client: XmlClient): await client.model_with_datetime_value.put(model) +@pytest.mark.asyncio +async def test_model_with_namespace(client: XmlClient): + model = ModelWithNamespace(id=123, title="The Great Gatsby") + assert await client.model_with_namespace_value.get() == model + await client.model_with_namespace_value.put(model) + + +@pytest.mark.asyncio +async def test_model_with_namespace_on_properties(client: XmlClient): + model = ModelWithNamespaceOnProperties(id=123, title="The Great Gatsby", author="F. Scott Fitzgerald") + assert await client.model_with_namespace_on_properties_value.get() == model + await client.model_with_namespace_on_properties_value.put(model) + + @pytest.mark.asyncio async def test_xml_error_value(client: XmlClient, core_library): with pytest.raises(core_library.exceptions.HttpResponseError) as ex: diff --git a/packages/http-client-python/generator/test/generic_mock_api_tests/test_payload_xml.py b/packages/http-client-python/generator/test/generic_mock_api_tests/test_payload_xml.py index 22f1c6f7c79..e301ff0a1e5 100644 --- a/packages/http-client-python/generator/test/generic_mock_api_tests/test_payload_xml.py +++ b/packages/http-client-python/generator/test/generic_mock_api_tests/test_payload_xml.py @@ -7,12 +7,21 @@ import pytest from payload.xml import XmlClient from payload.xml.models import ( + Author, + Book, SimpleModel, ModelWithSimpleArrays, ModelWithArrayOfModel, ModelWithAttributes, ModelWithUnwrappedArray, + ModelWithUnwrappedModelArray, ModelWithRenamedArrays, + ModelWithRenamedProperty, + ModelWithRenamedAttribute, + ModelWithRenamedNestedModel, + ModelWithRenamedWrappedModelArray, + ModelWithRenamedUnwrappedModelArray, + ModelWithRenamedWrappedAndItemModelArray, ModelWithOptionalField, ModelWithRenamedFields, ModelWithEmptyArray, @@ -21,6 +30,10 @@ ModelWithEncodedNames, ModelWithEnum, ModelWithDatetime, + ModelWithNamespace, + ModelWithNamespaceOnProperties, + ModelWithNestedModel, + ModelWithWrappedPrimitiveCustomItemNames, ) @@ -36,12 +49,24 @@ def test_simple_model(client: XmlClient): client.simple_model_value.put(model) +def test_model_with_renamed_property(client: XmlClient): + model = ModelWithRenamedProperty(title="foo", author="bar") + assert client.model_with_renamed_property_value.get() == model + client.model_with_renamed_property_value.put(model) + + def test_model_with_simple_arrays(client: XmlClient): model = ModelWithSimpleArrays(colors=["red", "green", "blue"], counts=[1, 2]) assert client.model_with_simple_arrays_value.get() == model client.model_with_simple_arrays_value.put(model) +def test_model_with_wrapped_primitive_custom_item_names(client: XmlClient): + model = ModelWithWrappedPrimitiveCustomItemNames(tags=["fiction", "classic"]) + assert client.model_with_wrapped_primitive_custom_item_names_value.get() == model + client.model_with_wrapped_primitive_custom_item_names_value.put(model) + + def test_model_with_array_of_model(client: XmlClient): model = ModelWithArrayOfModel( items_property=[ @@ -53,12 +78,62 @@ def test_model_with_array_of_model(client: XmlClient): client.model_with_array_of_model_value.put(model) +def test_model_with_unwrapped_model_array(client: XmlClient): + model = ModelWithUnwrappedModelArray( + items_property=[ + SimpleModel(name="foo", age=123), + SimpleModel(name="bar", age=456), + ] + ) + assert client.model_with_unwrapped_model_array_value.get() == model + client.model_with_unwrapped_model_array_value.put(model) + + +def test_model_with_renamed_wrapped_model_array(client: XmlClient): + model = ModelWithRenamedWrappedModelArray( + items_property=[ + SimpleModel(name="foo", age=123), + SimpleModel(name="bar", age=456), + ] + ) + assert client.model_with_renamed_wrapped_model_array_value.get() == model + client.model_with_renamed_wrapped_model_array_value.put(model) + + +def test_model_with_renamed_unwrapped_model_array(client: XmlClient): + model = ModelWithRenamedUnwrappedModelArray( + items_property=[ + SimpleModel(name="foo", age=123), + SimpleModel(name="bar", age=456), + ] + ) + assert client.model_with_renamed_unwrapped_model_array_value.get() == model + client.model_with_renamed_unwrapped_model_array_value.put(model) + + +def test_model_with_renamed_wrapped_and_item_model_array(client: XmlClient): + model = ModelWithRenamedWrappedAndItemModelArray( + books=[ + Book(title="The Great Gatsby"), + Book(title="Les Miserables"), + ] + ) + assert client.model_with_renamed_wrapped_and_item_model_array_value.get() == model + client.model_with_renamed_wrapped_and_item_model_array_value.put(model) + + def test_model_with_attributes(client: XmlClient): model = ModelWithAttributes(id1=123, id2="foo", enabled=True) assert client.model_with_attributes_value.get() == model client.model_with_attributes_value.put(model) +def test_model_with_renamed_attribute(client: XmlClient): + model = ModelWithRenamedAttribute(id=123, title="The Great Gatsby", author="F. Scott Fitzgerald") + assert client.model_with_renamed_attribute_value.get() == model + client.model_with_renamed_attribute_value.put(model) + + def test_model_with_unwrapped_array(client: XmlClient): model = ModelWithUnwrappedArray(colors=["red", "green", "blue"], counts=[1, 2]) assert client.model_with_unwrapped_array_value.get() == model @@ -77,6 +152,18 @@ def test_model_with_optional_field(client: XmlClient): client.model_with_optional_field_value.put(model) +def test_model_with_nested_model(client: XmlClient): + model = ModelWithNestedModel(nested=SimpleModel(name="foo", age=123)) + assert client.model_with_nested_model_value.get() == model + client.model_with_nested_model_value.put(model) + + +def test_model_with_renamed_nested_model(client: XmlClient): + model = ModelWithRenamedNestedModel(author=Author(name="foo")) + assert client.model_with_renamed_nested_model_value.get() == model + client.model_with_renamed_nested_model_value.put(model) + + def test_model_with_renamed_fields(client: XmlClient): model = ModelWithRenamedFields( input_data=SimpleModel(name="foo", age=123), @@ -127,6 +214,18 @@ def test_model_with_datetime(client: XmlClient): client.model_with_datetime_value.put(model) +def test_model_with_namespace(client: XmlClient): + model = ModelWithNamespace(id=123, title="The Great Gatsby") + assert client.model_with_namespace_value.get() == model + client.model_with_namespace_value.put(model) + + +def test_model_with_namespace_on_properties(client: XmlClient): + model = ModelWithNamespaceOnProperties(id=123, title="The Great Gatsby", author="F. Scott Fitzgerald") + assert client.model_with_namespace_on_properties_value.get() == model + client.model_with_namespace_on_properties_value.put(model) + + def test_xml_error_value(client: XmlClient, core_library): with pytest.raises(core_library.exceptions.HttpResponseError) as ex: client.xml_error_value.get() diff --git a/packages/http-client-python/package-lock.json b/packages/http-client-python/package-lock.json index b73bda80a11..1bebeb8549b 100644 --- a/packages/http-client-python/package-lock.json +++ b/packages/http-client-python/package-lock.json @@ -29,7 +29,7 @@ "@typespec/compiler": "^1.10.0", "@typespec/events": "~0.80.0", "@typespec/http": "^1.10.0", - "@typespec/http-specs": "0.1.0-alpha.35-dev.4", + "@typespec/http-specs": "0.1.0-alpha.35-dev.5", "@typespec/openapi": "^1.10.0", "@typespec/rest": "~0.80.0", "@typespec/spec-api": "0.1.0-alpha.14-dev.1", @@ -2492,9 +2492,9 @@ } }, "node_modules/@typespec/http-specs": { - "version": "0.1.0-alpha.35-dev.4", - "resolved": "https://registry.npmjs.org/@typespec/http-specs/-/http-specs-0.1.0-alpha.35-dev.4.tgz", - "integrity": "sha512-KI8b/wJDdWhNM8ypJEeOgl0Fj9xTxKqSQfmOUqgcQYqlaNeU+jpvqS/xD3wEOguh6YMrCUD9FG9h6mgp8409KA==", + "version": "0.1.0-alpha.35-dev.5", + "resolved": "https://registry.npmjs.org/@typespec/http-specs/-/http-specs-0.1.0-alpha.35-dev.5.tgz", + "integrity": "sha512-RYp2KkmlmLZYlTtLfSNXw9P4YySpWBDmbCd1j4RCNbIN3Ww7aSpsZSFgcv7br+6jW8n7h0IJhWnh6vWaPuPrvw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/packages/http-client-python/package.json b/packages/http-client-python/package.json index e76f85c0047..742d210db13 100644 --- a/packages/http-client-python/package.json +++ b/packages/http-client-python/package.json @@ -94,7 +94,7 @@ "@typespec/sse": "~0.80.0", "@typespec/streams": "~0.80.0", "@typespec/xml": "~0.80.0", - "@typespec/http-specs": "0.1.0-alpha.35-dev.4", + "@typespec/http-specs": "0.1.0-alpha.35-dev.5", "@types/js-yaml": "~4.0.5", "@types/node": "~25.0.2", "@types/semver": "7.5.8",