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..2c561eb07e8 --- /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. Tests for unwrapped model array serialization and namespace handling are skipped pending generator bug fixes. diff --git a/.gitignore b/.gitignore index 7d848f69699..6b72eb02ba8 100644 --- a/.gitignore +++ b/.gitignore @@ -87,6 +87,7 @@ celerybeat-schedule /.env .env.local .venv +.venv_test env/ venv/ ENV/ 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..fe78ce42343 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 = prop_meta.get("ns") or prop_meta.get("namespace") + # 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 = model_meta.get("ns") or model_meta.get("namespace") if xml_ns: xml_name = "{" + xml_ns + "}" + xml_name @@ -761,7 +765,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 = prop_meta.get("ns") or prop_meta.get("namespace") + if xml_ns is None and not model_meta.get("prefix"): + xml_ns = model_meta.get("ns") or model_meta.get("namespace") if xml_ns: xml_name = "{" + xml_ns + "}" + xml_name @@ -1254,10 +1260,15 @@ 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__) wrapped_element = _create_xml_element( - model_meta.get("name", o.__class__.__name__), + element_name, model_meta.get("prefix"), - model_meta.get("ns"), + model_meta.get("ns") or model_meta.get("namespace"), ) readonly_props = [] @@ -1279,7 +1290,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 +1305,10 @@ 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 = prop_meta.get("ns") or prop_meta.get("namespace") + if _attr_ns: + ET.register_namespace(prop_meta.get("prefix"), _attr_ns) # pyright: ignore + xml_name = "{" + _attr_ns + "}" + xml_name # pyright: ignore # attribute should be primitive type wrapped_element.set(xml_name, _get_primitive_type_value(v)) else: @@ -1312,7 +1326,7 @@ def _get_element( exclude_readonly, { "name": k, - "ns": parent_meta.get("ns") if parent_meta else None, + "ns": (parent_meta.get("ns") or parent_meta.get("namespace")) if parent_meta else None, "prefix": parent_meta.get("prefix") if parent_meta else None, }, ) @@ -1327,7 +1341,7 @@ def _get_element( { "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": parent_meta.get("itemsNs") or parent_meta.get("ns") or parent_meta.get("namespace"), }, ) @@ -1340,7 +1354,7 @@ def _get_wrapped_element( meta: typing.Optional[dict[str, typing.Any]], ) -> ET.Element: 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.get("ns") or meta.get("namespace")) if meta else None ) if isinstance(v, (dict, list)): wrapped_element.extend(_get_element(v, exclude_readonly, meta)) @@ -1365,7 +1379,16 @@ 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. + _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 62541d05682..c1735ae07a0 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",