Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: fix
packages:
- "@typespec/http-client-python"
---

Fix Python emitter using mangled name (e.g. `items_property`) instead of the original wire name (e.g. `items`) when building request bodies from spread body parameters whose names match Python Mapping protocol methods.
Original file line number Diff line number Diff line change
Expand Up @@ -362,9 +362,9 @@ def update_parameter(self, yaml_data: dict[str, Any]) -> None:
yaml_data["clientName"].lower(), PadType.PARAMETER, yaml_data
)
if yaml_data.get("propertyToParameterName"):
# need to create a new one with padded keys and values
# keys are wire names and must NOT be padded; only values (Python parameter names) need padding
yaml_data["propertyToParameterName"] = {
self.pad_reserved_words(prop, PadType.PROPERTY, yaml_data): self.pad_reserved_words(
prop: self.pad_reserved_words(
param_name, PadType.PARAMETER, yaml_data
).lower()
for prop, param_name in yaml_data["propertyToParameterName"].items()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,27 @@ async def test_model_properties_dict_methods(client: SpecialWordsClient):
)


@pytest.mark.asyncio
async def test_model_properties_spread_dict_methods(client: SpecialWordsClient):
"""Regression test: spread body parameters named with Mapping protocol names must use
the original name as the wire key, not the Python-mangled attribute name.

For example, 'items' must be sent as {"items": ...} on the wire, not {"items_property": ...}.
"""
await client.model_properties.spread_dict_methods(
keys="ok",
items="ok",
values="ok",
popitem="ok",
clear="ok",
update="ok",
setdefault="ok",
pop="ok",
get="ok",
copy="ok",
)


@pytest.mark.asyncio
async def test_model_properties_with_list(client: SpecialWordsClient):
await client.model_properties.with_list(models.ModelWithList(list="ok"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,26 @@ def test_model_properties_dict_methods(client: SpecialWordsClient):
)


def test_model_properties_spread_dict_methods(client: SpecialWordsClient):
"""Regression test: spread body parameters named with Mapping protocol names must use
the original name as the wire key, not the Python-mangled attribute name.

For example, 'items' must be sent as {"items": ...} on the wire, not {"items_property": ...}.
"""
client.model_properties.spread_dict_methods(
keys="ok",
items="ok",
values="ok",
popitem="ok",
clear="ok",
update="ok",
setdefault="ok",
pop="ok",
get="ok",
copy="ok",
)


def test_model_properties_with_list(client: SpecialWordsClient):
client.model_properties.with_list(models.ModelWithList(list="ok"))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,27 @@ async def test_model_properties_dict_methods(client: SpecialWordsClient):
)


@pytest.mark.asyncio
async def test_model_properties_spread_dict_methods(client: SpecialWordsClient):
"""Regression test: spread body parameters named with Mapping protocol names must use
the original name as the wire key, not the Python-mangled attribute name.

For example, 'items' must be sent as {"items": ...} on the wire, not {"items_property": ...}.
"""
await client.model_properties.spread_dict_methods(
keys="ok",
items="ok",
values="ok",
popitem="ok",
clear="ok",
update="ok",
setdefault="ok",
pop="ok",
get="ok",
copy="ok",
)


@pytest.mark.asyncio
async def test_model_properties_with_list(client: SpecialWordsClient):
await client.model_properties.with_list(model_properties_models.ModelWithList(list="ok"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,26 @@ def test_model_properties_dict_methods(client: SpecialWordsClient):
)


def test_model_properties_spread_dict_methods(client: SpecialWordsClient):
"""Regression test: spread body parameters named with Mapping protocol names must use
the original name as the wire key, not the Python-mangled attribute name.

For example, 'items' must be sent as {"items": ...} on the wire, not {"items_property": ...}.
"""
client.model_properties.spread_dict_methods(
keys="ok",
items="ok",
values="ok",
popitem="ok",
clear="ok",
update="ok",
setdefault="ok",
pop="ok",
get="ok",
copy="ok",
)


def test_model_properties_with_list(client: SpecialWordsClient):
client.model_properties.with_list(model_properties_models.ModelWithList(list="ok"))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4904,3 +4904,46 @@ def test_eq_nested_models():
optional_myself=OptionalModel(optional_str="inner"),
)
assert model1 != model2_different_pet


# --- Regression test for the spread-body wire-name bug fix ---
def test_as_attribute_dict_with_mapping_protocol_property_names(core_library):
"""Verify that azure.core.serialization.as_attribute_dict still returns Python attribute names
(e.g. 'items_property') even after the preprocess fix that corrects wire names in
propertyToParameterName. The fix only affects spread body parameter wire names; it must NOT
affect model property renaming (items -> items_property on the Python side).
"""
try:
# azure-flavored generated package layout
from specialwords.models import DictMethods
except ImportError:
# unbranded-flavored generated package layout
from specialwords.modelproperties.models import DictMethods # type: ignore[no-redef]

# Build from wire names (as would arrive in a service response)
wire_response = {
"keys": "ok",
"items": "ok",
"values": "ok",
"popitem": "ok",
"clear": "ok",
"update": "ok",
"setdefault": "ok",
"pop": "ok",
"get": "ok",
"copy": "ok",
}
model = DictMethods(wire_response)

# model.as_dict() must return the WIRE names
assert model.as_dict() == wire_response

# as_attribute_dict() must return the PYTHON attribute names (padded)
result = core_library.serialization.as_attribute_dict(model)
expected = {k + "_property": v for k, v in wire_response.items()}
assert result == expected, (
f"as_attribute_dict() returned wrong keys.\n"
f"Expected: {expected}\n"
f"Got: {result}\n"
f"The preprocess fix must NOT rename model property wire names."
)
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,37 @@
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# --------------------------------------------------------------------------
import pytest
from pygen.preprocess import PreProcessPlugin
from pygen.preprocess.python_mappings import PadType
from pygen.preprocess.python_mappings import PadType, RESERVED_TSP_MODEL_PROPERTIES


def pad_reserved_words(name: str, pad_type: PadType) -> str:
return PreProcessPlugin(output_folder="").pad_reserved_words(name, pad_type, {})


def make_tsp_plugin() -> PreProcessPlugin:
"""Create a PreProcessPlugin with is_tsp=True (as used by the TypeSpec Python emitter)."""
return PreProcessPlugin(output_folder="", tsp_file="true")


def make_body_parameter_yaml(property_to_parameter_name: dict) -> dict:
"""Create a minimal body parameter yaml_data dict for update_parameter tests."""
return {
"description": "",
"location": "body",
"clientName": "body",
"propertyToParameterName": property_to_parameter_name,
"type": {"type": "model"},
"wireName": None,
}


def mangled_property_name(name: str) -> str:
"""Return the Python-mangled form of a name when used as a model property client name."""
return name + PadType.PROPERTY.value


def test_escaped_reserved_words():
expected_conversion_model = {"Self": "Self", "And": "AndModel"}
for name in expected_conversion_model:
Expand All @@ -33,3 +56,106 @@ def test_escaped_reserved_words():
}
for name in expected_conversion_parameter:
assert pad_reserved_words(name, pad_type=PadType.PARAMETER) == expected_conversion_parameter[name]


def test_mapping_protocol_names_are_tsp_property_reserved_words():
"""Verify that all Mapping protocol method names are reserved for TSP model properties."""
# These names conflict with Python's Mapping/MutableMapping protocol, so model
# property *client names* must be renamed (e.g. items -> items_property).
for name in RESERVED_TSP_MODEL_PROPERTIES:
result = make_tsp_plugin().pad_reserved_words(name, PadType.PROPERTY, {})
expected = mangled_property_name(name)
assert result == expected, (
f"Expected '{name}' to be padded to '{expected}' "
f"when used as a model property client name, but got '{result}'"
)


def test_mapping_protocol_names_are_not_parameter_reserved_words():
"""Verify that Mapping protocol names are NOT reserved for method parameters.

Parameters can be named 'items', 'keys', etc. without renaming because they
are ordinary Python variables, not attributes on a Mapping subclass.
"""
for name in RESERVED_TSP_MODEL_PROPERTIES:
result = make_tsp_plugin().pad_reserved_words(name, PadType.PARAMETER, {})
assert result == name, (
f"Expected '{name}' to remain unchanged as a parameter name, but got '{result}'"
)


@pytest.mark.parametrize("mapping_name", RESERVED_TSP_MODEL_PROPERTIES)
def test_update_parameter_property_to_parameter_name_wire_keys_not_padded(mapping_name):
"""Wire-name keys in propertyToParameterName must NOT be padded.

When a TypeSpec operation uses a spread body (e.g. `op doSomething(items: string[]): void;`),
the emitter builds propertyToParameterName with wire names as keys and Python parameter names
as values. The preprocess step must leave the wire-name keys alone so they are sent correctly
on the wire (e.g. JSON key "items", not the mangled "items_property").
"""
plugin = make_tsp_plugin()
yaml_data = make_body_parameter_yaml({mapping_name: mapping_name})
plugin.update_parameter(yaml_data)

assert mapping_name in yaml_data["propertyToParameterName"], (
f"Wire name '{mapping_name}' must remain as the key in propertyToParameterName "
f"but was not found. Current keys: {list(yaml_data['propertyToParameterName'].keys())}"
)
mangled = mangled_property_name(mapping_name)
assert mangled not in yaml_data["propertyToParameterName"], (
f"Mangled name '{mangled}' must NOT appear as a key in propertyToParameterName "
f"because keys are wire names, not Python property names."
)


def test_update_parameter_property_to_parameter_name_values_are_padded():
"""Parameter-name values in propertyToParameterName ARE padded when they match reserved words.

The values are Python parameter names passed to the generated method. Names that conflict
with reserved parameter words (e.g. 'continuation_token') must still be renamed.
"""
plugin = make_tsp_plugin()
yaml_data = make_body_parameter_yaml({"continuation_token": "continuation_token"})
plugin.update_parameter(yaml_data)

# Wire-name key stays unchanged
assert "continuation_token" in yaml_data["propertyToParameterName"]
# Python parameter value is padded
assert yaml_data["propertyToParameterName"]["continuation_token"] == "continuation_token_parameter"


def test_update_parameter_items_wire_name_preserved():
"""Regression test: 'items' spread-body wire name must not be mangled to 'items_property'.

Reproduces the bug reported in GitHub issue:
op doSomething(items: string[]): void;
The request body JSON key must be "items", not "items_property".
"""
plugin = make_tsp_plugin()
yaml_data = make_body_parameter_yaml({"items": "items"})
plugin.update_parameter(yaml_data)

assert yaml_data["propertyToParameterName"] == {"items": "items"}, (
f"Expected {{'items': 'items'}} but got {yaml_data['propertyToParameterName']}. "
f"The wire name 'items' was incorrectly mangled."
)


def test_update_parameter_all_mapping_names_wire_names_preserved():
"""Regression test: all Mapping protocol names as spread-body wire names must be preserved."""
plugin = make_tsp_plugin()
# Build a dict with all mapping names as both wire names and parameter names
property_to_param = {name: name for name in RESERVED_TSP_MODEL_PROPERTIES}
yaml_data = make_body_parameter_yaml(property_to_param)
plugin.update_parameter(yaml_data)

result = yaml_data["propertyToParameterName"]
for wire_name in RESERVED_TSP_MODEL_PROPERTIES:
assert wire_name in result, (
f"Wire name '{wire_name}' was incorrectly mangled. "
f"Current keys: {list(result.keys())}"
)
mangled = mangled_property_name(wire_name)
assert mangled not in result, (
f"Mangled key '{mangled}' must not appear; wire names must not be padded."
)
25 changes: 25 additions & 0 deletions packages/http-specs/spec-summary.md
Original file line number Diff line number Diff line change
Expand Up @@ -4411,6 +4411,31 @@ Send
{ "SameAsModel": "ok" }
```

### SpecialWords_ModelProperties_spreadDictMethods

- Endpoint: `get /special-words/model-properties/spread-dict-methods`

Verify that spread body parameters can use names that are Python dict methods.
These names (keys, items, values, etc.) may conflict with Python's dict class methods,
but must still be sent with the correct wire name.

Send

```json
{
"keys": "ok",
"items": "ok",
"values": "ok",
"popitem": "ok",
"clear": "ok",
"update": "ok",
"setdefault": "ok",
"pop": "ok",
"get": "ok",
"copy": "ok"
}
```

### SpecialWords_ModelProperties_withList

- Endpoint: `get /special-words/model-properties/list`
Expand Down
37 changes: 37 additions & 0 deletions packages/http-specs/specs/special-words/main.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,43 @@ namespace ModelProperties {
@route("dict-methods")
op dictMethods(@body body: DictMethods): void;

@scenario
@scenarioDoc("""
Verify that spread body parameters can use names that are Python dict methods.
These names (keys, items, values, etc.) may conflict with Python's dict class methods,
but must still be sent with the correct wire name.

Send

```json
{
"keys": "ok",
"items": "ok",
"values": "ok",
"popitem": "ok",
"clear": "ok",
"update": "ok",
"setdefault": "ok",
"pop": "ok",
"get": "ok",
"copy": "ok"
}
```
""")
@route("spread-dict-methods")
op spreadDictMethods(
keys: string,
items: string,
values: string,
popitem: string,
clear: string,
update: string,
setdefault: string,
pop: string,
get: string,
copy: string,
): void;

// Test for model property named "list" which is a reserved word in many languages
model ModelWithList {
list: string;
Expand Down
Loading
Loading